Files
suaveplan/openspec/AGENTS.md
2026-05-27 23:29:53 +01:00

1148 lines
70 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OpenSpec — Agent Compliance Checklist
Read this file before implementing any OpenSpec change. Every section is a non-negotiable gate.
---
## 0. Research before implementing
Before writing a new package or migrating a library — mandatory, not optional:
1. **Search npm** for existing implementations. Prefer battle-tested over hand-rolled. Check the root `package.json` `catalog` block for deps already pinned.
2. **Check vendor docs via Context7** to confirm API behavior and version-specific details before implementing anything that wraps an external library.
3. **Search GitHub** (`gh search code --repo SuavePlan/suaveplan`) for reference implementations and patterns already used in the monorepo. Try accessing folder ../suaveplan/ first, as a local copy may be available.
4. If an existing implementation covers 80%+ of the requirement, port or wrap it — do not start from scratch.
5. If an existing implementation exists, but are starting from scratch, read its nested .md files first to get an understanding of its purpose.
6. **Check the dep graph** — if the new type or abstraction is shared across ≥2 packages, it belongs in `@suaveplan/types` (zero deps, types-only). Add it there first, then reference it. This prevents circular dependencies and keeps the public contract in one place.
---
## 0b. Package ecosystem awareness — read STATUS.md
Before writing a new package or migrating a library, **read the root `STATUS.md`** to learn which packages already exist in the ecosystem, which are completed on genesis, and which are pending migration from suaveplan.
Rules:
- **If the package you are building needs a dependency that is in the `Pending` list**, that dependency **must be migrated first** (or a compatible substitute must already exist on genesis). Do not hand-roll a replacement for a pending package without explicit justification in the parity checklist.
- **If the package itself exists in the `Pending` list**, it **must have an OpenSpec change written first** (`openspec/changes/<change-id>/proposal.md` + `tasks.md`) before any code is authored. Follow the migration parity checklist in §0a.
- **If the package does not exist in `Pending` or `Completed`**, it is a genesis-original package. It still **must have an OpenSpec change written first** before implementation begins.
- Never start coding without either (a) a completed migration of all pending dependencies, or (b) an approved OpenSpec proposal that explicitly scopes out missing dependencies with architectural justification.
The `STATUS.md` file is auto-generated from `../suaveplan/README.md` and `PACKAGES.md`. It is the single source of truth for what exists upstream and what has already landed on genesis. Checking it prevents duplicate work, broken downstream consumers, and incompatible re-implementations.
---
## 0a. Migration parity — no cut-down replacements
When migrating a library from suaveplan (or any upstream source) into genesis, **a greenfield rewrite is a last resort, not a default.** The upstream library exists because it solved real problems. Dropping features because they are "complex" or "not needed yet" is how we end up with broken consumers later.
### Mandatory parity checklist
Every migration proposal MUST include a `## Parity Checklist` section. Before writing any code, read the upstream source, the upstream openspec spec, and the upstream enterprise audit doc (`enterprise/<pkg>.md`). Then produce a table:
```markdown
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| Feature A | ✅ | ✅ | |
| Feature B | ✅ | 🚫 | Scoped out: reason … |
| Feature C | ✅ | ⬆️ | Enhanced: added … |
```
Rules:
- **No implicit drops.** If upstream has it and genesis does not, the box is 🚫 and MUST have a justification.
- **"Not needed yet" is banned as a justification.** Scope out only with a concrete architectural reason (e.g. "replaced by interceptor API in genesis which covers the same use case via composition").
- **If the justification is longer than one sentence, the feature is not scoped out — it is required.**
- **The checklist is a gate.** A proposal with unchecked 🚫 items without justification is rejected.
### Design quality bar
Migrations must be **modular and extensible**:
- Use TypeScript generics for type parameters, not `any` or `unknown` escape hatches.
- Prefer composition over inheritance. Public API surfaces should be interfaces that callers can implement.
- Avoid closed unions where module augmentation or registry patterns are possible.
- Preserve upstream subpath exports — every public module must be tree-shakeable.
- Merge upstream and downstream improvements. If genesis added `zigzagEncode` and upstream did not, the migrated package keeps it. If upstream has `AbortSignal` support and genesis does not, the migrated package gains it.
### Anti-patterns that block approval
| Anti-pattern | Why it fails |
|---|---|
| "Fresh build" with a different API | Consumers expecting upstream contracts break silently. |
| `export *` in any non-root barrel | Banned by §6a — feature barrels (`<feature>/index.ts`) must use explicit named re-exports. The barrel IS the contract; `export *` makes the contract whatever the source happens to declare today. The only exempt forms are generated `*.select/index.ts` forwarders, single-line `*.native/`/`*.noble/` impl-variant forwarders, and namespace re-exports (`export * as <ns> from`). When migrating from upstream, **convert every `export *` barrel to explicit named exports as part of the migration** — not as a follow-up. |
| Direct `from "vitest"` / `from "bun:test"` import in a non-exempt test file | Banned by §7 "Test runner imports — mandatory". Test files MUST import runner primitives from `@suaveplan/testing/runner` so they execute identically under `bun test` and `vitest run`. The exempt allowlist is exactly five testing-infra packages (`@suaveplan/testing`, `testing-dom`, `testing-e2e`, `testing-integration`, `testing-integration-step-ca`) — every other package, including `@suaveplan/testing` consumers and fixture extenders, MUST go through the wrapper. **When migrating from upstream or porting tests between packages, rewrite every direct runner import as part of the migration commit** — not as a follow-up. The CI gate (`bun scripts/lint/check-test-runner-imports.ts`) and §13 step 4b enforce this; code review must independently flag it because that is how 19 packages drifted before the gate existed. |
| Dropping schema-driven features for "simplicity" | Callers abandon the package and hand-roll the missing logic. |
| Replacing upstream errors with plain `Error` | Loses programmatic handling, OTel tagging, and i18n keys. |
| Ignoring upstream enterprise audit | The audit flagged real gaps; ignoring them recreates them. |
### Reference docs you must read
Before migrating any package:
1. `../suaveplan/openspec/specs/<pkg>/spec.md` — the canonical capability spec.
2. `../suaveplan/openspec/changes/*/proposal.md` — any active or archived changes that modified the package.
3. `../suaveplan/enterprise/<pkg>.md` — the enterprise audit with coverage, error, and quality gaps.
4. `../suaveplan/docs/superpowers/specs/*-<pkg>*.md` — design docs referenced by proposals.
If any of these are missing, note it in the parity checklist and explain the risk.
---
## 0c. Authoring the OpenSpec change — folder structure and spec-delta format
**Read this before you create a single file in `openspec/changes/`.** A batch of audio changes was
authored with the spec content dumped into `openspec/specs/` and written as freeform architecture
docs instead of spec deltas; the whole set had to be restructured. This section exists so that never
happens again. Every rule here is a gate — `openspec validate` and review enforce them.
### The change folder has exactly this shape
```text
openspec/changes/<change-id>/
proposal.md # what & why, impact, affected dependents
tasks.md # ordered implementation checklist
design.md # OPTIONAL — non-normative design: rationale, algorithms,
# code sketches, file layout, diagrams, trade-offs
specs/<capability>/spec.md # the SPEC DELTA — normative requirements only
```
- `<capability>` is the **package short name** without the `@suaveplan/` prefix — e.g. for
`@suaveplan/audio-core` the path is `specs/audio-core/spec.md`. One capability folder per package.
It mirrors the eventual canonical path `openspec/specs/<capability>/spec.md`.
- `design.md` is optional but **strongly preferred** for any non-trivial package. It is the home for
everything that is *not* a testable requirement: architecture, algorithm notes, code sketches,
file layout, integration narrative, benchmark plans, decisions & trade-offs. If you find yourself
writing prose that is not a `### Requirement:`, it belongs in `design.md`, not the spec delta.
### `openspec/specs/` is OFF LIMITS until archival
`openspec/specs/<capability>/spec.md` is the **canonical, shipped** spec. It is written to **only**
by the §14 archival fold step, **after** all gates pass. While a change is `PROPOSED` or in
progress:
- **NEVER create or edit anything under `openspec/specs/`.** Not a stub, not a placeholder, not a
"draft for reference". If `openspec/specs/<your-capability>/` exists before your change archives,
that is the mistake — delete it and move the content into your change's `specs/<capability>/spec.md`
delta.
- The file inside your **change** folder (`openspec/changes/<id>/specs/<capability>/spec.md`) is a
**delta**, not the canonical spec. It is folded into `openspec/specs/` on archive and the change
folder is moved to `openspec/archive/`.
### The spec delta is a normative requirements document — not an architecture dump
The delta MUST use the OpenSpec requirement/scenario format. A freeform "Public API Surface" or
"Architecture" document is **not** a spec delta and will be rejected. Skeleton:
```markdown
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: <capability> (<capability>)
**Package**: `@suaveplan/<capability>`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: <Imperative name>
The package SHALL … / MUST … — normative, testable prose. One requirement = one capability.
#### Scenario: <observable behaviour>
- **WHEN** <precondition / action>
- **THEN** <observable, checkable outcome>
**Verified by:** `src/<feature>/<feature>.test.ts::"<planned test name>"` AND `<another planned test>`
---
### Requirement: <next one>
…
```
Rules for the delta:
- Top-level headings are exactly `## ADDED Requirements`, `## MODIFIED Requirements`, and/or
`## REMOVED Requirements` — nothing else. A fresh package uses only `## ADDED Requirements`.
- Every `### Requirement:` has **normative prose** (SHALL / MUST / SHALL NOT), **one or more**
`#### Scenario:` blocks written as `- **WHEN**` / `- **THEN**` bullets, and **exactly one**
`**Verified by:**` line. Separate requirements with `---`.
- `**Verified by:**` is authored **at draft time**. The change is not implemented yet, so it names
**planned** test files (paths taken from `tasks.md` / the `design.md` file layout) in the
`path::"test name"` form. That is expected and correct — it is the contract the implementation
must satisfy.
- Cover the **whole contract**: public API surface, every error code (each must be reachable from a
real call site), Zod boundary validation, OTel instrumentation, `AbortSignal` propagation, subpath
exports, and tier purity (universal → no browser/Node APIs; browser → no Node APIs; server → no
browser APIs). Match the depth of the package — a large package is 15–25 requirements, a small
leaf is 8–12.
- Canonical reference to copy: any archived change's delta, e.g.
`openspec/archive/2026-05-12-storage-file/specs/storage-file/spec.md`.
### `proposal.md` and `tasks.md` obligations
- `proposal.md` MUST link its design and delta in the header block (`- **Design**: ./design.md`,
`- **Spec delta**: ./specs/<capability>/spec.md`) — **never** a `Reference spec:` pointer into
`openspec/specs/`. It MUST carry the `## Impact` → `### Affected dependents` section per §14a.
- `tasks.md` MUST end with a "Fold `specs/<capability>/spec.md` into
`openspec/specs/<capability>/spec.md`" step immediately before the archive step (see §14).
### Multi-package / cross-cutting feature sets
When one feature spans many packages (a family like `audio-*`, a wave of transports, etc.):
- **One change per package.** Each package gets its own `openspec/changes/<id>/` with its own
`specs/<capability>/spec.md` delta. Do **not** write a single umbrella spec file covering the
whole family, and do **not** put a shared `specs/<family>/` folder anywhere.
- **Shared architecture lives once, in the foundation package's `design.md`.** The foundation
package (the one every sibling depends on) carries the cross-cutting model in its `design.md`;
sibling `design.md` files **reference** it by relative path rather than duplicating it.
- Each change still stands alone: its `proposal.md`, `design.md`, `tasks.md`, and delta are
self-sufficient for that package.
### Pre-flight checklist — before you write any change file
1. The package is in `STATUS.md` (`–` or `❌`) or is a genesis-original — confirmed per §0b.
2. You have read the upstream references in §0a (for migrations) or the design inputs (for
genesis-originals).
3. You will create `proposal.md` + `tasks.md` + `specs/<capability>/spec.md` (+ `design.md`), and
you will create **nothing** under `openspec/specs/`.
4. The delta will be in `## ADDED/MODIFIED/REMOVED Requirements` form with `### Requirement:` /
`#### Scenario:` / `**Verified by:**` — not an API dump.
5. `proposal.md` will carry `### Affected dependents` (§14a); `tasks.md` will carry the fold step
(§14).
6. **You have created a numbered checklist of every `### Requirement` and every `#### Scenario`
in the spec delta.** This checklist lives in your working notes. You may not begin
implementation until every requirement is listed as a discrete, checkable item. As you
implement, you check each item off. An unchecked item at the end of implementation means the
package is not done. **Never silently skip a requirement because "tests already pass without it."**
That is the single most common cause of spec drift.
---
## 1. Dependency conventions
- All external deps use `catalog:<key>` — **never bare version strings** like `"^1.2.3"`.
- All internal (`@suaveplan/*`) deps use `workspace:^`. (Some Suaveplan skills say `workspace:*` — genesis uses `workspace:^` exclusively.)
- If a catalog key does not exist for a dep, add it to the root `package.json` `catalog` block first.
- `@vitest/coverage-istanbul` must be in `devDependencies` via catalog ref. Without it, `provider: 'istanbul'` silently falls back to v8.
- Testing packages (`@suaveplan/testing`, `testing-dom`, `testing-e2e`) have **zero** `@suaveplan/*` deps.
- After changing `overrides`, run the nuclear clean install:
```bash
rm -rf node_modules bun.lockb && find . -type d -name "node_modules" -exec rm -rf {} + && bun install --force
```
- For browser/react packages: verify only one React version exists after install:
```bash
find . -path "*/node_modules/react/package.json" | head -5
```
## 2. No banned patterns in `src/`
Before closing any task, scan for zero matches:
```bash
grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only\|\bany\b\|console\.log" \
packages/<plugin>/<pkg>/src/ --include="*.ts"
```
- `@deprecated` in JSDoc is banned — replace with prose: "Backwards-compatibility alias for X."
- `any` is banned — replace with `unknown` (then narrow via `instanceof` / type guards) or a proper generic. Never substitute `object`, `{}`, or a redundant union.
- `console.log` is banned in production `src/` — use `@suaveplan/logger` or the injectable `LoggerLike` from `@suaveplan/types`.
## 2b. NEVER ship stubs, placeholders, or "work-in-progress" code
**Genesis is an enterprise-grade collection of fully working feature-rich libraries — NOT a work-in-progress workspace.** Every package, every module, every function, every test, and every doc that lands on `genesis` MUST be complete and production-ready. There are no exceptions and no carve-outs for "I'll fix it later."
Concretely banned in any commit, archive, or merged change:
- **Stub functions** — `function frob() { throw new Error("not implemented"); }`, `function frob() { /* TODO */ return undefined as never; }`, identity pass-throughs that pretend to do work, no-op factories that "return a real implementation later."
- **Placeholder types** — `type Foo = unknown;`, `interface Foo {}` empty interfaces standing in for "the real shape", `as never` casts hiding incomplete data flow.
- **TODO / FIXME / XXX comments** — both in source AND in docs. Use the `git mv` review-process if a known follow-up needs naming; otherwise the work belongs in the same change or doesn't exist.
- **`@deprecated` placeholders** — every "deprecation" must reference a working replacement that already ships, or it's a placeholder by another name.
- **"Phase 1 / Phase 2" splits where Phase 2 is unwritten** — splitting work across changes is fine, but every shipped change must be feature-complete on the surface it claims. Never ship a `Drive.hydrateFrom` that "doesn't actually fetch chunks yet" with the assumption a follow-up will land.
- **Marketing language masking incomplete work** — "comprehensive", "fully-featured", "production-grade" used to describe code that has unimplemented branches. Either it IS those things, or the docs say it isn't.
- **Tautological tests** — `it.todo`, `it.skip`, `expect(true).toBe(true)`, `expect(1).toBe(1)`, `expect(result).toBeDefined()` standing alone, tests whose only assertion is the trivial round-trip the test setup already establishes, and tests that assert against the same hardcoded value the implementation returns. **Every `it(...)` block MUST verify a behavior the implementation could plausibly get wrong.** Coverage tools count tautologies as covered lines; reviewers don't. If you find yourself writing `expect(true).toBe(true)` to make the file "have a test," you are writing a placeholder — delete the file or write a real test, never both. This rule has been violated in the past and the resulting tests masked real defects; reviewers and pre-archive gates explicitly look for this anti-pattern.
- **Stub docs** — sibling `.md` files that say "TODO: write this", that have empty Features / API Reference sections, that describe a different module than the one they sit beside, or that fail the §4 word-count / required-section gates. The `docs:check` gate is non-negotiable; new docs ship complete, not as templates.
- **Empty test files** — `describe("TODO")`, `it.skip` placeholders, files imported only to keep coverage thresholds passing. If a file exists in `src/`, it has real tests; if it doesn't, it doesn't ship.
- **Silently dropped spec requirements** — skipping a `### Requirement` from the OpenSpec delta because it is "hard," "not needed yet," or "the tests pass without it." This is the most destructive form of placeholder because it is invisible: the code compiles, tests pass, coverage is green, and the spec is still violated. **If a requirement is in the spec delta, it ships. No exceptions.** If you genuinely believe a requirement should be scoped out, open a follow-up OpenSpec change with justification — never silently omit.
If the work isn't ready, **don't merge it.** Open the OpenSpec change, write the proposal, do the implementation, run the gates, archive the change, then ship. The genesis branch is what you publish to npm — it is the product, not the scratchpad. Reviewers will reject any PR with a placeholder, and the §13 pre-archive checklist hard-gates against §3 of CLAUDE.md ("Build feature-rich, production-grade packages — prefer comprehensive, enterprise-level implementations over minimal MVPs; no half-baked or mediocre libraries").
If you discover an existing stub during audit (e.g. a 40-line README that doesn't meet `docs:check`'s 200-word floor, a function whose body is `return undefined as never`, a barrel re-exporting nothing), the right answer is to FIX it inside the same change as your other work — leaving it in place is a vote for the placeholder.
## 2c. NEVER delete spec elements to satisfy a gate — wire them in
When a gate fails because a registered element (error code, schema, event topic, requirement, capability constant) is **declared but unused**, the failure is telling you the implementation hasn't caught up to the proposal. The fix is to **wire the element into the call site the proposal designed it for**, NOT to delete the element from the registry.
This rule exists because deletion is the path of least resistance and it silently strips features the proposal committed to. The proposal is the contract; the registry is the proposal expressed in code. Removing an entry to make a tool happy is the same defect as marking a failing test `.skip` to make CI green.
Concretely banned:
- **Removing an error code from the `defineCodes(...)` registry** because the openspec-conformance `error-codes-thrown` check flags it as unused. The proposal added that code for a reason — find the call site that should be raising / reporting it and wire it in. Codes that are referenced but not thrown (e.g. carried on a `Report.code` field, mapped via a `KIND_TO_CODE` table, returned in a discriminated-union outcome) all count as "used" and are valid expressions of the registry — but the wiring must exist somewhere in `src/` and be exercised by tests.
- **Deleting a Zod schema field** because no caller populates it yet. If the proposal says `LogEntry` carries `severity`, every appender populates `severity` — not "we'll add it later."
- **Removing an event topic from the constants module** because no emitter exists. Add the emitter; the topic is an API surface the proposal owns.
- **Quietly removing a `## ADDED Requirements` line from a spec delta** because the implementation doesn't cover it. Either implement the requirement or open a follow-up change that explicitly defers it with reasoning — never silently retract.
- **Lowering a coverage / docs-check threshold** to make a gate pass. The thresholds are the contract; the work moves up to meet them, not the other way around.
When in doubt, ask: *"Was this element part of the proposal I'm implementing?"* If yes, the answer is **wire it in**. If genuinely no (it crept in by mistake), removing it requires a one-line note in the change's `proposal.md` under a `### Deferred` or `### Removed` section so the deletion is auditable.
This rule has been violated and caught in review; the resulting deletions stripped functionality the proposal had specifically committed to. Reviewers and the §13 pre-archive checklist explicitly look for unmotivated registry shrinkage.
## 2a. Biome lint suppressions
Stale `// biome-ignore` comments break the lint gate hard — wrong-category names produce a parse error (`Failed to parse category`), and right-category names that no longer match a violation produce a `Suppression comment has no effect` warning. Both block CI.
Rules:
1. **Never write `// biome-ignore` for transient lint noise.** If biome flags something it's a real signal — fix the code, don't paper over it.
2. **If a deliberate violation is required** (e.g. exercising a defensive non-Error branch in a test), use a coding pattern biome can't statically reject, not a suppression. Specifically, for `useThrowOnlyError`:
```ts
// BANNED — suppression rots when biome rules rename or move category.
// biome-ignore lint/suspicious/noThrowLiterals: ...
throw "literal";
// REQUIRED — biome can't prove the value isn't Error-like.
const failure: unknown = "literal";
throw failure;
```
3. **If a suppression genuinely is the only path** (escape-hatch), pin both the current rule category and the reason: `// biome-ignore lint/<category>/<rule>: <one-sentence reason>`. Verify the category resolves with `bunx biome explain <rule>` before committing.
4. **Scan before closing any task:**
```bash
grep -rn "biome-ignore" packages/<plugin>/<pkg>/src/ --include="*.ts"
```
Each hit MUST be justified in code review.
The same rule applies to `// eslint-disable*` and any other static-analysis suppression comment encountered during library migration.
## 3. File size budget
- Source files: **300 lines hard limit** (250 soft).
- Test files: **500 lines max**.
- Functions: **50 lines soft limit** — extract helpers at natural responsibility boundaries.
Check:
```bash
find packages/<plugin>/<pkg>/src -name "*.ts" ! -name "*.test.ts" | \
xargs awk 'END { if (NR > 300) print FILENAME, NR " lines" }'
find packages/<plugin>/<pkg>/src -name "*.test.ts" | \
xargs awk 'END { if (NR > 500) print FILENAME, NR " lines" }'
```
Split at natural boundaries. Preserve all public exports.
## 4. Documentation — sibling `.md` files
Every implementation `.ts` file needs a sibling `.md`. Check for gaps:
```bash
for f in $(find packages/<plugin>/<pkg>/src -name "*.ts" \
! -name "*.test.ts" ! -name "*.spec.ts" ! -name "*.types.ts" \
! -name "index.ts" ! -name "*.stories.tsx"); do
md="${f%.ts}.md"
[ -f "$md" ] || echo "MISSING: $md"
done
```
### 3-stage doc writing workflow
Write docs in three passes — never one shot:
1. **Read** — read the full `.ts` file; note every exported symbol, parameter, and thrown error.
2. **Structure** — write Purpose + Features + API Reference first; get the facts right before writing examples.
3. **Examples** — write the Basic Example (simplest correct happy path), then the Advanced Example (real composition or edge case). Both are mandatory. Code must compile with real `@suaveplan/<pkg>` imports.
Required sections (≥200 words excluding code blocks):
1. Purpose (≥2 sentences — what and why)
2. Features (bullet list)
3. Usage: Basic Example
4. Usage: Advanced Example
5. API Reference (every exported symbol — params, return type, throws)
6. Implementation Notes (design decisions, limits, known edge cases)
Stale docs are defects — update when code changes.
### 4a. Doc-drift gate (mandatory)
When you modify any implementation file `<feature>.ts`, you MUST also update its sibling `<feature>.md` in the same change set if the modification affects:
- the public API surface (added / removed / renamed export, changed parameter type, changed return type, changed throws)
- documented behavior (error codes, side effects, semantics described in the `.md`)
- examples in the `.md` (they must still compile against the new code)
Editing the `.ts` without touching the `.md` is treated as a defect, identical in severity to a failing test. Before claiming any task complete, run the diff-pair check:
```bash
# For every changed .ts in the diff, confirm the sibling .md is also touched
git diff --name-only HEAD | awk '
/\.ts$/ && !/\.test\.ts$/ && !/\.spec\.ts$/ && !/\.types\.ts$/ && !/\/index\.ts$/ {
md = $0; sub(/\.ts$/, ".md", md)
cmd = "git diff --name-only HEAD -- " md
cmd | getline touched; close(cmd)
if (touched == "") print "DOC DRIFT: " md " not updated alongside " $0
}
'
```
The same rule applies to `README.md` when a public-surface item documented in the README's "Features" / "Modules" / "Quick start" sections is added, removed, or has its semantics changed. If a public counter, error code, or method now behaves differently, the README must reflect it before the change ships.
This is not a soft "should" — when an `.md` describes an old contract, downstream readers act on stale information. The first agent that introduces drift accumulates compounding cost on every later one.
## 5. Root `README.md`
Every package needs `README.md` at the package root. Required sections:
1. Package name (H1)
2. One-paragraph description
3. Install: `bun add @suaveplan/<pkg>`
4. Quick-start code example
5. **Modules** — links to every co-located `.md` in `src/` via relative paths
```bash
test -f packages/<plugin>/<pkg>/README.md && echo "OK" || echo "MISSING README"
```
## 6. Subpath exports per feature module
Every feature directory must have its own entry in the `exports` map so consumers can tree-shake:
```json
"./retry/retry": {
"import": { "types": "./dist/retry/retry.d.ts", "default": "./dist/retry/retry.js" }
},
"./circuit-breaker/circuit-breaker": {
"import": { "types": "./dist/circuit-breaker/circuit-breaker.d.ts", "default": "./dist/circuit-breaker/circuit-breaker.js" }
}
```
A single `"."` barrel is insufficient. Check: count subpaths matches feature module count.
Also verify entry points import nothing with side effects (OTel registration, global state mutations). A `sideEffects: false` declaration with a leaking side effect is a silent bug.
## 6a. Barrel exports — explicit named, not `export *`
Feature-module barrels (`<feature>/index.ts`) and package roots (`src/index.ts`) MUST use **explicit named re-exports**, not `export * from "..."`. The only place `export *` is allowed is inside a generated selector file (`<primitive>.select.ts`) where transparent forwarding of the impl surface is the entire point of the file.
```ts
// CORRECT — feature barrel: explicit named exports
export {
decryptStreamAead,
type DecryptStreamAeadOptions,
encryptStreamAead,
type EncryptStreamAeadOptions,
type StreamingAeadEngine,
} from "./streaming-aead.js";
// WRONG — feature barrel: leaks every new export from the source file
export * from "./streaming-aead.js";
// CORRECT — generated selector (transparent forwarder):
/* GENERATED — DO NOT EDIT. */
export * from "../impl/scrypt.noble/scrypt.noble.js";
```
Why:
- **Public API is explicit and reviewable.** The barrel is the contract; `export *` makes the contract whatever the source happens to declare today.
- **No accidental leakage.** Adding a private helper to the source must not auto-leak it to consumers.
- **Type-only re-exports stay clean.** `export { type X }` works per-symbol; `export type *` exists but mixes poorly with value re-exports in the same statement.
- **Tree-shaking is equivalent** under `"sideEffects": false` with rollup/esbuild — there is no bundle-size argument for `export *`.
Top-level package indexes that re-namespace primitives (e.g. `export * as ed25519 from "./primitives/ed25519/ed25519.js"`) are fine and recommended where multiple primitives share symbol names (`getPublicKey`, `sign`, `verify`) — that's a namespace export, not a `export *`.
## 7. Coverage thresholds
Set in `vitest.config.ts` from day one — **100 / 100 / 100 / 100** for every genesis package, no exceptions, no "foundation only" carve-out, no degraded-situation floor:
```ts
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 }
```
The pre-publish gate (`bunx turbo test:coverage --filter=@suaveplan/<pkg>`) MUST hard-fail below 100%. Mutation testing, contract testing, and property/fuzz coverage layer on top of 100% line coverage — they do not substitute for it, and 100% line coverage does not substitute for them.
### 7a. Never delete code that serves a purpose to make tests / coverage pass
**This is non-negotiable.** When a coverage report flags a function, branch, or guard as uncovered, the answer is to write a test that exercises the real behavior — NOT to delete the code that's hard to reach.
Concretely banned:
- **Deleting a defensive guard** because the current call sites don't trigger it. Defensive guards exist for the failure modes you haven't shipped yet.
- **Deleting a rollback / cleanup helper** because the synchronous code path can't reach it. The helper is there because async / future call sites will. Restructure the loop (e.g. `await Promise.resolve()` between iterations) so a real test can fire it, then write that test.
- **Deleting an `instanceof Error ? cause : undefined` branch** by replacing with `cause as Error`. The branch handles non-`Error` throwables — the right fix is either a test that throws a non-`Error` (`const x: unknown = "literal"; throw x;`) or — if the spec genuinely guarantees only `Error` reaches the branch — a comment + a property test that proves it.
- **Deleting an `if (oldest !== undefined) ...` guard** with `as string` cast, when the Map invariant guarantees non-empty. If the invariant truly holds, prove it with a test that pre-conditions the Map state and asserts the non-empty guarantee; if it doesn't, leave the guard.
- **Deleting branches inside `try { ... } catch { ... }` arms** because the underlying library doesn't currently throw. Library behavior changes between versions; the catch is forward-defensive.
What you SHOULD do instead, in priority order:
1. **Write a test that exercises the path.** Inject a controlled failure (custom storage that throws, custom `Rng` that returns specific values, custom `Clock` that returns a chosen instant, an explicit `AbortSignal` scheduled via `queueMicrotask`).
2. **Restructure to make the path reachable** without changing semantics. Adding `await Promise.resolve()` between sync iterations turns a single-tick loop into one where caller-side aborts can land — that's a behavior IMPROVEMENT, not a workaround.
3. **If the branch is genuinely unreachable** (e.g. TypeScript narrowing has already excluded the case), add a comment explaining WHY, then either (a) restructure to remove the dead form (e.g. `as never` on an exhaustive switch's default), or (b) accept the carve-out via an explicit `/* c8 ignore next */` annotation with a comment naming the unreachability proof.
The path NEVER taken is "delete the code, ship the diff, claim the gate is green." That violates §3 of CLAUDE.md ("Build feature-rich, production-grade packages") and §7 of this file.
**If you find yourself reaching for `delete-this-defensive-thing` to hit 100% coverage: stop. The code is there for a reason. Write the test instead.**
### Test runner imports — mandatory
All test files MUST import runner primitives (`describe`, `it`, `test`, `expect`, `expectTypeOf`, `vi`, `inject`, `beforeAll`/`beforeEach`/`afterAll`/`afterEach`, and the runner-neutral mock helpers `fn`, `spy`, `stub`, `mock`, `fakeClock`, `restoreMocks`, `restoreTimers`) from `@suaveplan/testing/runner`:
```ts
// CORRECT — runner-neutral, works under both bun test and vitest
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
// BANNED — breaks when the other runner is used
import { describe, expect, it } from "bun:test";
import { describe, expect, it } from "vitest";
```
`@suaveplan/testing/runner` is a conditional-export barrel:
- `bun` condition → re-exports from `bun:test`
- `default` / `vitest` condition → re-exports from `vitest`
This means tests compile and execute identically under `bun test` and `vitest run`. Never import a runner directly.
**Exempt allowlist (the only packages permitted to import from `vitest` / `bun:test` directly — they author or extend the wrapper):**
- `@suaveplan/testing`
- `@suaveplan/testing-dom`
- `@suaveplan/testing-e2e`
- `@suaveplan/testing-integration`
- `@suaveplan/testing-integration-step-ca`
Adding a package to the allowlist requires its own OpenSpec change with justification — extending fixtures or being a heavy `@suaveplan/testing` consumer does NOT qualify.
**Migration prerequisite — mirrors §6a:** when migrating any library from upstream (`../suaveplan/`) into the genesis monorepo, every `from "vitest"` / `from "bun:test"` import in the source MUST be rewritten to `from "@suaveplan/testing/runner"` **as part of the migration commit, not as a follow-up**. The same rule applies when porting test files between genesis packages, when copy-pasting an upstream test as a starting point, or when an LLM agent generates a fresh test file — direct runner imports are never acceptable in non-exempt packages, even transiently.
**Verification grep (zero output expected for any non-exempt package):**
```bash
# Replace <plugin>/<pkg> with the package being audited.
# A non-empty result means the package is bypassing the canonical wrapper
# and must be migrated to `@suaveplan/testing/runner` before publishing.
grep -rnE "from ['\"](vitest|bun:test)['\"]" \
packages/<plugin>/<pkg>/src/ --include="*.ts" --include="*.tsx"
```
The repo-wide CI gate (`bun scripts/lint/check-test-runner-imports.ts`, wired into `bunx turbo lint`) enforces this on every commit and is the source of truth — see §13 step 4b. Code review MUST also flag direct runner imports independently of the CI gate; treating CI as the only enforcement layer is how this rule drifted in 19 packages between 2026-04 and 2026-05 in the first place.
### Test structure
Use Arrange-Act-Assert. Name tests after the **behavior**, not the implementation:
```ts
// CORRECT
it('returns empty array when no results match the query', ...)
it('throws ValidationError when required field is missing', ...)
// WRONG
it('test1', ...)
it('works correctly', ...)
it('handleSearch', ...)
```
## 8. Turborepo filtering reference
This is a Bun-based monorepo — use `bunx turbo`:
```bash
bunx turbo test --filter=@suaveplan/<pkg> # run tests for a single package
bunx turbo test --filter=@suaveplan/<pkg>... # pkg + its deps
bunx turbo test --filter=...@suaveplan/<pkg> # pkg + its dependents
bunx turbo test --filter=...[main] # changed since main
bunx turbo test --filter=...[HEAD^1] # changed in last commit
bunx turbo test --filter=@suaveplan/{a,b,c} # multiple (OR)
bunx turbo test --filter=!@suaveplan/<pkg> # exclude
```
## 9. Benchmarks (mitata)
Every package must include mitata benchmarks in `src/__benchmarks__/`. Use `--cpu-prof-md` / `--heap-prof-md` (Bun ≥ 1.3.7) to generate Markdown profiling output for LLM-assisted analysis. Publish baseline numbers in README.
## 10. Non-redundant path naming
A file or folder name must **not** contain the singular or plural form of any ancestor folder in its path.
**Rule:** if a parent folder is named `adapters`, its children must not include `-adapter` or `-adapters`; if a parent is `strategies`, children must not include `-strategy`; etc.
| Parent folder | Banned suffix in child names |
|---|---|
| `adapters` | `-adapter`, `-adapters` |
| `strategies` | `-strategy`, `-strategies` |
| `providers` | `-provider`, `-providers` |
| `builders` | `-builder`, `-builders` |
| `plugins` | `-plugin`, `-plugins` |
| `handlers` | `-handler`, `-handlers` |
| `resolvers` | `-resolver`, `-resolvers` |
| `factories` / `factory` | `-factory`, `-factories` |
| `parsers` / `parser` | `-parser`, `-parsers` |
| `services` | `-service`, `-services` |
**Examples**
```text
# WRONG
adapters/memory-adapter/memory-adapter.ts
strategies/attribute-strategy/attribute-strategy.ts
providers/memory-provider/memory-provider.ts
builders/request-builder/request-builder.ts
# CORRECT
adapters/memory/memory.ts
strategies/attribute/attribute.ts
providers/memory/memory.ts
builders/request/request.ts
```
Check for violations before closing any task:
```bash
find packages/<plugin>/<pkg>/src -type d | awk -F/ '
NF>=2 {
parent=$(NF-1); child=$NF
singular=parent; sub(/ies$/, "y", singular); sub(/s$/, "", singular)
if (index(child, singular) > 0) print $0
}
'
```
## 11. Type design — no inline shapes
### No nested object literals in interfaces
Every nested object shape must be a named `interface` or `type`. Reference it by name.
```ts
// WRONG — nested object literal
interface CreateUserOptions {
profile: {
displayName: string
avatarUrl: string
}
notifications: {
email: boolean
push: boolean
}
}
// CORRECT — extracted interfaces
interface UserProfile {
displayName: string
avatarUrl: string
}
interface NotificationPreferences {
email: boolean
push: boolean
}
interface CreateUserOptions {
profile: UserProfile
notifications: NotificationPreferences
}
```
### No inline type definitions in function signatures
Parameters and return types must reference named interfaces/types, not inline shapes.
```ts
// WRONG — inline shapes in signature
function createSession(
user: { id: string; role: string },
opts: { ttl: number; secure: boolean }
): { token: string; expiresAt: Date } { ... }
// CORRECT — named types
interface SessionUser {
id: string
role: string
}
interface SessionOptions {
ttl: number
secure: boolean
}
interface SessionResult {
token: string
expiresAt: Date
}
function createSession(user: SessionUser, opts: SessionOptions): SessionResult { ... }
```
### Shared types belong in `@suaveplan/types`
If a type is referenced by ≥2 packages, define it in `@suaveplan/types` and import from there. This prevents circular dependencies — `@suaveplan/types` has zero runtime code and zero workspace deps, so any package can safely depend on it.
```ts
// In @suaveplan/types — canonical home for cross-package contracts
export interface LoggerLike { ... }
export interface Clock { ... }
export interface StorageLike<T> { ... }
// In consuming packages
import type { LoggerLike } from '@suaveplan/types'
```
Check for violations before closing any task:
```bash
# Inline object literals in interface/type bodies (heuristic — review matches)
grep -rn "^\s*\w\+:\s*{$" packages/<plugin>/<pkg>/src/ --include="*.ts"
# Inline object literals in function parameters
grep -rn "(\s*\w\+:\s*{" packages/<plugin>/<pkg>/src/ --include="*.ts"
```
## 12. Clean workspace
Before declaring any task done:
- Remove scratch files, debug outputs, and temporary artifacts.
- Verify `git status` shows only intentional changes.
## 12a. Spec-completeness verification — before any gate
**Never trust a compaction summary, archive status, or STATUS.md badge as proof that work is complete.** The only source of truth for what a change must deliver is its own `tasks.md` and `design.md`.
Before running ANY command in §13, do the following manually:
1. **Open the change's `tasks.md`.** For a migration, this is `../suaveplan/openspec/changes/<id>/tasks.md` or `openspec/changes/<id>/tasks.md`. For a genesis-original change, it is `openspec/changes/<id>/tasks.md`.
2. **Walk every task item.** For each task that claims a file, module, type, or feature exists, verify it is present in the actual file tree. A task that says "Implement `geo/distance` — `greatCircleDistance` and `vincentyDistance`" means BOTH functions must exist, have tests, have docs, and be exported from the barrel.
3. **For migrations, compare to upstream.** Open `../suaveplan/packages/<path>/src/` and diff it against the genesis equivalent. If upstream has `providers/admiralty/admiralty-provider.ts` with real fetch logic, and genesis has a 3-line stub in `provider/provider.ts`, the migration is incomplete — do not declare it done.
4. **Empty directories are a red flag, not cleanup.** If a directory exists in `src/` and is empty, it was scaffolded for a feature that was never implemented. The correct response is to implement the feature and populate the directory (following Pattern B: `<feature>.ts`, `index.ts`, `<feature>.test.ts`, `<feature>.md`), NOT to delete the directory to make the tree look tidy.
5. **Cross-check against the parity checklist.** For migrations, the proposal's `## Parity Checklist` table is a contract. Every 🚫 row MUST have a written justification; every ⬆️ row must be real. If the table says a feature is migrated but the file tree disagrees, the table is wrong — fix the implementation, not the table.
If any task item is unchecked or unverified, the change is **not done**. Do not proceed to §13. Do not archive. Do not update STATUS.md.
---
## 13. Pre-ship verification sequence
Run in order — all must be green. **Step 0 is a hard gate; do not skip it.**
```bash
# 0. Spec reconciliation — NON-NEGOTIABLE. Run this BEFORE any other gate.
# Walk every `### Requirement` and `#### Scenario` in the spec delta.
# For each requirement, grep the implementation to confirm the named
# class, function, constant, span, counter, histogram, event, or error
# code actually exists in src/. For each `**Verified by:**`, confirm
# the test file exists and the test name matches *verbatim*.
# If any requirement is unimplemented or any Verified-by is broken,
# STOP. Do not run step 1. Fix the implementation or update the spec.
# 1. Lint + typecheck + tests + biome check
bunx turbo lint typecheck test --filter=@suaveplan/<pkg>
bun run check # biome check --write --unsafe --max-diagnostics 500 src/
# 2. Coverage at 100%
bunx turbo test:coverage --filter=@suaveplan/<pkg>
# 3. Build
bunx turbo build --filter=@suaveplan/<pkg>
# 4. Banned-pattern scan (zero output expected)
grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only" \
packages/<plugin>/<pkg>/src/ --include="*.ts"
# 4a. Barrel-shape scan (§6a — zero output expected for any feature barrel).
# Restricted to `<feature>/index.ts` files specifically; the spec scope
# is feature-module barrels, not impl files. A non-empty result means a
# `<feature>/index.ts` (other than a generated `*.select/`/`*.native/`/
# `*.noble/` forwarder) is using `export *` instead of explicit named
# re-exports.
grep -rn "^\s*export\s\+\(type\s\+\)\?\*\s\+from" \
packages/<plugin>/<pkg>/src/ --include="index.ts" \
| grep -v "/src/index\.ts:" \
| grep -v "\.select/" \
| grep -v "\.native/index\.ts:" \
| grep -v "\.noble/index\.ts:" \
| grep -v "export \* as"
# 4b. Test-runner import scan (§7 / §0a anti-pattern row — zero output expected
# for any package whose name is NOT in the testing-infra exempt allowlist:
# @suaveplan/testing, @suaveplan/testing-dom, @suaveplan/testing-e2e,
# @suaveplan/testing-integration, @suaveplan/testing-integration-step-ca).
# A non-empty result means a non-exempt test file is bypassing the canonical
# `@suaveplan/testing/runner` wrapper and pinning itself to one runner —
# migrate it before shipping. The repo-wide CI gate
# (`bun scripts/lint/check-test-runner-imports.ts`) runs this across every
# non-exempt package on every commit.
grep -rnE "from ['\"](vitest|bun:test)['\"]" \
packages/<plugin>/<pkg>/src/ --include="*.ts" --include="*.tsx"
# 5. Missing .md scan (zero output expected)
for f in $(find packages/<plugin>/<pkg>/src -name "*.ts" \
! -name "*.test.ts" ! -name "*.spec.ts" ! -name "*.types.ts" \
! -name "index.ts"); do
md="${f%.ts}.md"; [ -f "$md" ] || echo "MISSING: $md"
done
# 6. README check
test -f packages/<plugin>/<pkg>/README.md && echo "README OK" || echo "MISSING README"
# 7. Bare version check (zero output expected)
node -e "
const p = JSON.parse(require('fs').readFileSync('packages/<plugin>/<pkg>/package.json','utf8'));
const all = {...p.dependencies,...p.devDependencies,...p.peerDependencies};
for (const [k,v] of Object.entries(all ?? {})) {
if (typeof v === 'string' && /^[\^~]?\d/.test(v)) console.log('BARE VERSION:', k, v);
}
"
# 8. Subpath export count (should match feature module count)
node -e "
const p = JSON.parse(require('fs').readFileSync('packages/<plugin>/<pkg>/package.json','utf8'));
const sub = Object.keys(p.exports ?? {}).filter(k => k.startsWith('./') && k !== '.');
console.log('Subpath exports:', sub.length, sub);
"
# 9. Security scan — hardcoded secrets + leaking error internals (zero output expected)
grep -rn \
"password\s*=\s*['\"][^'\"]\|secret\s*=\s*['\"][^'\"]\|apiKey\s*=\s*['\"][^'\"]\|token\s*=\s*['\"][^'\"]" \
packages/<plugin>/<pkg>/src/ --include="*.ts"
# 10. Dependent sweep — for foundation packages, run downstream tests too
# Foundation packages (where any consumer-visible behavior change ripples):
# @suaveplan/error, @suaveplan/types, @suaveplan/events,
# @suaveplan/logger, @suaveplan/validation
# For these, the publish gate MUST also include the dependents' test pass.
bunx turbo test --filter=...@suaveplan/<pkg>
```
When the change touches a foundation package, step 10 catches API-shape regressions in downstream packages BEFORE the change ships. A green local test for the foundation does not prove it didn't break consumers; only running their tests does.
For non-foundation packages, step 10 is optional — but if you altered the package's public surface (added / removed / renamed an export, changed a method signature, changed semantics), run it anyway. The cost is one extra turbo command; the cost of skipping is finding out from a consumer's CI break next week.
## 13a. Behavior-change propagation
When a change introduces a consumer-visible behavior change — any of:
- a new error code that consumers might assert on,
- a renamed / removed metric or counter field,
- a method that now throws where it used to silently succeed,
- a default value that flipped,
- a return shape that changed,
— you MUST do all three of the following before claiming complete:
1. **Self-doc**: update the package's own `.md` files (sibling docs + `README.md` Features section) so they describe the new behavior. The doc-drift gate (§4a) covers signature changes; this clause covers semantic ones.
2. **Dependent surface scan**: search dependents for the old name / pattern. Use `grep -rln "<old-name>" packages/ | grep -v "/dist/\|/coverage/\|/<own-pkg>/"` — every hit is a place that needs updating in the same change set.
3. **Migration note**: add a `## Migration` section to the OpenSpec `proposal.md` listing (a) the old behavior, (b) the new behavior, (c) the search command consumers should run, (d) the recommended replacement. This is what reviewers and future agents read when they need to understand why a downstream test broke.
Skipping any of these means a consumer somewhere is acting on an obsolete contract. The window between "shipped" and "discovered" is exactly how long it takes for someone else to lose half a day debugging.
## 13b. Root-doc refresh + markdownlint
Once §13 is green, but **before** the archival step in §14, refresh the repo-root manifest docs and run the markdown-lint pass. Skipping this is treated as the same severity as skipping the gate — these files are how every other agent (and the dependent-sweep tooling) discovers what shipped.
Mandatory updates:
- `MANIFEST.md` — auto-generated. Run `bun scripts/folder-reorg/generate-manifest.ts` to refresh after any package add / rename / dep-graph change.
- `MISSING.yml` — auto-generated. Run `bun scripts/generate-missing.ts` to refresh after any package landing.
- `STATUS.md` — hand-edited dashboard. Move the package row from `Pending` → `Completed` (or update its in-flight status), keeping the icon and link conventions used elsewhere in the file.
- `SUMMARY.md` — hand-edited. Update or add the per-package paragraph so it reflects the shipped surface (features, deps, key decisions).
- `MIGRATION.md` — hand-edited. Update only if the change affects the documented migration narrative for an upstream package (most pure-genesis changes do not).
Then run the markdown-lint auto-fix across the touched files:
```bash
bunx markdownlint-cli2 --fix
```
Re-stage everything the lint pass modified and verify `git status` shows only intentional changes before archiving. If `markdownlint-cli2 --fix` reports unfixable violations, resolve them by hand — never ship with lint failures, and never silence them with inline disables (same rule as §2a).
Verification before archival:
```bash
git status # only intentional changes — no scratch / debug
git diff MANIFEST.md MISSING.yml STATUS.md SUMMARY.md MIGRATION.md # confirm refresh happened
bunx markdownlint-cli2 # second pass, no --fix — must exit 0
```
## 14. OpenSpec archival
After all gates pass:
1. Fold spec deltas into `openspec/specs/<capability>/spec.md`.
2. Move `openspec/changes/<change-id>/` → `openspec/archive/<yyyy-mm-dd>-<change-id>/`.
## 14a. Proposal must list affected dependents
Every `openspec/changes/<change-id>/proposal.md` MUST contain an `## Impact` section that explicitly enumerates downstream packages and what work, if any, was done in each. Use this template:
```markdown
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/<pkg>` to enumerate. For each dependent, check one box:
- `@suaveplan/<dep-1>` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: …)
- `@suaveplan/<dep-2>` — [x] tests run / [x] docs reviewed / [ ] no action needed
- `@suaveplan/<dep-3>` — …
If `tests run` is unchecked for any dependent, the change MUST NOT be folded into the canonical spec — finish that work first or split the proposal.
```
The "no action needed" path requires a one-line reason (e.g. "consumer doesn't touch the changed surface"). It is NOT a free pass; it documents the conscious decision so the next agent reading the archive understands why a dependent wasn't touched.
For foundation packages (`error`, `types`, `events`, `logger`, `validation`), the Affected-dependents list is non-optional. For non-foundation packages with zero dependents, write "No dependents (verified by `bunx turbo ls --filter=...@suaveplan/<pkg>`)."
Combined with §13 step 10 (dependent sweep) and §13a (behavior-change propagation), this closes the loop: the proposal names the blast radius, the verification step proves the blast radius compiles and tests, and the migration note tells future readers what changed and how to adapt.
## 15. Cross-package contracts must live in contract packages
A type or interface that **packages other than the defining one are expected to implement** is a contract. Contracts MUST live in a Stage-1 contract package (`@suaveplan/api-types`, `@suaveplan/types`, or an analogous types-only package), NOT inside the implementation package that consumes them.
Rationale: when an interface like `PipelineRouter` is defined inside `api-host/src/request-pipeline/`, adapter authors (writing `api-router-hono` etc.) never see it during code review. They satisfy `api-router`'s `Router` contract, ship at 100% coverage, and the seam silently drifts. The first end-to-end boot is when the gap surfaces — too late.
### Lint gate
```bash
# A type that is implemented by another @suaveplan/* package
# must NOT be defined inside the consumer's src/.
bun scripts/lint/check-cross-package-contracts.ts
```
The script walks every `interface X` / `type X = ...` declaration in `packages/<cat>/<plugin>/<pkg>/src/` whose name appears as `implements X` or `: X` in ANOTHER package's `src/`. If the defining package is NOT the canonical contract package for the relevant family (`api-types` for api-stack, `types` for foundation), the script fails.
### How to fix a violation
1. **Move the type definition** to the contract package (or create one if the family doesn't have one).
2. **Re-export from the original site** if back-compat for in-repo consumers matters: `export type { X } from "@suaveplan/api-types/<subpath>"` at the original location.
3. **Update the spec** of both packages: the contract package's spec gains an `### Requirement` for the type; the consuming package's spec uses `**Imports:** @suaveplan/api-types#X` (see §16).
### Allowed exceptions
- Private types that are NOT implementation contracts (purely internal data shapes). Lint script only flags types appearing as `implements`/satisfies/: bound in another package.
- Types deliberately kept narrow (e.g. internal `LedgerDb` shape inside `api-host` that NO external package implements). Document the intent in the package's `README.md` so the next reviewer understands why the type doesn't move.
## 16. Conformance harnesses live with the contract, not the implementation
When a contract package exports `X`, it MUST also export a runnable conformance harness `runXConformance(factory)` from its `./testing` subpath. Every package that ships a concrete `X` MUST include a `tests/conformance/<name>.test.ts` that imports the harness and asserts compliance.
### Rules
1. **The harness lives in the contract package.** `api-router` exports `runRouterConformance`. `api-host` exports `runPipelineRouterConformance` (because PipelineRouter is api-host's contract until it migrates to api-types per §15). The harness is the contract's executable form.
2. **Every implementing package's pre-publish gate (§13) MUST run the harness against the impl.** Verified by file presence (`tests/conformance/<contract-name>.test.ts`) and by the harness's own assertions passing.
3. **A package implementing MULTIPLE contracts** ships ONE conformance test per contract. Example: `HonoRouter` satisfies both `Router` (from api-router) AND `PipelineRouter` (from api-host) — so `api-router-hono` ships `tests/conformance/router.test.ts` AND `tests/conformance/pipeline-router.test.ts`.
4. **Stage-4 adapters carry conformance under BOTH direct AND worker-proxy surfaces** (per the foundation design §13.4). Same harness, invoked twice: once against `createXxxAdapter(opts)`, once against `createXxxWorkerProxy(port, opts)`.
### Lint gate
```bash
bun scripts/lint/check-conformance-tests.ts
```
For every `@suaveplan/<pkg>` whose `package.json` `dependencies` references a contract-bearing `@suaveplan/<contract>` (detected by the dep also exporting a `./testing` subpath with a `runXxxConformance` function), the script asserts the consumer has a matching `tests/conformance/<contract>.test.ts`. Fails the gate if missing.
### Spec-delta annotation
When a `### Requirement` in a spec delta describes implementing a contract from another package, the Requirement body MUST include an `**Imports:**` line:
```markdown
### Requirement: HonoRouter satisfies PipelineRouter
**Imports:** `@suaveplan/api-host#PipelineRouter`, `@suaveplan/api-host#PipelineRoute`
The exported `HonoRouter` class MUST implement every method of the
`PipelineRouter` interface from `@suaveplan/api-host`. Verified by the
shipped `runPipelineRouterConformance` harness invoked against
`createHonoRouter()` in `tests/conformance/pipeline-router.test.ts`.
#### Scenario: register + matchPipeline round-trip
**Verified by:** `tests/conformance/pipeline-router.test.ts::"register + matchPipeline round-trip"`
WHEN a route is registered with `registerPipelineRoute({ plugin, method, path, handler })`
AND `matchPipeline({ method, path })` is invoked
THEN the returned `PipelineRoute` MUST carry the original `plugin`, the supplied `handler`, and an empty `statusMap` when none was provided.
```
The validate step (`bunx openspec validate <change-id> --strict`) parses every `**Imports:**` line and verifies the named symbol IS exported from the named package at the declared subpath. A missing or mistyped import is a hard fail.
## 17. Umbrella changes ship per-stage integration smoke
Every multi-package umbrella change (e.g. `api-plugin-foundation`) that ships an architecture in stages MUST include a `## Integration smoke` section per stage. A stage is NOT "done" — and the next stage MUST NOT begin — until its integration smoke is green.
### What "integration smoke" means
A test that boots a real composition of the stage's packages and exercises the cross-package seams. It lives in a top-level `e2e/<umbrella>-stage-<N>/` workspace package, owns its `package.json`, `playwright.config.ts` (via `@suaveplan/testing-e2e`), `src/` for the harness, and `tests/` for the assertions.
The umbrella's `tasks.md` includes per-stage rows:
```markdown
- [ ] Stage N smoke: `e2e/<umbrella>-stage-N/` boots a real composition and passes the conformance checklist (routes, lifecycle, error envelope, adapter slots).
```
The stage's "Verified by" pointer is the smoke's spec, not just the per-package conformance harnesses.
### Why per-package conformance is not enough
Per-package conformance proves each package satisfies its OWN contract. Per-stage integration smoke proves the packages COMPOSE — that adapter slots line up with host expectations, contract types are mutually assignable across the seam, and the request pipeline drives end-to-end without hand-wired shims.
The 5-gap analysis from `2026-05-26-foundation-fix-gap-{1,2,3,4,5}` (post-Stage-4) is the canonical example of what happens when integration smoke is deferred: every package green at 100% coverage; the foundation didn't compose.
### Smoke fail = stage incomplete
If the smoke needs hand-wired adapter shims to boot, the stage is incomplete. Open coherence-fix changes against the gap-bearing packages — do NOT paper over with shims in the smoke. The smoke must consume the canonical adapter surface a real consumer would.
### Smoke is not exempt from coverage gates
The smoke package itself ships at 100% coverage on its own `src/` (the boot wiring + the fixture plugin). Its `tests/` are Playwright specs and don't contribute to coverage — they ARE the gate.
## 18. Per-stage closure smoke is a stage deliverable, not an umbrella afterthought
§17 mandates per-stage integration smoke. §18 specifies **when** it must be authored: alongside the stage's packages, not deferred to "we'll do it at the umbrella".
### The mandate
Every stage of an umbrella change MUST list its closure smoke as a checkbox INSIDE the stage's own `tasks.md` block, dispatched in the same wave as the stage's packages. The umbrella's gate row in `## 2. Stage acceptance gates` is the OUTCOME line, not the deliverable spec.
Concretely, in any umbrella `tasks.md` Section 1:
```markdown
### Stage N — <description>
- [ ] N.1 `pkg-a`
- [ ] N.2 `pkg-b`
- [ ] N.3 `pkg-c`
- [ ] N.0 `e2e/<umbrella>-stage-N/` closure smoke — boots all of Stage-N's packages, drives the cross-package seam, MUST be authored as an OpenSpec change of its own (`openspec/changes/e2e-<umbrella>-stage-N/`) and dispatched in parallel with N.1..N.k
```
The `N.0` row exists from the day the umbrella is opened. It is NOT permitted to be added retroactively when the stage's packages are already shipped.
### Why authoring upfront, not at gate-close time
Deferring closure smoke to after the stage's packages ship causes the failure mode this project hit four separate times: Stage 4 (`foundation-fix-gap-{1,2,3,4,5}`), and again at Stage 7/8 closure. Each time, every per-package conformance was green and the cross-package composition didn't work — because the integration test was the FIRST thing that exercised the composition, and by then the contracts had drifted in incompatible ways across the packages.
Authoring the closure smoke alongside the stage forces the cross-package contracts to be designed upfront. The smoke is the consumer that proves the contracts are mutually assignable; without it, each package's author optimises for their own package's tests.
### Carry-forward dependencies
A closure smoke MAY carry forward from a prior stage's smoke (e.g. Stage 8 carries Stage 7's federation+WS + Stage 6's saga). When it does:
- The Verified-by line in the umbrella's gate row MUST cite the prior smoke's archive path.
- A carry-forward from `[ ]` (unshipped) gate to `[ ]` gate is **forbidden**. The earlier gate closes first. The lint script `scripts/lint/check-carry-forward-deps.ts` enforces this.
### Dispatch order
When opening an umbrella OpenSpec change, the per-stage `N.0` smoke is the LAST agent dispatched per stage — every other Stage-N package agent runs in parallel BEFORE the smoke. The smoke needs the packages to exist (or the smoke's `package.json` deps will fail to resolve). But it MUST be authored, proposal-archived, and dispatched within the same calendar wave as the stage's packages, not weeks later when the umbrella's gate row is the only thing the user is looking at.
## 19. Primitive packages ship with their host-integration design
Foundation-design pattern that bit us at Stage 8: `api-sandbox-vm`, `api-sandbox-wasm`, `api-sandbox-permissions` shipped as standalone runtime primitives. They have no integration with `Host.installPlugin` — that bridge layer was never designed.
### The rule
Every "primitive" package proposal (a package that supplies a runtime capability the rest of the system consumes — a sandbox, an isolation tier, a transport, a storage backend, an auth provider) MUST identify, AT PROPOSAL TIME:
1. **The concrete consumer.** Which existing package will consume this primitive? Cite it by name + the subpath the consumer imports.
2. **The bridge layer (if any).** If the primitive's surface (e.g. `vm.Context`, `WebAssembly.Instance`) is not directly compatible with the consumer's expected input (e.g. `Host.installPlugin` takes `PluginModule`, not a sandbox handle), the proposal MUST cite a `host-bridge` sibling package that mediates. The bridge package is shipped alongside the primitive, not later.
3. **The end-to-end test path.** Where in `e2e/<umbrella>-stage-N/` will this primitive be exercised? If the answer is "nowhere yet", the primitive is incomplete.
If any of (1), (2), (3) is missing from the primitive's proposal, the OpenSpec change is REJECTED at validate time. The lint script `scripts/lint/check-primitive-host-integration.ts` enforces this on proposals whose path matches `openspec/changes/<pkg>/` where `<pkg>` is on the configured primitive-package list.
### Examples
- `api-sandbox-vm` proposal MUST cite `api-sandbox-vm-host-bridge` as a sibling AND an `e2e/<umbrella>-stage-N/` test that deploys a plugin through the bridge.
- `api-sandbox-wasm` proposal MUST cite `api-sandbox-wasm-host-bridge` AND a WASM plugin fixture (or a TS→WASM compilation pipeline) AND an e2e test.
- `api-ws-broker-redis` proposal cites `@suaveplan/api-ws` as consumer + `e2e/foundation-stack-engines/` (Stage 7) as test path. (This one was correct.)
### Why "later" is forbidden
"We'll add the bridge later" creates two artifacts that diverge: the primitive optimises for its own conformance harness; the bridge must invert later to match whatever shape the consumer ended up with. The contracts drift before they meet. Co-authoring forces the seam to be designed once.
## 20. Verified-by lines are mandatory at change-author time, not at archive time
Every requirement in every spec delta — and every umbrella gate row — MUST carry a `**Verified by:**` line pointing to a specific test file path AND a specific `it("...")` title.
### Acceptable forms
```markdown
**Verified by:** `src/feature/feature.test.ts::"feature does the thing"`
**Verified by:** `tests/integration/composition.spec.ts::"full pipeline runs"` AND `src/api/api.test.ts::"surface matches contract"`
```
### Unacceptable forms
```markdown
**Verified by:** the test suite # vague — which test
**Verified by:** the conformance harness # which harness
**Verified by:** (none yet) # rejected at validate
```
### When the test doesn't exist yet (TDD)
If authoring a fresh proposal, the test file path is the file you commit to creating in this change. The `it("...")` title is the title you commit to giving the test. The lint script `scripts/lint/check-verified-by.ts` runs at change-author time AND at archive time:
- **Author time** (when proposal is first committed): the file path MUST be syntactically valid (matches `(src|tests)/.../.test\.ts::"..."`). The file doesn't need to exist yet.
- **Archive time** (when change is moved to `archive/`): the file at the cited path MUST exist AND must contain an `it()` whose title string-matches the cited title.
A Verified-by line whose cited test does not exist at archive time is a hard fail. The change cannot archive.
### Umbrella gate rows
Umbrella `tasks.md` gate rows in `## 2.` MUST carry Verified-by lines too. Example:
```markdown
- [ ] 2.7 Stage 7 ✓ — Yoga/Apollo + WS + SSE coexist on one host.
**Verified by:** `e2e/foundation-stack-engines/tests/engines.spec.ts::"all four engines accept requests on the same host"` AND `e2e/foundation-stack-engines/tests/federation.spec.ts::"graphql federates across plugins"`
```
This makes "what does it mean for this gate to close" mechanically checkable.
## 21. Cross-package contracts authored before consumers
§15-16 say cross-package contracts live in contract packages and conformance harnesses colocate with the contract. §21 specifies **the ordering**: the contract package + conformance harness ship FIRST, before any consumer is dispatched.
### The mandate
For any feature requiring a contract shared between N packages (Principal, PluginModule, PipelineEvents, WsBroker, PermissionPolicy, etc.):
1. **Contract package proposal lands first.** Defines the interface in `api-types` (or the relevant types package) + ships the conformance harness in `<contract-pkg>/testing/`.
2. **Implementations dispatched after.** Each implementor's proposal cites the contract package's path + the conformance harness it must pass.
3. **Consumers dispatched after implementations.** Each consumer's proposal cites which implementations it expects to find at runtime AND uses the contract's TYPE (not a structural-mirror local type).
### Banned pattern: "structural mirror"
A package that mirrors a sibling's type as a local interface — "structurally compatible with `@suaveplan/api-host`'s `PipelineRouter`" — is BANNED. It looks like decoupling but is actually contract duplication, and the two surfaces drift.
Allowed only when:
- The contract package doesn't exist yet (in which case author it FIRST per §21).
- A genuine 0-dep optimisation matters (cite the bytes saved + name the reviewer who approved). Otherwise: import the type directly.
The lint script `scripts/lint/check-structural-mirror.ts` greps for the phrase `Structural shapes mirrored from` and similar prose, flagging any code-comment or `.md` that documents a structural mirror without a contract-package follow-up change cited.
### Detection at agent dispatch
When dispatching a consumer agent (or a primitive agent), the orchestrator's brief MUST cite the contract package + conformance harness it'll use. If the agent reports "the contract isn't shipped" (Stage 7's `api-ws-broker-redis` agent did this, three times) — STOP. The contract package's status is verified by the orchestrator before the consumer dispatch, not by the consumer agent at runtime.
A pre-dispatch shell snippet that EVERY consumer brief includes:
```bash
test -f openspec/specs/<contract-pkg>/spec.md || { echo "FATAL: <contract-pkg> not shipped — re-dispatch contract first"; exit 1; }
test -d packages/<category>/<plugin>/<contract-pkg>/src/testing || { echo "FATAL: <contract-pkg> conformance harness missing"; exit 1; }
```
This is the "shipped deps test" pattern that prevented the `api-ws-broker-redis` agent from looping a third time. §21 elevates it from "good idea on this dispatch" to "every consumer brief must include this".