Initial commit
This commit is contained in:
681
docs/architecture/agent-first-workflow-and-conformance.md
Normal file
681
docs/architecture/agent-first-workflow-and-conformance.md
Normal file
@@ -0,0 +1,681 @@
|
||||
---
|
||||
title: Agent-first development workflow + feature conformance
|
||||
status: design
|
||||
created: 2026-05-12
|
||||
authors: [danijel, claude]
|
||||
related:
|
||||
- docs/architecture/feature-conformance-explainer.html
|
||||
- docs/architecture/vertical-feature-spec.md
|
||||
- docs/guides/tdd-workflow.md
|
||||
- docs/guides/scaffolding-a-feature.md
|
||||
- CLAUDE.md
|
||||
---
|
||||
|
||||
# Agent-first development workflow + feature conformance
|
||||
|
||||
## Why this document exists
|
||||
|
||||
`template-vertical` is being shaped around the assumption that **AI coding agents will author most feature work**. Humans set direction, write PRDs (with agent help), review diffs, and intervene when escalation is needed; agents do the bulk of the coding. This document defines:
|
||||
|
||||
1. The **feature-conformance enforcement system** that gives agents tight, layered, machine-readable feedback on architectural drift.
|
||||
2. The **agent-first workflow** — manifest → contracts → tests → code — that the conformance system enforces.
|
||||
3. The **local task system** at `docs/work/` that holds PRDs, epics, stories, and tasks as markdown, parseable by both humans and agents.
|
||||
4. The **sandcastle orchestrator** that dispatches implementer and reviewer agents per task, respecting a dependency DAG.
|
||||
|
||||
These four pillars are co-designed. The conformance system is the enforcement substrate; the workflow is the shape of work; the task system is the address space; sandcastle is the dispatch loop.
|
||||
|
||||
The conformance design is illustrated separately at [`docs/architecture/feature-conformance-explainer.html`](./feature-conformance-explainer.html). This document complements that with the surrounding workflow + tooling.
|
||||
|
||||
## 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` |
|
||||
|
||||
The same artifacts are read by humans, AI implementers, AI reviewers, the orchestrator, and the pre-commit hooks. There is no separate "process layer."
|
||||
|
||||
## Hierarchy
|
||||
|
||||
```
|
||||
PRD (initiative — one .prd.md)
|
||||
└── Epic (large body of work — one folder + _epic.md)
|
||||
└── Story (one use case OR one technical capability)
|
||||
└── Task (one vertical slice = one PR = one commit)
|
||||
└── Subtask (rare; only for unexpectedly complex slices)
|
||||
```
|
||||
|
||||
**Feature is metadata, not a hierarchy level.** The use-case identifier (`auth.signUp`) already encodes the feature. ClickUp/Linear/etc. tags can carry it; in this system it sits in frontmatter.
|
||||
|
||||
**Story type is metadata** — `user-story` or `technical-story` — same hierarchy slot, different body template:
|
||||
|
||||
- **User story:** `As a <role>, I want <action>, so that <outcome>`
|
||||
- **Technical story:** `Goal / Why / Done when`
|
||||
|
||||
## File system
|
||||
|
||||
```
|
||||
docs/work/
|
||||
├── README.md # how this folder is used
|
||||
├── _state.json # derived, committed, orchestrator-managed
|
||||
├── _templates/
|
||||
│ ├── prd.template.md
|
||||
│ ├── epic.template.md
|
||||
│ ├── user-story.template.md
|
||||
│ ├── technical-story.template.md
|
||||
│ └── task.template.md
|
||||
├── prds/
|
||||
│ ├── 2026-05-12-conformance-system.prd.md
|
||||
│ └── ...
|
||||
├── conformance-system-v1/ # one folder per epic
|
||||
│ ├── _epic.md
|
||||
│ ├── 01-define-feature-helper/ # one folder per story
|
||||
│ │ ├── _story.md
|
||||
│ │ ├── 01-define-feature-helper-exists.task.md
|
||||
│ │ ├── 02-instrumented-brand-attached.task.md
|
||||
│ │ └── ...
|
||||
│ └── ...
|
||||
└── work-system-v1/
|
||||
└── ...
|
||||
|
||||
.sandcastle/
|
||||
├── Dockerfile # extends existing CI image
|
||||
├── implementer.prompt.md
|
||||
├── reviewer.prompt.md
|
||||
├── decomposer.prompt.md
|
||||
├── prd-eliciter.prompt.md
|
||||
└── .env.example
|
||||
|
||||
scripts/
|
||||
├── work-prd-new.ts # invokes PRD elicitation skill
|
||||
├── work-decompose.ts # PRD → epic + stories
|
||||
├── work-decompose-tasks.ts # story → tasks
|
||||
├── work-dispatch.ts # orchestrator loop
|
||||
├── work-status.ts # human-readable status tree
|
||||
└── work-rebuild-state.ts # regen _state.json from markdown
|
||||
```
|
||||
|
||||
### Naming conventions
|
||||
|
||||
- **Underscored system files** (`_epic.md`, `_story.md`, `_state.json`, `_templates/`) — orchestrator-managed indexes or templates
|
||||
- **Numeric prefix on filenames** (`01-`, `02-`) — execution order; doubles as sort key
|
||||
- **`<slug>.task.md`** — individual tasks
|
||||
- **`<date>-<slug>.prd.md`** — PRDs date-prefixed for chronological sort
|
||||
|
||||
## File formats
|
||||
|
||||
### PRD — `docs/work/prds/<date>-<slug>.prd.md`
|
||||
|
||||
```markdown
|
||||
---
|
||||
id: 2026-05-12-conformance-system
|
||||
title: Feature Conformance System
|
||||
type: prd
|
||||
status: draft | in-review | approved | superseded
|
||||
author: danijel
|
||||
elicitation-session: <agent-session-id>
|
||||
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 "<one-line idea>"`
|
||||
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`
|
||||
4. Human reviews, edits, flips to `status: approved`
|
||||
5. **Decomposer refuses to run on `draft` PRDs.**
|
||||
|
||||
### Epic — `<epic-slug>/_epic.md`
|
||||
|
||||
```markdown
|
||||
---
|
||||
id: conformance-system-v1
|
||||
prd: 2026-05-12-conformance-system
|
||||
title: Conformance system v1
|
||||
type: epic
|
||||
status: todo | in-progress | done | cancelled
|
||||
features: [cross-cutting]
|
||||
created: 2026-05-12
|
||||
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)
|
||||
- ...
|
||||
```
|
||||
|
||||
### Story (technical) — `<epic>/<story>/_story.md`
|
||||
|
||||
```markdown
|
||||
---
|
||||
id: 01-define-feature-helper
|
||||
epic: conformance-system-v1
|
||||
title: defineFeature helper + Instrumented brand
|
||||
type: technical-story
|
||||
status: todo | in-progress | done
|
||||
feature: core-shared
|
||||
depends-on: []
|
||||
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<F>`, `Captured<F>`, `Audited<F>`
|
||||
- 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)
|
||||
- ...
|
||||
```
|
||||
|
||||
### Story (user) — same skeleton, body uses As a / I want / So that
|
||||
|
||||
```markdown
|
||||
---
|
||||
id: 01-sign-up
|
||||
epic: auth-v1
|
||||
title: Sign up with email and password
|
||||
type: user-story
|
||||
status: todo
|
||||
feature: auth
|
||||
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)
|
||||
- ...
|
||||
```
|
||||
|
||||
### Task — `<epic>/<story>/<slug>.task.md`
|
||||
|
||||
```markdown
|
||||
---
|
||||
id: 02-instrumented-brand-attached
|
||||
story: 01-define-feature-helper
|
||||
epic: conformance-system-v1
|
||||
title: Attach Instrumented<F> brand via withSpan
|
||||
type: task
|
||||
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
|
||||
attempts: { implementer: 0, reviewer: 0 }
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Attach the `Instrumented<F>` 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>` = `F & { readonly __instrumented: true }`
|
||||
- [ ] `withSpan` return type is `Instrumented<typeof fn>`
|
||||
- [ ] Brand re-exported from `@repo/core-shared/conformance`
|
||||
- [ ] Test asserts wrapped function carries brand at the type level
|
||||
- [ ] 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.
|
||||
```
|
||||
|
||||
## State file — `docs/work/_system/_state.json`
|
||||
|
||||
A derived, committed, orchestrator-written index. Markdown is source of truth; `_state.json` is a fast-to-query mirror.
|
||||
|
||||
```json
|
||||
{
|
||||
"updated_at": "2026-05-12T16:42:00Z",
|
||||
"ready": ["02-instrumented-brand-attached"],
|
||||
"in_progress": [],
|
||||
"blocked": [],
|
||||
"escalated": [],
|
||||
"epics": {
|
||||
"conformance-system-v1": {
|
||||
"status": "in-progress",
|
||||
"ac_total": 47,
|
||||
"ac_completed": 8,
|
||||
"stories": {
|
||||
"01-define-feature-helper": {
|
||||
"status": "in-progress",
|
||||
"ac_total": 9,
|
||||
"ac_completed": 4,
|
||||
"tasks": {
|
||||
"01-define-feature-helper-exists": {
|
||||
"status": "done",
|
||||
"depends_on": [],
|
||||
"blocks": ["02-instrumented-brand-attached"],
|
||||
"ac_total": 4,
|
||||
"ac_completed": 4,
|
||||
"attempts": { "implementer": 1, "reviewer": 1 },
|
||||
"branch": "task/01-define-feature-helper-exists",
|
||||
"completed_at": "2026-05-12T14:23:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Rules for `_state.json`
|
||||
|
||||
1. **Committed to git.** Audit trail visible in PRs.
|
||||
2. **Single writer:** orchestrator + pre-commit hook only. No agent writes it directly.
|
||||
3. **Derived from markdown.** Regenerable any time via `pnpm work rebuild-state`.
|
||||
4. **Canonical formatting.** Sorted keys, stable indentation, no trailing whitespace. Pre-commit normalizes via Prettier.
|
||||
5. **Merges serialized.** Orchestrator merges PRs one at a time. Parallel implementation in sandboxes is fine; only merge step is sequential.
|
||||
6. **Pre-commit regen.** When any `.task.md` / `.story.md` / `_epic.md` is staged, the hook regenerates `_state.json` from the markdown, re-stages it, and lets the commit proceed. The hook only blocks the commit if regeneration itself fails (e.g. malformed frontmatter, broken `depends-on` reference). This makes the markdown the unambiguous source of truth: if humans edit checkboxes directly, the JSON quietly catches up.
|
||||
|
||||
## Scope guards
|
||||
|
||||
| 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.
|
||||
|
||||
## Conformance system integration
|
||||
|
||||
The five 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 |
|
||||
| Fallow audit (`pnpm fallow`) | ~30–60s | whole-codebase — dead exports, duplicate code, circular deps, complexity hotspots |
|
||||
|
||||
### 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<F>` — 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 five layers pass
|
||||
6. PR submitted
|
||||
|
||||
Each step gives sub-second feedback. The agent's iteration loop is dominated by think + write, not by waiting for feedback.
|
||||
|
||||
## Workflow ordering (per task)
|
||||
|
||||
For any new use case or new behavior:
|
||||
|
||||
1. **Manifest** — declare the use case (or update audits/publishes/consumes if the task adds them). Pure declaration.
|
||||
2. **Contracts** — `xInputSchema`, `xOutputSchema`, `IXUseCase` type alias in the use-case file. Factory body throws `"not implemented"` if not yet written.
|
||||
3. **Tests (red)** — import contracts; write failing assertions that match the AC bullet.
|
||||
4. **Implementation (green)** — fill factory body, repository, binding, until tests pass.
|
||||
|
||||
For incremental work on an existing use case, step 1 is often a no-op (manifest already declared). For the first slice of a new use case, all four steps happen in one commit.
|
||||
|
||||
## Work shapes
|
||||
|
||||
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/<feature>/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.
|
||||
|
||||
### Frontend (see `docs/guides/frontend-work-shape.md`)
|
||||
|
||||
- **Atomic design tiers** — atoms / molecules / organisms / templates / pages, generated via `pnpm turbo gen core-ui-component`. Tier-direction enforced by a new ESLint rule (`atomic-tier-import-direction`).
|
||||
- **Storybook is the spec.** Each AC bullet on a UI task maps to a story variant or a Storybook `play` function. Story files become the shared visual contract between human, implementer, and reviewer.
|
||||
- **Two test layers:**
|
||||
- **Component tests** (Vitest + Testing Library, or `play` on stories) — pre-commit gate, <5s
|
||||
- **Visual regression** via **Playwright screenshot tests** — CI gate, 30–120s, blocks PR merge on unapproved visual diffs
|
||||
- **Adapted four-step ordering for a pure UI slice:**
|
||||
1. **Story file** (the visual spec — analogous to the manifest entry for backend work)
|
||||
2. **Contracts** — props interface, variant types
|
||||
3. **Tests (red)** — component test + a default story
|
||||
4. **Implementation (green)** — make the component render and pass tests
|
||||
- **Reviewer agent** uses the existing Storybook MCP at `http://localhost:6006/mcp` to read existing components, list variants, and verify story coverage against the task's AC. Visual diff verdicts come from the Playwright screenshot CI step.
|
||||
- **Story split for page-level features:**
|
||||
```
|
||||
Epic: auth-v1
|
||||
├── Story (user): auth.signUp use case ← backend slices
|
||||
├── Story (technical): SignUpForm component(s) ← depends-on: signUp use case
|
||||
└── Story (technical): /sign-up page composition + E2E ← depends-on: SignUpForm
|
||||
```
|
||||
Each `depends-on` edge enforces sequential dispatch by the orchestrator.
|
||||
|
||||
### Infrastructure (see `docs/guides/infrastructure-work-shape.md`)
|
||||
|
||||
- **ADRs precede infrastructure work** the same way PRDs precede feature work — decision first, code second. ADRs live at `docs/adr/NNN-<slug>.md`.
|
||||
- **Two categories:**
|
||||
- **New optional core package** — `pnpm turbo gen core-package <name>`. Generator + conformance already accommodate (via `requiredCores` in manifests). No conformance extensions required.
|
||||
- **New infrastructure layer** (Redis, CDN, alternative CMS, additional message bus, …) — ADR + integration PRD + multiple stories.
|
||||
- **ADR authoring flow:**
|
||||
1. Human runs `pnpm work adr-new "<one-line proposal>"`
|
||||
2. **Dedicated ADR elicitation skill** interviews the human on Context / Drivers / Considered options / Trade-offs / Decision / Consequences (similar shape to the PRD elicitation skill but distinct template + heuristics)
|
||||
3. Agent drafts ADR at `docs/adr/NNN-<slug>.md` with `status: proposed`
|
||||
4. Human reviews, flips to `status: accepted` (or `rejected` / `superseded`)
|
||||
5. Accepted ADR(s) trigger integration PRD(s); the PRD flow proceeds normally
|
||||
- **Conformance extensions for infra:**
|
||||
- `core-package-shape-conforms-to-generator` — extends milestone iv's scaffold-drift check to core packages
|
||||
- `required-cores-in-workspace` — manifest declarations must match `pnpm-workspace.yaml` (already in milestone iv)
|
||||
|
||||
Two elicitation skills now sit at the funnel mouth — one for PRDs, one for ADRs. Same interview-style intake; different templates and decision frameworks.
|
||||
|
||||
## Pre-commit gates
|
||||
|
||||
When a commit lands in the sandbox or locally:
|
||||
|
||||
1. **Type-check** — brand satisfaction, manifest typing
|
||||
2. **Lint** — manifest ↔ code rules, in-file shape rules, pattern restrictions
|
||||
3. **Conformance script** — `pnpm conformance` (boot-style assertion at static scope)
|
||||
4. **Tests for changed feature** — `pnpm test --filter @repo/<feature>` 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.
|
||||
|
||||
## Agent roles
|
||||
|
||||
### PRD eliciter agent
|
||||
|
||||
- Skill: dedicated PRD elicitation (interview-style, similar shape to `superpowers:brainstorming`)
|
||||
- Inputs: short brief from human (`pnpm work prd-new "<idea>"`)
|
||||
- Behavior: asks questions one at a time, builds shared understanding across Problem / Goal / Scope / Constraints / Success / Requirements
|
||||
- Output: `<date>-<slug>.prd.md` with `status: draft`
|
||||
- Hand-off: human reviews, flips to `status: approved`
|
||||
|
||||
### ADR eliciter agent
|
||||
|
||||
- Skill: dedicated ADR elicitation (interview-style; distinct from PRD elicitation)
|
||||
- Inputs: short brief from human (`pnpm work adr-new "<proposal>"`)
|
||||
- Behavior: drives the conversation across Context / Drivers / Considered options / Trade-offs / Decision / Consequences. Pushes the human to articulate alternatives explicitly before settling on a decision.
|
||||
- Output: `docs/adr/NNN-<slug>.md` with `status: proposed`
|
||||
- Hand-off: human reviews, flips to `status: accepted` (or `rejected` / `superseded`)
|
||||
- Accepted ADRs are the trigger for downstream integration PRDs
|
||||
|
||||
### Decomposer agent
|
||||
|
||||
- Skill: structured PRD-to-epic-and-stories decomposition
|
||||
- Inputs: a PRD file with `status: approved`
|
||||
- Behavior: produces `_epic.md` and one `_story.md` per requirement, with story-level AC bullets that hint at task decomposition
|
||||
- **Default scope: stories only.** Task-level decomposition is a second pass: `pnpm work decompose-tasks <story>`
|
||||
- Does NOT write `_state.json` directly (orchestrator does that on next dispatch tick)
|
||||
|
||||
### Implementer agent
|
||||
|
||||
- Sandcastle dispatch with `implementer.prompt.md`
|
||||
- Inputs: a single task markdown file (full context)
|
||||
- Behavior: writes code + tests to satisfy the AC; runs `pnpm test --filter` and `pnpm conformance` locally; commits; pushes the sandbox branch
|
||||
- **Read-only on task markdown.** Returns structured output via sandcastle:
|
||||
```json
|
||||
{
|
||||
"status": "complete" | "blocked" | "needs-clarification",
|
||||
"ac_satisfied": [0, 1, 2, 3],
|
||||
"files_changed": ["packages/.../with-span.ts", "..."],
|
||||
"commit_sha": "abc123",
|
||||
"notes": "..."
|
||||
}
|
||||
```
|
||||
- Does NOT edit `.task.md`, `.story.md`, `_epic.md`, or `_state.json`. The orchestrator translates the structured output into markdown checkbox flips and JSON state updates in a single post-merge commit.
|
||||
|
||||
### Reviewer agent
|
||||
|
||||
- Sandcastle dispatch with `reviewer.prompt.md`
|
||||
- Inputs: task markdown + diff from implementer
|
||||
- Behavior: verifies each AC bullet against the diff; checks `Out of scope` is respected; verifies tests cover AC bullets; runs `pnpm conformance` and `pnpm test --filter`
|
||||
- Returns structured output:
|
||||
```json
|
||||
{
|
||||
"decision": "approve" | "reject",
|
||||
"ac_verified": [0, 1, 2, 3],
|
||||
"scope_violations": [],
|
||||
"notes": "..."
|
||||
}
|
||||
```
|
||||
- Does NOT edit anything in the repo.
|
||||
- **For frontend tasks**, the reviewer additionally:
|
||||
- Queries the Storybook MCP (`http://localhost:6006/mcp`) to verify story coverage and inspect rendered output
|
||||
- Treats the Playwright screenshot CI step's verdict as a required input — unapproved visual diffs trigger `reject`
|
||||
|
||||
### Orchestrator
|
||||
|
||||
- Plain TypeScript script (`scripts/work-dispatch.ts`)
|
||||
- Reads `_state.json` to find ready tasks (all deps `done`)
|
||||
- For each ready task:
|
||||
1. Marks task `status: in-progress`, regenerates `_state.json`, commits the marker
|
||||
2. Dispatches sandcastle implementer
|
||||
3. On implementer return: dispatches sandcastle reviewer with task + diff
|
||||
4. On reviewer `approve`: serial-merges to `main`, in one commit flips task checkbox, increments parent story checkbox if all tasks done, increments parent epic checkbox if all stories done, regenerates `_state.json`
|
||||
5. On reviewer `reject`: appends reviewer notes to the task's "Reviewer notes" section, increments `attempts.implementer`, re-dispatches (subject to `max-attempts`)
|
||||
6. On `attempts.implementer >= max-attempts`: marks `status: escalated`, posts a summary, stops dispatching
|
||||
- Continues until no ready tasks remain
|
||||
|
||||
## Sandcastle config
|
||||
|
||||
`.sandcastle/Dockerfile` **extends the existing CI image**. To be identified during the work-system-v1 epic. Must include:
|
||||
|
||||
- Node + pnpm at repo's pinned versions
|
||||
- `pnpm install --frozen-lockfile` baked in
|
||||
- Access to `pnpm conformance`, `pnpm test`, `pnpm lint`, `pnpm typecheck`
|
||||
- Git config for agent commits
|
||||
|
||||
Prompt templates use sandcastle's `{{VAR}}` substitution + `` !`cmd` `` injection:
|
||||
|
||||
- `implementer.prompt.md` uses `{{TASK_FILE_CONTENT}}` and `` !`git log -1 --oneline` `` for context
|
||||
- `reviewer.prompt.md` uses `{{TASK_FILE_CONTENT}}` + `{{DIFF}}`
|
||||
- `decomposer.prompt.md` uses `{{PRD_FILE_CONTENT}}`
|
||||
- `prd-eliciter.prompt.md` uses `{{INITIAL_BRIEF}}` and runs the interview loop
|
||||
|
||||
Branch strategy: per-task feature branch (`task/<task-id>`), merged sequentially to `main` by the orchestrator.
|
||||
|
||||
## Bootstrap order — conformance first
|
||||
|
||||
### 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.
|
||||
|
||||
Stories in order (each itself a vertical slice — system code + generator update + doc update + applied to one feature):
|
||||
|
||||
1. **defineFeature helper + Instrumented brand** — applied to `auth.signIn`
|
||||
2. **Captured + Audited brands** — wrappers updated
|
||||
3. **`assertConformance` + boot wiring** — rolled to all three apps
|
||||
4. **AST-aware ESLint rules** — 5–6 new type-aware rules
|
||||
5. **CI drift gate** (`pnpm conformance`)
|
||||
6. **Generator emits manifest + contracts + test stubs**
|
||||
7. **Documentation rewrite** — agent-workflow.md, CLAUDE.md, AGENTS.md, tdd-workflow.md
|
||||
8. **Migrate auth feature** to the new pattern (reference)
|
||||
|
||||
### Tier 2 — Work system (human-driven, bootstraps automation)
|
||||
|
||||
Once conformance is in place, build the dispatch substrate:
|
||||
|
||||
1. `docs/work/` skeleton, README, templates
|
||||
2. `_state.json` schema + `pnpm work rebuild-state`
|
||||
3. Orchestrator script + DAG + retry-cap logic
|
||||
4. `.sandcastle/` config (extends existing CI image)
|
||||
5. PRD elicitation skill
|
||||
6. ADR elicitation skill (separate skill, similar interview shape)
|
||||
7. Decomposer agent + prompts
|
||||
8. Implementer + reviewer prompts (with Storybook MCP wiring for frontend reviewer)
|
||||
9. `pnpm work` CLI surface (including `work adr-new`)
|
||||
10. Pre-commit hooks (state regen, conformance gate)
|
||||
11. Playwright screenshot test infrastructure (CI gate for frontend tasks)
|
||||
|
||||
### Tier 3 — Migration + future work (dispatch-driven)
|
||||
|
||||
With both systems in place, remaining feature migrations and all future work flows through sandcastle:
|
||||
|
||||
- Migrate `blog`, `media`, `navigation`, `marketing-pages` (one story each)
|
||||
- All new features authored via PRD → decompose → dispatch loop
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
These are explicitly deferred until the tier that needs them:
|
||||
|
||||
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.
|
||||
|
||||
## Open questions (to revisit during implementation)
|
||||
|
||||
- **Q1: Single manifest registry per app, or per-feature?** (From the explainer §10.) Lean: per-feature, with a tiny app-side aggregator.
|
||||
- **Q2: How much of the manifest is generated vs hand-written?** Lean: humans edit; ESLint flags mismatches without auto-fixing.
|
||||
- **Q3: Inline symbol declaration in manifest, or registry mapping?** Lean: registry holds the mapping, manifest stays content-only.
|
||||
- **Q4: What happens when an optional core is absent?** Lean: typed surface gates the field — `audits: readonly never[]` when `core-audit` is unbound.
|
||||
- **Q5: Escape hatch for legitimate exceptions?** Lean: `// @conformance-skip: <rule> — <reason>` comment honoured by ESLint + boot assertion, with allowlist growth gated in CI.
|
||||
|
||||
## Acceptance criteria (for this whole design)
|
||||
|
||||
This design is "done" when:
|
||||
|
||||
- [ ] `docs/work/` exists with templates, `_state.json` schema, README
|
||||
- [ ] Conformance system v1 is implemented through all five enforcement layers
|
||||
- [ ] All five feature packages have manifests
|
||||
- [ ] All three apps run `assertConformance` at boot
|
||||
- [ ] `pnpm conformance` is a CI gate
|
||||
- [ ] `turbo gen feature` emits manifest + contracts + test stubs
|
||||
- [ ] `.sandcastle/` config exists with implementer/reviewer/decomposer/eliciter prompts
|
||||
- [ ] PRD elicitation skill exists and is invocable
|
||||
- [ ] ADR elicitation skill exists and is invocable
|
||||
- [ ] Orchestrator (`pnpm work dispatch`) runs end-to-end on a real task
|
||||
- [ ] Frontend ESLint rules cover atomic-tier direction and story/test sibling presence
|
||||
- [ ] Playwright screenshot tests run as a CI gate for frontend work
|
||||
- [ ] Documentation reflects the manifest-first workflow (CLAUDE.md, AGENTS.md, `docs/guides/`)
|
||||
- [ ] Frontend + infrastructure work-shape guides exist at `docs/guides/`
|
||||
|
||||
## References
|
||||
|
||||
- [Feature conformance explainer (interactive)](./feature-conformance-explainer.html)
|
||||
- [Vertical feature spec](./vertical-feature-spec.md)
|
||||
- [Template tiers](./template-tiers.md)
|
||||
- [Frontend work shape guide](../guides/frontend-work-shape.md)
|
||||
- [Infrastructure work shape guide](../guides/infrastructure-work-shape.md)
|
||||
- [TDD workflow guide](../guides/tdd-workflow.md)
|
||||
- [Scaffolding a feature guide](../guides/scaffolding-a-feature.md)
|
||||
- [Adding a feature guide](../guides/adding-a-feature.md)
|
||||
- [Sandcastle (orchestration library)](https://github.com/mattpocock/sandcastle)
|
||||
1553
docs/architecture/audit-and-compliance-explainer.html
Normal file
1553
docs/architecture/audit-and-compliance-explainer.html
Normal file
File diff suppressed because it is too large
Load Diff
3266
docs/architecture/data-flow-explainer.html
Normal file
3266
docs/architecture/data-flow-explainer.html
Normal file
File diff suppressed because it is too large
Load Diff
178
docs/architecture/dependency-flow.md
Normal file
178
docs/architecture/dependency-flow.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Dependency Flow
|
||||
|
||||
```
|
||||
+-------------+ +-----------------+ +-----------+
|
||||
| apps/web- | | apps/web- | | apps/cms |
|
||||
| next | | tanstack | | |
|
||||
+------+------+ +--------+--------+ +-----+-----+
|
||||
| | |
|
||||
+------------------+--------------+ | |
|
||||
| | | | |
|
||||
+----v-----+ +-----v------+ +-----v----v---+ +-------v------+
|
||||
| core-api | | core-trpc | | feature | | core-cms |
|
||||
| | | | | packages | | |
|
||||
+-----+----+ +-----+------+ +------+-------+ +-------+------+
|
||||
| | | |
|
||||
| | | |
|
||||
+--+-------+------+---------------+----+ +-------------+
|
||||
| | | |
|
||||
+----v---+ +-v---------+ +-------v---v---+
|
||||
| core- | | core-ui | | core-shared |
|
||||
| shared | | | | |
|
||||
+--------+ +-----------+ +----------------+
|
||||
|
||||
Boundary rules (enforced by ESLint + Turborepo boundaries):
|
||||
app → app, core, core-composition, feature, tooling
|
||||
feature → core, feature, tooling
|
||||
core → core, core-composition, tooling
|
||||
core-composition → core, core-composition, feature, tooling
|
||||
tooling → tooling
|
||||
|
||||
feature → feature: a feature may import another feature's PUBLIC exports
|
||||
(its @repo/<feature> contract barrel — types, errors, schemas, event
|
||||
contracts). It must NOT reach another feature's internals, and
|
||||
cross-feature behaviour still flows through IEventBus — never a direct
|
||||
use-case call.
|
||||
|
||||
Composition exceptions:
|
||||
core-api → @repo/<feature>/api (subpath only)
|
||||
core-cms → @repo/<feature>/cms (subpath only)
|
||||
|
||||
App-side feature subpaths:
|
||||
@repo/<feature> — contracts (types, errors, schemas, IUseCase aliases, router type, constants)
|
||||
@repo/<feature>/ui — UI artifacts (query builders, components)
|
||||
```
|
||||
|
||||
## Concrete examples
|
||||
|
||||
Allowed:
|
||||
|
||||
```ts
|
||||
// in apps/web-next
|
||||
import { appRouter } from "@repo/core-api";
|
||||
import { NextTrpcProvider } from "@repo/core-trpc/next";
|
||||
import { bindProductionBlog } from "@repo/blog/di/bind-production";
|
||||
import { signInInputSchema, type SignInInput } from "@repo/auth"; // contracts
|
||||
import { articleBySlugQuery } from "@repo/blog/ui"; // queries
|
||||
|
||||
// in packages/blog
|
||||
import { slugifyIfMissing } from "@repo/core-shared/payload";
|
||||
import { userSignedUpEvent } from "@repo/auth"; // ✓ another feature's public contract
|
||||
|
||||
// in packages/core-api
|
||||
import { blogRouter } from "@repo/blog/api"; // composition exception
|
||||
import { router } from "@repo/core-shared/trpc/init"; // core → core fine
|
||||
|
||||
// in packages/core-cms
|
||||
import { articles } from "@repo/blog/cms"; // composition exception
|
||||
```
|
||||
|
||||
Disallowed:
|
||||
|
||||
```ts
|
||||
// in packages/blog (reaching another feature's internals)
|
||||
import { signInUseCase } from "@repo/auth/src/application/use-cases/sign-in.use-case"; // ❌ not a public export
|
||||
|
||||
// in packages/blog (deep import past public exports)
|
||||
import { articles } from "@repo/blog/src/integrations/cms/collections/articles"; // ❌ no-private
|
||||
|
||||
// in packages/core-shared
|
||||
import { blogRouter } from "@repo/blog/api"; // ❌ core → feature
|
||||
import { ArticleNotFoundError } from "@repo/blog"; // ❌ core → feature
|
||||
// (defineErrorMiddleware takes Error
|
||||
// constructors as args from features —
|
||||
// core-shared never imports them)
|
||||
|
||||
// in packages/core-shared (or any non-composition core package)
|
||||
import { someBlogThing } from "@repo/blog"; // ❌ core → feature (only core-api/core-cms have exception)
|
||||
|
||||
// in apps (using the wrong subpath)
|
||||
import { articleBySlugQuery } from "@repo/blog"; // ❌ queries live on ./ui
|
||||
import { Article } from "@repo/blog/ui"; // ❌ types live on the root subpath
|
||||
```
|
||||
|
||||
## Enforcement strategy
|
||||
|
||||
Three layers work in tandem:
|
||||
|
||||
1. **`package.json` dependencies** — if you didn't declare it, you can't import it
|
||||
2. **`exports` map** — blocks deep imports; only public subpaths are accessible
|
||||
3. **Two parallel automated checks** (both enforcing the same five-tag model):
|
||||
- **ESLint `eslint-plugin-boundaries`** runs at lint time, catching direct-import violations
|
||||
- **Turborepo `boundaries`** runs at build time, validating the entire workspace graph including transitive dependencies
|
||||
|
||||
The two enforcement layers are independent but complementary. ESLint is stricter on per-import context (e.g., file-specific exemptions via `// @boundaries-ignore`), while Turborepo catches transitive issues that lint-time checking misses. Run `pnpm lint` and `pnpm turbo boundaries` in CI to catch all violations.
|
||||
|
||||
## TRACER / LOGGER / METRICS / AUDIT (ADR-014, ADR-017, ADR-018)
|
||||
|
||||
The instrumentation layer is **per-feature container** but **app-wide instance**: each feature container binds `INSTRUMENTATION_SYMBOLS.ITracer`, `INSTRUMENTATION_SYMBOLS.ILogger`, and `INSTRUMENTATION_SYMBOLS.IMetrics` to the SAME instances, constructed once by the app's `bindAll()` dispatcher (Rule 0).
|
||||
|
||||
**Substrate:** OpenTelemetry SDK. Sentry is the exporter via `@sentry/opentelemetry`. PII scrubbing runs at the OTel processor layer (`PiiScrubSpanProcessor` + `PiiScrubLogRecordProcessor`) before the Sentry exporter sees the data. Feature code is never coupled to the OTel SDK or Sentry SDK directly (ADR-017).
|
||||
|
||||
**Audit is a parallel channel** with different durability and redaction contracts (ADR-018). Whereas OTel is sampled and pruned (best-effort observability), audit is append-only and retained for compliance. `auditLog` is bound by `resolveAudit` and added to `ctx` for feature binders that opt in; the binding is wrapped in `TraceIdEnrichingAuditLog` so every `AuditEntry` auto-correlates with the active OTel span via `correlationId`. See `docs/guides/audit-and-compliance.md` and the visual explainer at `docs/architecture/audit-and-compliance-explainer.html`.
|
||||
|
||||
```
|
||||
apps/web-next/src/server/bind-production.ts (bindAll)
|
||||
│
|
||||
├─ Rule 0: WEB_NEXT_SENTRY_DSN set?
|
||||
│ yes → bindOtelInstrumentation(sharedContainer, { dsn, app: "web-next" })
|
||||
│ → initOtelServerNode(dsn, ...) → OTel SDK + Sentry exporter + PII scrub processors
|
||||
│ no → bindNoopInstrumentation(sharedContainer)
|
||||
│ ↓
|
||||
│ tracer (OtelTracer) + logger (OtelLogger) + metrics (OtelMetrics) instances
|
||||
│ ↓
|
||||
├─ resolveEventsAndJobs* → IEventBus + IJobQueue (ADR-015)
|
||||
│ production → PayloadJobsEventBus + PayloadJobQueue
|
||||
│ dev-seed → InMemoryEventBus + InMemoryJobQueue
|
||||
│ ↓
|
||||
├─ resolveRealtime → IRealtimeBroadcaster + IRealtimeHandlerRegistry (ADR-016)
|
||||
│ server.ts → SocketIORealtimeBroadcaster + RealtimeHandlerRegistry (passed in from server.ts)
|
||||
│ page/test → InMemoryRealtimeBroadcaster + RealtimeHandlerRegistry (defaults)
|
||||
│ ↓
|
||||
├─ resolveAudit → IAuditLog (ADR-018)
|
||||
│ production → TraceIdEnrichingAuditLog( MultiSinkAuditLog([StdoutJsonAuditLog, PayloadAuditLog]) )
|
||||
│ dev-seed → TraceIdEnrichingAuditLog( StdoutJsonAuditLog ) — or Noop when core-audit is absent
|
||||
│ ↓
|
||||
├─ build ctx: BindProductionContext = { config, tracer, logger, metrics?, bus, queue, realtime, realtimeRegistry, auditLog }
|
||||
│ Required: tracer, logger, config (production only)
|
||||
│ Optional: metrics, bus, queue, realtime, realtimeRegistry, auditLog (guard with ?. when used)
|
||||
│ ↓
|
||||
├─ bindProductionBlog(ctx: BindProductionContext)
|
||||
│ │
|
||||
│ ├─ blogContainer.bind(TRACER).toConstantValue(tracer)
|
||||
│ ├─ blogContainer.bind(LOGGER).toConstantValue(logger)
|
||||
│ ├─ ArticlesRepository(config, tracer, logger) → bound to IArticlesRepository
|
||||
│ │ (real repo: inline this.tracer.startSpan + this.logger.captureException per method)
|
||||
│ ├─ withSpan(tracer, ..., withCapture(logger, ..., useCase(deps))) → UseCase symbol
|
||||
│ ├─ withSpan(tracer, ..., withCapture(logger, ..., controller(uc))) → Controller symbol
|
||||
│ ├─ // <gen:event-handlers> bus.subscribe(...) (ADR-015, gen event consume)
|
||||
│ ├─ // <gen:jobs> queue.register(...) in dev-seed; Payload task in prod
|
||||
│ └─ // <gen:realtime-handlers> realtimeRegistry.register(...) (ADR-016, gen realtime handler)
|
||||
│
|
||||
└─ (same for auth, marketing-pages, navigation, media — each receives the same ctx)
|
||||
```
|
||||
|
||||
**`BindContext` shape (from `@repo/core-shared/di`):**
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| ------------------ | ------------------------------ | --------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `tracer` | `ITracer` | always | Resolved by Rule 0 (OTel+Sentry vs Noop) |
|
||||
| `logger` | `ILogger` | always | Resolved by Rule 0 |
|
||||
| `metrics` | `MetricsProtocol?` | optional | Resolved by Rule 0; per-feature adoption is opportunistic |
|
||||
| `config` | `SanitizedConfig` | production only | Present in `BindProductionContext`, absent in `BindContext` |
|
||||
| `bus` | `EventBusProtocol?` | optional | `IEventBus` at the aggregator; protocol surface at binders |
|
||||
| `queue` | `IJobQueue?` | optional | Present when `core-shared/jobs` is wired |
|
||||
| `realtime` | `RealtimeBroadcasterProtocol?` | optional | `IRealtimeBroadcaster` at the aggregator |
|
||||
| `realtimeRegistry` | `RealtimeRegistryProtocol?` | optional | `IRealtimeHandlerRegistry` at the aggregator |
|
||||
| `auditLog` | `AuditLogProtocol?` | optional | `IAuditLog` at the aggregator; pre-wrapped in `TraceIdEnrichingAuditLog` so callers don't supply `correlationId` (ADR-018) |
|
||||
| `analytics` | `AnalyticsProtocol?` | optional | `IAnalytics` product-analytics channel at the aggregator (ADR-024) |
|
||||
| `consentFactory` | `ConsentFactoryProtocol?` | optional | builds a per-subject consent checker; present when `core-consent` is wired (ADR-025) |
|
||||
| `rateLimit` | `IRateLimit?` | optional | per-use-case rate-limit budgets; present when rate limiting is wired (ADR-025) |
|
||||
|
||||
Feature binders destructure `ctx` and use optional fields with `?.` or cast to the full interface when the feature unconditionally requires them (e.g. `bus as IEventBus` when a use case always needs the event bus).
|
||||
|
||||
**Why per-feature containers also get the binding:** lets internal DI-resolved code in a feature pull TRACER/LOGGER without going through the app dispatcher. In practice, only repository classes and feature-internal services would use this — controllers and use cases receive instrumentation via the bind-time wrapper.
|
||||
|
||||
**Why the shared container exists at all:** isolates Rule 0 resolution from feature containers. Feature containers don't need to know if Sentry is the exporter or not — they just receive an `ITracer` instance.
|
||||
|
||||
**Boundary rule:** feature packages MUST NOT import `@sentry/*` or `@opentelemetry/sdk-*` directly (R40 + R52, ESLint-enforced). The OTel bridge (`otel/sentry-bridge.ts`), browser init files (`sentry/init-client*.ts`), and app-level `instrumentation*.{ts,mjs}` / `next.config.{mjs}` / `vite.config.{ts}` entries are the only allowlisted paths. Likewise, features MUST NOT import `@repo/core-audit` directly — they consume `IAuditLog` via the `AuditLogProtocol` type re-exported from `@repo/core-shared/di/bind-protocols`.
|
||||
1461
docs/architecture/di-explainer.html
Normal file
1461
docs/architecture/di-explainer.html
Normal file
File diff suppressed because it is too large
Load Diff
2097
docs/architecture/feature-conformance-explainer.html
Normal file
2097
docs/architecture/feature-conformance-explainer.html
Normal file
File diff suppressed because it is too large
Load Diff
152
docs/architecture/overview.md
Normal file
152
docs/architecture/overview.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Architecture Overview
|
||||
|
||||
A vertical-feature monorepo. Business capabilities are top-level packages; non-business foundations are `core-*`.
|
||||
|
||||
## Package map
|
||||
|
||||
```
|
||||
packages/
|
||||
# Must-have foundation (no business logic)
|
||||
core-shared/ Generic primitives — Payload field/block helpers, tRPC init/context,
|
||||
instrumentation interfaces, audit protocol + truncate-ip + AuditEntry shape,
|
||||
BindContext + protocol types for optional cross-cutting cores
|
||||
core-cms/ Composition only: assembles feature CMS exports into one Payload config
|
||||
core-api/ Composition only: aggregates feature tRPC routers into one appRouter
|
||||
|
||||
# Optional cross-cutting cores (scaffold on demand via `pnpm turbo gen core-package <name>`)
|
||||
core-trpc/ Frontend tRPC client + per-framework providers (Next.js, TanStack)
|
||||
core-ui/ Design-system primitives (atoms, molecules, generic organisms, templates)
|
||||
core-realtime/ Socket.IO server + broadcaster + handler registry (ADR-016)
|
||||
core-events/ In-memory + Payload-backed event bus + job queue (ADR-015)
|
||||
core-audit/ DPA-compliant audit logging — sinks, hook factories, eraseSubject (ADR-018)
|
||||
core-analytics/ Product analytics capture channel (ADR-024)
|
||||
core-consent/ Consent records + cookie-consent banner (ADR-025)
|
||||
core-dsr/ Data-subject-rights — export, delete, rectify, restrict (ADR-025)
|
||||
|
||||
# Business capabilities
|
||||
auth/ Users + sign-in/sign-up/sign-out + session/cookie domain
|
||||
blog/ Articles collection + publishing flow
|
||||
media/ Media upload collection (skeleton; expand with optimization, CDN, etc.)
|
||||
marketing-pages/ Pages collection + SiteSettings global
|
||||
navigation/ Header global + menu items
|
||||
|
||||
# Tooling
|
||||
core-eslint/ Shared ESLint flat config + conformance rules + boundary rules
|
||||
core-typescript/ Shared tsconfig + vitest base
|
||||
core-testing/ Factories, contract suites, recording test doubles
|
||||
```
|
||||
|
||||
See `docs/architecture/template-tiers.md` for the must-have/optional split and the scaffold commands.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
React component
|
||||
↓ useQuery(trpc.blog.articleBySlug.queryOptions({slug})) ← @repo/<feature>/ui (queries)
|
||||
HTTP /api/trpc
|
||||
↓
|
||||
tRPC procedure (xProcedure.input(xInputSchema)) ← integrations/api/router.ts
|
||||
↓ xProcedure has defineErrorMiddleware applied ← integrations/api/procedures.ts
|
||||
↓ container.get<IXController>(SYMBOL)
|
||||
Controller factory (xInputSchema.safeParse) ← interface-adapters/controllers/<verb-noun>.controller.ts
|
||||
↓ (useCase) => async (input: unknown) => Promise<view>
|
||||
Use case factory ← application/use-cases/<verb-noun>.use-case.ts
|
||||
↓ (deps) => async (input: XInput) => XOutput
|
||||
↓ ends with xOutputSchema.parse(result)
|
||||
Repository implementation ← infrastructure/repositories/<noun>.repository.ts
|
||||
↓ getPayload({ config })
|
||||
Payload Local API → Postgres
|
||||
↓ on throw:
|
||||
domain error → defineErrorMiddleware → TRPCError(code, cause)
|
||||
↓ on success:
|
||||
controller's `function presenter(value: XOutput)` shapes the view
|
||||
↓
|
||||
tRPC response
|
||||
```
|
||||
|
||||
Use cases and controllers are **factory functions** — they take their dependencies
|
||||
as arguments and return the callable. The container wires them via
|
||||
`.toDynamicValue((ctx) => factoryFn(ctx.container.get(...)))`. Each exports
|
||||
`export type I*UseCase = ReturnType<typeof xUseCase>` (and the analogous
|
||||
`I*Controller`) so consumers can depend on the type without importing the impl.
|
||||
Controllers are **one per use case** — no multi-method controller files.
|
||||
|
||||
**Schemas live in the use-case file**: every use case exports
|
||||
`xInputSchema` (a `z.ZodObject` with `.strict()`; `z.object({}).strict()` for
|
||||
void inputs) and, for non-void use cases, `xOutputSchema`. Controllers and tRPC
|
||||
procedures import the schema — never redefine it. The use case body validates
|
||||
its output via `xOutputSchema.parse(...)` before returning, so a misbehaving
|
||||
repository fails loudly at the layer that owns the contract.
|
||||
|
||||
**Controllers reshape via a co-located presenter**: every non-void
|
||||
controller defines a top-level `function presenter(value: XOutput)` and returns
|
||||
`Promise<ReturnType<typeof presenter>>`. Identity is fine — `return value;` —
|
||||
but the function form is always present so adding a transform later is a
|
||||
one-line edit. Void controllers (e.g. `signOutController`,
|
||||
`deleteMediaController`) return `Promise<void>` and skip the presenter.
|
||||
|
||||
**Domain errors map to `TRPCError` per feature**: each feature owns
|
||||
`integrations/api/procedures.ts` exporting `xProcedure = t.procedure.use(
|
||||
defineErrorMiddleware([[Ctor, "TRPC_CODE"], ...]))`. Routers use `xProcedure`
|
||||
instead of bare `publicProcedure`. `core-shared` provides the
|
||||
`defineErrorMiddleware` factory but never enumerates a feature's errors —
|
||||
each feature passes its own constructors in.
|
||||
|
||||
## Three enforcement layers
|
||||
|
||||
1. **`package.json` deps** — only declare allowed deps
|
||||
2. **`exports` map** — each feature exposes a small public surface (`.`, `./ui`, `./cms`, `./api`, `./di/bind-production`, `./di/bind-dev-seed`)
|
||||
3. **Two parallel automated checks**:
|
||||
- **ESLint `eslint-plugin-boundaries`** (lint-time) — enforces boundary rules at linting
|
||||
- **Turborepo `boundaries`** (build-graph time) — validates entire workspace dependency graph, including transitive reaches
|
||||
|
||||
Both use the same five-tag model; see "Five tags" section below.
|
||||
|
||||
## Five tags
|
||||
|
||||
The workspace is organized into five mutually exclusive tags:
|
||||
|
||||
- **app** (4 packages): `apps/cms`, `apps/web-next`, `apps/web-tanstack`, `apps/storybook`
|
||||
- **core-composition** (2 must-have): `packages/core-api`, `packages/core-cms`. Plus `packages/core-trpc` when scaffolded via `pnpm turbo gen core-package trpc` (optional).
|
||||
- **core** (1 must-have): `packages/core-shared`. Plus `core-ui`, `core-realtime`, `core-events`, `core-audit`, `core-analytics`, `core-consent`, `core-dsr` when scaffolded via `pnpm turbo gen core-package <name>` (optional).
|
||||
- **feature** (5 packages): `packages/auth`, `packages/blog`, `packages/media`, `packages/marketing-pages`, `packages/navigation`
|
||||
- **tooling** (3 packages): `packages/core-eslint`, `packages/core-typescript`, `packages/core-testing`
|
||||
|
||||
See `docs/architecture/template-tiers.md` for the must-have/optional split and the scaffold commands.
|
||||
|
||||
**Allowed dependency directions:**
|
||||
|
||||
| Tag | May depend on |
|
||||
| ---------------- | --------------------------------------------- |
|
||||
| app | app, core, core-composition, feature, tooling |
|
||||
| core-composition | core, core-composition, feature, tooling |
|
||||
| core | core, core-composition, tooling |
|
||||
| feature | core, feature, tooling |
|
||||
| tooling | tooling |
|
||||
|
||||
A feature may import another feature's **public exports** — its `@repo/<feature>` contract barrel (types, errors, schemas, event contracts). It must not reach another feature's internals (the `exports` map seals those), and cross-feature _behaviour_ still flows through `IEventBus` — a feature never imports and invokes another feature's use cases directly.
|
||||
|
||||
**Composition exceptions:**
|
||||
|
||||
- `core-api` may import from `@repo/<feature>/api` subpath exports only
|
||||
- `core-cms` may import from `@repo/<feature>/cms` subpath exports only
|
||||
- `core-trpc` reaches features transitively through `core-api`'s `AppRouter` type
|
||||
|
||||
## Per-feature DI containers
|
||||
|
||||
Each feature owns its own InversifyJS `Container` + symbol table. No shared symbols, no cross-feature DI coupling. Tests rebind per feature without touching others. Apps call `bindProduction*(config)` per feature at boot to swap the default mock implementations for Payload-backed ones.
|
||||
|
||||
## Spec reference
|
||||
|
||||
`docs/architecture/vertical-feature-spec.md` is the canonical design.
|
||||
|
||||
## Interactive explainers
|
||||
|
||||
Four single-file HTML walkthroughs sit alongside this overview. They're self-contained (no build step) and best viewed locally in a browser.
|
||||
|
||||
- [`data-flow-explainer.html`](./data-flow-explainer.html) — request and event flow through one feature, with the DI binding mode toggle and feature swap controls.
|
||||
- [`di-explainer.html`](./di-explainer.html) — the full container + symbols + binder lifecycle.
|
||||
- [`feature-conformance-explainer.html`](./feature-conformance-explainer.html) — companion to ADR-020-adjacent conformance design (also linked from `agent-first-workflow-and-conformance.md` and `runbook.md`).
|
||||
- [`audit-and-compliance-explainer.html`](./audit-and-compliance-explainer.html) — companion to ADR-018 (also linked from `dependency-flow.md` and `guides/audit-and-compliance.md`).
|
||||
|
||||
The four pages cross-link to each other; entering any of them surfaces the others.
|
||||
31
docs/architecture/template-tiers.md
Normal file
31
docs/architecture/template-tiers.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Template tiers
|
||||
|
||||
This template ships in three tiers:
|
||||
|
||||
## Must-have (always present)
|
||||
|
||||
- `@repo/core-shared` — protocol types, `BindContext`, instrumentation interfaces, jobs interface, tRPC primitives, payload helpers
|
||||
- `@repo/core-eslint` — flat-config preset + repo-rules ESLint plugin + boundaries
|
||||
- `@repo/core-typescript` — tsconfig presets (base, react-library, nextjs)
|
||||
- `@repo/core-testing` — vitest helpers, factories, mocks, contracts
|
||||
- `@repo/core-cms` — Payload config base
|
||||
- `@repo/core-api` — top-level tRPC router root (mounts feature routers)
|
||||
|
||||
Plus all 5 feature packages: auth, blog, marketing-pages, navigation, media.
|
||||
|
||||
## Optional (scaffolded on demand)
|
||||
|
||||
| Package | Generator | ADR | Guide |
|
||||
| -------------- | --------------------------------------- | ------- | ----------------------------------- |
|
||||
| core-realtime | `pnpm turbo gen core-package realtime` | ADR-016 | docs/guides/realtime.md |
|
||||
| core-events | `pnpm turbo gen core-package events` | ADR-015 | docs/guides/events-and-jobs.md |
|
||||
| core-trpc | `pnpm turbo gen core-package trpc` | (none) | (none) |
|
||||
| core-ui | `pnpm turbo gen core-package ui` | (none) | (none) |
|
||||
| core-audit | `pnpm turbo gen core-package audit` | ADR-018 | docs/guides/audit-and-compliance.md |
|
||||
| core-analytics | `pnpm turbo gen core-package analytics` | ADR-024 | docs/guides/analytics.md |
|
||||
| core-consent | `pnpm turbo gen core-package consent` | ADR-025 | docs/guides/consent.md |
|
||||
| core-dsr | `pnpm turbo gen core-package dsr` | ADR-025 | docs/guides/dsr.md |
|
||||
|
||||
## Why optional
|
||||
|
||||
Each optional package addresses a specific need (realtime delivery, cross-feature events, tRPC, design system). Projects that don't need them get a slimmer template. The generator emits byte-identical copies of the packages as they shipped — see `turbo/generators/__snapshots__/core-package/<name>.snapshot.json` for the canonical content hashes.
|
||||
691
docs/architecture/vertical-feature-spec.md
Normal file
691
docs/architecture/vertical-feature-spec.md
Normal file
@@ -0,0 +1,691 @@
|
||||
# Vertical Feature Architecture Spec
|
||||
|
||||
> **Architecture reference.** This document is the canonical design spec for the vertical-feature architecture.
|
||||
|
||||
---
|
||||
|
||||
# Vertical Feature Monorepo Refactor — Design Spec
|
||||
|
||||
**Date:** 2026-04-21
|
||||
**Status:** Approved for implementation planning
|
||||
**Supersedes (partially):** 2026-04-06-clean-architecture-monorepo-template-design.md
|
||||
**Source spec:** `monorepo-architecture-spec-detailed-v5.md` (v1 + addenda v3/v4/v5)
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Refactor the template from a horizontal Clean Architecture monorepo (single `packages/core`, single `packages/api`, etc.) into a vertical feature-package monorepo where business capabilities (`auth`, `blog`, `media`, `marketing-pages`, `navigation`) are the top-level organizing principle, supported by `core-*` foundation packages for non-business concerns.
|
||||
|
||||
The refactor preserves Clean Architecture layering _inside_ each feature (the existing rigor) while reorganizing _between_ packages by business capability.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state (summary)
|
||||
|
||||
- `packages/core` — all domains (auth, content) share one Clean Architecture layout with InversifyJS DI container
|
||||
- `packages/api` — single tRPC aggregator + per-domain routers
|
||||
- `packages/api-client` — React Query hooks + `ApiProvider` + `useTRPC`
|
||||
- `packages/cms-core` — Payload config + all collections (Users, Articles, Media, SiteSettings global)
|
||||
- `packages/cms-client` — dual-mode Payload client wrapper; defined but unused
|
||||
- `packages/ui` — atomic-design component library
|
||||
- `apps/web-next` (empty shell), `apps/web-tanstack` (empty shell), `apps/cms` (just stabilized), `apps/storybook`
|
||||
- Mock repositories only — no Payload-backed infrastructure
|
||||
- 9 Vitest unit tests under `packages/core/tests/unit/`
|
||||
- Extensive per-directory AGENTS.md; 5 ADRs; 2 guides; 6 dated superpower plans
|
||||
|
||||
---
|
||||
|
||||
## 3. Target state (summary)
|
||||
|
||||
- Three must-have `core-*` packages: `core-shared`, `core-cms`, `core-api`. Five optional cross-cutting cores scaffold on demand via `pnpm turbo gen core-package <name>`: `core-trpc`, `core-ui`, `core-realtime` (ADR-016), `core-events` (ADR-015), `core-audit` (ADR-018). See `docs/architecture/template-tiers.md`.
|
||||
- Five feature packages: `auth`, `blog`, `media`, `marketing-pages`, `navigation`
|
||||
- Two tooling packages renamed: `eslint-config` → `core-eslint`, `typescript-config` → `core-typescript`
|
||||
- Three apps unchanged in name: `apps/web-next`, `apps/web-tanstack`, `apps/cms`
|
||||
- Packages deleted: `packages/core`, `packages/api`, `packages/api-client`, `packages/cms-core`, `packages/cms-client`, `packages/ui`
|
||||
- Per-feature InversifyJS containers (no shared container)
|
||||
- Clean Architecture controllers retained inside each feature (`interface-adapters/controllers/`)
|
||||
- Spec's `adapters/` renamed to `integrations/` to avoid collision with `interface-adapters/`
|
||||
- Boundary enforcement via `eslint-plugin-boundaries` + `package.json` deps + `exports` maps + Turborepo tags
|
||||
- Playwright e2e set up from day one in `web-next` and `web-tanstack`
|
||||
|
||||
---
|
||||
|
||||
## 4. Decision log
|
||||
|
||||
All decisions captured from the brainstorming conversation:
|
||||
|
||||
| # | Decision | Rationale |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | **Big-bang migration** (not incremental) | Template has empty reference apps and no external consumers; incremental dual-maintenance is overhead without benefit |
|
||||
| 2 | **Feature scope:** `auth` + `blog` + `media` + `marketing-pages` + `navigation` (all real, none as empty skeletons) | Template value is in worked examples; reference app needs something to render; spec §15A.2 forbids empty folders |
|
||||
| 3 | **Keep InversifyJS** | User wants to preserve existing DI pattern rather than move to plain function injection |
|
||||
| 4 | **Per-feature DI containers** (not a shared container) | Each feature owns its own `Container`, symbols, `getInjection()`. Perfect vertical ownership; no composition package needed for DI; tests unbind/rebind their own container |
|
||||
| 5 | **`media` is a feature package**, not `core-media` | Application can live without media; it's a business capability per spec §3. Site-wide concerns (SiteSettings) fold into `marketing-pages`, not a separate `site` package |
|
||||
| 6 | **Keep all three apps** (`web-next`, `web-tanstack`, `cms`) with existing names | `web-tanstack` proves features are framework-agnostic; no rename avoids churn |
|
||||
| 7 | **Keep `interface-adapters/controllers/` layer** | Transport-agnostic controllers enable CLI/cron reuse; template should demonstrate growth room for presenters/gateways |
|
||||
| 8 | **Rename spec's `adapters/` → `integrations/`** | Avoids collision with Clean Architecture's `interface-adapters/`; captures spec's "role not implementation" intent equally well; bounded deviation from spec |
|
||||
| 9 | **Delete existing tests, rewrite fresh**; rewrite AGENTS.md, add ADRs, mark old ADRs superseded where relevant | DI pattern change + file layout change make porting more work than rewriting; ADRs preserve architectural history |
|
||||
| 10 | **Include `eslint-plugin-boundaries`** from day one | Spec §13A.7 explicitly requires three-layer enforcement; package.json deps + exports + lint rules |
|
||||
| 11 | **Playwright set up immediately** in `web-next` and `web-tanstack` | Not deferred; demonstrates framework portability end-to-end |
|
||||
| 12 | **Keep `articles` collection/entity naming** (not rename to `posts`) | Simpler migration; collapses CMS-doc vs domain distinction which is fine for a template |
|
||||
|
||||
Deviations from source spec (document explicitly as new ADRs):
|
||||
|
||||
- Keep InversifyJS (spec examples use plain function injection)
|
||||
- Keep controller layer between tRPC and use-case (spec goes tRPC→use-case direct)
|
||||
- Rename spec's `adapters/` to `integrations/`
|
||||
- Omit `core-payload-client` wrapper (aligned with spec §10)
|
||||
|
||||
---
|
||||
|
||||
## 5. Target package layout
|
||||
|
||||
```
|
||||
repo/
|
||||
apps/
|
||||
web-next/ # Next.js 15 App Router (port 3000)
|
||||
app/
|
||||
layout.tsx # <TrpcProvider> from @repo/core-trpc/next
|
||||
trpc/[trpc]/route.ts # tRPC fetch adapter → @repo/core-api appRouter
|
||||
blog/[slug]/page.tsx
|
||||
about/page.tsx
|
||||
page.tsx # home — navigation + marketing-pages
|
||||
e2e/
|
||||
playwright.config.ts
|
||||
blog-post.spec.ts
|
||||
marketing-page.spec.ts
|
||||
home-nav.spec.ts
|
||||
package.json next.config.mjs tsconfig.json turbo.json
|
||||
|
||||
web-tanstack/ # TanStack Start (port 3002)
|
||||
... # parallel tRPC wiring; own providers from @repo/core-trpc/tanstack
|
||||
e2e/
|
||||
playwright.config.ts
|
||||
blog-post.spec.ts
|
||||
|
||||
cms/ # Payload admin host (port 3001)
|
||||
app/(payload)/ # unchanged from current stabilized state
|
||||
package.json next.config.mjs tsconfig.json turbo.json
|
||||
|
||||
storybook/ # unchanged; updates imports from @repo/ui → @repo/core-ui
|
||||
|
||||
packages/
|
||||
# ─── CORE — must-have (tagged "core" / "core-composition") ───
|
||||
core-shared/ # generic primitives (no business knowledge)
|
||||
core-cms/ # Payload composition only (aggregates feature cms exports)
|
||||
core-api/ # tRPC composition only (aggregates feature api exports)
|
||||
# ─── CORE — optional (scaffold via `pnpm turbo gen core-package <name>`) ───
|
||||
core-trpc/ # frontend tRPC platform (client, providers per framework)
|
||||
core-ui/ # design-system primitives (atoms/molecules/templates)
|
||||
core-events/ # in-memory + Payload-backed event bus + job queue (ADR-015)
|
||||
core-realtime/ # Socket.IO broadcaster + handler registry (ADR-016)
|
||||
core-audit/ # DPA-compliant audit logging (ADR-018)
|
||||
core-analytics/ # product analytics capture channel (ADR-024)
|
||||
core-consent/ # consent + cookie banner (ADR-025)
|
||||
core-dsr/ # data-subject-rights — export/delete/rectify/restrict (ADR-025)
|
||||
|
||||
# ─── FEATURES (business capabilities, tagged "feature") ───
|
||||
auth/ # Users collection + sign-in/up/out
|
||||
blog/ # Articles collection + article use-cases
|
||||
media/ # Media collection + upload helpers
|
||||
marketing-pages/ # pages collection + SiteSettings global
|
||||
navigation/ # header global
|
||||
|
||||
# ─── TOOLING (tagged "tooling") ───
|
||||
core-eslint/ # ESLint preset + the 15 conformance rules + boundary rules
|
||||
core-typescript/ # tsconfig presets (base, react-library, nextjs)
|
||||
core-testing/ # factories, contract suites, recording test doubles
|
||||
|
||||
docs/
|
||||
architecture/
|
||||
overview.md # rewritten
|
||||
dependency-flow.md # rewritten
|
||||
vertical-feature-spec.md # copy of source spec
|
||||
decisions/
|
||||
adr-001 … adr-NNN # 25 ADRs at time of writing — see §11 and `docs/decisions/`
|
||||
guides/
|
||||
adding-a-feature.md # rewritten
|
||||
testing-strategy.md # rewritten
|
||||
work/ # epic + story tracking
|
||||
|
||||
CLAUDE.md AGENTS.md docker-compose.yml package.json pnpm-lock.yaml
|
||||
pnpm-workspace.yaml tsconfig.base.json turbo.json
|
||||
```
|
||||
|
||||
**Package count:** the `packages/` workspace holds **19 packages** — 3 must-have cores (`core-shared`, `core-cms`, `core-api`), 8 optional cores scaffolded on demand (`core-ui`, `core-events`, `core-realtime`, `core-trpc`, `core-audit`, `core-analytics`, `core-consent`, `core-dsr`), 3 tooling packages (`core-eslint`, `core-typescript`, `core-testing`), and 5 feature packages — plus 4 apps. A minimal project that scaffolds none of the optional cores ships 11 packages.
|
||||
|
||||
---
|
||||
|
||||
## 6. Feature package internal shape
|
||||
|
||||
Canonical mature shape (e.g., `packages/blog/`):
|
||||
|
||||
```
|
||||
packages/blog/
|
||||
src/
|
||||
config.ts # constants if needed
|
||||
|
||||
entities/
|
||||
models/
|
||||
article.ts # Zod schema + Article type
|
||||
article.test.ts
|
||||
errors/
|
||||
article.ts # ArticleNotFoundError (sets this.name)
|
||||
common.ts # InputParseError
|
||||
errors.test.ts
|
||||
|
||||
application/
|
||||
repositories/
|
||||
articles.repository.interface.ts # IArticlesRepository
|
||||
use-cases/
|
||||
get-articles.use-case.ts # factory + getArticlesInputSchema + getArticlesOutputSchema + parse
|
||||
get-articles.use-case.test.ts # incl. R25 output-validation test
|
||||
get-article-by-slug.use-case.ts
|
||||
get-article-by-slug.use-case.test.ts
|
||||
create-article.use-case.ts
|
||||
create-article.use-case.test.ts
|
||||
|
||||
infrastructure/
|
||||
repositories/
|
||||
articles.repository.ts # real Payload-backed impl (getPayload({ config }) from core-cms)
|
||||
articles.repository.mock.ts # MockArticlesRepository
|
||||
articles.repository.test.ts
|
||||
articles.repository.mock.test.ts
|
||||
|
||||
interface-adapters/ # Clean Arch grouping (controllers now; presenters/gateways later)
|
||||
controllers/
|
||||
get-articles.controller.ts # factory + safeParse(getArticlesInputSchema) + function presenter
|
||||
get-articles.controller.test.ts # incl. R27/R28 if presenter reshapes
|
||||
get-article-by-slug.controller.ts # one file per use case
|
||||
get-article-by-slug.controller.test.ts
|
||||
create-article.controller.ts
|
||||
create-article.controller.test.ts
|
||||
|
||||
di/ # feature-local InversifyJS container
|
||||
symbols.ts # BLOG_SYMBOLS
|
||||
module.ts # ContainerModule — .toDynamicValue() for use cases + controllers
|
||||
container.ts # blogContainer singleton
|
||||
bind-production.ts # swaps mock → real Payload impls at app boot
|
||||
bind-dev-seed.ts # swaps empty mock → populated mock for dev mode
|
||||
bind-dev-seed.test.ts
|
||||
container.test.ts
|
||||
|
||||
integrations/ # renamed from spec's adapters/
|
||||
api/
|
||||
procedures.ts # blogProcedure = t.procedure.use(defineErrorMiddleware([...]))
|
||||
router.ts # blogProcedure.input(xInputSchema).query/mutation(...)
|
||||
router.test.ts # incl. R26 router error-mapping test
|
||||
index.ts
|
||||
cms/
|
||||
collections/
|
||||
articles.ts # Payload CollectionConfig
|
||||
hooks/
|
||||
<lifecycle-hook>.ts # if needed
|
||||
index.ts # exports: articles (for core-cms composition)
|
||||
|
||||
ui/
|
||||
index.ts # re-exports query builders (apps import from @repo/blog/ui)
|
||||
query.ts # trpc.blog.articleBySlug.queryOptions(...)
|
||||
|
||||
__factories__/
|
||||
article.factory.ts # test data factories
|
||||
|
||||
__contracts__/
|
||||
articles-repository.contract.ts # repo interface contract suite
|
||||
|
||||
__seeds__/
|
||||
dev.ts # buildDev<Entities>() — uses factory; consumed by bind-dev-seed
|
||||
|
||||
index.ts # contracts only: types, errors, schemas, IUseCase/IController aliases, router type, constants
|
||||
|
||||
tests/
|
||||
article-by-slug.feature.test.ts # cross-layer integration test
|
||||
|
||||
package.json # exports: ".", "./ui", "./api", "./cms", "./di/bind-production", "./di/bind-dev-seed"
|
||||
tsconfig.json
|
||||
turbo.json # tags: ["feature"]
|
||||
```
|
||||
|
||||
Small-feature variant (e.g., `packages/navigation/`) omits folders without meaningful code per spec §15 / addendum v5 ("create folders only when needed"):
|
||||
|
||||
```
|
||||
packages/navigation/
|
||||
src/
|
||||
entities/
|
||||
models/ header.ts
|
||||
errors/ header.ts common.ts
|
||||
application/repositories/ header.repository.interface.ts
|
||||
infrastructure/repositories/ header.repository.ts header.repository.mock.ts
|
||||
di/ symbols.ts module.ts container.ts bind-production.ts container.test.ts
|
||||
interface-adapters/controllers/ get-header.controller.ts get-header.controller.test.ts
|
||||
integrations/
|
||||
cms/ globals/ header.ts + index.ts
|
||||
api/ procedures.ts router.ts router.test.ts index.ts
|
||||
ui/ index.ts query.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
Optional `events/`, `jobs/`, `integrations/cms/jobs/` directories (see ADR-015 and `docs/guides/events-and-jobs.md`); optional `realtime/` and `realtime/handlers/` directories (see ADR-016 and `docs/guides/realtime.md`); features may grow any of them on demand. The spec's canonical layout above remains correct as the minimum.
|
||||
|
||||
**Request flow:**
|
||||
|
||||
```
|
||||
useQuery(articleBySlugQuery({ slug })) ui/index.ts (typed tRPC client, via @repo/blog/ui)
|
||||
↓
|
||||
tRPC router.articleBySlug integrations/api/router.ts
|
||||
↓ blogProcedure has defineErrorMiddleware applied
|
||||
↓ .input(getArticleBySlugInputSchema)
|
||||
articlesController.getBySlug(input: unknown) interface-adapters/controllers/
|
||||
↓ getArticleBySlugInputSchema.safeParse(input)
|
||||
↓ throws InputParseError on failure
|
||||
↓ delegates to use case
|
||||
getArticleBySlugUseCase(parsed.data) application/use-cases/
|
||||
↓ deps injected by container at xProcedure.use(...) time
|
||||
↓ throws ArticleNotFoundError on miss
|
||||
↓ ends with getArticleBySlugOutputSchema.parse(result)
|
||||
ArticlesRepository.getArticleBySlug infrastructure/repositories/
|
||||
↓ getPayload({ config }) from @repo/core-cms
|
||||
Payload Local API → PostgreSQL
|
||||
↑ on throw:
|
||||
domain error → defineErrorMiddleware
|
||||
→ TRPCError(code, cause)
|
||||
↓ on success:
|
||||
controller's `function presenter(value)`
|
||||
shapes the view
|
||||
```
|
||||
|
||||
**DI placement rationale:** `di/` sits at feature root (not under `infrastructure/`) because the container wires `application/` interfaces to `infrastructure/` implementations — it has knowledge of both layers and is a sibling to them, not a sub-layer.
|
||||
|
||||
---
|
||||
|
||||
## 7. Core package responsibilities
|
||||
|
||||
### `core-shared/`
|
||||
|
||||
Generic reusable primitives. Zero business knowledge.
|
||||
|
||||
```
|
||||
src/
|
||||
lib/
|
||||
env.ts date.ts
|
||||
payload/
|
||||
access/ is-admin.ts
|
||||
fields/ slug-field.ts seo-fields.ts
|
||||
blocks/ cta.ts
|
||||
hooks/ set-published-at.ts slugify-if-missing.ts
|
||||
index.ts
|
||||
trpc/
|
||||
init.ts # initTRPC.create + router/publicProcedure
|
||||
context.ts # createTrpcContext + TrpcContext type
|
||||
index.ts
|
||||
```
|
||||
|
||||
Exports: `.`, `./payload`, `./trpc/init`, `./trpc/context`.
|
||||
|
||||
Forbidden: importing any feature package.
|
||||
|
||||
### `core-cms/`
|
||||
|
||||
Payload composition only.
|
||||
|
||||
```
|
||||
src/
|
||||
payload.config.ts # imports feature /cms exports, composes buildConfig
|
||||
generated-types.ts # Payload type generator output
|
||||
index.ts # re-exports config as default
|
||||
```
|
||||
|
||||
Exports: `.`, `./generated-types`.
|
||||
|
||||
Allowed exception: may import `@repo/<feature>/cms` subpath exports only.
|
||||
|
||||
### `core-api/`
|
||||
|
||||
tRPC composition only.
|
||||
|
||||
```
|
||||
src/
|
||||
root.ts # aggregates feature routers → appRouter
|
||||
index.ts # re-exports appRouter + AppRouter type
|
||||
```
|
||||
|
||||
Allowed exception: may import `@repo/<feature>/api` subpath exports only.
|
||||
|
||||
### `core-trpc/`
|
||||
|
||||
Frontend tRPC platform with framework-specific provider shims.
|
||||
|
||||
```
|
||||
src/
|
||||
client.ts # createTRPCReact<AppRouter>()
|
||||
query-client.ts # makeQueryClient()
|
||||
providers/
|
||||
next-provider.tsx # 'use client' — Next.js App Router pattern
|
||||
tanstack-provider.tsx # TanStack Start pattern
|
||||
index.ts
|
||||
```
|
||||
|
||||
Exports: `.`, `./next`, `./tanstack`.
|
||||
|
||||
Forbidden: importing any feature package.
|
||||
|
||||
### `core-ui/`
|
||||
|
||||
Design-system primitives only.
|
||||
|
||||
```
|
||||
src/
|
||||
atoms/ button/ input/ label/
|
||||
molecules/ form-field/
|
||||
organisms/ (generic only — e.g., modal, tabs, navigation-menu)
|
||||
templates/ auth-layout/ dashboard-layout/
|
||||
lib/ utils.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
Generic organisms (Modal, Tabs, NavigationMenu, Command) live here. Feature-specific organisms (e.g., `ArticleCard`, `PricingSection`, `HeaderNavMenu`) live in the owning feature's `ui/`. Spec §6.5 boundary.
|
||||
|
||||
Forbidden: importing any feature package.
|
||||
|
||||
---
|
||||
|
||||
## 8. Payload collection & global ownership
|
||||
|
||||
| Current | → New location | Slug / type | Notes |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------ | --------------- | ------------------------------- |
|
||||
| `cms-core/src/collections/users/` | `packages/auth/src/integrations/cms/collections/users.ts` | `users` | Authenticated collection |
|
||||
| `cms-core/src/collections/articles/` | `packages/blog/src/integrations/cms/collections/articles.ts` | `articles` | Name preserved per decision #12 |
|
||||
| `cms-core/src/collections/media/` | `packages/media/src/integrations/cms/collections/media.ts` | `media` | Upload collection |
|
||||
| `cms-core/src/globals/site-settings/` | `packages/marketing-pages/src/integrations/cms/globals/site-settings.ts` | `site-settings` | Site-wide metadata |
|
||||
| (new) | `packages/marketing-pages/src/integrations/cms/collections/pages.ts` | `pages` | |
|
||||
| (new) | `packages/navigation/src/integrations/cms/globals/header.ts` | `header` | |
|
||||
|
||||
Composition in `core-cms/src/payload.config.ts`:
|
||||
|
||||
```ts
|
||||
import { buildConfig } from "payload";
|
||||
import { users } from "@repo/auth/cms";
|
||||
import { articles } from "@repo/blog/cms";
|
||||
import { pages, siteSettings } from "@repo/marketing-pages/cms";
|
||||
import { header } from "@repo/navigation/cms";
|
||||
import { media } from "@repo/media/cms";
|
||||
|
||||
export default buildConfig({
|
||||
collections: [users, articles, pages, media],
|
||||
globals: [header, siteSettings],
|
||||
typescript: {
|
||||
outputFile: new URL("./generated-types.ts", import.meta.url).pathname,
|
||||
declare: false,
|
||||
},
|
||||
// db, admin config unchanged from current cms-core
|
||||
});
|
||||
```
|
||||
|
||||
**Hook routing policy:**
|
||||
|
||||
- Generic hooks (e.g., `slugify-if-missing`, `set-published-at`) live in `core-shared/src/payload/hooks/` and are imported by any collection that needs them.
|
||||
- Business-specific hooks (e.g., "revalidate blog post page when published") live in the feature's `integrations/cms/hooks/`, which call the feature's `effects/` for reusable side effects.
|
||||
|
||||
---
|
||||
|
||||
## 9. Boundaries + enforcement
|
||||
|
||||
### 9.1 Five tags
|
||||
|
||||
Package-level `turbo.json` tags (refined from earlier ADR-006's three-tag model):
|
||||
|
||||
| Tag | Packages |
|
||||
| ------------------ | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `app` | `apps/web-next`, `apps/web-tanstack`, `apps/cms`, `apps/storybook` |
|
||||
| `core-composition` | `packages/core-api`, `core-cms`, plus `core-trpc` when scaffolded (optional) |
|
||||
| `core` | `packages/core-shared`, plus `core-ui`, `core-realtime`, `core-events`, `core-audit` when scaffolded (optional) |
|
||||
| `feature` | `packages/auth`, `blog`, `media`, `marketing-pages`, `navigation` |
|
||||
| `tooling` | `packages/core-eslint`, `core-typescript` |
|
||||
|
||||
Note: `core-trpc` is `core-composition` (not plain `core`) because it transitively reaches features through `core-api`'s `AppRouter` type. The other optional cross-cutting cores stay in plain `core` — they expose protocol types (consumed via `@repo/core-shared/di/bind-protocols`) and never reach into feature packages.
|
||||
|
||||
### 9.2 Allowed dependency directions
|
||||
|
||||
| Tag | May depend on |
|
||||
| ---------------- | --------------------------------------------- |
|
||||
| app | app, core, core-composition, feature, tooling |
|
||||
| core-composition | core, core-composition, feature, tooling |
|
||||
| core | core, core-composition, tooling |
|
||||
| feature | core, feature, tooling |
|
||||
| tooling | tooling |
|
||||
|
||||
A feature may import another feature's **public exports** — its `@repo/<feature>` contract barrel (types, errors, schemas, event contracts). It must not reach another feature's internals, and cross-feature _behaviour_ still flows through `IEventBus`, never a direct use-case call.
|
||||
|
||||
### 9.3 Composition exceptions
|
||||
|
||||
- `core-cms` may import `@repo/<feature>/cms` subpath exports only.
|
||||
- `core-api` may import `@repo/<feature>/api` subpath exports only.
|
||||
- No other package may deviate from the five-tag rules.
|
||||
|
||||
### 9.4 Four enforcement layers
|
||||
|
||||
1. **`package.json` dependencies** — only allowed deps declared.
|
||||
2. **`exports` maps** — feature packages expose `.`, `./ui`, `./cms`, `./api`, `./di/bind-production` only (no deep source paths).
|
||||
3. **ESLint `eslint-plugin-boundaries`** (lint-time) — configured in `packages/core-eslint/` flat config:
|
||||
- Enforces the five-tag rules (same rules as Turbo boundaries)
|
||||
- File-specific exemptions via `// @boundaries-ignore` comments
|
||||
4. **Turborepo `boundaries`** (build-graph time) — configured in root `turbo.json`:
|
||||
- Validates the entire workspace dependency graph, including transitive dependencies
|
||||
- Catches issues that lint-time checking misses (e.g., transitive feature reaches)
|
||||
- Run `pnpm turbo boundaries` to validate
|
||||
|
||||
### 9.5 Root `turbo.json` (unchanged concept)
|
||||
|
||||
The snippet below shows the original task shape. The live `turbo.json` has evolved — additional tasks (`conformance`, `fallow`, `boundaries`, `test:stories`, `build-storybook`), tweaks to `dependsOn` (`test` and `typecheck` no longer depend on `^build`), and the `boundaries.tags` block enforcing the dependency matrix (see §9.2). See the actual root `turbo.json` for the authoritative shape; the principle below is unchanged.
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": {
|
||||
"build": { "dependsOn": ["^build"], "outputs": [".next/**", "dist/**"] },
|
||||
"lint": { "dependsOn": ["^lint"] },
|
||||
"typecheck": { "dependsOn": ["^typecheck"] },
|
||||
"test": { "dependsOn": ["^build"] },
|
||||
"test:e2e": { "dependsOn": ["^build"], "cache": false }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Tags govern architectural boundaries; `dependsOn: ["^build"]` governs task execution order — separate concerns per spec §13A.6.
|
||||
|
||||
---
|
||||
|
||||
## 10. Test placement + tooling
|
||||
|
||||
### 10.1 Placement
|
||||
|
||||
| Scope | Location | Suffix |
|
||||
| --------------------------- | --------------------------- | ------------------- |
|
||||
| Entity / value-object | colocated | `*.test.ts` |
|
||||
| Use-case (with fake repo) | colocated | `*.test.ts` |
|
||||
| Controller (Zod validation) | colocated | `*.test.ts` |
|
||||
| Infrastructure repository | colocated | `*.test.ts` |
|
||||
| DI container bindings | colocated | `*.test.ts` |
|
||||
| React component | colocated | `*.test.tsx` |
|
||||
| Query helper | colocated | `*.test.ts` |
|
||||
| Core-shared primitive | colocated in `core-shared` | `*.test.ts` |
|
||||
| Feature-level cross-layer | `packages/<feature>/tests/` | `*.feature.test.ts` |
|
||||
| Browser e2e | `apps/<app>/e2e/` | `*.spec.ts` |
|
||||
|
||||
### 10.2 Vitest
|
||||
|
||||
- Each package has its own `vitest.config.ts`.
|
||||
- Shared base in `packages/core-typescript/vitest.base.ts`; packages extend it and pick `environment: 'jsdom' | 'node'` per need.
|
||||
- Turbo `test` task runs `vitest run` per package.
|
||||
|
||||
### 10.3 DI in tests (per-feature container)
|
||||
|
||||
**Default (use case + controller tests) — direct factory injection.** Construct mock dependencies and pass them into the factory function. No container involvement:
|
||||
|
||||
```ts
|
||||
// Use case test — direct factory injection (ADR-012)
|
||||
const repo = new MockArticlesRepository();
|
||||
const useCase = getArticleBySlugUseCase(repo);
|
||||
const result = await useCase({ slug: "hello-world" });
|
||||
|
||||
// Controller test — same pattern
|
||||
const repo = new MockArticlesRepository();
|
||||
const useCase = getArticleBySlugUseCase(repo);
|
||||
const controller = getArticleBySlugController(useCase);
|
||||
const result = await controller({ slug: "hello-world" });
|
||||
```
|
||||
|
||||
**Router tests — container rebinding still appropriate.** tRPC routers resolve controllers via `container.get<IXController>(SYMBOL)`, so router tests must rebind the container:
|
||||
|
||||
```ts
|
||||
// Router test (only here is container rebinding still appropriate)
|
||||
beforeEach(() => {
|
||||
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
|
||||
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
|
||||
}
|
||||
blogContainer
|
||||
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
|
||||
.toConstantValue(new MockArticlesRepository());
|
||||
});
|
||||
```
|
||||
|
||||
No shared `initializeContainer()` / `destroyContainer()`.
|
||||
|
||||
### 10.4 Actual test coverage
|
||||
|
||||
- `pnpm test` runs green across every workspace package. Coverage thresholds are declared per-feature in `feature.manifest.ts` (ADR-020) — `entities` and `use-cases` at 100% statements/branches/functions/lines, `controllers` at 100/95/100/100, with a baseline of 80/75/80/80 elsewhere. The four coverage layers (L0 thresholds → L1 diff coverage → L2 aggregate trend → L3 mutation) are documented in `docs/guides/coverage.md`.
|
||||
|
||||
Key coverage areas:
|
||||
|
||||
- Output-validation: every non-void use case has a test asserting `xOutputSchema.parse` throws on malformed repository data
|
||||
- Router error-mapping: every feature has a router test asserting domain error → correct `TRPCError.code` translation
|
||||
- Presenter shape: `auth` sign-in/sign-up controllers assert the presenter-reshaped view (cookie, not full session object)
|
||||
|
||||
### 10.5 Playwright (included from day one)
|
||||
|
||||
- `apps/web-next/e2e/`:
|
||||
- `playwright.config.ts` — starts dev server on port 3000 via `webServer`, chromium only initially
|
||||
- `blog-post.spec.ts`, `marketing-page.spec.ts`, `home-nav.spec.ts`
|
||||
- `apps/web-tanstack/e2e/`:
|
||||
- Parallel config (port 3002)
|
||||
- `blog-post.spec.ts` (validates framework-agnostic feature claim)
|
||||
- `apps/cms/` — no e2e (Payload has its own admin tests)
|
||||
- Root script: `pnpm test:e2e` via Turbo
|
||||
- ESLint config adds `eslint-plugin-playwright` for e2e folders
|
||||
- Playwright's `globalSetup` verifies Postgres is running; fails fast with a helpful message otherwise
|
||||
|
||||
### 10.6 Test obligations per layer
|
||||
|
||||
Every new use case and controller is expected to satisfy these rules.
|
||||
|
||||
| Rule | Description | Layer | Where the test lives |
|
||||
| ---- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
|
||||
| R10 | Controller input must be typed `unknown`; schema is the gate | `interface-adapters/controllers/` | `*.controller.test.ts` — assert `InputParseError` on invalid input |
|
||||
| R24 | Use-case + controller tests use direct factory injection; no `container.unbind/bind` | `application/use-cases/` + `interface-adapters/controllers/` | `*.use-case.test.ts`, `*.controller.test.ts` |
|
||||
| R25 | Non-void use case has a test asserting `xOutputSchema.parse` throws on malformed repo data | `application/use-cases/` | `*.use-case.test.ts` |
|
||||
| R26 | Every feature has a router test asserting domain error → expected `TRPCError.code` | `integrations/api/` | `router.test.ts` — call via `xRouter.createCaller({})` and assert `TRPCError.code` |
|
||||
| R27 | When presenter strips/renames/transforms, controller test asserts the resulting view shape | `interface-adapters/controllers/` | `*.controller.test.ts` — assert omitted fields absent, transformed fields present |
|
||||
| R28 | When controller has a non-identity presenter, tests assert the _view_ shape (not `XOutput`) | `interface-adapters/controllers/` | `*.controller.test.ts` — catches regressions where presenter short-circuits to identity |
|
||||
|
||||
Identity presenters do not require R27/R28 tests. Void-output controllers (e.g., `signOutController`, `deleteMediaController`) are exempt from R11 (presenter), R25, R27, and R28.
|
||||
|
||||
---
|
||||
|
||||
## 11. Docs + ADR strategy
|
||||
|
||||
> **Historical.** This section captures the ADR strategy at the time of the vertical-feature refactor — 5 existing ADRs (001–005), 4 new ADRs (006–009), and the 2 post-spec ADRs (012–013). The **canonical, current ADR set** lives in `docs/decisions/` and now spans **25 ADRs** — adding boundaries (010), TDD foundation (011), instrumentation + OpenTelemetry (014, 017), events / realtime / audit (015, 016, 018), Sandcastle (019), coverage (020), hybrid versioning (021), library policy + CI security (022, 023), product analytics (024), and the EU compliance baseline (025). Read `docs/decisions/` for the authoritative list; the tables below are preserved as the refactor's original record.
|
||||
|
||||
### 11.1 Existing ADRs
|
||||
|
||||
| File | Action | Notes |
|
||||
| ----------------------------- | ---------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `adr-001-monorepo-tool.md` | Keep unchanged | Turborepo + pnpm still accurate |
|
||||
| `adr-002-di-framework.md` | Keep; append note | InversifyJS kept, but now per-feature containers |
|
||||
| `adr-003-cms-separation.md` | Mark v1 superseded, write v2 | New architecture splits `cms-core` into `core-cms` + feature-owned collections |
|
||||
| `adr-004-dual-mode-client.md` | Mark superseded | `cms-client` deleted per spec §10 |
|
||||
| `adr-005-atomic-design.md` | Keep; append scope note | Applies to `core-ui/` only |
|
||||
|
||||
### 11.2 New ADRs
|
||||
|
||||
- `adr-006-vertical-feature-packages.md` — the main architectural pivot; references source spec
|
||||
- `adr-007-drop-cms-client-wrapper.md` — rationale for removing `packages/cms-client`
|
||||
- `adr-008-per-feature-di-containers.md` — why each feature owns its InversifyJS container
|
||||
- `adr-009-integrations-folder-naming.md` — why spec's `adapters/` is renamed `integrations/`
|
||||
|
||||
### 11.3 Rewritten docs
|
||||
|
||||
- `docs/architecture/overview.md` — new diagram, new flow, vertical package organization
|
||||
- `docs/architecture/dependency-flow.md` — new graph, three-tag boundary model
|
||||
- `docs/architecture/vertical-feature-spec.md` — copy of source spec for offline reference
|
||||
- `docs/guides/adding-a-feature.md` — new recipe (small feature + mature feature, per addendum v5)
|
||||
- `docs/guides/testing-strategy.md` — new placement table
|
||||
|
||||
### 11.4 AGENTS.md
|
||||
|
||||
All rewritten:
|
||||
|
||||
- Root `AGENTS.md` — new package map, new data flow, new rules, new boundary model
|
||||
- Per-core-package: responsibilities + forbidden imports (~60 lines each)
|
||||
- Per-feature-package: layer rules, addendum v5 folder-creation checklist, test placement
|
||||
- Per-layer inside features: local import rules, test patterns (short)
|
||||
- Per-app: purpose, imports, port, dev commands, e2e commands
|
||||
|
||||
Root `CLAUDE.md` — updated "Read First" pointers, unchanged port table, added boundary-enforcement note.
|
||||
|
||||
### 11.6 Post-spec ADRs
|
||||
|
||||
Two additional ADRs were added after the initial vertical-feature refactor and now form part of the permanent decision record:
|
||||
|
||||
- `adr-012-feature-conventions.md` — factory-function use cases + controllers, one-per-use-case controllers, canonical file-layout conventions, real Payload implementations for `auth`, full `media` scaffold. Accepts the pattern with four intentional divergences (inversify retained, per-feature DI containers, colocated tests, no Sentry wrapping).
|
||||
- `adr-013-input-output-unification.md` — use-case file is the single source of truth for `xInputSchema`/`xOutputSchema`; runtime output validation (`xOutputSchema.parse(result)`); co-located `function presenter` in every non-void controller; per-feature `procedures.ts` for domain error → `TRPCError` mapping via `defineErrorMiddleware` from `core-shared`; `./ui` subpath separates UI artifacts from contracts.
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
- Multi-browser Playwright matrix (chromium only initially; add firefox/webkit later)
|
||||
- Payload subscriptions / realtime (spec addendum v4); no feature requires it yet
|
||||
- CMS app Next.js 15.5 + Payload 3.81 stabilization concerns (recently patched; monitor; no specific action in this refactor)
|
||||
|
||||
---
|
||||
|
||||
## 13. Success criteria
|
||||
|
||||
- `pnpm install && pnpm typecheck && pnpm lint && pnpm test && pnpm build` all green
|
||||
- `pnpm test:e2e` green against running dev servers
|
||||
- `pnpm dev --filter @repo/web-next` serves a home page with navigation + marketing content + a blog index, and `/blog/[slug]` shows an article — all fed by tRPC → feature controllers → use-cases → Payload Local API
|
||||
- `pnpm dev --filter @repo/web-tanstack` renders the same blog post using the same feature packages
|
||||
- Storybook builds showing `core-ui` primitives
|
||||
- Any deep import (e.g., `import x from '@repo/blog/src/...'`) fails `pnpm lint`
|
||||
- Cross-feature imports are restricted to event contracts (ADR-015): the `feature` boundary tag accepts other `feature` tags, but rule E1 (`no-handler-reexport`) keeps consumer handlers, use cases, and repositories private. Importing a publisher's contract is allowed; importing anything else still fails `pnpm lint`.
|
||||
- Root `AGENTS.md` and one-per-package AGENTS.md reflect the new architecture
|
||||
- 9 new ADRs (5 existing maintained/appended/superseded + 4 new)
|
||||
- Zero references to deleted packages anywhere in the codebase
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
|
||||
**Substrate:** OpenTelemetry SDK. Sentry is the exporter via `@sentry/opentelemetry`. PII scrubbing happens at the OTel processor layer before the Sentry exporter. Feature code depends only on `ITracer`, `ILogger`, `IMetrics` interfaces — no Sentry or OTel SDK imports.
|
||||
|
||||
**File additions per feature:**
|
||||
|
||||
- `infrastructure/repositories/<entity>.repository.ts` — constructor takes `(config, tracer, logger)` with Noop defaults; every public method's body is wrapped in `tracer.startSpan(...)` and any `catch` block calls `logger.captureException(err, { tags: { feature, repo, method } })` before re-throwing.
|
||||
- `infrastructure/repositories/<entity>.repository.mock.ts` — same constructor/wrapping shape (no catch — mocks don't originate infra errors).
|
||||
- `di/bind-production.ts` — signature `(ctx: BindProductionContext)` (from `@repo/core-shared/di`). Destructures `{ config, tracer, logger, metrics?, bus, queue, realtime, realtimeRegistry, auditLog }` from `ctx`. Binds TRACER + LOGGER to the feature container; constructs the real repo with tracer/logger; wraps every use case + controller via `withSpan(withCapture(factory(deps)))` at bind time. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow; `withCapture` honours the `__sentryReported` flag so a bubbled error from the repo isn't re-captured. Optional fields (`metrics`, `bus`, `queue`, `realtime`, `realtimeRegistry`, `auditLog`) are guarded with `?.` or cast to the full interface when the feature unconditionally requires them (e.g. an authoritative action calls `ctx.auditLog?.record({...})`; the `?.` makes it safe whether or not `@repo/core-audit` is scaffolded).
|
||||
- `di/bind-dev-seed.ts` — signature `(ctx: BindContext)` (no `config`). Same wrapping as bind-production but with the populated mock.
|
||||
|
||||
**Required exports (per feature root):** unchanged.
|
||||
|
||||
**Public surface impact:** none for `./` (contracts) and `./ui`. The `./di/bind-production` and `./di/bind-dev-seed` subpaths now have new signatures — any consumer outside the app dispatcher is unaffected (the dispatcher is the only consumer per ADR-008).
|
||||
|
||||
**Test patterns:**
|
||||
|
||||
- **Direct injection** of `RecordingTracer` / `RecordingLogger` from `@repo/core-testing/instrumentation` for span/capture assertions.
|
||||
- **Contract suite span assertions** — every repo's contract suite (`__contracts__/*-repository.contract.ts`) includes a `span emission (R50)` describe block enumerating one assertion per method.
|
||||
|
||||
**Tradeoff:** every public repo method gains ~6 lines of `tracer.startSpan(...)` boilerplate. Worth the per-method visibility in production traces; if it ever proves excessive, a `withRepoSpan` helper can collapse the wrapping.
|
||||
Reference in New Issue
Block a user