Files
agentic-dev-template/docs/work/prds/binder-wrap-helper.prd.md
Danijel Martinek bae4b66fa4 refactor(work): drop date prefixes + move _state.json into _system/
Convention shift: epic folders + PRD filenames + frontmatter id
fields are now bare slugs. The created: timestamp (Phase 2) carries
the date; folder names don't repeat it. A future <task-id>-<slug>
shape (e.g. ClickUp) lands cleanly when that integration ships.

Renames (git mv preserves history):
- docs/work/2026-05-13-binder-wrap-helper/
    -> docs/work/binder-wrap-helper/
- docs/work/2026-05-14-library-evaluation-policy/
    -> docs/work/library-evaluation-policy/
- docs/work/2026-05-14-ci-security-and-supply-chain/
    -> docs/work/ci-security-and-supply-chain/
- docs/work/prds/2026-05-13-binder-wrap-helper.prd.md
    -> docs/work/prds/binder-wrap-helper.prd.md
- docs/work/prds/2026-05-13-coverage-architecture.prd.md
    -> docs/work/prds/coverage-architecture.prd.md
- docs/work/prds/2026-05-14-library-evaluation-policy.prd.md
    -> docs/work/prds/library-evaluation-policy.prd.md
- docs/work/prds/2026-05-14-ci-security-and-supply-chain.prd.md
    -> docs/work/prds/ci-security-and-supply-chain.prd.md

Frontmatter updates inside the renamed files: epic id, epic prd,
story epic, PRD id, PRD builds-on all drop date prefixes.

System folder + state file move:
- New docs/work/_system/ holds framework-managed state.
- docs/work/_state.json -> docs/work/_system/_state.json.
- state-builder.mjs adds _system to SKIP_FOLDERS.
- cli.mjs + state-sync-guard.mjs + .husky/pre-commit point at the
  new path.

template-reset-v1 epic deleted entirely (one-off cleanup epic from
the pre-date-convention era; status was already done).

Generator-template updates (so new artifacts ship in the right
shape):
- .sandcastle/decomposer.prompt.md emits bare-slug folder names +
  ISO created: timestamp.
- .claude/skills/to-prd/SKILL.md template uses bare-slug filename +
  bare-slug id field + ISO created: timestamp.

Doc reference updates: glossary, runbook, agent-first-workflow-
and-conformance, reviewer prompt, ADR-020, ADR-022, ADR-023 all
point at the new paths/slugs.
2026-05-14 21:16:51 +02:00

17 KiB
Raw Blame History

id, title, type, status, author, elicitation-session, created, updated
id title type status author elicitation-session created updated
binder-wrap-helper Collapse binder duplication via wireUseCase helper prd approved danijel improve-codebase-architecture-2026-05-13 2026-05-13T00:00:00Z 2026-05-14T19:16:52.691Z

Problem

Every feature's bind-production.ts and bind-dev-seed.ts independently inline the same withSpan(tracer, opts, withCapture(logger, tags, factory(deps))) wrapping for each use case. Concretely:

  • Five of pnpm fallow's top-ten clone groups come from binder pairs (auth, blog, media, marketing-pages, navigation).
  • Per binder pair the duplication runs 3079 lines.
  • Adding a use case means editing two parallel blocks (production + dev-seed); forgetting one is silent drift not caught until pnpm dev boots (assertFeatureConformance fires) or coverage gates fail.
  • The wrapping shape is structurally identical across features and modes: span options, capture tags, and the factory call are the only things that vary per use case.

The cost is paid at three sites:

  1. Authoring — every new use case requires writing ~12 lines of mechanical boilerplate, twice.
  2. Reading — every diff that touches a binder is dominated by repetitive shape; signal-to-noise is poor.
  3. Refactoring — changes to the wrapping shape (e.g., adding a new brand, adjusting span attributes) require N × 2 simultaneous edits across the workspace.

The wrapping itself is correct and stays at DI bind time per ADR-014 / ADR-017. What's missing is the abstraction: a single point of composition that the binders call instead of inline.

Goal

Introduce a wireUseCase(...) helper in @repo/core-shared/conformance/ that encapsulates the withSpan + withCapture (+ optional withAudit) composition. Refactor all five features' binders to call the helper. The wrapping shape becomes a single source of truth; per-feature binders shrink to their decision content (which adapter to bind for which mode) plus a list of wireUseCase calls.

