diff --git a/AGENTS.md b/AGENTS.md index 1dec450..308ecae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` | `IReader` 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` 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) diff --git a/CLAUDE.md b/CLAUDE.md index 52fbeff..74537be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`) | ~30–60s | 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/.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 (`IReader`). 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 `IReader` from `./reader` subpath (`integrations/readers/`). The implementation (`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` 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: [""]` 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/-.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` diff --git a/docs/decisions/adr-026-cross-feature-readers.md b/docs/decisions/adr-026-cross-feature-readers.md index d984b7e..3084f7d 100644 --- a/docs/decisions/adr-026-cross-feature-readers.md +++ b/docs/decisions/adr-026-cross-feature-readers.md @@ -193,9 +193,12 @@ Readers delegate to use cases that are already wrapped with `withSpan` and `with ## Out of scope (deferred) -1. **`pnpm turbo gen reader` generator.** Will scaffold `integrations/readers/` + `./reader` export + manifest `reads` field. Deferred until the pattern is exercised with a real reader. -2. **ESLint rule `no-undeclared-reader`.** Follows the `no-undeclared-event-publish` pattern. Deferred until the manifest schema is extended. -3. **Contract evolution / versioning for readers.** Same as event contracts (ADR-015 §deferred-3) — no migration story for breaking reader interface changes yet. +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 diff --git a/docs/glossary.md b/docs/glossary.md index b6a2c96..66439d8 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -242,7 +242,7 @@ The server-side push interface in `@repo/core-realtime`. Use cases call `broadca A consumer's reaction to an inbound client message on a channel. Lives at `packages//src/realtime/handlers/*.handler.ts`. **Always private** — never re-exported (rule R1, enforced by `no-realtime-handler-reexport`). **Reader** (`IReader`): -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//src/integrations/readers/`. See ADR-026. +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//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). @@ -429,7 +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 `IReader` (cross-feature synchronous domain query, ADR-026); not a file reader, stream reader, or CQRS read model. +- **"reader"** — always `IReader` (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**. diff --git a/docs/guides/adding-a-feature.md b/docs/guides/adding-a-feature.md index a7588dd..a8fc951 100644 --- a/docs/guides/adding-a-feature.md +++ b/docs/guides/adding-a-feature.md @@ -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/` | `IReader` 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 | diff --git a/docs/guides/conformance-quickref.md b/docs/guides/conformance-quickref.md index e095603..e780289 100644 --- a/docs/guides/conformance-quickref.md +++ b/docs/guides/conformance-quickref.md @@ -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..audits` | string[] | Audit event types this use case emits via `auditLog.record({ type: "X" })` | | `useCases..publishes` | string[] | Cross-feature events this use case publishes via `bus.publish("X")` | | `useCases..consumes` | string[] | Cross-feature events this use case consumes (via an event handler) | +| `useCases..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` | diff --git a/docs/guides/scaffolding-a-feature.md b/docs/guides/scaffolding-a-feature.md index d16acd6..a2ceb6e 100644 --- a/docs/guides/scaffolding-a-feature.md +++ b/docs/guides/scaffolding-a-feature.md @@ -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 `// ` anchor comments. Generated features include four of them automatically (the `// ` 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 `// ` 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 diff --git a/packages/core-shared/src/conformance/define-feature.ts b/packages/core-shared/src/conformance/define-feature.ts index d3fcb06..48620a0 100644 --- a/packages/core-shared/src/conformance/define-feature.ts +++ b/packages/core-shared/src/conformance/define-feature.ts @@ -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[]; };