diff --git a/apps/web-next/next.config.mjs b/apps/web-next/next.config.mjs index 9050a92..f110565 100644 --- a/apps/web-next/next.config.mjs +++ b/apps/web-next/next.config.mjs @@ -16,7 +16,7 @@ const nextConfig = { }; export default withSentryConfig(nextConfig, { - // R52 — token is build-time only; CI sets SENTRY_AUTH_TOKEN + // Token is build-time only; CI sets SENTRY_AUTH_TOKEN. silent: process.env.CI !== "true", authToken: process.env.SENTRY_AUTH_TOKEN, org: process.env.SENTRY_ORG, diff --git a/apps/web-next/src/server/bind-production.test.ts b/apps/web-next/src/server/bind-production.test.ts index fd84693..4c5b52c 100644 --- a/apps/web-next/src/server/bind-production.test.ts +++ b/apps/web-next/src/server/bind-production.test.ts @@ -177,7 +177,7 @@ describe("bindAll dispatcher", () => { }); }); -describe("bindAll instrumentation orthogonality (Rule 0, R47)", () => { +describe("bindAll instrumentation orthogonality", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); diff --git a/docs/architecture/agent-first-workflow-and-conformance.md b/docs/architecture/agent-first-workflow-and-conformance.md index af426bf..fff7ee2 100644 --- a/docs/architecture/agent-first-workflow-and-conformance.md +++ b/docs/architecture/agent-first-workflow-and-conformance.md @@ -28,12 +28,12 @@ The conformance design is illustrated separately at [`docs/architecture/feature- ## Mental model -| Pillar | What it is | Primary artifact | -|---|---|---| -| **Conformance engine** | manifest + TS brands + ESLint + boot-time assertion + CI gate | `feature.manifest.ts` per feature; `_state.json` snapshot of compliance | -| **Agent workflow** | PRD → Epic → Story → Task; manifest-first ordering; TDD-per-slice | `docs/work/**/*.md` | -| **Local task system** | filesystem markdown, single state file, dispatchable | `docs/work/` | -| **Sandcastle orchestrator** | implementer + reviewer agents per task, DAG-respecting, retry-capped | `.sandcastle/` config + `scripts/work-*.ts` | +| Pillar | What it is | Primary artifact | +| --------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| **Conformance engine** | manifest + TS brands + ESLint + boot-time assertion + CI gate | `feature.manifest.ts` per feature; `_state.json` snapshot of compliance | +| **Agent workflow** | PRD → Epic → Story → Task; manifest-first ordering; TDD-per-slice | `docs/work/**/*.md` | +| **Local task system** | filesystem markdown, single state file, dispatchable | `docs/work/` | +| **Sandcastle orchestrator** | implementer + reviewer agents per task, DAG-respecting, retry-capped | `.sandcastle/` config + `scripts/work-*.ts` | The same artifacts are read by humans, AI implementers, AI reviewers, the orchestrator, and the pre-commit hooks. There is no separate "process layer." @@ -120,32 +120,41 @@ created: 2026-05-12 --- ## Problem + What's broken or missing today? Who hurts because of it? ## Goal + What state are we trying to reach? ## In scope + - ... ## Out of scope + - ... ## Constraints + - ... ## Success criteria + - ... ## Requirements + - R1: ... - R2: ... ## Open questions + - Q1: ... ``` **Authoring flow:** + 1. Human runs `pnpm work prd-new ""` 2. Agent invokes the **PRD elicitation skill** — runs a question-driven interview with the human (similar in shape to `superpowers:brainstorming`) until it has enough context across Problem / Goal / Scope / Constraints / Success / Requirements 3. Agent drafts PRD with `status: draft` @@ -167,19 +176,24 @@ target: 2026-Q3 --- ## Goal + Build the feature-conformance enforcement system so AI agents get layered, sub-second feedback on drift between manifest and code. ## Why + (brief — link to PRD for detail) ## In scope + - ... ## Out of scope + - ... ## Stories + - [ ] [01 — defineFeature helper + Instrumented brand](01-define-feature-helper/_story.md) - [ ] [02 — Boot assertions](02-boot-assertions/_story.md) - ... @@ -200,30 +214,36 @@ blocks: [02-boot-assertions, 05-generator-updates] --- ## Goal + Manifest helper + brand types enable type-level enforcement that every use-case binding is wrapped with `withSpan` + `withCapture` (and `withAudit` when mutating). ## Why + Compile-time feedback is the cheapest layer and the foundation every other milestone reads. ## Done when + Compile-time TS2322 fires at the IDE when an unwrapped factory is bound through `ProductionUseCase<...>`. ## In scope + - `defineFeature` helper signature + tests - Brand types: `Instrumented`, `Captured`, `Audited` - Wiring brands into existing wrappers (no API changes) - `auth` as the reference feature using the new pattern ## Out of scope + - Migration of other features (each is its own story) - Boot-time `assertConformance` (story 02) - ESLint rules consuming the brands (story 03) ## Tasks + - [ ] [01 — defineFeature helper exists](01-define-feature-helper-exists.task.md) - [ ] [02 — Instrumented brand attached via withSpan](02-instrumented-brand-attached.task.md) - ... @@ -243,21 +263,25 @@ depends-on: [] --- ## As a / I want / So that + **As a** visitor **I want** to create an account with email and password **So that** I can access member-only content ## In scope + - Email/password sign-up flow - Password hashing - Audit + event emission on success - tRPC procedure exposure ## Out of scope + - OAuth sign-up (separate story) - Email verification (separate story) ## Tasks + - [ ] [01 — reject invalid email format + scaffold](01-reject-invalid-email-format.task.md) - [ ] [02 — reject duplicate email](02-reject-duplicate-email.task.md) - ... @@ -276,18 +300,21 @@ status: todo | ready | in-progress | done | escalated depends-on: [01-define-feature-helper-exists] blocks: [06-signin-rebound-via-branded-slot] sandbox: default -max-attempts: 3 # default; override per task +max-attempts: 3 # default; override per task attempts: { implementer: 0, reviewer: 0 } --- ## Goal + Attach the `Instrumented` brand to functions returned by `withSpan`. ## Why this matters + The brand is the type-level seam the binding signature checks. Without it, the compiler can't tell a wrapped factory from an unwrapped one. ## Acceptance criteria + - [ ] `Instrumented` = `F & { readonly __instrumented: true }` - [ ] `withSpan` return type is `Instrumented` - [ ] Brand re-exported from `@repo/core-shared/conformance` @@ -295,17 +322,20 @@ the compiler can't tell a wrapped factory from an unwrapped one. - [ ] All existing tests still pass ## Out of scope + - Updating `with-capture` and `with-audit` (separate tasks) - Refactoring `withSpan`'s existing signature beyond adding the brand - Adding runtime brand markers (type-only) - Renaming existing types or symbols ## Files likely touched + - `packages/core-shared/src/instrumentation/with-span.ts` - `packages/core-shared/src/instrumentation/with-span.test.ts` - `packages/core-shared/src/conformance/index.ts` ## Reviewer notes + Reject if brand is implemented with runtime tag rather than pure type. ``` @@ -316,10 +346,10 @@ A derived, committed, orchestrator-written index. Markdown is source of truth; ` ```json { "updated_at": "2026-05-12T16:42:00Z", - "ready": ["02-instrumented-brand-attached"], + "ready": ["02-instrumented-brand-attached"], "in_progress": [], - "blocked": [], - "escalated": [], + "blocked": [], + "escalated": [], "epics": { "conformance-system-v1": { "status": "in-progress", @@ -360,11 +390,11 @@ A derived, committed, orchestrator-written index. Markdown is source of truth; ` ## Scope guards -| Level | In scope | Out of scope | -|---|---|---| -| PRD | **required** | **required** | -| Story | **required** | **required** | -| Task | implicit (= AC list) | **optional but encouraged** | +| Level | In scope | Out of scope | +| ----- | -------------------- | --------------------------- | +| PRD | **required** | **required** | +| Story | **required** | **required** | +| Task | implicit (= AC list) | **optional but encouraged** | The reviewer agent **explicitly checks the task's `Out of scope` section against the diff**. Rejects if the diff touches anything declared out of scope. This is the cheapest possible enforcement of "don't over-engineer" — pure text match, no AST needed. @@ -372,19 +402,19 @@ The reviewer agent **explicitly checks the task's `Out of scope` section against The four enforcement layers (detailed in [`feature-conformance-explainer.html`](./feature-conformance-explainer.html)): -| Layer | Latency | Catches | -|---|---|---| -| TypeScript brands | 0s | forgotten `withSpan` / `withAudit`; manifest ↔ binding-slot type mismatch | -| AST-aware ESLint | <1s | manifest ↔ code drift; undeclared `bus.publish` / `auditLogger.log`; required cores not installed | -| Boot assertion (`assertConformance`) | ~3s | binding type-casts that hid unwrapped factories; manifests edited without rebinding | -| CI drift gate (`pnpm conformance`) | ~120s | orphan event consumers; scaffold drift from generator; required-cores ↔ workspace mismatch | +| Layer | Latency | Catches | +| ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------- | +| TypeScript brands | 0s | forgotten `withSpan` / `withAudit`; manifest ↔ binding-slot type mismatch | +| AST-aware ESLint | <1s | manifest ↔ code drift; undeclared `bus.publish` / `auditLogger.log`; required cores not installed | +| Boot assertion (`assertConformance`) | ~3s | binding type-casts that hid unwrapped factories; manifests edited without rebinding | +| CI drift gate (`pnpm conformance`) | ~120s | orphan event consumers; scaffold drift from generator; required-cores ↔ workspace mismatch | ### How conformance interacts with tasks When a task adds an audit emission (e.g. `audits: ["user.created"]`): 1. Agent edits `feature.manifest.ts` -2. The binding's branded slot type *now* demands `Audited` — TS2322 if the wrapper is missing +2. The binding's branded slot type _now_ demands `Audited` — TS2322 if the wrapper is missing 3. Agent adds `withAudit(...)` in `bind-production.ts` → TS goes quiet 4. Agent adds `auditLogger.log(...)` in the use-case factory → ESLint goes quiet 5. Pre-commit `pnpm conformance` confirms all four layers pass @@ -407,11 +437,11 @@ For incremental work on an existing use case, step 1 is often a no-op (manifest The Epic / Story / Task hierarchy holds for everything; the **inner workflow shape varies** with the kind of work. Three shapes are recognised: -| Shape | Default home | Manifest involvement | Test gates | -|---|---|---|---| -| **Backend** | feature packages | full (use cases, audits, publishes, consumes, jobs, realtime) | type-check + lint + conformance + unit/integration | -| **Frontend** | `@repo/core-ui` and `features//src/ui/` | partial — pages consume use cases via controllers | type-check + lint + component tests + Playwright screenshot (CI) | -| **Infrastructure** | core packages, `apps/*/server/`, `docker-compose.yml`, `.github/`, ADRs | declarative — `requiredCores`, bind context | type-check + lint + conformance + ADR review | +| Shape | Default home | Manifest involvement | Test gates | +| ------------------ | ----------------------------------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------- | +| **Backend** | feature packages | full (use cases, audits, publishes, consumes, jobs, realtime) | type-check + lint + conformance + unit/integration | +| **Frontend** | `@repo/core-ui` and `features//src/ui/` | partial — pages consume use cases via controllers | type-check + lint + component tests + Playwright screenshot (CI) | +| **Infrastructure** | core packages, `apps/*/server/`, `docker-compose.yml`, `.github/`, ADRs | declarative — `requiredCores`, bind context | type-check + lint + conformance + ADR review | The default shape is **backend** — what the rest of this doc describes. The two adapted shapes are summarised below; operational detail lives in their guides. @@ -465,7 +495,7 @@ When a commit lands in the sandbox or locally: 4. **Tests for changed feature** — `pnpm test --filter @repo/` passes 5. **`_state.json` ↔ markdown sync** — pre-commit regen verifies consistency -Tests for *all* features are NOT required to pass at pre-commit (that's CI's job). The gate enforces local soundness without blocking work in unaffected areas. +Tests for _all_ features are NOT required to pass at pre-commit (that's CI's job). The gate enforces local soundness without blocking work in unaffected areas. ## Agent roles @@ -561,9 +591,9 @@ Prompt templates use sandcastle's `{{VAR}}` substitution + `` !`cmd` `` injectio Branch strategy: per-task feature branch (`task/`), merged sequentially to `main` by the orchestrator. -## Phasing — conformance-first bootstrap +## Bootstrap order — conformance first -### Phase 1 — Conformance system (human-driven) +### Tier 1 — Conformance system (human-driven) Build the conformance system manually. `docs/work/conformance-system-v1/` markdown files capture the work (practising the task format on real work) but no `_state.json`, no sandcastle dispatch, no orchestrator. @@ -578,7 +608,7 @@ Stories in order (each itself a vertical slice — system code + generator updat 7. **Documentation rewrite** — agent-workflow.md, CLAUDE.md, AGENTS.md, tdd-workflow.md 8. **Migrate auth feature** to the new pattern (reference) -### Phase 2 — Work system (human-driven, bootstraps automation) +### Tier 2 — Work system (human-driven, bootstraps automation) Once conformance is in place, build the dispatch substrate: @@ -594,7 +624,7 @@ Once conformance is in place, build the dispatch substrate: 10. Pre-commit hooks (state regen, conformance gate) 11. Playwright screenshot test infrastructure (CI gate for frontend tasks) -### Phase 3 — Migration + future work (dispatch-driven) +### Tier 3 — Migration + future work (dispatch-driven) With both systems in place, remaining feature migrations and all future work flows through sandcastle: @@ -603,10 +633,10 @@ With both systems in place, remaining feature migrations and all future work flo ## Deferred decisions -These are explicitly deferred until the phase that needs them: +These are explicitly deferred until the tier that needs them: -1. **Existing `task-create` skill (ClickUp mirror)** — coexist or retire. Decide during Phase 2 or after. Frontmatter is open-ended so `clickup-id` can be added later if needed. -2. **Existing CI Docker image identity** — identify and document during the `.sandcastle/Dockerfile` story in Phase 2. +1. **Existing `task-create` skill (ClickUp mirror)** — coexist or retire. Decide once the work system is in place. Frontmatter is open-ended so `clickup-id` can be added later if needed. +2. **Existing CI Docker image identity** — identify and document during the `.sandcastle/Dockerfile` story. 3. **Per-epic state files vs. one global** — start with one global `_state.json`. Move to per-epic only if serialized merges become a throughput bottleneck. 4. **Custom git merge driver for `_state.json`** — start without; serialized merges should suffice. Add if needed. diff --git a/docs/architecture/vertical-feature-spec.md b/docs/architecture/vertical-feature-spec.md index d3734e6..b2334d9 100644 --- a/docs/architecture/vertical-feature-spec.md +++ b/docs/architecture/vertical-feature-spec.md @@ -221,10 +221,10 @@ packages/blog/ query.ts # trpc.blog.articleBySlug.queryOptions(...) __factories__/ - article.factory.ts # test data factories (Plan 7) + article.factory.ts # test data factories __contracts__/ - articles-repository.contract.ts # repo interface contract suite (Plan 7) + articles-repository.contract.ts # repo interface contract suite __seeds__/ dev.ts # buildDev() — uses factory; consumed by bind-dev-seed @@ -631,29 +631,7 @@ Two additional ADRs were added after the initial vertical-feature refactor and n --- -## 12. Migration sequencing - -Big-bang refactor executed as 11 internal phases; each phase ends with a verification gate (`pnpm typecheck && pnpm test`) before proceeding. Commit per phase (or per feature within Phase 5) so `git bisect` works. - -| # | Phase | Key artifact | Gate | -| --- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| 1 | Scaffold core packages (empty shells) | `packages/core-shared/`, `core-cms/`, `core-api/`, `core-trpc/`, `core-ui/` with stubs | `pnpm install` + `pnpm typecheck` green | -| 2 | Populate `core-shared` | Fields, blocks, access helpers, hooks, tRPC init/context | `pnpm test --filter @repo/core-shared` passes | -| 3 | Populate `core-cms` stub **and repoint `apps/cms`** | `payload.config.ts` lifted from `cms-core` to `core-cms`; `collections: []`, `globals: []` initially; `apps/cms` updates its import from `@repo/cms-core` to `@repo/core-cms` | `pnpm dev --filter @repo/cms` boots admin UI; `pnpm generate:types` succeeds | -| 4 | Migrate `blog` feature end-to-end (first vertical — proves pattern) | Full canonical shape; Articles collection; per-feature DI container; tRPC router; UI | `pnpm typecheck && pnpm test --filter @repo/blog` green | -| 5 | Migrate remaining features: `auth`, `marketing-pages`, `navigation`, `media` | Each follows the blog template | Per-feature typecheck + tests + `core-cms` regenerates | -| 6 | Populate `core-trpc` + wire apps | Client, per-framework providers; route handlers in `web-next` + `web-tanstack`; example pages | `pnpm dev` serves pages; tRPC returns Payload data | -| 7 | Migrate `core-ui` | Move `packages/ui/` contents; relocate feature-shaped organisms into features; update Storybook imports | Storybook builds; `pnpm test` green | -| 8 | Delete old packages | `core/`, `api/`, `api-client/`, `cms-core/`, `cms-client/`, `ui/` | `pnpm install && pnpm typecheck && pnpm test` green | -| 9 | Boundary enforcement | Install `eslint-plugin-boundaries`; add package-level `turbo.json` tags; write lint rules | `pnpm lint` zero violations | -| 10 | Playwright setup | Configs, initial specs in both frontends; root `test:e2e` script | `pnpm test:e2e` green | -| 11 | Docs rewrite | Copy spec; rewrite overview, dependency-flow, guides; new ADRs; all AGENTS.md; delete stale plans | Human review | - -**Commit strategy:** one commit per phase. Phase 5 may be multiple commits (one per feature). Every commit builds + tests green. - ---- - -## 13. Out of scope (deferred) +## 12. Out of scope (deferred) - Real Payload integration tests with a test database (stub with mock repos; write real integration tests later) - Coverage reporting aggregation across packages (initial vitest setup per-package; aggregation is a follow-up) @@ -663,7 +641,7 @@ Big-bang refactor executed as 11 internal phases; each phase ends with a verific --- -## 14. Success criteria +## 13. Success criteria - `pnpm install && pnpm typecheck && pnpm lint && pnpm test && pnpm build` all green - `pnpm test:e2e` green against running dev servers @@ -678,13 +656,7 @@ Big-bang refactor executed as 11 internal phases; each phase ends with a verific --- -## 15. Post-approval next step - -Invoke the `superpowers:writing-plans` skill to produce a detailed, executable implementation plan based on the 11-phase sequencing in §12. Each phase becomes a plan section with concrete file-by-file steps, verification commands, and commit-message templates. - ---- - -## 16. Instrumentation & error capture (ADR-014, ADR-017) +## 14. Instrumentation & error capture (ADR-014, ADR-017) **Decisions:** `docs/decisions/adr-014-instrumentation-sentry.md` (interface decisions); `docs/decisions/adr-017-opentelemetry-migration.md` (OTel substrate, supersedes ADR-014 impl section). diff --git a/docs/decisions/adr-011-tdd-foundation.md b/docs/decisions/adr-011-tdd-foundation.md index 8d530a7..c827fa8 100644 --- a/docs/decisions/adr-011-tdd-foundation.md +++ b/docs/decisions/adr-011-tdd-foundation.md @@ -33,7 +33,7 @@ mock/real drift. run against every implementation (Mock + Payload). Eliminates the class of bug where the mock and the real impl drift apart. -5. **Tests in core-* packages and apps** — composition smoke tests +5. **Tests in core-\* packages and apps** — composition smoke tests (appRouter, payloadConfig, bind-production, providers). 6. **Storybook test-runner** — every story executed as a smoke test. @@ -60,7 +60,7 @@ mock/real drift. - New package to maintain (small, mostly stable surface). - Coverage thresholds may fail builds initially; we add tests to cross - threshold as part of Plan 7. + threshold as features land. - Sequence shuffle may surface latent flakes; we fix as found. - Templates for new features now require writing tests first; this is by design. diff --git a/docs/decisions/adr-012-feature-conventions.md b/docs/decisions/adr-012-feature-conventions.md index 5091b80..84cc1f3 100644 --- a/docs/decisions/adr-012-feature-conventions.md +++ b/docs/decisions/adr-012-feature-conventions.md @@ -94,7 +94,7 @@ Clean Architecture pattern, with four intentional divergences (§Adaptations bel | --------------- | ------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------ | | DI library | `@evyweb/ioctopus` | `inversify` | Already integrated; equivalent expressive power via `.toDynamicValue()`. | | DI scope | One global `ApplicationContainer` | One per feature (`authContainer`, `blogContainer`, …) | Vertical-feature isolation (ADR-008). | -| Test placement | `tests/unit/...` mirror | Colocated `*.test.{ts,tsx}` | Established by ADR-011 / Plan 7; clearer per-file ownership. | +| Test placement | `tests/unit/...` mirror | Colocated `*.test.{ts,tsx}` | Established by ADR-011; clearer per-file ownership. | | Instrumentation | Sentry/observability service wrapping | Not adopted | Out of scope; revisit when observability becomes a requirement. | `InputParseError` is also duplicated per feature (~6 lines × 5 @@ -161,7 +161,6 @@ beats DRY for a class this small. ## References -- Reference repo: https://github.com/nikolovlazar/nextjs-clean-architecture - Prior ADRs: ADR-006 (vertical-feature-packages), ADR-008 (per-feature DI containers), ADR-011 (TDD foundation) ## Update — 2026-05-06 diff --git a/docs/decisions/adr-015-events-and-jobs.md b/docs/decisions/adr-015-events-and-jobs.md index 0a1c47a..9548449 100644 --- a/docs/decisions/adr-015-events-and-jobs.md +++ b/docs/decisions/adr-015-events-and-jobs.md @@ -94,16 +94,14 @@ A shared `assertAnchors(repoRoot, relPath, anchors[])` helper at `turbo/generato - Two queue implementations means dev-seed handlers register via `queue.register(slug, ...)` while production relies on Payload tasks resolving from the per-feature container. The dispatch story differs by environment; the abstraction hides it but it's a real surface. - `InMemoryEventBus` is synchronous; `PayloadJobsEventBus` is asynchronous and at-least-once. Subscribers must be idempotent. - Six anchor comments in every feature is more visual noise than the average reader expects. Mitigated by the CI guard (so they can't drift accidentally) and the generators (so contributors don't need to know they exist). -- `void bus; void queue;` lines linger in feature binders that haven't yet wired any event/job (Phase 6 placeholder so `no-unused-vars` passes). Cosmetic; removed naturally as features adopt the system. +- `void bus; void queue;` lines linger in feature binders that haven't yet wired any event/job (placeholder so `no-unused-vars` passes). Cosmetic; removed naturally as features adopt the system. -## Notes from execution +## Implementation notes -- **Plan paused at the Phase 5/6 seam, not at a failure.** Phases 1–5 (additive — new packages, new lint rules, new anchor comments) finished cleanly. Phases 6–9 (invasive — every binder signature, every app's bindAll, generators, proof-of-life, docs) ran in a continuation session. -- **`InMemoryJobQueue.register()` was already in scope at Task 5** (`feat(core-shared/jobs): InMemoryJobQueue with register()`) so Task 34 Step 3's "add register" sub-step was already complete by the time bindAll-wiring landed. - **Auth is username-based, not email-based.** The spec's example contract had `email`; this ADR's `userSignedUpEvent` schema does keep `email`, but `signUpUseCase` synthesizes `${username}@example.local` to satisfy the contract. The proof-of-life flows record this synthesized email — the realism of the address is incidental to the cross-feature plumbing being verified. - **`apps/auth/src/di/module.ts` (the default-fallback DI module) gains `new InMemoryEventBus()` per `.toDynamicValue()` resolution.** Real cross-feature wiring runs through `bindProductionAuth` / `bindDevSeedAuth` where the bindAll-resolved bus is shared; the module's per-resolution bus is acceptable because the module is a default-mock fallback, not a runtime path. - **`@repo/auth` and `@repo/marketing-pages` exports were extended** for the e2e test: `./di/container`, `./di/symbols`, plus `marketing-pages` exposes `./services/mailer` and `./services/recording-mailer`. Containers and symbols being public is consistent with the binders already being public. -- **Generator-level fixes folded in during Phase 8:** dropped publisher prompt's `when` clause (Plop `--args` cannot bypass conditional prompts); switched event-task template to `TaskConfig<{ input; output }>` shape (runtime slugs aren't keys of `TypedJobs['tasks']`); registered a custom Handlebars `eq` helper for the void/typed branch in `gen job`'s template. +- **Generator-level fixes:** dropped publisher prompt's `when` clause (Plop `--args` cannot bypass conditional prompts); switched event-task template to `TaskConfig<{ input; output }>` shape (runtime slugs aren't keys of `TypedJobs['tasks']`); registered a custom Handlebars `eq` helper for the void/typed branch in `gen job`'s template. ## Out of scope (deferred) diff --git a/docs/decisions/adr-016-realtime-layer.md b/docs/decisions/adr-016-realtime-layer.md index c14a326..8d2b94d 100644 --- a/docs/decisions/adr-016-realtime-layer.md +++ b/docs/decisions/adr-016-realtime-layer.md @@ -25,7 +25,7 @@ The vendor-isolation principle from ADR-014 and ADR-015 carries over without mod **3. Four auth checkpoints, pure function authorization.** The connect handler (gate 1) reads the session cookie, calls `IRealtimeAuthenticator.authenticate()`, and attaches `{ userId, roles } | null` to `socket.data.user`. Channel subscribe (gate 2) matches the requested name against registered descriptors (template-aware for `"notifications.user.{userId}"`-style channels), calls `authorize(descriptor, params, user)`, and on success calls `socket.join("ch:")`. Inbound message (gate 3) re-validates schema and re-applies `authorize` as defense-in-depth, then invokes the wrapped handler with `ctx = { userId, roles }`. Broadcast (gate 4) has no gate — `io.to("ch:").emit(...)` fans out to whoever cleared gate 2; subscribe is the single source of truth. `authorize` is a pure function with no DB hit. -**4. Hybrid bus-bridge / direct-broadcast model.** The existing `IEventBus` from ADR-015 is reused as a *third consumer* in a bridge pattern: a `bindRealtimeBridge(bus, broadcaster, allowlist)` step in `bindAll()` subscribes to allowlisted bus events and forwards them onto realtime channels. The bridge allowlist ships empty in v1; the first entries land with the dashboard PR. Direct broadcast (feature use case adds `realtime: IRealtimeBroadcaster` to its factory deps and calls `realtime.broadcast(channel, payload)`) is the primary path; the bridge is for cases where bus events already exist and realtime is additive. +**4. Hybrid bus-bridge / direct-broadcast model.** The existing `IEventBus` from ADR-015 is reused as a _third consumer_ in a bridge pattern: a `bindRealtimeBridge(bus, broadcaster, allowlist)` step in `bindAll()` subscribes to allowlisted bus events and forwards them onto realtime channels. The bridge allowlist ships empty in v1; the first entries land with the dashboard PR. Direct broadcast (feature use case adds `realtime: IRealtimeBroadcaster` to its factory deps and calls `realtime.broadcast(channel, payload)`) is the primary path; the bridge is for cases where bus events already exist and realtime is additive. **5. Custom Node server for `apps/web-next`.** `apps/web-next/server.ts` replaces `next start` / `next dev` as the boot entry. Both Next.js and Socket.IO share one http server on port 3000. The `bindAll()` dispatcher gains two new resolution steps: `resolveRealtime()` (picks `InMemoryRealtimeBroadcaster` vs `SocketIORealtimeBroadcaster` by env) and the bridge wiring call. `bindAll(deps?)` is optional — callers may pass pre-constructed broadcaster/registry instances (the server does) or omit them and receive `InMemoryRealtimeBroadcaster` defaults (page-handler callers, existing tests). A `bound` guard ensures the Noop defaults are never silently accepted in production. @@ -59,6 +59,7 @@ Handlers are wrapped in the same `withSpan(tracer, { op: "realtime-handler" }, w ## Consequences **Positive:** + - Server-push to connected browser tabs without polling, across any feature on demand. - Vendor-swappable: replacing Socket.IO means writing one new adapter pair (`IRealtimeBroadcaster` + `IRealtimeServer`). - The existing `IEventBus` is reused as the bridge source; features that already publish events get realtime fan-out for free via one `allowlist` entry. @@ -66,14 +67,15 @@ Handlers are wrapped in the same `withSpan(tracer, { op: "realtime-handler" }, w - `RecordingRealtimeBroadcaster` in `core-testing` gives use-case tests a drop-in broadcaster that records calls, mirroring `RecordingEventBus`. **Negative:** + - `bindProductionX` / `bindDevSeedX` now take seven arguments `(config, tracer, logger, bus, queue, realtime, realtimeRegistry)`. Future expansion may warrant collapsing to a single `BindContext` object; deferred. - The custom Node server for `apps/web-next` means `next start` / `next dev` are no longer sufficient entry points. The CMS and TanStack apps still use their existing runtimes until they need realtime. - `InMemoryRealtimeBroadcaster` has no room/socket model — it stores all broadcasts in a flat array. Sufficient for unit testing; insufficient for integration tests that assert specific sockets received a broadcast. The `realtime-ping` integration test uses a real `SocketIORealtimeServer` in-process. ## Notes from execution -- **`RecordingRealtimeBroadcaster` scope-type alias widened during Phase 6.** The spec's local type alias `RealtimeChannelDescriptor` in `core-testing` was widened to handle the discriminated-union shape of the actual descriptor correctly. The recorded-broadcast entries use `{ channel: string; payload: unknown }` to avoid tying the recording type to the exact generic parameters. -- **`IRealtimeHandlerRegistry` gained `registerChannel` / `listChannels` during Phase 9.** The original spec had only `register` / `getInboundDescriptor` / `list`. `registerChannel` and `listChannels` were added to support outbound-only channel subscription: gate 2 (subscribe authorization) iterates `listChannels` + `register`ed descriptors independently of inbound handler registration, separating the "is this a known channel?" check from "does this channel have an inbound handler?" +- **`RecordingRealtimeBroadcaster` scope-type alias widened.** The spec's local type alias `RealtimeChannelDescriptor` in `core-testing` was widened to handle the discriminated-union shape of the actual descriptor correctly. The recorded-broadcast entries use `{ channel: string; payload: unknown }` to avoid tying the recording type to the exact generic parameters. +- **`IRealtimeHandlerRegistry` gained `registerChannel` / `listChannels`.** The original spec had only `register` / `getInboundDescriptor` / `list`. `registerChannel` and `listChannels` were added to support outbound-only channel subscription: gate 2 (subscribe authorization) iterates `listChannels` + `register`ed descriptors independently of inbound handler registration, separating the "is this a known channel?" check from "does this channel have an inbound handler?" - **`bindAll(deps?)` is optional with `InMemoryRealtimeBroadcaster` defaults.** Existing page-handler callers that invoke `bindAll()` with no args continue to work without modification. A `bound` guard ensures that in production, where `bindAll()` is always called from `server.ts` with explicit `SocketIORealtimeBroadcaster` / `RealtimeHandlerRegistry` args, the in-memory fallback is never silently wired. ## Out of scope (deferred) @@ -93,7 +95,7 @@ Items surfaced by the final branch review that were intentionally not landed in 3. **`matchChannelTemplate` placeholders cannot contain dots** (`packages/core-realtime/src/channel-template.ts:14-17` uses `([^.]+)`). Fine for UUID-style identifiers; document the constraint in `defineRealtimeChannel`'s JSDoc when the first non-UUID key shape arrives. 4. **`SocketIORealtimeServer` swallows handler errors with bare `catch {}`** (`packages/core-realtime/src/socket-io-realtime-server.ts:108-117`). Wrapped handlers (`withCapture`) already record the error; unwrapped handlers lose it. Adding a server-injected logger that records "handler_error for channel X" would help debug connection-level issues — defer until a debugging incident actually motivates it. 5. **`bindAll(deps?: Partial)` permits a half-populated deps object** that mixes a real broadcaster with a fresh registry (or vice versa). In practice no caller does this, but the type doesn't enforce all-or-nothing semantics. Tighten to `deps?: BindAllDeps` (full or absent) when the next consumer lands. -6. **AGENTS.md anchor count phrasing.** AGENTS.md says "three fixed `// ` anchor comments per feature." There are three *kinds* but four placements (the handlers anchor lives in both `bind-production.ts` and `bind-dev-seed.ts`). Tighten to "three anchor kinds across both bind files" when the AGENTS.md is next touched. +6. **AGENTS.md anchor count phrasing.** AGENTS.md says "three fixed `// ` anchor comments per feature." There are three _kinds_ but four placements (the handlers anchor lives in both `bind-production.ts` and `bind-dev-seed.ts`). Tighten to "three anchor kinds across both bind files" when the AGENTS.md is next touched. ## Related diff --git a/docs/guides/realtime.md b/docs/guides/realtime.md index 4cbe40a..52fdd62 100644 --- a/docs/guides/realtime.md +++ b/docs/guides/realtime.md @@ -31,12 +31,12 @@ pnpm turbo gen realtime --args channel blog article-feed public The fourth argument is the channel scope. Valid values: -| Scope arg | What it means | -|---|---| -| `public` | Any connected socket may subscribe (no auth required) | -| `authenticated` | Socket must have a valid session (gate 1 cleared) | -| `role:` | Socket must have the named role in `roles[]` | -| `user-scoped` | Socket must match `params.userId === socket.data.user.userId` (template channel) | +| Scope arg | What it means | +| --------------- | -------------------------------------------------------------------------------- | +| `public` | Any connected socket may subscribe (no auth required) | +| `authenticated` | Socket must have a valid session (gate 1 cleared) | +| `role:` | Socket must have the named role in `roles[]` | +| `user-scoped` | Socket must match `params.userId === socket.data.user.userId` (template channel) | This scaffolds: @@ -101,10 +101,7 @@ import type { IRealtimeBroadcaster } from "@repo/core-realtime"; import { articleFeedChannel } from "../../realtime/article-feed.channel"; export const publishArticleUseCase = - ( - articles: IArticlesRepository, - realtime: IRealtimeBroadcaster, - ) => + (articles: IArticlesRepository, realtime: IRealtimeBroadcaster) => async (input: PublishArticleInput): Promise => { const article = await articles.publish(input.id); await realtime.broadcast(articleFeedChannel, { @@ -143,7 +140,8 @@ export function bindProductionBlog( publishArticleUseCase(articlesRepo, realtime), ), ); - blogContainer.bind(BLOG_SYMBOLS.IPublishArticleUseCase) + blogContainer + .bind(BLOG_SYMBOLS.IPublishArticleUseCase) .toConstantValue(wrappedPublishArticle); } ``` @@ -248,16 +246,24 @@ it("marks the user as viewing the article", async () => { const handler = onArticleFeedHandler(presence); await handler( - { id: "article-1", slug: "hello", title: "Hello", publishedAt: "2026-05-08T00:00:00.000Z" }, + { + id: "article-1", + slug: "hello", + title: "Hello", + publishedAt: "2026-05-08T00:00:00.000Z", + }, { userId: "user_1", roles: [] }, ); expect(presence.markViewingCalls).toHaveLength(1); - expect(presence.markViewingCalls[0]).toMatchObject({ userId: "user_1", articleId: "article-1" }); + expect(presence.markViewingCalls[0]).toMatchObject({ + userId: "user_1", + articleId: "article-1", + }); }); ``` -Handlers MUST NOT be re-exported from the feature's public surface (rule R1 — enforced by `core-eslint`'s `no-realtime-handler-reexport` rule). The bind files wire handlers internally; `src/index.ts` must never re-export from `realtime/handlers/`. +Handlers MUST NOT be re-exported from the feature's public surface (enforced by `core-eslint`'s `no-realtime-handler-reexport` rule). The bind files wire handlers internally; `src/index.ts` must never re-export from `realtime/handlers/`. **Verify:** @@ -271,12 +277,12 @@ pnpm --filter @repo/blog lint typecheck test Three fixed anchor comments live in every feature for realtime: -| File | Anchor | Used by | -|---|---|---| -| `src/index.ts` | `// ` | `gen realtime channel` | -| `src/di/symbols.ts` | `// ` | `gen realtime handler` | -| `src/di/bind-production.ts` | `// ` | `gen realtime handler` | -| `src/di/bind-dev-seed.ts` | `// ` | `gen realtime handler` | +| File | Anchor | Used by | +| --------------------------- | ----------------------------------- | ---------------------- | +| `src/index.ts` | `// ` | `gen realtime channel` | +| `src/di/symbols.ts` | `// ` | `gen realtime handler` | +| `src/di/bind-production.ts` | `// ` | `gen realtime handler` | +| `src/di/bind-dev-seed.ts` | `// ` | `gen realtime handler` | The CI guard at `packages/core-eslint/anchors.test.js` asserts these stay present in every feature. Remove one and CI fails — restore it and the test goes green. diff --git a/docs/scaffolding/core-package-generator.md b/docs/scaffolding/core-package-generator.md index ecdbc26..e621a45 100644 --- a/docs/scaffolding/core-package-generator.md +++ b/docs/scaffolding/core-package-generator.md @@ -17,17 +17,17 @@ The generator emits the package files, updates consuming-app config (e.g. `apps/ ## Available templates -| Name | Description | Phase added | -|---|---|---| -| `realtime` | Socket.IO realtime layer (ADR-016) | Phase 3 | -| `events` | Cross-feature event bus + Payload jobs adapter (ADR-015) | Phase 4 | -| `trpc` | tRPC server setup | Phase 5 | -| `ui` | Design-system package | Phase 6 | -| `audit` | DPA-compliant audit logging (ADR-018) | Phase 7 | +| Name | Description | +| ---------- | -------------------------------------------------------- | +| `realtime` | Socket.IO realtime layer (ADR-016) | +| `events` | Cross-feature event bus + Payload jobs adapter (ADR-015) | +| `trpc` | tRPC server setup | +| `ui` | Design-system package | +| `audit` | DPA-compliant audit logging (ADR-018) | ## Verifying an existing project -If your project already has a core-* package and you want to verify the generator's template hasn't drifted from the shipped source, use the byte-identical reconstruction snapshot: +If your project already has a core-\* package and you want to verify the generator's template hasn't drifted from the shipped source, use the byte-identical reconstruction snapshot: ```bash git stash -u @@ -36,4 +36,4 @@ git diff packages/core-/ # Expect: zero diff (modulo .hbs strip + trailing-newline normalization) ``` -Snapshots live at `turbo/generators/__snapshots__/core-package/.snapshot.json` (added in Phases 3-6). +Snapshots live at `turbo/generators/__snapshots__/core-package/.snapshot.json`. diff --git a/packages/core-eslint/base.js b/packages/core-eslint/base.js index efa673b..7fff7db 100644 --- a/packages/core-eslint/base.js +++ b/packages/core-eslint/base.js @@ -13,7 +13,15 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, "..", ".."); export default [ - { ignores: ["dist/**", "node_modules/**", ".next/**", ".turbo/**", "storybook-static/**"] }, + { + ignores: [ + "dist/**", + "node_modules/**", + ".next/**", + ".turbo/**", + "storybook-static/**", + ], + }, js.configs.recommended, { files: ["**/*.{mjs,cjs,js}", "**/*.config.{ts,tsx}"], @@ -34,15 +42,9 @@ export default [ rules: { // Structural conformance rules (milestone iii.a). // All 5 features now have manifests; promoted to ERROR. - "conformance/feature-must-have-manifest": [ - "error", - { repoRoot }, - ], + "conformance/feature-must-have-manifest": ["error", { repoRoot }], "conformance/usecase-must-have-test-file": "error", - "conformance/required-cores-installed": [ - "error", - { repoRoot }, - ], + "conformance/required-cores-installed": ["error", { repoRoot }], "conformance/no-undeclared-event-publish": ["warn", { repoRoot }], "conformance/no-undeclared-audit": ["warn", { repoRoot }], "conformance/component-must-have-story": "warn", @@ -83,7 +85,10 @@ export default [ { default: "disallow", rules: [ - { from: "app", allow: ["app", "core", "core-composition", "feature", "tooling"] }, + { + from: "app", + allow: ["app", "core", "core-composition", "feature", "tooling"], + }, { from: "feature", allow: ["core", "tooling"] }, { from: "core", allow: ["core", "tooling"] }, { from: "core-composition", allow: ["core", "feature", "tooling"] }, @@ -93,7 +98,7 @@ export default [ ], }, }, - // R40 — block direct @sentry/* imports outside the allowlisted instrumentation paths + // Block direct @sentry/* imports outside the allowlisted instrumentation paths. { files: ["**/*.{ts,tsx,mjs,cjs,js}"], rules: { @@ -104,19 +109,17 @@ export default [ { group: ["@sentry/*"], message: - "Import from @repo/core-shared/instrumentation instead — feature packages must not depend on Sentry directly (R40).", + "Import from @repo/core-shared/instrumentation instead — feature packages must not depend on Sentry directly.", }, ], }, ], }, }, - // R40 allowlist — the only paths permitted to import @sentry/*. - // After the OTel migration (ADR-017): server-side Sentry SDK usage is limited to - // the OTel bridge + browser/client init files. The @sentry/* ESLint restriction - // stays; the allowlist is narrowed from the original "**/instrumentation/sentry/**". - // Patterns are double-star prefixed so they match whether eslint runs from - // the repo root or from inside a sub-package. + // Allowlist — the only paths permitted to import @sentry/*. + // Per ADR-017, server-side Sentry SDK usage is limited to the OTel bridge + // and browser/client init files. Patterns are double-star prefixed so they + // match whether eslint runs from the repo root or from inside a sub-package. { files: [ // OTel bridge — the only server-side file that may import @sentry/opentelemetry @@ -152,7 +155,7 @@ export default [ "no-restricted-imports": "off", }, }, - // R52 — OTel SDK packages (@opentelemetry/sdk-*, @opentelemetry/resources, + // OTel SDK packages (@opentelemetry/sdk-*, @opentelemetry/resources, // @opentelemetry/semantic-conventions, @opentelemetry/instrumentation-*, // @sentry/opentelemetry) are restricted to core-shared/instrumentation/otel/ // and app-level init paths. diff --git a/packages/core-testing/src/setup/no-instrumentation.ts b/packages/core-testing/src/setup/no-instrumentation.ts index 8d2752b..d8d453e 100644 --- a/packages/core-testing/src/setup/no-instrumentation.ts +++ b/packages/core-testing/src/setup/no-instrumentation.ts @@ -1,7 +1,7 @@ import { vi } from "vitest"; /** - * R49 — guard against real Sentry SDK + OTel SDK initialization in test processes. + * Guard against real Sentry SDK + OTel SDK initialization in test processes. * * Mocks @sentry/* and key @opentelemetry/sdk-* modules at the module level so * any code that imports them receives a no-op surface. Tests that need to assert @@ -78,8 +78,12 @@ vi.mock("@opentelemetry/sdk-node", () => ({ BatchSpanProcessor: class { onStart() {} onEnd() {} - forceFlush() { return Promise.resolve(); } - shutdown() { return Promise.resolve(); } + forceFlush() { + return Promise.resolve(); + } + shutdown() { + return Promise.resolve(); + } }, }, })); @@ -88,8 +92,12 @@ vi.mock("@sentry/opentelemetry", () => ({ SentrySpanProcessor: class { onStart() {} onEnd() {} - forceFlush() { return Promise.resolve(); } - shutdown() { return Promise.resolve(); } + forceFlush() { + return Promise.resolve(); + } + shutdown() { + return Promise.resolve(); + } }, // SentryLogRecordProcessor does NOT exist in @sentry/opentelemetry v10 — omitted. // No-op Sentry.init wrapper used by sentry-bridge.ts diff --git a/turbo/generators/templates/core-package/realtime-eslint-rules/no-realtime-handler-reexport.js.hbs b/turbo/generators/templates/core-package/realtime-eslint-rules/no-realtime-handler-reexport.js.hbs index b428360..ca4cf48 100644 --- a/turbo/generators/templates/core-package/realtime-eslint-rules/no-realtime-handler-reexport.js.hbs +++ b/turbo/generators/templates/core-package/realtime-eslint-rules/no-realtime-handler-reexport.js.hbs @@ -1,5 +1,5 @@ // packages/core-eslint/rules/no-realtime-handler-reexport.js -// R1 — Realtime handlers are private. A feature's realtime/handlers/*.handler.ts +// Realtime handlers are private. A feature's realtime/handlers/*.handler.ts // must only be wired in the feature's own bind-production / bind-dev-seed files. // They must never be re-exported from barrel files or other public surfaces.