diff --git a/docs/architecture/agent-first-workflow-and-conformance.md b/docs/architecture/agent-first-workflow-and-conformance.md index d93d52f..22e15bb 100644 --- a/docs/architecture/agent-first-workflow-and-conformance.md +++ b/docs/architecture/agent-first-workflow-and-conformance.md @@ -400,7 +400,7 @@ The reviewer agent **explicitly checks the task's `Out of scope` section against ## Conformance system integration -The four enforcement layers (detailed in [`feature-conformance-explainer.html`](./feature-conformance-explainer.html)): +The five enforcement layers (detailed in [`feature-conformance-explainer.html`](./feature-conformance-explainer.html)): | Layer | Latency | Catches | | ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------- | @@ -408,6 +408,7 @@ The four enforcement layers (detailed in [`feature-conformance-explainer.html`]( | AST-aware ESLint | <1s | manifest ↔ code drift; undeclared `bus.publish` / `auditLogger.log`; required cores not installed | | Boot assertion (`assertConformance`) | ~3s | binding type-casts that hid unwrapped factories; manifests edited without rebinding | | CI drift gate (`pnpm conformance`) | ~120s | orphan event consumers; scaffold drift from generator; required-cores ↔ workspace mismatch | +| Fallow audit (`pnpm fallow`) | ~30–60s | whole-codebase — dead exports, duplicate code, circular deps, complexity hotspots | ### How conformance interacts with tasks @@ -417,7 +418,7 @@ When a task adds an audit emission (e.g. `audits: ["user.created"]`): 2. The binding's branded slot type _now_ demands `Audited` — TS2322 if the wrapper is missing 3. Agent adds `withAudit(...)` in `bind-production.ts` → TS goes quiet 4. Agent adds `auditLogger.log(...)` in the use-case factory → ESLint goes quiet -5. Pre-commit `pnpm conformance` confirms all four layers pass +5. Pre-commit `pnpm conformance` confirms all five layers pass 6. PR submitted Each step gives sub-second feedback. The agent's iteration loop is dominated by think + write, not by waiting for feedback. @@ -653,7 +654,7 @@ These are explicitly deferred until the tier that needs them: This design is "done" when: - [ ] `docs/work/` exists with templates, `_state.json` schema, README -- [ ] Conformance system v1 is implemented through all four enforcement layers +- [ ] Conformance system v1 is implemented through all five enforcement layers - [ ] All five feature packages have manifests - [ ] All three apps run `assertConformance` at boot - [ ] `pnpm conformance` is a CI gate diff --git a/docs/architecture/audit-and-compliance-explainer.html b/docs/architecture/audit-and-compliance-explainer.html index 3a97d82..e8724a8 100644 --- a/docs/architecture/audit-and-compliance-explainer.html +++ b/docs/architecture/audit-and-compliance-explainer.html @@ -983,7 +983,11 @@ footer .colophon { | "UPDATE" | "DELETE" | "EXPORT" - | "PERMISSION_CHANGE"; + | "PERMISSION_CHANGE" + | "CONSENT_GRANT" + | "CONSENT_WITHDRAW" + | "RESTRICT" + | "UNRESTRICT"; /** * `from_where` fragment per DPA. IP truncated to /24 (IPv4) or /48 (IPv6) @@ -1381,6 +1385,7 @@ footer .colophon { const ctx: BindProductionContext = { tracer, logger, config, bus, queue, realtime, realtimeRegistry, auditLog, // ← NEW + analytics, consentFactory, rateLimit, // optional (ADR-024 + ADR-025) }; await bindProductionAuth(ctx); diff --git a/docs/architecture/data-flow-explainer.html b/docs/architecture/data-flow-explainer.html index 30f9ae0..ed68162 100644 --- a/docs/architecture/data-flow-explainer.html +++ b/docs/architecture/data-flow-explainer.html @@ -1703,16 +1703,56 @@ footer .colophon {

blog/di/bind-production.ts

