5 Commits

Author SHA1 Message Date
danijel-lf
0a34b45bb7 feat(auth): implement session methods with Payload-backed JWT
Some checks failed
CI / typecheck + lint + boundaries + test + build (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
CI / Playwright e2e (push) Has been cancelled
CI / Storybook smoke tests + visual regression (push) Has been cancelled
Mutation testing (nightly) / mutate (push) Has been cancelled
Library trace revalidation (weekly) / revalidate (push) Has been cancelled
Replace NotImplementedError stubs in AuthenticationService with working
implementations: createSession signs a HS256 JWT using Payload's instance
secret, validateSession verifies and decodes the token then looks up the
user, invalidateSession returns a blank cookie with maxAge 0. No external
JWT dependency — uses Node crypto HMAC directly.

Also clarify withAudit/withAnalytics comments: the wrappers intentionally
delegate recording to the use case body (only it knows which fields to
extract), so the TODO was misleading.
2026-05-28 22:41:30 +02:00
danijel-lf
0fbb880c82 fix(web-next): correct idempotency test to use bindAll not bindAllProduction
bindAllProduction has no idempotency guard — the promise cache lives in
bindAll. Test was calling the wrong function, causing the spy to fire
twice.
2026-05-28 22:41:10 +02:00
danijel-lf
5b74939a51 feat(conformance): implement ReadOnly brand and reader generator
- Add ReadOnly<F> phantom brand to core-shared/conformance (compile-time
  enforcement that readers only wrap non-mutating use cases)
- Add isReadOnly runtime predicate for boot-time assertReaderPurity
- Scaffold pnpm turbo gen reader: creates integrations/readers/ with
  interface, implementation, test, barrel, and adds ./reader export
  subpath to package.json
2026-05-28 22:01:51 +02:00
danijel-lf
b97e6105d3 feat(conformance): wire cross-feature reader pattern into docs and schema
Add reads field to UseCaseManifest, update CLAUDE.md with Q0-Q3 rules,
add ./reader subpath to AGENTS.md exports table, and cascade reader
conventions through conformance quickref, adding-a-feature guide, and
scaffolding guide. Moves gen reader from deferred to planned.
2026-05-28 20:55:34 +02:00
danijel-lf
d4ce68d738 docs(architecture): add ADR-026 cross-feature synchronous readers
Introduce readers as a fourth cross-feature mechanism alongside events,
jobs, and realtime. Readers solve synchronous domain queries across
verticals (e.g. permission checks) where Payload relationTo gives raw
data but the answer requires business-rule evaluation by the owning
feature.

- Define rules Q0-Q3 (query-only, contract public, read-only, no cycles)
- Reader wraps existing use cases via ReadOnly<F> brand enforcement
- Lives under integrations/readers/ with ./reader export subpath
- Manifest reads: ["auth"] field for conformance gate visibility
- Update glossary with Reader, reads, ReadOnly<F> terms
2026-05-28 20:32:56 +02:00
21 changed files with 570 additions and 76 deletions

View File

@@ -311,6 +311,7 @@ Each feature package exposes exactly these subpath exports:
| `./ui` | Hooks (`useX`), components, query builders (`queryOptions`) | App packages |
| `./api` | tRPC router (`xRouter` + `XRouter` type) | `@repo/core-api` only |
| `./cms` | Payload collections | `@repo/core-cms` only |
| `./reader` | `I<Feature>Reader` type (cross-feature domain query contract) | Other feature packages |
| `./di/bind-production` | App boot side-effect swaps mock for real Payload impl | App packages only |
| `./di/bind-dev-seed` | App boot side-effect swaps empty mock for populated mock | App packages, storybook |
@@ -416,6 +417,8 @@ Actual function names: `bindProductionAuth`, `bindProductionBlog`, `bindProducti
Each feature binder signature is `(ctx: BindProductionContext): void` for production and `(ctx: BindContext): Promise<void>` for dev-seed. Required ctx fields: `tracer`, `logger`. Production-only: `config`. Optional: `bus`, `queue`, `realtime`, `realtimeRegistry`.
**Cross-feature readers:** Features that expose domain queries return a reader from their binder: `bindProductionAuth(ctx)` returns `{ reader: IAuthReader }`. Consuming features accept readers as a second parameter: `bindProductionBlog(ctx, { authReader: authResult.reader })`. Ordering in `bindAll()` is explicit owning feature first, consumers after. Reader cycles are a design error (rule Q3). Readers live at `integrations/readers/`, exported via `./reader` subpath. See the cross-feature readers ADR for full design.
---
### Conformance contract (every feature)

View File

@@ -23,6 +23,7 @@ pnpm turbo gen feature # Scaffold a new feature package
pnpm turbo gen event # Scaffold an event contract or handler
pnpm turbo gen job # Scaffold a background job
pnpm turbo gen realtime # Scaffold a realtime channel or handler
pnpm turbo gen reader # Scaffold a cross-feature reader
pnpm turbo gen core-package # Scaffold an optional core package
pnpm turbo gen core-ui-component # Scaffold an atomic-design component
docker compose up -d # Start PostgreSQL
@@ -64,7 +65,7 @@ Turborepo + pnpm monorepo organized by vertical features. Each feature (`auth`,
## Conformance system
Every feature has a `src/feature.manifest.ts` declaring its use cases, audits, publishes, consumes, required cores, `rateLimit?: RateLimitBudget[]` (when applicable, for per-use-case rate-limit budgets), and (when applicable) `requiresConsent: ConsentCategory[]` for features that gate behaviour behind user consent. Drift is caught at five latencies:
Every feature has a `src/feature.manifest.ts` declaring its use cases, audits, publishes, consumes, reads (cross-feature reader deps), required cores, `rateLimit?: RateLimitBudget[]` (when applicable, for per-use-case rate-limit budgets), and (when applicable) `requiresConsent: ConsentCategory[]` for features that gate behaviour behind user consent. Drift is caught at five latencies:
| Layer | Latency | Catches |
| -------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------- |
@@ -74,7 +75,7 @@ Every feature has a `src/feature.manifest.ts` declaring its use cases, audits, p
| **CI drift gate** (`pnpm conformance`) | ~120s | orphan event consumers across features |
| **Fallow** (`pnpm fallow`) | ~3060s | dead exports / unused files; duplicate code; circular deps; complexity hotspots; AI-change audit drift |
The fifteen conformance ESLint rules: `feature-must-have-manifest` (error), `usecase-must-have-test-file` (error), `required-cores-installed` (error), `usecase-must-be-wired` (error), `no-undeclared-event-publish` (warn), `no-undeclared-audit` (warn), `no-undeclared-analytics-event` (warn), `pii-declaration-must-be-complete` (warn), `component-must-have-story` (warn), `component-must-have-test` (warn), `atomic-tier-import-direction` (warn), `no-undeclared-consent-check` (warn), `no-undeclared-rate-limit` (warn), `entity-must-have-test` (warn), `no-relative-parent-import-in-tests` (warn). Fallow runs as a fifth layer, post-ESLint, whole-codebase.
The sixteen conformance ESLint rules: `feature-must-have-manifest` (error), `usecase-must-have-test-file` (error), `required-cores-installed` (error), `usecase-must-be-wired` (error), `no-undeclared-event-publish` (warn), `no-undeclared-audit` (warn), `no-undeclared-analytics-event` (warn), `no-undeclared-reader` (warn), `pii-declaration-must-be-complete` (warn), `component-must-have-story` (warn), `component-must-have-test` (warn), `atomic-tier-import-direction` (warn), `no-undeclared-consent-check` (warn), `no-undeclared-rate-limit` (warn), `entity-must-have-test` (warn), `no-relative-parent-import-in-tests` (warn). Fallow runs as a fifth layer, post-ESLint, whole-codebase.
See `docs/architecture/agent-first-workflow-and-conformance.md` for the full design and `docs/guides/conformance-quickref.md` for the day-to-day reference.
@@ -124,9 +125,16 @@ See `docs/guides/coverage.md` for the cookbook and ADR-020 for the full rational
- **Realtime is for state delivery, not for replacing tRPC (R0)** Persistent request/response operations belong on tRPC procedures. Use realtime when the server needs to push without a request or the data is too high-frequency for HTTP
- **Realtime channel descriptors are exported; handlers are private (R1)** A feature's `realtime/<name>.channel.ts` is re-exported from the root barrel; `realtime/handlers/*.handler.ts` is wired only in bind-\* files and never re-exported (ESLint-enforced via `no-realtime-handler-reexport`)
- **`socket.io` lives in `@repo/core-realtime` only (R2)** Feature packages MUST NOT import `socket.io` or `socket.io-client`. ESLint rule `no-direct-socket-io` enforces this; allowlist covers `core-realtime/src/socket-io-*.ts` and `apps/*/server.ts`
- **Cross-feature domain queries go through readers (Q0)** When a use case needs another vertical's domain-evaluated answer on the request path (e.g., permission check), use a reader (`I<Feature>Reader`). For raw data joins, use Payload `relationTo`. For reactions/side effects, use the event bus
- **Reader contracts are public; implementations are private (Q1)** The owning feature exports `I<Feature>Reader` from `./reader` subpath (`integrations/readers/`). The implementation (`<Feature>Reader`) is internal, constructed by the binder. Consumers import the type only
- **Readers are strictly read-only; cross-feature writes go through events (Q2)** A reader may only wrap use cases declared `mutates: false`. Enforced by `ReadOnly<F>` brand at compile time and `assertReaderPurity` at boot time
- **Reader cycles are a design error (Q3)** If Feature A reads from Feature B and vice versa, the boundaries are wrong. Break via: (a) UI composition at app layer, (b) event for one direction, (c) merge the features
- **Readers wrap existing use cases, not repositories** The reader is a thin facade; if the domain logic doesn't exist as a use case yet, create the use case first (manifest-first). No `MockReader` needed same class works in dev-seed because the use cases beneath it are backed by mock repos
- **Manifest `reads` field** Use cases that query another feature's reader declare `reads: ["<feature>"]` in `feature.manifest.ts`. Verified by `assertFeatureConformance` at boot and `no-undeclared-reader` ESLint rule
- **Binders return readers; `bindAll()` threads them** `bindProductionAuth(ctx)` returns `{ reader: IAuthReader }`. `bindAll()` passes it: `bindProductionBlog(ctx, { authReader: authResult.reader })`. Ordering in `bindAll()` is explicit owning feature first, consumers after
- **Manifest-first ordering** for any new use case, the workflow is **(1) manifest entry** **(2) contracts** (`xInputSchema`, `xOutputSchema`, `IXUseCase`) **(3) tests (red)** **(4) implementation (green)**. The generator emits the manifest + a self-asserting `bind-production.ts` so new features are conformance-compliant by default
- **Self-asserting `bindProductionX(ctx)`** every feature's bind-production calls `assertFeatureConformance(container, manifest, symbols, ctx)` at its tail. `pnpm dev` refuses to boot on drift
- **`pnpm conformance`** cross-feature event-closure check; fails CI on orphan consumers
- **`pnpm conformance`** cross-feature event-closure and reader-closure check; fails CI on orphan consumers or unresolvable `reads` entries
- **New runtime dependencies require a library trace** adding a runtime dependency to a feature- or core-tier package requires a trace at `docs/library-decisions/<date>-<name>.md` produced by the `/evaluate-library` skill; see ADR-022 and `docs/guides/adding-a-library.md`
- **CI security + supply-chain enforcement** Renovate for bumps + Action SHA pinning, Socket for supply-chain behavior, weekly trace revalidation, CodeQL + audit signatures + gitleaks. See ADR-023 + `docs/guides/ci-security.md`

View File

@@ -69,12 +69,13 @@ describe("bindAllProduction", () => {
expect(bindProductionMedia).toHaveBeenCalledOnce();
});
it("is idempotent — second call does not re-bind", async () => {
const { bindAllProduction } = await import("./bind-production");
it("is idempotent via bindAll — second call does not re-bind", async () => {
vi.stubEnv("NODE_ENV", "production");
const { bindAll } = await import("./bind-production");
const { bindProductionBlog } =
await import("@repo/blog/di/bind-production");
await bindAllProduction();
await bindAllProduction();
await bindAll();
await bindAll();
expect(bindProductionBlog).toHaveBeenCalledOnce();
});

View File

@@ -0,0 +1,208 @@
# ADR-026 — Cross-feature synchronous readers
**Status:** Accepted
**Date:** 2026-05-28
## Context
The monorepo's vertical-slice architecture (ADR-006) enforces strict feature isolation: each vertical owns its data end-to-end, and cross-feature communication flows through the event bus (ADR-015, rule E0). This works well for **reactions** ("user signed up → send welcome email"), but the architecture has no mechanism for **synchronous domain queries** across features.
Three concrete scenarios expose the gap:
1. **Permission checks.** Blog's `createArticle` needs to verify the author has the "editor" role. The raw user record is available via Payload's `relationTo`, but evaluating "does role X grant permission Y in context Z?" is domain logic that belongs to the auth vertical.
2. **Computed state.** A billing feature needs to know whether a subscription is active after applying trial logic, grace periods, and plan rules. That evaluation belongs to the subscriptions vertical.
3. **Validated existence.** A comments feature needs to verify a referenced article exists and is in "published" status — a check that includes blog-domain invariants, not just a row lookup.
Payload's `relationTo` handles raw data joins at the database level (and should continue to be used for that), but it cannot evaluate business rules owned by another vertical. Events cannot answer synchronous questions. The architecture needs a third cross-feature mechanism.
## Decision
**1. Introduce readers: synchronous, read-only cross-feature query contracts.**
A **reader** is a minimal interface exported by a feature that exposes domain queries to other verticals. It complements events (async reactions) and `relationTo` (raw data joins) without replacing either.
| Cross-feature need | Mechanism | Sync/Async | Example |
| ---------------------- | -------------------- | ----------- | ------------------------------ |
| Raw data join | Payload `relationTo` | Sync (DB) | Article card shows author name |
| Domain query | Reader | Sync (code) | "Does user have editor role?" |
| Reaction / side effect | Event bus (ADR-015) | Async | "User signed up → send email" |
| Deferred work | Job queue (ADR-015) | Async | "Resize uploaded image" |
| State delivery / push | Realtime (ADR-016) | Async | "New comment appeared" |
**2. Four rules, parallel to events (E0/E1) and jobs (J0).**
- **Q0 — Readers are for cross-feature synchronous domain queries only.** In-feature reads are direct use-case calls. If the caller and the data owner are in the same vertical, use the use case directly — don't route through a reader.
- **Q1 — Reader contracts (interfaces) are public; implementations are private.** The owning feature exports `I<Feature>Reader` from a `./reader` subpath. The implementation class (`<Feature>Reader`) is internal, constructed by the feature's binder. Consumers import the type only. Parallel to rule E1 for event handlers.
- **Q2 — Readers are strictly read-only. Cross-feature writes go through events.** A reader may only delegate to use cases declared `mutates: false` in the feature manifest. Enforced by `ReadOnly<F>` TypeScript brand at compile time and `assertReaderPurity` at boot time. If you need to tell another vertical that something happened, publish an event.
- **Q3 — Reader cycles are a design error.** If Feature A reads from Feature B and Feature B reads from Feature A, the boundaries are wrong. Resolution strategies: (a) one direction is a UI composition concern — compose at the app layer instead; (b) one direction can be async — use an event; (c) the two features should be one vertical.
**3. One reader per feature, grown on demand.**
Each feature that exposes cross-feature queries ships a single `I<Feature>Reader` interface (e.g., `IAuthReader`, `ITenantReader`). The interface starts minimal and grows as consumers need more methods. If the interface becomes bloated, that's a signal the vertical is too fat.
**4. Readers wrap existing use cases — they don't add domain logic.**
The reader is a thin facade over the owning feature's use cases. It does not contain business rules itself. If a reader needs logic that doesn't exist as a use case, the correct response is to create the use case first (manifest-first ordering), then have the reader delegate to it.
```typescript
// packages/auth/src/infrastructure/readers/auth.reader.ts (INTERNAL)
export class AuthReader implements IAuthReader {
constructor(
private checkRole: ReadOnly<ICheckUserRoleUseCase>,
private getUser: ReadOnly<IGetUserUseCase>,
) {}
async hasRole(userId: string, role: string): Promise<boolean> {
return this.checkRole({ userId, role });
}
async exists(userId: string): Promise<boolean> {
const user = await this.getUser({ id: userId });
return user !== null;
}
}
```
Because the reader wraps use cases, no `MockReader` class is needed. In dev-seed mode the same `AuthReader` class works — the use cases beneath it are backed by mock repositories populated with seed data. In consumer tests, an inline vitest mock of `IAuthReader` suffices.
**5. Readers live under `integrations/readers/`, exported via `./reader` subpath.**
The reader is an outward-facing integration boundary, parallel to `integrations/api/` (HTTP consumers) and `integrations/cms/` (Payload admin). File layout:
```
packages/<feature>/src/
integrations/
api/ # outward: HTTP consumers
cms/ # outward: Payload admin
readers/ # outward: other verticals
<feature>.reader.interface.ts # IFeatureReader (PUBLIC)
<feature>.reader.ts # FeatureReader (INTERNAL)
<feature>.reader.test.ts
index.ts # exports type { IFeatureReader } only
```
The `package.json` exports map gains a `./reader` entry:
```json
{ "./reader": "./src/integrations/readers/index.ts" }
```
**6. Wiring: binder returns reader, `bindAll()` threads it to consumers.**
Feature binders that expose a reader return it:
```typescript
// bindProductionAuth(ctx) returns { reader: IAuthReader }
const authResult = bindProductionAuth(ctx);
bindProductionBlog(ctx, { authReader: authResult.reader });
```
Consuming binders accept readers as a second parameter alongside `ctx`:
```typescript
export function bindProductionBlog(
ctx: BindProductionContext,
readers: { authReader: IAuthReader },
): void;
```
Ordering in `bindAll()` is explicit — the owning feature binds first, then consumers. A cycle in `bindAll()` is a compile-time error (TypeScript cannot type the return before the call), which enforces rule Q3 structurally.
**7. Manifest field: `reads: ["<feature>"]` per use case.**
The feature manifest declares cross-feature read dependencies:
```typescript
useCases: {
createArticle: {
mutates: true, // this use case mutates its OWN feature's data
reads: ["auth"], // this use case queries ANOTHER feature's reader (read-only on auth side)
publishes: [],
consumes: [],
audits: [],
},
}
```
Note: `mutates` and `reads` are orthogonal. `mutates` describes whether this use case writes to its own feature's repositories. `reads` describes which other features' readers it queries. A mutating use case can read from another feature's reader — the read-only constraint (Q2) is enforced on the **provider** side (the reader can only wrap non-mutating use cases), not on the consumer side.
Conformance gates verify:
- **ESLint rule `no-undeclared-reader`:** Code calls a reader method but manifest doesn't declare `reads`. (Parallel to `no-undeclared-event-publish`.)
- **Boot assertion `assertReaderPurity`:** Every use case wired into a reader is declared `mutates: false` in the manifest.
- **Boot assertion `assertFeatureConformance`:** Every `reads` entry has a corresponding reader injected into the binder.
- **`pnpm conformance`:** Cross-feature reader closure — every `reads: ["auth"]` resolves to a feature that exports `./reader`.
**8. Read-only enforcement via `ReadOnly<F>` brand.**
A new branded type prevents mutating use cases from being wired into readers at compile time:
```typescript
type ReadOnly<F> = F & { readonly __readonly: unique symbol };
```
Use cases declared `mutates: false` receive the `ReadOnly` brand at bind time. The reader constructor only accepts `ReadOnly`-branded use cases. Passing a mutating use case produces a TypeScript error.
The brand is verified at boot time by `assertReaderPurity`, which cross-references the reader's wired use cases against the manifest's `mutates` field. If a `mutates: true` use case is wired into a reader, the app refuses to boot.
**9. No reader-level instrumentation.**
Readers delegate to use cases that are already wrapped with `withSpan` and `withCapture` at bind time. Adding reader-level spans would create redundant parent spans for every cross-feature query. Use case spans are sufficient for tracing.
## Alternatives considered
- **Events for everything (status quo).** Rejected for domain queries — events are async and fire-and-forget. You cannot `await bus.publish("auth.check-role")` and get an answer back. Forcing queries through the event bus would require request-scoped correlation IDs, reply channels, and timeouts — essentially rebuilding synchronous RPC over an async bus.
- **Direct use-case imports across features.** Rejected — violates vertical isolation. If blog imports `checkUserRoleUseCase` from auth, it takes a transitive dependency on auth's repository interfaces, DI symbols, and internal structure. A change inside auth's use case can break blog's compilation.
- **Shared query interfaces in `core-shared`.** Rejected — `core-shared` is infrastructure. Putting `IAuthReader` there means core-shared accumulates feature-specific domain types, which inverts the dependency direction (core depends on feature concepts).
- **A standalone `core-protocols` package.** Rejected as premature — adds a new package for what is currently a type-only export. If the number of readers grows beyond 5-6, this can be reconsidered. For now, the owning feature is the natural home.
- **Gateways (reader + writer in one interface).** Rejected — synchronous cross-feature writes are dangerous. A failure in the target feature's write path would fail the caller's request. Writes should be fire-and-forget (events) so the caller's request path is not coupled to the target's write availability. See rule Q2.
- **Bidirectional readers (allowing cycles).** Rejected — cycles indicate wrong feature boundaries. Three resolution strategies exist (UI composition, event for one direction, merge features), making a runtime cycle-breaking mechanism unnecessary. See rule Q3.
- **Rely solely on Payload `relationTo`.** Rejected as the sole mechanism — `relationTo` gives raw data, not domain-evaluated answers. It also doesn't work in dev-seed/test mode with mock repositories. However, `relationTo` remains the correct choice for raw data joins where no domain logic is needed.
## Consequences
**Positive:**
- Verticals can answer synchronous domain queries for other verticals without violating isolation.
- The manifest's `reads` field makes cross-feature coupling visible, greppable, and agent-readable — same as `publishes`/`consumes` for events.
- Read-only enforcement (`ReadOnly<F>` brand + `assertReaderPurity`) prevents accidental cross-feature mutations.
- Cycle detection is structural (compile-time in `bindAll()`) — no runtime checks needed.
- No new mock infrastructure — existing use case mocks power the reader in dev-seed; inline vitest mocks suffice for consumer tests.
- The pattern is consistent with existing conventions: integration boundary (`integrations/readers/`), public contract + private implementation (rule Q1 parallels E1), manifest declaration + conformance check.
**Negative:**
- Adds a fourth cross-feature coupling mechanism (alongside events, jobs, and realtime). Developers and agents must choose correctly. The decision matrix in section 1 mitigates this.
- Feature binders that expose readers change their return type (from `void` to `{ reader: I<Feature>Reader }`). `bindAll()` ordering becomes explicit. This is intentional — it makes the dependency graph visible — but it's a change to existing binder signatures.
- The `reads` manifest field and `no-undeclared-reader` ESLint rule are new conformance machinery. Implementation cost is bounded (follows the exact pattern of `publishes`/`consumes` + `no-undeclared-event-publish`).
- Reader interfaces can grow organically in ways that are hard to audit. Mitigated by the "one reader per feature, grown on demand" rule and the principle that a bloated reader signals a fat vertical.
## Implementation notes
- **Generator:** A `pnpm turbo gen reader` generator should be added to scaffold the `integrations/readers/` structure, add the `./reader` export to `package.json`, and create the interface + implementation + test files. Not required for day one — hand-authoring the first reader is acceptable while the pattern stabilizes.
- **Existing features:** None of the five template features (auth, blog, media, marketing-pages, navigation) currently need readers. The first reader will be created when a product vertical requires a cross-feature domain query. Auth is the most likely candidate (`IAuthReader` for permission checks).
- **`BindContext` is unchanged.** Readers flow as binder-to-binder parameters (via `bindAll()`), not through `ctx`. This keeps `BindContext` focused on infrastructure concerns.
- **Payload `relationTo` continues unchanged.** Readers supplement it, they don't replace it. Use `relationTo` for raw data joins; use readers for domain-evaluated queries.
## Out of scope (deferred)
1. **ESLint rule `no-undeclared-reader`.** Follows the `no-undeclared-event-publish` pattern. Deferred until the first reader is exercised.
2. **Contract evolution / versioning for readers.** Same as event contracts (ADR-015 §deferred-3) — no migration story for breaking reader interface changes yet.
## Planned
1. **`pnpm turbo gen reader` generator.** Will scaffold `integrations/readers/` + `./reader` export subpath + interface + implementation + test. Follows the `gen event` Plop pattern with anchor protocol.
## Related
- ADR-006 — Vertical feature packages (the isolation model readers operate within)
- ADR-008 — Per-feature DI containers (reader wiring uses the same container model)
- ADR-010 — Turborepo boundaries (feature → feature type imports are allowed; reader contracts are type-only)
- ADR-015 — Cross-feature events and background jobs (readers complement events; rules Q0Q3 parallel E0/E1/J0)

View File

@@ -241,6 +241,17 @@ The server-side push interface in `@repo/core-realtime`. Use cases call `broadca
**Realtime handler**:
A consumer's reaction to an inbound client message on a channel. Lives at `packages/<feature>/src/realtime/handlers/*.handler.ts`. **Always private** — never re-exported (rule R1, enforced by `no-realtime-handler-reexport`).
**Reader** (`I<Feature>Reader`):
A synchronous, read-only cross-feature query contract. Exported by the owning feature from `./reader` subpath; implementation is private. Wraps the feature's existing use cases (only those declared `mutates: false`). Lives at `packages/<feature>/src/integrations/readers/`.
_Use when:_ you need another vertical's **domain-evaluated** answer on the request path (e.g., "does this user have the editor role?"). **Don't use for raw data lookups** — that's Payload `relationTo`. **Don't use for side effects** — that's the event bus (rule Q2).
_Avoid:_ confusing readers with repositories (repositories are inward-facing data access; readers are outward-facing domain query contracts).
**`reads`** (manifest field):
Per-use-case array of feature names whose readers this use case depends on. Example: `reads: ["auth"]`. Parallel to `publishes`/`consumes` for events. Verified by `assertFeatureConformance` at boot and `no-undeclared-reader` ESLint rule at lint time.
**`ReadOnly<F>`** (brand):
A TypeScript phantom type applied to use cases declared `mutates: false`. Readers only accept `ReadOnly`-branded use cases in their constructor — prevents mutating use cases from being wired into a reader at compile time. Verified at boot by `assertReaderPurity`.
**Audit log**:
A DPA-compliant record of a use case's side effects. Emitted via `auditLog.record(...)`; declared in the manifest's `audits:` array. See ADR-018.
@@ -405,7 +416,9 @@ The Renovate-triggered re-walk of `evaluate-library` when a runtime dep's major
- A **Controller** has at most one **`presenter`** (omitted for void outputs).
- A **Feature** owns its **Repositories**, **Services**, **Use cases**, **Controllers**, **Events**, **Jobs**, **Channels**, and **DI Container**.
- **Cross-feature reactions** travel through the **Event bus**; **in-feature reactions** are direct use-case calls.
- **Cross-feature domain queries** travel through a **Reader**; raw data joins use Payload `relationTo`.
- An **Event descriptor** is public; its **Event handler** is always private.
- A **Reader** contract (`I<Feature>Reader`) is public; its implementation is always private.
- A **Channel descriptor** is public; its **Realtime handler** is always private.
- **Brands** are attached only at **DI bind time**, by **`withSpan` / `withCapture` / `withAudit`**.
- **Conformance** asserts the **Manifest** and code agree, at five latency tiers.
@@ -416,6 +429,7 @@ The Renovate-triggered re-walk of `evaluate-library` when a runtime dep's major
- **"feature"** — always a vertical feature package; never a CMS-collection field or a generic capability.
- **"service"** — a DI-injected port (e.g. `IAuthenticationService`); not a Kubernetes service, Payload collection, or generic "service object".
- **"reader"** — always `I<Feature>Reader` (cross-feature synchronous domain query); not a file reader, stream reader, or CQRS read model.
- **"handler"** — qualify by context: **event handler** (cross-feature) | **realtime handler** (inbound socket message) | **task handler** (Payload job).
- **"schema"** — qualify: **Zod schema** (input/output contracts) | **Payload collection schema** (CMS field definitions).
- **"config"** — qualify: **Payload config** | **Next config** | **Vitest config** | **TS config**.

View File

@@ -29,7 +29,7 @@ per-use-case patterns below.
For any new use case, follow these four steps in order:
1. **Manifest entry** — declare the use case in `src/feature.manifest.ts` with its `mutates` flag and (initially empty) `audits` / `publishes` / `consumes` arrays.
1. **Manifest entry** — declare the use case in `src/feature.manifest.ts` with its `mutates` flag and (initially empty) `audits` / `publishes` / `consumes` / `reads` arrays.
2. **Contracts** — export `xInputSchema`, `xOutputSchema`, and the `IXUseCase` type alias from the use-case file. Factory body starts as `throw new Error("not implemented")`.
3. **Tests (red)** — write the failing test that exercises the contract via the factory + a mock repository.
4. **Implementation (green)** — fill the factory body until the tests pass.
@@ -55,6 +55,7 @@ Every feature package owns:
| `di/` | `symbols.ts` + `module.ts` + `container.ts` + `bind-production.ts` |
| `integrations/api/` | `procedures.ts` (feature error map) + `router.ts` (uses `xProcedure.input(xInputSchema)`) |
| `integrations/cms/` | Payload collection/global configs |
| `integrations/readers/` | `I<Feature>Reader` interface + implementation (when feature exposes cross-feature queries) |
| `ui/` | Query builders and future React components (behind `./ui` subpath) |
| `__factories__/` | Test data factories |
| `__contracts__/` | Contract suites shared by mock and real repository tests |

View File

@@ -21,6 +21,7 @@ export const fooManifest = defineFeature({
audits: ["thing.created"],
publishes: ["foo.thing-created"],
consumes: [],
reads: ["auth"], // cross-feature reader dependency
},
},
realtimeChannels: [],
@@ -40,6 +41,7 @@ Field reference:
| `useCases.<name>.audits` | string[] | Audit event types this use case emits via `auditLog.record({ type: "X" })` |
| `useCases.<name>.publishes` | string[] | Cross-feature events this use case publishes via `bus.publish("X")` |
| `useCases.<name>.consumes` | string[] | Cross-feature events this use case consumes (via an event handler) |
| `useCases.<name>.reads` | string[] | Other features whose readers this use case queries (e.g. `["auth"]`) |
| `realtimeChannels` | string[] | Realtime channels this feature owns |
| `jobs` | string[] | Job slugs this feature enqueues |
| `requiresConsent` | ConsentCategory[] | Consent categories feature use cases require; drives `withConsent` wrapping + `no-undeclared-consent-check` |

View File

@@ -111,6 +111,7 @@ pnpm turbo gen event consume # consumer handler + Payload event-task
pnpm turbo gen job # background job + TaskConfig
pnpm turbo gen realtime channel # realtime channel descriptor (ADR-016)
pnpm turbo gen realtime handler # inbound realtime handler (ADR-016)
pnpm turbo gen reader # cross-feature reader interface + implementation
```
The event/job generators insert at six fixed `// <gen:*>` anchor comments. Generated features include four of them automatically (the `// <gen:job-tasks>` location is in `integrations/cms/index.ts`, which is manually authored as part of the post-scaffold wiring); pre-existing features were retrofitted in ADR-015.
@@ -127,4 +128,5 @@ The realtime generators insert at three additional fixed `// <gen:realtime-*>` a
- `docs/decisions/adr-013-input-output-unification.md` — schemas-in-use-case + presenter
- `docs/decisions/adr-014-instrumentation-sentry.md` — span + capture wiring
- `docs/decisions/adr-015-events-and-jobs.md` — cross-feature events + background jobs
- `docs/decisions/adr-026-cross-feature-readers.md` — cross-feature synchronous readers
- `docs/decisions/adr-016-realtime-layer.md` — Socket.IO realtime channels + handlers

View File

@@ -34,28 +34,27 @@ describe("AuthenticationService", () => {
});
it("returns false for malformed stored hash", async () => {
const valid = await service.verifyPassword("not-a-valid-hash", "anything");
const valid = await service.verifyPassword(
"not-a-valid-hash",
"anything",
);
expect(valid).toBe(false);
});
});
describe("deferred methods (NotImplementedError)", () => {
const user = {
id: "test-id",
username: "testuser",
passwordHash: "hashed_password",
};
describe("session methods (require Payload)", () => {
// createSession and validateSession call getPayload() internally,
// so they require a running Payload instance. These are exercised
// by the mock service in use-case tests and by integration tests.
// Here we only test invalidateSession (no Payload dependency).
it("createSession throws NotImplementedError", async () => {
await expect(service.createSession(user)).rejects.toThrow("NotImplemented");
});
it("validateSession throws NotImplementedError", async () => {
await expect(service.validateSession("some-session")).rejects.toThrow("NotImplemented");
});
it("invalidateSession throws NotImplementedError", async () => {
await expect(service.invalidateSession("some-session")).rejects.toThrow("NotImplemented");
it("invalidateSession returns a blank cookie with maxAge 0", async () => {
const { blankCookie } = await service.invalidateSession("any-token");
expect(blankCookie.name).toBe("payload-token");
expect(blankCookie.value).toBe("");
expect(blankCookie.attributes.maxAge).toBe(0);
expect(blankCookie.attributes.httpOnly).toBe(true);
expect(blankCookie.attributes.path).toBe("/");
});
});
});

View File

@@ -1,39 +1,21 @@
import crypto from "node:crypto";
import type { SanitizedConfig } from "payload";
import { getPayload, type SanitizedConfig } from "payload";
import type { IAuthenticationService } from "../../application/services/authentication.service.interface";
import type { Cookie } from "../../entities/models/cookie";
import type { Session } from "../../entities/models/session";
import type { User } from "../../entities/models/user";
// ---------------------------------------------------------------------------
// Deferred methods
// ---------------------------------------------------------------------------
// `createSession`, `validateSession`, and `invalidateSession` require Payload's
// internal JWT-based auth session machinery, which does not map cleanly to a
// generic session interface without deep integration with Payload's REST/local
// API and cookie infrastructure.
//
// TODO: Implement these three methods once the session
// cookie strategy is settled. Until then they throw NotImplementedError to
// keep the production-shaped file in place without silently no-oping.
//
// The mock (`authentication.service.mock.ts`) handles all test paths.
class NotImplementedError extends Error {
constructor(method: string) {
super(`NotImplemented: AuthenticationService.${method}`);
this.name = "NotImplementedError";
}
}
const SALT_LENGTH = 16;
const KEY_LENGTH = 64;
const ITERATIONS = 100_000;
const DIGEST = "sha512";
const SEPARATOR = ":";
const COOKIE_NAME = "payload-token";
const SESSION_DURATION_SECONDS = 7200; // 2 hours (matches Payload default)
export class AuthenticationService implements IAuthenticationService {
constructor(private _config: SanitizedConfig) {}
constructor(private config: SanitizedConfig) {}
generateUserId(): string {
return crypto.randomUUID();
@@ -81,30 +63,118 @@ export class AuthenticationService implements IAuthenticationService {
);
}
// TODO: Implement using Payload's local.login / JWT session issuance.
// Payload creates sessions via its REST auth endpoint; mapping that to a
// generic { session: Session; cookie: Cookie } shape requires understanding
// the JWT payload structure and the cookie name/attributes Payload uses.
async createSession(
_user: User,
user: User,
): Promise<{ session: Session; cookie: Cookie }> {
throw new NotImplementedError("createSession");
const payload = await getPayload({ config: this.config });
const expiresAt = new Date(Date.now() + SESSION_DURATION_SECONDS * 1000);
const token = this.signToken(user.id, payload.secret);
const session: Session = {
id: crypto.randomUUID(),
userId: user.id,
expiresAt,
};
const cookie: Cookie = {
name: COOKIE_NAME,
value: token,
attributes: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
path: "/",
sameSite: "lax",
maxAge: SESSION_DURATION_SECONDS,
},
};
return { session, cookie };
}
// TODO: Implement using Payload's JWT verify mechanism.
// Need to call Payload's local API to verify the token and retrieve the user.
async validateSession(
_sessionId: string,
token: string,
): Promise<{ user: User; session: Session }> {
throw new NotImplementedError("validateSession");
const payload = await getPayload({ config: this.config });
const decoded = this.verifyToken(token, payload.secret);
if (!decoded) throw new Error("Invalid or expired session token");
const userDoc = await payload.findByID({
collection: "users" as "users",
id: decoded.id,
overrideAccess: true,
});
const user: User = {
id: userDoc.id as string,
username: (userDoc as Record<string, unknown>).username as string,
passwordHash: (userDoc as Record<string, unknown>).passwordHash as string,
};
const session: Session = {
id: token,
userId: user.id,
expiresAt: new Date(decoded.exp * 1000),
};
return { user, session };
}
// TODO: Implement by clearing the session token.
// Payload does not have a server-side session store by default; invalidation
// is typically done client-side by clearing the cookie.
async invalidateSession(
_sessionId: string,
): Promise<{ blankCookie: Cookie }> {
throw new NotImplementedError("invalidateSession");
return {
blankCookie: {
name: COOKIE_NAME,
value: "",
attributes: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
path: "/",
sameSite: "lax",
maxAge: 0,
},
},
};
}
/** Sign a HS256 JWT using Payload's instance secret. No external dependency. */
private signToken(userId: string, secret: string): string {
const header = Buffer.from(
JSON.stringify({ alg: "HS256", typ: "JWT" }),
).toString("base64url");
const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS;
const body = Buffer.from(
JSON.stringify({ id: userId, collection: "users", exp }),
).toString("base64url");
const signature = crypto
.createHmac("sha256", secret)
.update(`${header}.${body}`)
.digest("base64url");
return `${header}.${body}.${signature}`;
}
/** Verify and decode a HS256 JWT. Returns null on invalid/expired token. */
private verifyToken(
token: string,
secret: string,
): { id: string; exp: number } | null {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [header, body, signature] = parts as [string, string, string];
const expected = crypto
.createHmac("sha256", secret)
.update(`${header}.${body}`)
.digest("base64url");
if (signature !== expected) return null;
try {
const decoded = JSON.parse(Buffer.from(body, "base64url").toString()) as {
id: string;
exp: number;
};
if (decoded.exp < Math.floor(Date.now() / 1000)) return null;
return decoded;
} catch {
return null;
}
}
}

