The drift problem.
+A feature-based monorepo lasts for years and is increasingly authored by AI agents. Conventions that lived in someone's head when the second feature was written are not legible to an agent writing the eighth. Agents can't be trained by code review, can't infer culture from PR history, and iterate dozens of times per hour. The job of conformance is to turn every convention into a machine-readable signal that fires fast enough to close the agent's correction loop.
+Every convention is a promise. Every promise needs a watchman.
+This repo already enforces some promises: no-handler-reexport, no-direct-socket-io, the PII grep gate in CI. Those are watchmen. The question is how to scale the watchman model so that every new core capability — audit, events, realtime, observability — gets the same enforcement quality, and so that the resulting signals are sharp enough for an AI agent to read, understand, and act on without human triage.
+The answer below is a single declarative primitive (a feature manifest) read by four independent enforcement layers, each catching what the layer above missed. The earlier the layer fires, the tighter the agent's correction loop.
+Built for the agent loop.
+An AI agent writing a new feature does not have intuition, taste, or memory of last quarter's incident. It has exactly what the toolchain emits — diagnostics, exit codes, stack traces, and the contents of error messages. Every design choice in the four layers below is shaped by that single fact: the system's output is the agent's correction signal.
+Fast
+Sub-second feedback at compile and lint time. Agents iterate dozens of times per hour; a 2-minute CI gate is the wrong place to discover most mistakes.
+Structured
+Error output is parseable: rule id, file path, line, machine-readable code. An agent grepping output recognises a category, not a prose blurb.
+Actionable
+Every diagnostic ends with a Fix: line — the concrete next edit. Diagnosis without prescription forces the agent to guess.
Layered
+Four checks at four moments. If one is silenced or skipped, another catches it. No silent passes — exactly one layer must complain.
+Write · read · correct.
+The middle step is the bottleneck. The faster and clearer the signal, the more loops the agent can run inside a unit of work — and the smaller the chance of the agent's mental model diverging from what the codebase actually requires.
+$ pnpm dev + +[bindAll] resolving billing feature… +✗ ConformanceError + billing.charge: binding missing brand '__audited' + manifest declares mutates: true with audits: ["payment.captured"] + but binding is not wrapped in withAudit(). + + Fix: in packages/features/billing/src/di/bind-production.ts:36 + wrap the use case as: withSpan(t, opts, withAudit(a, withCapture(l, tags, chargeUseCase(...)))) + +# Agent reads diagnostic. +# Agent edits bind-production.ts:36 per the Fix line. +# Agent re-runs pnpm dev. Loop closes in ~3s.+
The manifest.
+Every feature owns a typed manifest describing its contract with the core packages: which use cases mutate state, which audit events they emit, which cross-feature events they publish or consume, which realtime channels they own. The manifest is the only place these facts are declared — code that contradicts the manifest is what enforcement catches.
+import { defineFeature } from "@repo/core-shared/conformance"; + +export const authManifest = defineFeature({ + name: "auth", + requiredCores: ["audit", "events"], // hard deps + useCases: { + signIn: { mutates: false, audits: [], publishes: [], consumes: [] }, + signUp: { mutates: true, audits: ["user.created"], publishes: ["auth.signed-up"], consumes: [] }, + signOut: { mutates: true, audits: ["session.ended"], publishes: [], consumes: [] }, + }, + realtimeChannels: [], + jobs: ["auth.welcome-email"], +} as const);+ +
Manifest playground
+ +signUp
+ Type-checking signUp use-case binding…+
Linting feature.manifest.ts and use-case files…+
Asserting container conformance…+
Running cross-feature checks…+
Four enforcement points.
+Each layer runs in a different process at a different moment, and so each layer sees something the others don't. The compiler sees types but not runtime calls. ESLint sees the AST but not bindings. The dev server sees the wired container but not what's missing. CI sees the whole repo. Composed, they catch nearly every drift class.
+TypeScript brands
+If you forget the wrapper, you can't even bind.
+Wrap helpers — withSpan, withCapture, withAudit — return branded function types. The DI bind signature requires those brands as input. A use-case factory that hasn't been wrapped is not assignable to the binding slot.
The manifest is consumed at the type level: a use case declared mutates: true demands an Audited brand in its binding. The IDE lights up the moment you save.
-
+
- No runtime cost. No test required. +
- Refactor-safe: rename a wrapper and every call site fails at once. +
- Doesn't catch behavior — only structural omissions. +
withSpan; forgotten withAudit on mutation; missing dependency on the binder's ctx type.
+ export type Instrumented<F> = F & { readonly __instrumented: true }; + +export function withSpan<I, O>( + tracer: ITracer, + opts: SpanOpts, + fn: (input: I) => Promise<O>, +): Instrumented<(input: I) => Promise<O>> { /* … */ } + +// core-shared/di/bind.ts +type ProductionUseCase<I, O, M extends UseCaseManifest> = + & Instrumented<(input: I) => Promise<O>> + & (M["mutates"] extends true ? Audited<any> : unknown); + +// In bind-production.ts: +bind<ISignUpUseCase>(SYMBOL).toDynamicValue< + ProductionUseCase<SignUpInput, SignUpOutput, AuthManifest["useCases"]["signUp"]> +>(ctx => withSpan(tracer, opts, withAudit(auditLogger, withCapture(logger, tags, signUpUseCase(...))))); + +// Forget withAudit on a mutating use case: +Type '...' is not assignable to type 'Audited<...>'. + Property '__audited' is missing.+
A catalog of mistakes.
+A working enforcement system is best judged by the mistakes it catches and where. Below is a matrix of common drift patterns mapped to the four layers — click any row to see the actual error message each layer surfaces. The earlier the catch, the cheaper the fix; the rightmost catch is the last line of defence.
+Layer composition.
+The layers don't replace each other — they compose. Each is independently shippable, and each is best at catching a different class of mistake. Read left-to-right: the further right, the longer the feedback loop and the more situational the rule.
++ Bar length is feedback latency. Layer order is also the order in which mistakes get progressively more expensive to discover — a CI failure on a merge-day branch costs more than a red squiggle on save. +
+Build order.
+Four independently shippable milestones. Build them in this order because each is small on its own, and each catches mistakes the next milestone otherwise has to handle. Stop after any milestone and the remainder still works as a manual checklist.
+Manifest helper & branded wrappers
+What ships
+ +Files created or touched
+What it catches
+ +Why this milestone first
+ +Anchor points already here.
+Most of the foundation for this system already exists in template-vertical. The list below shows what's in place (green), what's partially there (amber), and what needs to be built fresh (red). The plan is to extend existing muscle, not introduce a parallel mechanism.
@repo/core-eslint custom rules
+The watchman registry.
+no-handler-reexport, no-realtime-handler-reexport, no-direct-socket-io. The plugin scaffolding is in place — new rules drop in next to the existing ones.
BindContext + bindAll() dispatcher
+The boot-time assertion site.
+Each feature exports bindProductionX(ctx); the app aggregator composes them. This is the natural place to run a single assertConformance(container, manifests) at the tail of bindAll().
withSpan / withCapture composition
+The wrap convention.
+Use cases are already wrapped at bind time with withSpan(tracer, opts, withCapture(logger, tags, factory(deps))). Brand types attach a phantom flag — zero runtime cost.
Instrumented<F>, Captured<F>, and a new Audited<F> when core-audit is onturbo gen feature / event / job / realtime
+The scaffold-on-creation pipeline.
+Generators already exist for features, events, jobs, realtime channels. They become core-aware: read pnpm-workspace.yaml, scaffold matching wiring and a manifest entry alongside the use-case file.
auditLogger dep when core-audit is present, register the symbolPII grep gate
+One example of a CI-only check.
+The PII grep runs only in CI. The same shape applies to conformance: a pnpm conformance task that's cheap enough to also run locally as a watch, but authoritative in CI.
The manifest primitive itself
+The new declarative source of truth.
+Nothing today plays this role. defineFeature needs to live in core-shared/conformance/ with a strict as const contract, plus a typed registry the dispatcher can iterate.
Beyond the four.
+Two natural extensions came out of design conversations — sharper AST machinery, and a separate story for code conventions. Both lean on the same four-layer chassis; neither requires a new tool.
+Make ESLint type-aware first.
+The rules in §04 walk the syntax tree but don't ask the compiler questions. Three escalating options, ordered by leverage for an agent loop.
+-
+
- Type-aware ESLint via
parserServices.program— cross-file checks like "factory signature matches manifest deps", "binding-slot type resolves toAudited<F>". Same<1slayer; 10–100× slower than syntax rules but still sub-second.
+ - Template-literal types on the manifest — e.g.
audits: Array<`${FeatureName}.${string}`>. Pushes invariants down to the0scompiler tier. A feature can't declare an audit it doesn't own.
+ - TS Language-Service plugin — same diagnostics injected into
tsserver, surfaced instantly without an ESLint round-trip. Marginal gain for agents (no IDE), big gain for humans.
+
Structure vs. shape, two different layers.
+Code conventions split cleanly. Each camp wants a different primitive — don't pick one tool and try to bend it across both.
+-
+
- Structural — file layout, required exports, mock siblings, scaffold shape. Use
turbo gen featureas the canonical source; CI regenerates into a tmp dir and diffs. One tool catches dozens of conventions; cost scales sublinearly with each new convention. (Milestone iv.)
+ - In-file shape — factory signature,
.strict()on schemas, the presenter function, trailingoutputSchema.parse(result), repository methods callingthis.tracer.startSpaninline. Each is one small ESLint rule in@repo/core-eslint.
+ - Pattern restrictions — "no
socket.iooutsidecore-realtime", "nopayload.jobs.queue()outsidecore-shared/jobs/".no-restricted-importsplus the existingno-handler-reexport/no-direct-socket-iocustom rules. Already in place; keep extending.
+ - Type-encodable — anything expressible in a type, push to the compiler. Binding-slot brands,
as constmanifests, branded IDs. Free at runtime, refactor-safe.
+
Open questions.
+Decisions to make before the spec is written. None block starting milestone i, but the answers shape how the manifest is shaped and which layer carries which check.
+Where does the manifest live?
+Inside the feature package (src/feature.manifest.ts, co-located with use cases) so it ships with the package — or in the app's server/ directory so it's authoritative per-app? The first feels right for clean architecture; the second matches the existing aggregator pattern. Recommendation: feature package, with a tiny app-side registry that imports each.
How much of the manifest is generated vs hand-written?
+The generator writes the initial scaffold. After that: do humans edit it directly, or does ESLint auto-fix from the use-case file's signature? The auto-fix is convenient but means the manifest is no longer the source of truth — the code is. Recommendation: humans edit; ESLint flags mismatches both ways without fixing.
+Does the manifest enumerate symbols, or does the registry?
+The bind-time assertion needs to know which container symbol each use case is bound to. Either the manifest declares it inline, or the registry holds a mapping. Inline is more self-contained; registry is less repetitive.
+What happens when an optional core is absent?
+If core-audit isn't installed, a manifest entry like audits: ["user.created"] should ideally not even type-check. This requires the manifest's typed surface to depend on which cores are present in pnpm-workspace.yaml — non-trivial. Alternative: type the field as readonly never[] when audit is absent, gated by a build-time const.
Escape hatch?
+Real systems need exceptions. A // @conformance-skip: <rule> — <reason> comment that ESLint and the boot assertion both honour, recorded in a single allowlist file so exceptions are visible and reviewable. Recommendation: yes, plus a CI step that fails when allowlist entries grow without an issue link.