Called from each app's bootstrap (apps/web-next/src/server/bind-production.ts) with the ctx object built once by the aggregator. BindProductionContext is imported from @repo/core-shared/di.

export function bindProductionBlog(ctx: BindProductionContext): void {
-  // bus, realtime, realtimeRegistry, auditLog are optional — present only when the corresponding optional package is scaffolded
-  const { config, tracer, logger, bus, queue, realtime, realtimeRegistry, auditLog } = ctx;
+  // bus, realtime, realtimeRegistry, auditLog are optional — present only when
+  // the corresponding optional package is scaffolded
+  const { config, tracer, logger, bus, queue } = ctx;
+
+  // 1) Swap the mock repo for the real Payload-backed impl
   if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
     blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
   }
-  blogContainer
-    .bind(BLOG_SYMBOLS.IArticlesRepository)
-    .toConstantValue(new ArticlesRepository(config));
-  // Use cases + controllers stay untouched.
-  // They'll resolve through the new repo automatically.
+  const repo = new ArticlesRepository(config, tracer, logger);
+  blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository).toConstantValue(repo);
+
+  // 2) Wire each use case via wireUseCase — composes
+  //    withSpan → withCapture → withAudit → withAnalytics → withConsent → withRateLimit → factory(deps)
+  //    and binds the brand-stacked closure to the symbol. One call per use case.
+  const wrappedGetArticles = wireUseCase({
+    container: blogContainer,
+    symbol: BLOG_SYMBOLS.IGetArticlesUseCase,
+    factory: getArticlesUseCase,
+    deps: [repo],
+    feature: "blog", layer: "use-case", name: "getArticles",
+    tracer, logger,
+  });
+  // ... wireUseCase calls for the other use cases ...
+
+  // 3) Wrap each controller — controllers still use withSpan + withCapture manually,
+  //    because they take the already-wired use case as their dep.
+  if (blogContainer.isBound(BLOG_SYMBOLS.IGetArticlesController)) {
+    blogContainer.unbind(BLOG_SYMBOLS.IGetArticlesController);
+  }
+  blogContainer.bind(BLOG_SYMBOLS.IGetArticlesController).toConstantValue(
+    withSpan(tracer, { name: "blog.getArticles", op: "controller" },
+      withCapture(logger, { feature: "blog", layer: "controller", name: "blog.getArticles" },
+        getArticlesController(wrappedGetArticles),
+      ),
+    ),
+  );
+
+  // bus + queue are passed through; generated event/job handlers consume them at the anchors below.
+  void bus; void queue;
+  // <gen:event-handlers>
+  // <gen:jobs>
+
+  // 4) Self-asserting tail — refuses to boot if any use-case binding is missing a required brand
+  //    (withSpan / withCapture / withAudit / withAnalytics / withConsent / withRateLimit) or drifts from the manifest.
+  assertFeatureConformance(
+    blogContainer,
+    blogManifest,
+    { getArticles: BLOG_SYMBOLS.IGetArticlesUseCase, /* … */ },
+    ctx,
+  );
 }
