docs(architecture): refresh explainers and spec to the shipped system
Some checks failed
CI / typecheck + lint + boundaries + test + build (push) Has been cancelled
CI / Playwright e2e (push) Has been cancelled
CI / Storybook smoke tests + visual regression (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Coverage snapshot / snapshot (push) Has been cancelled
Release Please / release-please (push) Has been cancelled
Sentry PII guard (R31) / pii-guard (push) Has been cancelled
Mutation testing (nightly) / mutate (push) Has been cancelled
Library trace revalidation (weekly) / revalidate (push) Has been cancelled

Bring docs/architecture/ in line with the current repo:

- feature-conformance-explainer.html: drop the "proposed / not yet
  implemented" framing — the system is shipped. Four enforcement points
  become five (adds `pnpm fallow` as the whole-codebase audit). Manifest
  playground shows `coverage`, `analyticsEvents`, `rateLimit`,
  `requiresConsent`. Milestone / anchor / open-question sections kept
  but marked historical.
- agent-first-workflow-and-conformance.md: four → five enforcement
  layers; layer table gains the Fallow row.
- di-explainer.html: bind-production sample rewritten to show
  wireUseCase() + assertFeatureConformance() + the full wrapper stack
  (span → capture → audit? → analytics? → consent? → rateLimit?).
- data-flow-explainer.html: same bind-production refresh for the
  data-flow narrative.
- audit-and-compliance-explainer.html: AuditAction enum 6 → 10 values
  (CONSENT_GRANT / WITHDRAW / RESTRICT / UNRESTRICT);
  BindProductionContext example gains analytics, consentFactory,
  rateLimit.
- vertical-feature-spec.md: §5 layout lists the 8 optional cores plus
  core-testing; §9.5 hedges the turbo.json snippet against the live
  file; §10.4 drops the dated "360 tests" metric for the ADR-020
  coverage architecture; §11 gains a historical lead-in pointing at
  docs/decisions/ as the canonical 25-ADR set.
This commit is contained in:
2026-05-23 14:06:03 +02:00
parent b455ae8018
commit 0748f9e5ed
6 changed files with 251 additions and 51 deletions

View File

@@ -756,7 +756,7 @@ footer .colophon {
<div class="cast-tag">file 04 · prod swap</div>
<h3>bind-production.ts</h3>
<p class="role">Replaces the mock repository binding with a real Payload-backed one at app boot.</p>
<p>Exports <code>bindProductionBlog(ctx: BindProductionContext)</code>. Destructures <code>{ config, tracer, logger, bus, queue, realtime, realtimeRegistry }</code> from <code>ctx</code>. Function body: <code>blogContainer.unbind(symbol)</code> if already bound, then <code>.bind(symbol).toConstantValue(new ArticlesRepository(config, tracer, logger))</code>. Use cases and controllers are wrapped via <code>withSpan(withCapture(factory(deps)))</code> at bind time so they inherit instrumentation without changing their factory bodies. The optional <code>bus</code> and <code>queue</code> fields come from the app's <code>resolveEventsAndJobs*</code> step (ADR-015) and feed the <code>// &lt;gen:event-handlers&gt;</code> / <code>// &lt;gen:jobs&gt;</code> injection sites. <code>BindProductionContext</code> is imported from <code>@repo/core-shared/di</code> — features never import the optional packages directly for the binder signature.</p>
<p>Exports <code>bindProductionBlog(ctx: BindProductionContext)</code>. Destructures the ctx (always: <code>config, tracer, logger</code>; optional: <code>bus, queue, realtime, realtimeRegistry, metrics, auditLog, analytics, consentFactory, rateLimit</code>). Function body: <code>blogContainer.unbind(symbol)</code> if already bound, then <code>.bind(symbol).toConstantValue(new ArticlesRepository(config, tracer, logger))</code>. Use cases are wired via <code>wireUseCase({...})</code> from <code>@repo/core-shared/conformance</code> — which composes <code>withSpanwithCapture → withAudit? → withAnalytics? → withConsent? → withRateLimit? → factory(deps)</code> (each <code>?</code> wrapper applies only when the manifest declares that channel). Controllers are wrapped manually with <code>withSpan(withCapture(...))</code>. The file ends with <code>assertFeatureConformance(container, manifest, symbolMap, ctx)</code> — the boot gate that refuses to start on manifest ↔ binding drift. The optional <code>bus</code> and <code>queue</code> fields come from the app's <code>resolveEventsAndJobs*</code> step (ADR-015) and feed the <code>// &lt;gen:event-handlers&gt;</code> / <code>// &lt;gen:jobs&gt;</code> injection sites. <code>BindProductionContext</code> is imported from <code>@repo/core-shared/di</code> — features never import the optional packages directly for the binder signature.</p>
<div class="runs-when"><strong>When it runs:</strong> called from app boot (<code>apps/web-next/src/server/bind-production.ts</code>) when <code>USE_DEV_SEED ≠ "true"</code> AND Payload config is resolvable.</div>
</div>
@@ -924,15 +924,59 @@ footer .colophon {
<div class="step-num">06</div>
<div class="step-body">
<div class="when">App boot · per feature</div>
<h4>Each binder <em>swaps the repo binding</em>.</h4>
<p>Whichever binder runs, it does the same thing for the repository symbol: <code>unbind</code> the old binding, <code>bind</code> a new <code>.toConstantValue(impl)</code>. Use case and controller bindings stay untouched — they keep their <code>.toDynamicValue</code> closures and will pick up the new repo on next resolve.</p>
<h4>Each binder <em>swaps the repo binding</em>, then re-wires use cases + controllers.</h4>
<p>The binder destructures <code>ctx</code>, unbinds + rebinds the repository as <code>.toConstantValue(impl)</code>, then re-wires every use case through <code>wireUseCase({ ... })</code> (which composes <code>withSpan → withCapture → withAudit? → withAnalytics? → withConsent? → withRateLimit? → factory(deps)</code> and binds the result). Controllers are wrapped with <code>withSpan(withCapture(...))</code> at bind time. The binder ends with <code>assertFeatureConformance(container, manifest, symbolMap, ctx)</code>, which refuses to boot if any use-case binding is missing a brand the manifest required.</p>
</div>
<pre class="code" data-lang="typescript // packages/blog/src/di/bind-production.ts (or bind-dev-seed.ts)"><span class="k">if</span> (<span class="n">blogContainer</span>.<span class="n">isBound</span>(<span class="n">BLOG_SYMBOLS</span>.<span class="n">IArticlesRepository</span>)) {
<span class="n">blogContainer</span>.<span class="n">unbind</span>(<span class="n">BLOG_SYMBOLS</span>.<span class="n">IArticlesRepository</span>);
}
<span class="n">blogContainer</span>
.<span class="n">bind</span>(<span class="n">BLOG_SYMBOLS</span>.<span class="n">IArticlesRepository</span>)
.<span class="n">toConstantValue</span>(<span class="k">new</span> <span class="t">ArticlesRepository</span>(<span class="n">config</span>)); <span class="c">// or new MockArticlesRepository() with seed data</span></pre>
<pre class="code" data-lang="typescript // packages/auth/src/di/bind-production.ts (illustrative excerpt)"><span class="k">export function</span> <span class="n">bindProductionAuth</span>(<span class="n">ctx</span>: <span class="t">BindProductionContext</span>): <span class="t">void</span> {
<span class="k">const</span> { <span class="n">config</span>, <span class="n">tracer</span>, <span class="n">logger</span>, <span class="n">bus</span>, <span class="n">consentFactory</span> } = <span class="n">ctx</span>;
<span class="c">// 1. Swap the repository binding.</span>
<span class="k">if</span> (<span class="n">authContainer</span>.<span class="n">isBound</span>(<span class="n">AUTH_SYMBOLS</span>.<span class="n">IUsersRepository</span>)) {
<span class="n">authContainer</span>.<span class="n">unbind</span>(<span class="n">AUTH_SYMBOLS</span>.<span class="n">IUsersRepository</span>);
}
<span class="k">const</span> <span class="n">repo</span> = <span class="k">new</span> <span class="t">UsersRepository</span>(<span class="n">config</span>, <span class="n">tracer</span>, <span class="n">logger</span>);
<span class="n">authContainer</span>.<span class="n">bind</span>&lt;<span class="t">IUsersRepository</span>&gt;(<span class="n">AUTH_SYMBOLS</span>.<span class="n">IUsersRepository</span>).<span class="n">toConstantValue</span>(<span class="n">repo</span>);
<span class="c">// 2. Wire each use case through the conformance helper.</span>
<span class="c">// wireUseCase composes withSpan → withCapture → withAudit? → withAnalytics?</span>
<span class="c">// → withConsent? → withRateLimit? → factory(deps), then binds the result.</span>
<span class="k">const</span> <span class="n">wrappedSignIn</span> = <span class="n">wireUseCase</span>({
<span class="n">container</span>: <span class="n">authContainer</span>,
<span class="n">symbol</span>: <span class="n">AUTH_SYMBOLS</span>.<span class="n">ISignInUseCase</span>,
<span class="n">factory</span>: <span class="n">signInUseCase</span>,
<span class="n">deps</span>: [<span class="n">repo</span>, <span class="n">authService</span>, <span class="n">ctx</span>.<span class="n">rateLimit</span> ?? <span class="k">new</span> <span class="t">NoopRateLimit</span>()],
<span class="n">feature</span>: <span class="s">"auth"</span>, <span class="n">layer</span>: <span class="s">"use-case"</span>, <span class="n">name</span>: <span class="s">"signIn"</span>,
<span class="n">tracer</span>, <span class="n">logger</span>,
<span class="n">rateLimit</span>: <span class="n">ctx</span>.<span class="n">rateLimit</span> ?? <span class="k">new</span> <span class="t">NoopRateLimit</span>(), <span class="c">// manifest declares rateLimit</span>
});
<span class="k">const</span> <span class="n">wrappedSignUp</span> = <span class="n">wireUseCase</span>({
<span class="n">container</span>: <span class="n">authContainer</span>,
<span class="n">symbol</span>: <span class="n">AUTH_SYMBOLS</span>.<span class="n">ISignUpUseCase</span>,
<span class="n">factory</span>: <span class="n">signUpUseCase</span>,
<span class="n">deps</span>: [<span class="n">repo</span>, <span class="n">authService</span>, <span class="n">bus</span>, <span class="n">consentFactory</span>],
<span class="n">feature</span>: <span class="s">"auth"</span>, <span class="n">layer</span>: <span class="s">"use-case"</span>, <span class="n">name</span>: <span class="s">"signUp"</span>,
<span class="n">tracer</span>, <span class="n">logger</span>,
});
<span class="c">// 3. Wrap controllers with span + capture at bind time.</span>
<span class="n">authContainer</span>
.<span class="n">bind</span>(<span class="n">AUTH_SYMBOLS</span>.<span class="n">ISignInController</span>)
.<span class="n">toConstantValue</span>(
<span class="n">withSpan</span>(<span class="n">tracer</span>, { <span class="n">name</span>: <span class="s">"auth.signIn"</span>, <span class="n">op</span>: <span class="s">"controller"</span> },
<span class="n">withCapture</span>(<span class="n">logger</span>, { <span class="n">feature</span>: <span class="s">"auth"</span>, <span class="n">layer</span>: <span class="s">"controller"</span>, <span class="n">name</span>: <span class="s">"auth.signIn"</span> },
<span class="n">signInController</span>(<span class="n">wrappedSignIn</span>),
),
),
);
<span class="c">// 4. Boot-time conformance check — refuses to start on missing brands.</span>
<span class="n">assertFeatureConformance</span>(
<span class="n">authContainer</span>,
<span class="n">authManifest</span>,
{ <span class="n">signIn</span>: <span class="n">AUTH_SYMBOLS</span>.<span class="n">ISignInUseCase</span>, <span class="n">signUp</span>: <span class="n">AUTH_SYMBOLS</span>.<span class="n">ISignUpUseCase</span> },
<span class="n">ctx</span>,
);
}</pre>
</div>
<div class="lifecycle-stage">
@@ -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
└─ // &lt;gen:event-handlers&gt; / // &lt;gen:jobs&gt; / // &lt;gen:realtime-handlers&gt; / // &lt;gen:audit-hooks&gt; injection sites</code></pre>
<p class="trace-p"><strong>Why per-feature containers also get the binding:</strong> repository classes resolve TRACER/LOGGER through the container; controllers and use cases receive instrumentation via the bind-time wrapper instead.</p>
</div>
</div>
<h3 class="trace-h3" style="margin-top: 36px;">Two wrappers, applied as a sandwich</h3>
<p class="trace-p"><code>withSpan</code> and <code>withCapture</code> are higher-order functions that take a <code>(args) =&gt; Promise&lt;R&gt;</code> and return the same shape. The binders compose them: <code>withSpan(withCapture(factory(deps)))</code>. Span is outermost so an errored span's timing reflects the capture-and-rethrow.</p>
<h3 class="trace-h3" style="margin-top: 36px;">The wrapper stack, applied as a sandwich</h3>
<p class="trace-p">Each wrapper is a higher-order function taking <code>(args) =&gt; Promise&lt;R&gt;</code> and returning the same shape. For use cases, <code>wireUseCase</code> composes them in this order (outermost → innermost) and binds the result:</p>
<pre class="trace-tree"><code>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)</code></pre>
<p class="trace-p"><code>withSpan</code> is always outermost so an errored span's timing reflects the capture-and-rethrow. The four optional wrappers (<code>withAudit</code>, <code>withAnalytics</code>, <code>withConsent</code>, <code>withRateLimit</code>) are conditionally inserted by <code>wireUseCase</code> based on the use case's manifest entry — a use case without any of those declarations gets the plain <code>withSpan(withCapture(factory(deps)))</code> sandwich. Controllers always use just <code>withSpan(withCapture(...))</code>.</p>
<table class="capture-rules">
<thead>
<tr><th>Wrapper</th><th>What it does</th><th>Where it fires</th></tr>
<tr><th>Wrapper</th><th>What it does</th><th>Applied when</th></tr>
</thead>
<tbody>
<tr>
<td><code>withSpan(tracer, opts, fn)</code></td>
<td>Calls <code>tracer.startSpan(opts, () =&gt; fn(...))</code>. Pure delegation — no error handling of its own; status-on-error logic lives in the tracer impl.</td>
<td>Around every use case + controller, at DI bind time</td>
<td>Always — every use case + every controller</td>
</tr>
<tr>
<td><code>withCapture(logger, tags, fn)</code></td>
<td>On throw: checks <code>__sentryReported</code>; if not set, calls <code>logger.captureException(err, { tags })</code>, marks the flag, re-throws. If already set, just re-throws.</td>
<td>Around every use case + controller, inside the span wrapper</td>
<td>Always — every use case + every controller, inside the span wrapper</td>
</tr>
<tr>
<td><code>withAudit</code></td>
<td>Forwarding wrapper that attaches the <code>__audited</code> brand so the conformance gate sees the binding is audit-aware. Automated audit recording from manifest declarations is reserved.</td>
<td>Use case only, when <code>manifest.useCases[x].audits</code> is non-empty</td>
</tr>
<tr>
<td><code>withAnalytics</code></td>
<td>Forwarding wrapper that attaches the <code>__analyzed</code> brand. Automated analytics recording from manifest declarations is reserved.</td>
<td>Use case only, when <code>manifest.useCases[x].analyticsEvents</code> is declared</td>
</tr>
<tr>
<td><code>withConsent</code></td>
<td>Gates execution on the consent categories declared by the manifest; throws / no-ops when consent is missing.</td>
<td>Use case only, when <code>manifest.useCases[x].requiresConsent</code> is declared</td>
</tr>
<tr>
<td><code>withRateLimit</code></td>
<td>Innermost of the optional wrappers — consults the rate-limit budgets and rejects before <code>factory(deps)</code> runs.</td>
<td>Use case only, when <code>manifest.useCases[x].rateLimit</code> is declared</td>
</tr>
</tbody>
</table>