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

70 KiB
Raw Permalink Blame History

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:

| 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

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:

<!-- 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:

    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:

    find . -path "*/node_modules/react/package.json" | head -5
    

2. No banned patterns in src/

Before closing any task, scan for zero matches:

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:

    // 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:

    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:

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:

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:

# 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
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:

"./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.

// 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:

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:

// 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):

# 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:

// 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:

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

# 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:

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.

// 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.

// 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.

// 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:

# 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.

# 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:

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:

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:

## 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

# 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

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:

### 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:

- [ ] 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:

### 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

**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

**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:

- [ ] 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:

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".