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.
208 lines
17 KiB
Markdown
208 lines
17 KiB
Markdown
---
|
||
id: binder-wrap-helper
|
||
title: Collapse binder duplication via wireUseCase helper
|
||
type: prd
|
||
status: approved
|
||
author: danijel
|
||
elicitation-session: improve-codebase-architecture-2026-05-13
|
||
created: 2026-05-13T00:00:00Z
|
||
updated: 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 30–79 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 enforcement** — `Instrumented` / `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 coverage** — `core-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:
|
||
|
||
```ts
|
||
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:
|
||
|
||
```ts
|
||
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.
|