In scope

  • New module: @repo/core-shared/conformance/wire-use-case.ts exporting the helper + its types.
  • Tests: a colocated wire-use-case.test.ts covering the helper's behaviour (span composition, capture composition, audit composition when applicable, brand attachment).
  • Migration: all five features' bind-production.ts and bind-dev-seed.ts files (10 files) updated to call wireUseCase instead of inlining the wrappers.
  • Existing binder-level tests must continue to pass (smoke-level coverage for the per-feature wiring).
  • The assertFeatureConformance boot-time assertion (already at the tail of each binder) continues to fire on drift — the helper does not bypass it.
  • Generator update: pnpm turbo gen feature template emits wireUseCase calls in the scaffolded binders, not the longhand inline form.

Out of scope

  • Sub-shape (b) (pre-wired factory exports at the use-case file level) — explicitly rejected during the architecture grilling that produced this PRD. Helper-inside-binder (sub-shape (a)) keeps the wrapping a binder concern, which preserves ADR-008's per-feature DI isolation.
  • Hoisting brand attachment out of withSpan / withCapture / withAudit — the helper composes these existing wrappers; it does not replace them.
  • Changing the manifest schema — manifests stay as they are. The helper reads what the binder passes; it does not consult the manifest directly.
  • Apps' bindAll() dispatcher — the apps/web-next/src/server/bind-production.ts (and equivalent for tanstack/cms) stays untouched. It calls per-feature binders, which is unchanged.
  • Repository/service binding patterns — only use-case bindings (the wrapped factories) move through the helper. Repository and service bindings stay as direct .toConstantValue() calls.
  • Controller bindings — covered by the helper too (controllers are also wrapped the same way per ADR-013), but if scope creep is a concern, controllers can land in a follow-up.