View File

@@ -21,11 +21,10 @@ export type Analyzed<F> = F & { readonly __analyzed: true };
* tests).
*/
export function withAnalytics<Args extends unknown[], R>(
// TODO: wire automated event recording from manifest declarations.
// `analyticsEvents[]` declarations. For now, the wrapper exists to:
// (1) require callers to pass the analytics instance at bind time (dep is available)
// (2) attach the `__analyzed` brand so the boot-time assertion can verify
// use cases were bound through the analytics-aware path.
// The wrapper attaches the brand and ensures the analytics dependency is
// available at bind time. Actual `analytics.track()` calls live in the
// use case body — only the use case knows which properties to extract
// from its input/output for the analytics event.
analytics: IAnalytics,
fn: (...args: Args) => Promise<R>,
): Analyzed<(...args: Args) => Promise<R>> {

View File

@@ -21,11 +21,10 @@ export type Audited<F> = F & { readonly __audited: true };
* tests).
*/
export function withAudit<Args extends unknown[], R>(
// TODO: wire automated recording from manifest declarations.
// `audits[]` declarations. For now, the wrapper exists to:
// (1) require callers to pass the auditLog at bind time (dep is available)
// (2) attach the `__audited` brand so the boot-time assertion can verify
// mutating use cases were bound through the audit-aware path.
// The wrapper attaches the brand and ensures the auditLog dependency is
// available at bind time. Actual `auditLog.record()` calls live in the
// use case body — only the use case knows which fields to extract from
// its input/output for the audit entry.
auditLog: IAuditLog,
fn: (...args: Args) => Promise<R>,
): Audited<(...args: Args) => Promise<R>> {

View File

@@ -13,7 +13,7 @@
* commitment, not a mutable flag.
*/
import type { Analyzed, ConsentChecked, RateLimited } from "./brands";
import type { Analyzed, ConsentChecked, RateLimited, ReadOnly } from "./brands";
type Brand =
| "__instrumented"
@@ -21,7 +21,8 @@ type Brand =
| "__audited"
| "__analyzed"
| "__consentChecked"
| "__rateLimited";
| "__rateLimited"
| "__readonly";
/**
* Attaches the brand as a non-enumerable property on the given function.
@@ -75,3 +76,7 @@ export function isRateLimited<F extends object>(
): fn is RateLimited<F> {
return hasBrand(fn, "__rateLimited");
}
export function isReadOnly<F extends object>(fn: unknown): fn is ReadOnly<F> {
return hasBrand(fn, "__readonly");
}

View File

@@ -12,3 +12,9 @@ export type Captured<F> = F & { readonly __captured: true };
export type Analyzed<F> = F & { readonly __analyzed: true };
export type ConsentChecked<F> = F & { readonly __consentChecked: true };
export type RateLimited<F> = F & { readonly __rateLimited: true };
/**
* Brand for use cases declared `mutates: false`. Readers only accept
* `ReadOnly`-branded use cases in their constructor — prevents mutating
* use cases from being wired into a reader at compile time.
*/
export type ReadOnly<F> = F & { readonly __readonly: true };

View File

@@ -4,14 +4,17 @@ import type { RateLimitBudget } from "../rate-limit/rate-limit.interface";
/**
* Per-use-case manifest entry. Declares what the use case does at the contract
* level: whether it mutates state, what audit events it emits, what cross-feature
* events it publishes or consumes. The conformance system reads these to
* derive binding-slot types and to verify code against manifest declarations.
* events it publishes or consumes, and what cross-feature readers it depends on.
* The conformance system reads these to derive binding-slot types and to verify
* code against manifest declarations.
*/
export type UseCaseManifest = {
readonly mutates: boolean;
readonly audits: readonly string[];
readonly publishes: readonly string[];
readonly consumes: readonly string[];
/** Feature names whose readers this use case queries. */
readonly reads?: readonly string[];
readonly analyticsEvents?: readonly string[];
readonly rateLimit?: readonly RateLimitBudget[];
};

View File

@@ -4,6 +4,7 @@ export type {
Analyzed,
ConsentChecked,
RateLimited,
ReadOnly,
} from "./brands";
export type { FeatureManifest, UseCaseManifest } from "./define-feature";
export { defineFeature } from "./define-feature";
@@ -31,6 +32,7 @@ export {
isAnalyzed,
isConsentChecked,
isRateLimited,
isReadOnly,
} from "./brand-runtime";
export { ConformanceError } from "./conformance-error";
export { assertFeatureConformance } from "./assert-bindings";

View File

@@ -476,6 +476,50 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void {
},
});
/**
* Turbo generator: `reader`
*
* Scaffolds a cross-feature reader under
* `packages/<feature>/src/integrations/readers/` and adds the `./reader`
* export subpath to the feature's `package.json`. The reader interface is
* the public contract; the implementation and test are internal.
*
* See ADR-026 for the full design (rules Q0Q3).
*/
plop.setGenerator("reader", {
description:
"Scaffold a cross-feature reader interface + implementation (ADR-026)",
prompts: [
{
type: "input",
name: "feature",
message: "Feature that will EXPOSE the reader (kebab-case):",
validate(input: string) {
if (!/^[a-z][a-z0-9-]*$/.test(input)) return "Must be kebab-case";
if (!existsSync(join(process.cwd(), "packages", input, "src"))) {
return `packages/${input}/src does not exist`;
}
const readersDir = join(
process.cwd(),
"packages",
input,
"src",
"integrations",
"readers",
);
if (existsSync(readersDir)) {
return `packages/${input}/src/integrations/readers/ already exists — this feature already has a reader`;
}
return true;
},
},
],
actions(answers) {
const a = answers as { feature: string };
return readerActions(a);
},
});
/**
* Turbo generator: `realtime`
*
@@ -1034,6 +1078,82 @@ function jobBindBlock(a: { feature: string; job: string }): string {
${containerVar}.bind(${symbol}).toConstantValue(wrapped${pascalCase(a.job)});`;
}
function readerActions(a: { feature: string }): PlopTypes.ActionType[] {
const base = `packages/${a.feature}/src/integrations/readers`;
const pkgJsonPath = `packages/${a.feature}/package.json`;
return [
{
type: "add",
path: `${base}/${a.feature}.reader.interface.ts`,
templateFile: "templates/reader/reader.interface.ts.hbs",
data: a,
},
{
type: "add",
path: `${base}/${a.feature}.reader.ts`,
templateFile: "templates/reader/reader.ts.hbs",
data: a,
},
{
type: "add",
path: `${base}/${a.feature}.reader.test.ts`,
templateFile: "templates/reader/reader.test.ts.hbs",
data: a,
},
{
type: "add",
path: `${base}/index.ts`,
templateFile: "templates/reader/index.ts.hbs",
data: a,
},
// Add ./reader subpath to package.json exports map
() => {
const fs = require("node:fs");
const pkgPath = join(process.cwd(), pkgJsonPath);
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
if (!pkg.exports) pkg.exports = {};
pkg.exports["./reader"] = "./src/integrations/readers/index.ts";
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
return `Added "./reader" export to ${pkgJsonPath}`;
},
() => printReaderNextSteps(a),
];
}
function printReaderNextSteps(a: { feature: string }): string {
const pascal = pascalCase(a.feature);
return [
"",
"─────────────────────────────────────────────────────────────",
` Reader scaffolded for @repo/${a.feature}`,
"─────────────────────────────────────────────────────────────",
"",
" Next steps:",
"",
` 1. Add methods to I${pascal}Reader interface:`,
` packages/${a.feature}/src/integrations/readers/${a.feature}.reader.interface.ts`,
"",
` 2. Implement methods in ${pascal}Reader (delegate to use cases):`,
` packages/${a.feature}/src/integrations/readers/${a.feature}.reader.ts`,
"",
` 3. Construct the reader in bind-production.ts and return it:`,
` const reader = new ${pascal}Reader(/* injected use cases */);`,
` return { reader };`,
"",
` 4. In bindAll(), pass the reader to consuming features:`,
` const ${a.feature}Result = bindProduction${pascal}(ctx);`,
` bindProductionConsumer(ctx, { ${a.feature}Reader: ${a.feature}Result.reader });`,
"",
` 5. In consuming features' manifest, declare: reads: ["${a.feature}"]`,
"",
" Rules: Q0 (cross-feature only), Q1 (interface public, impl private),",
" Q2 (read-only — wraps only mutates:false use cases),",
" Q3 (reader cycles are a design error)",
"",
"─────────────────────────────────────────────────────────────",
].join("\n");
}
function printJobNextSteps(a: { feature: string; job: string }): string {
return [
"",

View File

@@ -0,0 +1,3 @@
// packages/{{kebabCase feature}}/src/integrations/readers/index.ts
// Public surface: type-only export. Implementation is internal.
export type { I{{pascalCase feature}}Reader } from "./{{kebabCase feature}}.reader.interface";

View File

@@ -0,0 +1,14 @@
// packages/{{kebabCase feature}}/src/integrations/readers/{{kebabCase feature}}.reader.interface.ts
/**
* Cross-feature read-only query contract for the {{kebabCase feature}} vertical.
* Consumers import this type from `@repo/{{kebabCase feature}}/reader`.
* Implementation is private — constructed by the feature's binder.
*
* Rules: Q0 (cross-feature domain queries only), Q1 (interface public,
* impl private), Q2 (read-only — wraps only mutates:false use cases).
*/
export interface I{{pascalCase feature}}Reader {
// Add methods as consumers need them. Each method must delegate to a
// use case declared mutates: false in feature.manifest.ts.
}

View File

@@ -0,0 +1,10 @@
// packages/{{kebabCase feature}}/src/integrations/readers/{{kebabCase feature}}.reader.test.ts
import { describe, it, expect } from "vitest";
import { {{pascalCase feature}}Reader } from "@/integrations/readers/{{kebabCase feature}}.reader";
describe("{{pascalCase feature}}Reader", () => {
it("can be constructed", () => {
const reader = new {{pascalCase feature}}Reader();
expect(reader).toBeDefined();
});
});

View File

@@ -0,0 +1,25 @@
// packages/{{kebabCase feature}}/src/integrations/readers/{{kebabCase feature}}.reader.ts
import type { I{{pascalCase feature}}Reader } from "./{{kebabCase feature}}.reader.interface";
/**
* Internal implementation of I{{pascalCase feature}}Reader.
* Wraps existing use cases — does not add domain logic.
* Constructed by bind-production / bind-dev-seed; never exported.
*
* All injected use cases MUST be ReadOnly-branded (mutates: false).
*/
export class {{pascalCase feature}}Reader implements I{{pascalCase feature}}Reader {
// Inject ReadOnly-branded use cases via constructor:
//
// constructor(
// private getUser: ReadOnly<IGetUserUseCase>,
// ) {}
//
// Then delegate:
//
// async exists(userId: string): Promise<boolean> {
// const user = await this.getUser({ id: userId });
// return user !== null;
// }
}