diff --git a/docs/architecture/di-explainer.html b/docs/architecture/di-explainer.html index 4d587ca..1500d2d 100644 --- a/docs/architecture/di-explainer.html +++ b/docs/architecture/di-explainer.html @@ -756,7 +756,7 @@ footer .colophon {
file 04 · prod swap

bind-production.ts

Replaces the mock repository binding with a real Payload-backed one at app boot.

-

Exports bindProductionBlog(ctx: BindProductionContext). Destructures { config, tracer, logger, bus, queue, realtime, realtimeRegistry } from ctx. Function body: blogContainer.unbind(symbol) if already bound, then .bind(symbol).toConstantValue(new ArticlesRepository(config, tracer, logger)). Use cases and controllers are wrapped via withSpan(withCapture(factory(deps))) at bind time so they inherit instrumentation without changing their factory bodies. The optional bus and queue fields come from the app's resolveEventsAndJobs* step (ADR-015) and feed the // <gen:event-handlers> / // <gen:jobs> injection sites. BindProductionContext is imported from @repo/core-shared/di — features never import the optional packages directly for the binder signature.

+

Exports bindProductionBlog(ctx: BindProductionContext). Destructures the ctx (always: config, tracer, logger; optional: bus, queue, realtime, realtimeRegistry, metrics, auditLog, analytics, consentFactory, rateLimit). Function body: blogContainer.unbind(symbol) if already bound, then .bind(symbol).toConstantValue(new ArticlesRepository(config, tracer, logger)). Use cases are wired via wireUseCase({...}) from @repo/core-shared/conformance — which composes withSpan → withCapture → withAudit? → withAnalytics? → withConsent? → withRateLimit? → factory(deps) (each ? wrapper applies only when the manifest declares that channel). Controllers are wrapped manually with withSpan(withCapture(...)). The file ends with assertFeatureConformance(container, manifest, symbolMap, ctx) — the boot gate that refuses to start on manifest ↔ binding drift. The optional bus and queue fields come from the app's resolveEventsAndJobs* step (ADR-015) and feed the // <gen:event-handlers> / // <gen:jobs> injection sites. BindProductionContext is imported from @repo/core-shared/di — features never import the optional packages directly for the binder signature.

When it runs: called from app boot (apps/web-next/src/server/bind-production.ts) when USE_DEV_SEED ≠ "true" AND Payload config is resolvable.
@@ -924,15 +924,59 @@ footer .colophon {
06
App boot · per feature
-

Each binder swaps the repo binding.

-

Whichever binder runs, it does the same thing for the repository symbol: unbind the old binding, bind a new .toConstantValue(impl). Use case and controller bindings stay untouched — they keep their .toDynamicValue closures and will pick up the new repo on next resolve.

+

Each binder swaps the repo binding, then re-wires use cases + controllers.

+

The binder destructures ctx, unbinds + rebinds the repository as .toConstantValue(impl), then re-wires every use case through wireUseCase({ ... }) (which composes withSpan → withCapture → withAudit? → withAnalytics? → withConsent? → withRateLimit? → factory(deps) and binds the result). Controllers are wrapped with withSpan(withCapture(...)) at bind time. The binder ends with assertFeatureConformance(container, manifest, symbolMap, ctx), which refuses to boot if any use-case binding is missing a brand the manifest required.

-
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
-  blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
-}
-blogContainer
-  .bind(BLOG_SYMBOLS.IArticlesRepository)
-  .toConstantValue(new ArticlesRepository(config));  // or new MockArticlesRepository() with seed data
+
export function bindProductionAuth(ctx: BindProductionContext): void {
+  const { config, tracer, logger, bus, consentFactory } = ctx;
+
+  // 1. Swap the repository binding.
+  if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) {
+    authContainer.unbind(AUTH_SYMBOLS.IUsersRepository);
+  }
+  const repo = new UsersRepository(config, tracer, logger);
+  authContainer.bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository).toConstantValue(repo);
+
+  // 2. Wire each use case through the conformance helper.
+  //    wireUseCase composes withSpan → withCapture → withAudit? → withAnalytics?
+  //    → withConsent? → withRateLimit? → factory(deps), then binds the result.
+  const wrappedSignIn = wireUseCase({
+    container: authContainer,
+    symbol: AUTH_SYMBOLS.ISignInUseCase,
+    factory: signInUseCase,
+    deps: [repo, authService, ctx.rateLimit ?? new NoopRateLimit()],
+    feature: "auth", layer: "use-case", name: "signIn",
+    tracer, logger,
+    rateLimit: ctx.rateLimit ?? new NoopRateLimit(),  // manifest declares rateLimit
+  });
+  const wrappedSignUp = wireUseCase({
+    container: authContainer,
+    symbol: AUTH_SYMBOLS.ISignUpUseCase,
+    factory: signUpUseCase,
+    deps: [repo, authService, bus, consentFactory],
+    feature: "auth", layer: "use-case", name: "signUp",
+    tracer, logger,
+  });
+
+  // 3. Wrap controllers with span + capture at bind time.
+  authContainer
+    .bind(AUTH_SYMBOLS.ISignInController)
+    .toConstantValue(
+      withSpan(tracer, { name: "auth.signIn", op: "controller" },
+        withCapture(logger, { feature: "auth", layer: "controller", name: "auth.signIn" },
+          signInController(wrappedSignIn),
+        ),
+      ),
+    );
+
+  // 4. Boot-time conformance check — refuses to start on missing brands.
+  assertFeatureConformance(
+    authContainer,
+    authManifest,
+    { signIn: AUTH_SYMBOLS.ISignInUseCase, signUp: AUTH_SYMBOLS.ISignUpUseCase },
+    ctx,
+  );
+}
@@ -1204,29 +1248,61 @@ footer .colophon { └─ required: tracer, logger, config | optional: metrics, bus, queue, realtime, realtimeRegistry, auditLog └─ bindProductionX(ctx) ← single ctx object passed to each feature binder └─ feature container also binds TRACER + LOGGER - └─ withSpan(withCapture(...)) at every use case + controller + └─ wireUseCase({...}) at every use case: + withSpan → withCapture → withAudit? → withAnalytics? → withConsent? → withRateLimit? → factory(deps) + (the ? wrappers apply only when the manifest declares that channel) + └─ withSpan(withCapture(...)) at every controller + └─ assertFeatureConformance(container, manifest, symbolMap, ctx) at the tail └─ // <gen:event-handlers> / // <gen:jobs> / // <gen:realtime-handlers> / // <gen:audit-hooks> injection sites

Why per-feature containers also get the binding: repository classes resolve TRACER/LOGGER through the container; controllers and use cases receive instrumentation via the bind-time wrapper instead.

-

Two wrappers, applied as a sandwich

-

withSpan and withCapture are higher-order functions that take a (args) => Promise<R> and return the same shape. The binders compose them: withSpan(withCapture(factory(deps))). Span is outermost so an errored span's timing reflects the capture-and-rethrow.

+

The wrapper stack, applied as a sandwich

+

Each wrapper is a higher-order function taking (args) => Promise<R> and returning the same shape. For use cases, wireUseCase composes them in this order (outermost → innermost) and binds the result:

+
withSpan
+  └─ withCapture
+       └─ withAudit?       ← only if manifest declares `audits`
+            └─ withAnalytics?  ← only if manifest declares `analyticsEvents`
+                 └─ withConsent?    ← only if manifest declares `requiresConsent`
+                      └─ withRateLimit?  ← only if manifest declares `rateLimit`
+                           └─ factory(deps)
+

withSpan is always outermost so an errored span's timing reflects the capture-and-rethrow. The four optional wrappers (withAudit, withAnalytics, withConsent, withRateLimit) are conditionally inserted by wireUseCase based on the use case's manifest entry — a use case without any of those declarations gets the plain withSpan(withCapture(factory(deps))) sandwich. Controllers always use just withSpan(withCapture(...)).

- + - + - + + + + + + + + + + + + + + + + + + + + +
WrapperWhat it doesWhere it fires
WrapperWhat it doesApplied when
withSpan(tracer, opts, fn) Calls tracer.startSpan(opts, () => fn(...)). Pure delegation — no error handling of its own; status-on-error logic lives in the tracer impl.Around every use case + controller, at DI bind timeAlways — every use case + every controller
withCapture(logger, tags, fn) On throw: checks __sentryReported; if not set, calls logger.captureException(err, { tags }), marks the flag, re-throws. If already set, just re-throws.Around every use case + controller, inside the span wrapperAlways — every use case + every controller, inside the span wrapper
withAuditForwarding wrapper that attaches the __audited brand so the conformance gate sees the binding is audit-aware. Automated audit recording from manifest declarations is reserved.Use case only, when manifest.useCases[x].audits is non-empty
withAnalyticsForwarding wrapper that attaches the __analyzed brand. Automated analytics recording from manifest declarations is reserved.Use case only, when manifest.useCases[x].analyticsEvents is declared
withConsentGates execution on the consent categories declared by the manifest; throws / no-ops when consent is missing.Use case only, when manifest.useCases[x].requiresConsent is declared
withRateLimitInnermost of the optional wrappers — consults the rate-limit budgets and rejects before factory(deps) runs.Use case only, when manifest.useCases[x].rateLimit is declared
diff --git a/docs/architecture/feature-conformance-explainer.html b/docs/architecture/feature-conformance-explainer.html index f0bdbcb..fa42639 100644 --- a/docs/architecture/feature-conformance-explainer.html +++ b/docs/architecture/feature-conformance-explainer.html @@ -729,7 +729,7 @@ footer.colophon ul li a { font-family: "JetBrains Mono", monospace; font-size: 1

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.

+

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

Contents @@ -737,7 +737,7 @@ footer.colophon ul li a { font-family: "JetBrains Mono", monospace; font-size: 1
  • 01Drift
  • 02Agent Loop
  • 03Manifest
  • -
  • 04Four Layers
  • +
  • 04Five Layers
  • 05Mistakes
  • 06Composition
  • 07Build Order
  • @@ -765,7 +765,7 @@ footer.colophon ul li a { font-family: "JetBrains Mono", monospace; font-size: 1

    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.

    +

    The answer below is a single declarative primitive (a feature manifest) read by five independent enforcement layers, each catching what the layer above missed. The earlier the layer fires, the tighter the agent's correction loop.

    @@ -777,7 +777,7 @@ footer.colophon ul li a { font-family: "JetBrains Mono", monospace; font-size: 1 § 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.

    +

    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 five layers below is shaped by that single fact: the system's output is the agent's correction signal.

    @@ -803,7 +803,7 @@ footer.colophon ul li a { font-family: "JetBrains Mono", monospace; font-size: 1
    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.

    +

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

    @@ -844,18 +844,37 @@ footer.colophon ul li a { font-family: "JetBrains Mono", monospace; font-size: 1 -
    import { defineFeature } from "@repo/core-shared/conformance";
    +    
    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: [] },
    +    signIn:  {
    +      mutates: false, audits: [], publishes: [], consumes: [],
    +      rateLimit: [                                                // optional: per-use-case budgets
    +        { name: "ip",      window: "1m", budget: 5 },
    +        { name: "account", window: "1h", budget: 10 },
    +      ],
    +    },
    +    signUp:  {
    +      mutates: true, audits: ["user.created"], publishes: ["auth.signed-up"], consumes: [],
    +      analyticsEvents: ["auth.signup.completed"],         // optional: declares withAnalytics emissions
    +    },
    +    signOut: { mutates: true, audits: ["session.ended"], publishes: [], consumes: [] },
       },
       realtimeChannels: [],
       jobs: ["auth.welcome-email"],
    +  coverage: {                                                  // optional: ADR-020 coverage bands
    +    bands: {
    +      baseline:    { statements: 80,  branches: 75,  functions: 80,  lines: 80 },
    +      entities:    { statements: 100, branches: 100, functions: 100, lines: 100 },
    +      "use-cases": { statements: 100, branches: 95,  functions: 100, lines: 100 },
    +      controllers: { statements: 100, branches: 95,  functions: 100, lines: 100 },
    +    },
    +    mutationTargets: ["entities", "use-cases"],         // L3 mutation testing surface
    +  },
    +  // requiresConsent?: [...]   — optional: consent categories gated by withConsent
     } as const);
    @@ -963,14 +982,14 @@ footer.colophon ul li a { font-family: "JetBrains Mono", monospace; font-size: 1
    - +
    § 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.

    +

    Five 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. Fallow sees the whole codebase — dead exports, duplicates, circular deps, complexity hotspots that none of the other four are shaped to catch. Composed, they catch nearly every drift class.

    @@ -981,6 +1000,7 @@ footer.colophon ul li a { font-family: "JetBrains Mono", monospace; font-size: 1 +
    @@ -1039,7 +1059,7 @@ bind<ISignUpUseCase>(SYMBOL).toDynamicValue< § 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.

    +

    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 first four layers — click any row to see the actual error message each layer surfaces. (Fallow, the fifth layer, catches a different class of drift — accretion rather than contradiction — and is covered in §04.) The earlier the catch, the cheaper the fix; the rightmost catch is the last line of defence.

    @@ -1122,7 +1142,7 @@ bind<ISignUpUseCase>(SYMBOL).toDynamicValue< § 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.

    +

    Historical — this was the original build plan; the work is complete. Four independently shippable milestones. Built in this order because each was 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.

    @@ -1167,7 +1187,7 @@ bind<ISignUpUseCase>(SYMBOL).toDynamicValue< § 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.

    +

    Historical — written before the system was built; the “missing” pieces below have since been built on top of the green anchors. Most of the foundation for this system already existed in template-vertical. The list below shows what was in place (green), what was partially there (amber), and what needed to be built fresh (red). The plan was to extend existing muscle, not introduce a parallel mechanism — and that's what shipped.

    @@ -1224,14 +1244,14 @@ bind<ISignUpUseCase>(SYMBOL).toDynamicValue<
    - +
    § 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.

    +

    Beyond the five.

    +

    Two natural extensions came out of design conversations — sharper AST machinery, and a separate story for code conventions. Both lean on the same five-layer chassis; neither requires a new tool.

    @@ -1277,7 +1297,7 @@ bind<ISignUpUseCase>(SYMBOL).toDynamicValue< § 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.

    +

    Historical — these were the open questions before the spec was written; all five have since been answered in code. Kept here for the reasoning trail. 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.

    @@ -1333,7 +1353,7 @@ bind<ISignUpUseCase>(SYMBOL).toDynamicValue<

    Colophon

    Fraunces (display + body) & JetBrains Mono (code) over Linseed Mill.

    -

    An explainer of a proposed enforcement system. Not yet implemented. Read alongside the ADRs.

    +

    Shipped — this page now reflects the live system. defineFeature, wireUseCase, assertFeatureConformance, the fifteen ESLint rules, and the pnpm conformance / pnpm fallow gates are all in the repo. Read alongside the ADRs.

    Sibling explainers

    @@ -1719,6 +1739,52 @@ bind<ISignUpUseCase>(SYMBOL).toDynamicValue< } }`, }, + fallow: { + title: 'Whole-codebase fallow audit', + latency: '~30–60s', + tag: 'post-eslint', + heading: 'The view that no single rule can take.', + body: ` +

    The four layers above all read a single file, a single binding, or the cross-feature event graph. None of them are shaped to ask the corpus-level question: which exports nobody imports? which functions are duplicated? which imports form a cycle? which file is now too complex to safely touch? Fallow is that fifth eye.

    +

    pnpm fallow wraps knip (dead exports / unused files), jscpd (duplicate code), madge (circular deps), and a complexity scan into one whole-codebase pass that runs after ESLint. pnpm fallow:audit is the AI-change audit variant — run it before committing agent-authored work to catch drift the per-file rules can't see.

    +
      +
    • Catches what shape-rules miss: accretion, not contradiction.
    • +
    • Runs post-ESLint locally; gated in CI; surfaced as a pre-commit advisory.
    • +
    • Slow enough that it doesn't fire on save — fast enough to run before every commit.
    • +
    `, + catches: 'dead exports & unused files; duplicate code blocks; circular module dependencies; complexity hotspots; AI-change audit drift.', + codeLang: 'pnpm fallow — composed audit', + code: `// scripts/fallow.ts — orchestrates the whole-codebase audit. +import { runKnip } from "./fallow/knip"; // dead exports + files +import { runJscpd } from "./fallow/jscpd"; // duplicate blocks +import { runMadge } from "./fallow/madge"; // circular deps +import { runComplex } from "./fallow/complexity"; // hotspots + +const findings = [ + ...await runKnip(), + ...await runJscpd(), + ...await runMadge(), + ...await runComplex(), +]; + +if (findings.length > 0) { + for (const f of findings) report(f); + process.exit(1); +} + +// Sample output an agent reads: +fallow: knip + packages/auth/src/repositories/legacy.repository.ts + Export 'findByLegacyId' is declared but never imported. + +fallow: jscpd + packages/blog/src/use-cases/list-articles.usecase.ts:18-44 + packages/media/src/use-cases/list-media.usecase.ts:22-48 + 27-line duplicate block. Extract or accept. + +fallow: madge + Cycle: features/auth → core-shared → features/auth (via di/bind-production)`, + }, }; function show(layerKey) { diff --git a/docs/architecture/vertical-feature-spec.md b/docs/architecture/vertical-feature-spec.md index ccca293..5fde81c 100644 --- a/docs/architecture/vertical-feature-spec.md +++ b/docs/architecture/vertical-feature-spec.md @@ -111,12 +111,19 @@ repo/ storybook/ # unchanged; updates imports from @repo/ui → @repo/core-ui packages/ - # ─── CORE (foundation, tagged "core") ─── + # ─── CORE — must-have (tagged "core" / "core-composition") ─── core-shared/ # generic primitives (no business knowledge) core-cms/ # Payload composition only (aggregates feature cms exports) core-api/ # tRPC composition only (aggregates feature api exports) + # ─── CORE — optional (scaffold via `pnpm turbo gen core-package `) ─── core-trpc/ # frontend tRPC platform (client, providers per framework) core-ui/ # design-system primitives (atoms/molecules/templates) + core-events/ # in-memory + Payload-backed event bus + job queue (ADR-015) + core-realtime/ # Socket.IO broadcaster + handler registry (ADR-016) + core-audit/ # DPA-compliant audit logging (ADR-018) + core-analytics/ # product analytics capture channel (ADR-024) + core-consent/ # consent + cookie banner (ADR-025) + core-dsr/ # data-subject-rights — export/delete/rectify/restrict (ADR-025) # ─── FEATURES (business capabilities, tagged "feature") ─── auth/ # Users collection + sign-in/up/out @@ -126,8 +133,9 @@ repo/ navigation/ # header global # ─── TOOLING (tagged "tooling") ─── - core-eslint/ - core-typescript/ + core-eslint/ # ESLint preset + the 15 conformance rules + boundary rules + core-typescript/ # tsconfig presets (base, react-library, nextjs) + core-testing/ # factories, contract suites, recording test doubles docs/ architecture/ @@ -135,7 +143,7 @@ repo/ dependency-flow.md # rewritten vertical-feature-spec.md # copy of source spec decisions/ - adr-001 … adr-009 # five existing + four new (see §10) + adr-001 … adr-NNN # 25 ADRs at time of writing — see §11 and `docs/decisions/` guides/ adding-a-feature.md # rewritten testing-strategy.md # rewritten @@ -469,6 +477,8 @@ A feature may import another feature's **public exports** — its `@repo/ **Historical.** This section captures the ADR strategy at the time of the vertical-feature refactor — 5 existing ADRs (001–005), 4 new ADRs (006–009), and the 2 post-spec ADRs (012–013). The **canonical, current ADR set** lives in `docs/decisions/` and now spans **25 ADRs** — adding boundaries (010), TDD foundation (011), instrumentation + OpenTelemetry (014, 017), events / realtime / audit (015, 016, 018), Sandcastle (019), coverage (020), hybrid versioning (021), library policy + CI security (022, 023), product analytics (024), and the EU compliance baseline (025). Read `docs/decisions/` for the authoritative list; the tables below are preserved as the refactor's original record. + ### 11.1 Existing ADRs | File | Action | Notes |