Constraints

  • ADR-008 — per-feature DI containers. The helper takes the container as a parameter; it does NOT introduce a global registry.
  • ADR-012, ADR-013 — factory-function use cases + controllers. The helper consumes factories of shape (deps) => async (input) => output; it does NOT change the factory shape.
  • ADR-014, ADR-017 — instrumentation interfaces (ITracer, ILogger) + OTel substrate. The helper composes the existing withSpan / withCapture wrappers; it does NOT import @opentelemetry/sdk-* or @sentry/* directly (rule R52 enforces this; the helper lives in core-shared/conformance/, not core-shared/instrumentation/otel/).
  • ADR-018 — audit logging. The helper composes withAudit when an audit emitter is provided in the call.
  • TS brand-slot enforcementInstrumented / Captured / Audited brands must remain present at the bind-time return value. The helper's return type carries the full brand stack so assertFeatureConformance continues to find the brands at boot.
  • Conformance ESLint rules — the five rules in core-eslint/rules/ (manifest must have a file, use case must have a test, etc.) keep firing unchanged. The helper does not interact with ESLint.
  • pnpm fallow clone-group baseline — after the migration, the five top-ten clone groups should disappear. The PR's coverage check + fallow audit verify this.
  • L0 coveragecore-shared/conformance/wire-use-case.ts lives in core-shared, not a feature, so its L0 band is core-shared's vitest config's defaults (80/75/80/80 baseline). The new file is small enough that 100% is achievable; aim for 100%.
  • Generator-first — the feature generator template must emit the new call shape; the legacy inline form is no longer emitted.
  • Hybrid versioning (ADR-021) — this PR touches core-shared (bumps the root template version) and all five feature packages (bumps each feature's package version). The release-please rolling PR will reflect that.

Success criteria

  • wireUseCase lives at packages/core-shared/src/conformance/wire-use-case.ts and is exported from @repo/core-shared/conformance.
  • A colocated wire-use-case.test.ts covers: span composition (Instrumented brand attached), capture composition (Captured brand attached), audit composition (Audited brand attached when audit emitter passed), and the no-audit branch.
  • All ten binder files (auth × 2, blog × 2, media × 2, marketing-pages × 2, navigation × 2) call wireUseCase for their use cases (and controllers, if covered in this PRD).
  • pnpm typecheck && pnpm test && pnpm lint && pnpm conformance && pnpm fallow:audit green.
  • pnpm test -- --coverage green; per-feature L0 bands hold (100% on entities/use-cases/controllers).
  • pnpm coverage:diff reports pass against origin/main.
  • pnpm fallow dupes shows the five binder-pair clone groups have disappeared (down from the current 5-in-top-10 footprint).
  • pnpm dev boots — assertFeatureConformance accepts every wired use case (brands present, manifest entries match).

User stories

  1. As a feature author, I want adding a use case to be a single-line binder edit (one wireUseCase call per mode) instead of a ~12-line inline wrapper block, so that I don't pay boilerplate cost for mechanical work.
  2. As a future architect, I want the wrapping shape (withSpan + withCapture + optional withAudit) to live in one place, so that adjusting it across the workspace is a single-file change.
  3. As an AI implementer agent, I want the generator to emit the helper-based shape, so that scaffolded features ship with the consolidated wrapping by default and don't drift back to inline form.
  4. As an AI reviewer agent, I want the binders to read as decision content (which adapter, which mode) rather than mechanical boilerplate, so that diff review surfaces real changes.
  5. As a maintainer of core-shared, I want the helper's behaviour fully unit-tested in isolation, so that changes to the wrapping composition are caught at pnpm test rather than at every feature's downstream binder test.
  6. As a CI consumer, I want pnpm fallow dupes to no longer flag the five binder-pair clone groups, so that fallow output's signal-to-noise improves.
  7. As a template adopter, I want pnpm turbo gen feature to scaffold binders that use the helper, so that new features participate in the consolidated wrapping pattern from the first commit.
  8. As an on-call engineer, I want assertFeatureConformance to keep its current "missing brand fails boot" semantics, so that the refactor doesn't weaken the boot-time conformance gate.

Implementation decisions

The helper's call shape

The helper takes an options object covering everything the inline form expresses today:

  • the DI container (so the helper performs the bind(...).toConstantValue(...) step)
  • the DI symbol
  • the use-case factory function + its deps tuple
  • the feature name, layer, and use-case name (used to derive both span.name + capture tags)
  • the tracer + logger (always required)
  • the auditLog + audit input schema (optional — only when the manifest declares audits for this use case)

A second variant (or a wireController peer) covers controllers, which share the wrapping shape but have a different brand identity per ADR-013.

The helper returns the brand-stacked wired value so callers can hold a reference (e.g., for tests that bypass the container).

Exact API surface (signatures, generic parameters, the deps-tuple typing strategy, whether one entry point covers both use cases and controllers or two peer helpers do) lands during the implementation TDD cycle. The architecture grilling fixed the shape, not the exact signature.

Where the helper lives

packages/core-shared/src/conformance/wire-use-case.ts, alongside the existing define-feature.ts, coverage.ts, assert-bindings.ts. Exported through @repo/core-shared/conformance. NOT in core-shared/instrumentation/ (the helper composes interfaces from there but doesn't depend on the OTel SDK; placing it in instrumentation/ would muddy the vendor-isolation boundary).

Brand attachment

Brands continue to attach inside withSpan / withCapture / withAudit (the existing wrappers). The helper just orders the composition correctly (withSpan outermost per ADR-014). No brand-attachment logic moves into the helper.

Migration shape per binder file

Inline blocks like:

const wrappedSignIn: ProductionUseCase<
  SignInInput,
  SignInOutput,
  AuthManifest["useCases"]["signIn"]
> = withSpan(
  tracer,
  { name: "auth.signIn", op: "use-case" },
  withCapture(
    logger,
    { feature: "auth", layer: "use-case", name: "auth.signIn" },
    signInUseCase(repo, authService),
  ),
);
authContainer
  .bind<ISignInUseCase>(AUTH_SYMBOLS.ISignInUseCase)
  .toConstantValue(wrappedSignIn);

become:

wireUseCase({
  container: authContainer,
  symbol: AUTH_SYMBOLS.ISignInUseCase,
  factory: signInUseCase,
  deps: [repo, authService],
  feature: "auth",
  layer: "use-case",
  name: "signIn",
  tracer,
  logger,
});

Span name (auth.signIn) is derived from feature + "." + name. Capture tags are derived from feature + layer + name. The container unbind + bind pattern (already idempotent per ADR-008) is encapsulated.

Audit-bearing use cases

When the manifest declares audits: [...] for a use case, the binder passes auditLog + the audit schema into wireUseCase. The helper composes withAudit inside withCapture. When audits: [], the audit composition is skipped (no Audited brand attached, no audit emission).

Generator template

turbo/generators/templates/feature/src/di/bind-production.ts.hbs and bind-dev-seed.ts.hbs (assuming they exist; if not, equivalent files) get updated to emit the wireUseCase call shape. The anchor <gen:use-cases> continues to mark the injection point.

Per-feature impact

Feature Use cases to migrate Audit-bearing?
auth 3 (signIn, signUp, signOut) check manifest
blog 3 (getArticles, getArticleBySlug, createArticle) check manifest
media 3 (getMedia, listMedia, deleteMedia) check manifest
marketing-pages 2 (getPageBySlug, getSiteSettings) check manifest
navigation 1 (getHeader) check manifest

Total: 12 use cases × 2 modes = 24 inline wrap sites to migrate.

Testing decisions

  • What "good test" means here: tests assert on observable outcomes at the helper's interface (the wired value's behaviour when called, the brands attached, the container's binding shape) — not on internal composition order.
  • Helper-level unit tests in wire-use-case.test.ts:
    • Wiring without audit composes withSpan(withCapture(factory)); both Instrumented + Captured brands present.
    • Wiring with audit composes withSpan(withCapture(withAudit(factory))); all three brands present.
    • Span name derivation: <feature>.<name> for use cases; check the convention matches existing behaviour.
    • Capture tags: { feature, layer, name } correctly applied.
    • Container binding: symbol → wired value; idempotent re-bind (unbind + bind) when called twice.
    • Brand presence asserted via the existing isInstrumented / isCaptured / isAudited runtime helpers from core-shared/conformance/brand-runtime.ts.
  • Per-feature binder tests: should continue to pass without modification. If they assert exact shape of the wrapping internals (rather than observable behaviour), refactor them to assert through the helper's contract.
  • Integration tests: existing feature.test.ts and *-flow.feature.test.ts files (e.g., packages/auth/tests/sign-in-flow.feature.test.ts) must continue to pass — they exercise the wired chain end-to-end.
  • Coverage: wire-use-case.ts should hit 100% on entities/use-cases/controllers band equivalents (it lives in core-shared, baseline 80/75/80/80, but the file is small and a single helper should be exhaustively tested).
  • Prior art: the existing wrappers' tests in core-shared/instrumentation/ and core-audit/with-audit.test.ts are the closest pattern to mirror for the new helper's test file.

Open questions

  • Q1: One helper or two (use cases + controllers)? Use cases and controllers share the wrapping shape but the brand identity differs. Recommended: start with one wireUseCase that takes a layer: "use-case" | "controller" discriminator; if the controller path diverges meaningfully during implementation, split into wireController then.
  • Q2: Should the helper consult the manifest directly to decide whether to apply withAudit? The manifest declares audits: [...] per use case. The binder could pass this in, OR the helper could read the manifest. Recommended: binder passes — keeps the helper a pure composition concern; avoids the helper depending on the manifest schema.
  • Q3: Does the helper handle the unbind + bind idempotency, or do callers? Recommended: helper handles it. Every existing binder already does if (container.isBound(sym)) container.unbind(sym); container.bind(sym).toConstantValue(...); — consolidate.
  • Q4: Where do tracer + logger come from inside the helper? Passed in by the caller (the binder already has them via ctx). The helper takes them as parameters; it does not reach into a global.

Out of scope (deferred)

  • Pre-wired factory exports (sub-shape (b) from the architecture grilling) — rejected for this iteration; revisit only if the helper-based approach fails to deliver the locality benefit.
  • A wireRepository / wireService peer for repositories and services. Those bindings are direct .toConstantValue(new RealOrMock(...)) calls and aren't structurally duplicated like the use cases; deepening them is a separate ADR conversation.
  • The dupes that fallow surfaces outside the binders (e.g., the 117-line clone between init-client.ts and init-client-react.ts in core-shared/instrumentation/sentry/) — own initiative, not part of this PRD.
  • Identity-presenter cleanup (Candidate 2 from the architecture skill's exploration) — separate PRD.

Further notes

  • This PRD is the output of the improve-codebase-architecture skill's grilling loop on Candidate 1, sub-shape (a). The companion candidates (2 / 3 / 4 / 5 housekeeping) remain as future deepening targets.
  • After the implementation epic ships, pnpm fallow dupes will become a cleaner gate — losing five top-ten clone groups should let it surface remaining duplication signal more clearly.
  • The release-please rolling PR will bump both template-vertical (root, for the generator + core-shared changes) and all five feature packages (for their binder changes). Per ADR-021's commit-path bump targeting, this is expected.