diff --git a/docs/architecture/feature-conformance-explainer.html b/docs/architecture/feature-conformance-explainer.html new file mode 100644 index 0000000..f0bdbcb --- /dev/null +++ b/docs/architecture/feature-conformance-explainer.html @@ -0,0 +1,2031 @@ + + + + + +feature-conformance / template-vertical / explainer + + + + + + + +
+
+
+ template-vertical + vol. 04 / explainer / conformance + folio 01 +
+
+

Feature
Conformance

+

A four-layer feedback system for AI agents writing features. The manifest is the source of truth; the compiler, the editor, the dev server, and CI are the agent's correction signal — fast, structured, layered.

+
+ +
+
+ +
+ + +
+
+
+ § 01 +
+

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.

+
+
+
+ + +
+
+
+ § 02 +
+

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.

+
+
+ +
+
+
Loop property
+

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.

+
+ +
+
Loop property
+

Structured

+

Error output is parseable: rule id, file path, line, machine-readable code. An agent grepping output recognises a category, not a prose blurb.

+
+ +
+
Loop property
+

Actionable

+

Every diagnostic ends with a Fix: line — the concrete next edit. Diagnosis without prescription forces the agent to guess.

+
+ +
+
Loop property
+

Layered

+

Four checks at four moments. If one is silenced or skipped, another catches it. No silent passes — exactly one layer must complain.

+
+
+ +
+
+
+
Agent iteration model
+

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.
+
+
+
+
+ + +
+
+
+ § 03 +
+

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

+ toggle properties · watch enforcement light up +
+ +
+ +
+ +
+
Use case configure one use case below
+
+ signUp +
+
+ +
+
Behavior
+
+ +
+
+ +
+
Audit emissions
+
+ + +
+
+ +
+
Cross-feature publishes
+
+ + +
+
+ +
+
Required cores
+
+ + + +
+
+ +
+
Implementation present?
+
+ + + +
+
+ +
+ + +
+ +
+
+ compiler▮ tsc / IDE — 0s +
+
+
Type-checking signUp use-case binding…
+
+
+ +
+
+ editor▮ eslint — <1s +
+
+
Linting feature.manifest.ts and use-case files…
+
+
+ +
+
+ dev server▮ pnpm dev — ~3s +
+
+
Asserting container conformance…
+
+
+ +
+
+ continuous integration▮ pnpm conformance — ~120s +
+
+
Running cross-feature checks…
+
+
+ +
+
+
+
+
+ + +
+
+
+ § 04 +
+

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.

+
+
+ +
+ Layer +
+ + + + +
+
+ +
+
+

TypeScript brands

+
0sred squiggle, real-time
+
+ +
+
+ compile time +

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.
  • +
+
+ Catches + forgotten 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.
+
+
+
+
+ + +
+
+
+ § 05 +
+

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.

+
+
+ +
+
+ Mistake + tsc + eslint + boot + ci +
+ +
+
+
+ + +
+
+
+ § 06 +
+

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.

+
+
+ +
+
+ LayerSurfaceLatencyBest at +
+ +
+ tsc +
+
+
+ 0s + shape +
+ +
+ eslint +
+
+
+ <1s + policy +
+ +
+ boot +
+
+
+ ~3s + wiring +
+ +
+ ci +
+
+
+ ~120s + closure +
+ +

+ 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. +

+
+
+
+ + +
+
+
+ § 07 +
+

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.

+
+
+ +
+ Milestone +
+ + + + +
+
+ +
+
+ i. +

Manifest helper & branded wrappers

+
2–3 daysscope · effort
+
+
+
+
What ships
+

+
Files created or touched
+
    +
    +
    +
    What it catches
    +

    +
    Why this milestone first
    +

    +
    +
    +
    +
    +
    + + +
    +
    +
    + § 08 +
    +

    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.

    +
    +
    + +
    +
    +
    exists
    +

    @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.

    +
    Extends to → manifest/usecase-signature-matches, manifest/no-undeclared-event-publish, manifest/no-undeclared-audit
    +
    + +
    +
    exists
    +

    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().

    +
    Extends to → read every feature manifest, walk the container, fail boot on mismatch
    +
    + +
    +
    exists
    +

    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.

    +
    Extends to → add Instrumented<F>, Captured<F>, and a new Audited<F> when core-audit is on
    +
    + +
    +
    exists
    +

    turbo 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.

    +
    Extends to → write the manifest stub, wire auditLogger dep when core-audit is present, register the symbol
    +
    + +
    +
    partial
    +

    PII 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.

    +
    Extends to → add the conformance script, surface ESLint rules in the editor so CI is the backstop, not the front line
    +
    + +
    +
    missing
    +

    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.

    +
    Build → define-feature.ts, feature.manifest.ts in each feature, registry export in the app's bind-production.ts
    +
    + +
    +
    +
    + + +
    +
    +
    + § 09 +
    +

    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.

    +
    +
    + +
    +
    + ast · extensions +

    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 to Audited<F>". Same <1s layer; 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 the 0s compiler 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.
    • +
    +
    + Recommendation + Start with type-aware ESLint — same layer, no new tool, biggest reach. Add template-literal types for invariants the type system can model directly. Skip the LS plugin unless humans complain about lint lag. +
    +
    + +
    + conventions · two camps +

    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 feature as 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, trailing outputSchema.parse(result), repository methods calling this.tracer.startSpan inline. Each is one small ESLint rule in @repo/core-eslint.
    • +
    • Pattern restrictions — "no socket.io outside core-realtime", "no payload.jobs.queue() outside core-shared/jobs/". no-restricted-imports plus the existing no-handler-reexport / no-direct-socket-io custom rules. Already in place; keep extending.
    • +
    • Type-encodable — anything expressible in a type, push to the compiler. Binding-slot brands, as const manifests, branded IDs. Free at runtime, refactor-safe.
    • +
    +
    + Recommendation + Lean on the generator-drift gate for structure; reserve ESLint for in-file shape. Resist writing 30 layout rules — one scaffold-diff catches the same drift, and the cost of a new convention drops to "regenerate and commit", not "write a rule". +
    +
    +
    +
    +
    + + +
    +
    +
    + § 10 +
    +

    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.

    +
    +
    + +
    +
    + i. +
    +
    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.

    +
    +
    + +
    + ii. +
    +
    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.

    +
    +
    + +
    + iii. +
    +
    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.

    +
    +
    + +
    + iv. +
    +
    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.

    +
    +
    + +
    + v. +
    +
    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.

    +
    +
    +
    +
    +
    + +
    + + + + + + +