# Capability Spec: api-host **Package**: `@suaveplan/api-host` **Status**: SHIPPED (Chunks A + B + C + D, 2026-05-25) > Every requirement below carries a `**Verified by:**` line. (See `openspec/AGENTS.md` Section 13.) Requirements are grouped by surface cluster: lifecycle, hot reload, capability registry, plugin ledger, admin API, error boundary, request pipeline, host composition, OTel, `definePlugin` DX. ## Requirements ### Requirement: Lifecycle FSM drives every plugin through documented states The package SHALL implement a per-plugin lifecycle driver covering every state and transition from design doc §4: `uninstalled`, `installing`, `installed`, `starting`, `started`, `rolled_back`, `crashed`, `stopping`, `stopped`, `uninstalling`, `install_failed`, `start_failed`, `uninstall_failed`. Each plugin SHALL have a per-plugin mutex; concurrent transition triggers MUST queue (only one transition in flight at a time). The FSM MUST emit `plugin.lifecycle_transitions_total{plugin, from, to, outcome}` on every transition. A handler throw inside a route or job tick MUST NOT transition the FSM — that is the error boundary's job (see "Error boundary" requirement). #### Scenario: every documented transition is reachable - **WHEN** a freshly-installed plugin is driven `install → installed → starting → started → stopping → stopped → uninstalling → uninstalled` - **THEN** every transition fires AND `plugin.lifecycle_transitions_total` records each one #### Scenario: per-plugin mutex collapses concurrent triggers - **WHEN** two `start()` calls arrive in the same microtask - **THEN** the second awaits the first AND only one `starting → started` transition is recorded #### Scenario: handler throw does not transition the FSM - **WHEN** a route handler throws `SuaveplanError` - **THEN** the error boundary catches it AND the plugin's state remains `started` **Verified by:** `src/lifecycle/lifecycle.test.ts::"every documented transition fires"` AND `src/lifecycle/lifecycle.test.ts::"per-plugin mutex collapses concurrent start triggers"` AND `src/error-boundary/error-boundary.test.ts::"handler throw does not transition FSM"` --- ### Requirement: `crashed` state is reachable only from worker tiers The package SHALL transition a plugin to `crashed` ONLY when its `isolation` tier is `isolated` or `sandboxed` AND its worker dies, OOMs, or exceeds its wall-clock budget. Trusted-tier plugins cannot reach `crashed` — a handler throw stays in `started` via the error boundary. Auto-restart with exponential backoff MUST trigger up to `crashRestartMax` (default 3) within `crashRestartWindowMs`; after the budget is exhausted the plugin parks in `crashed` until an operator calls `POST /admin/plugins/{name}/restart`. #### Scenario: trusted plugin handler throw stays started - **WHEN** a `trusted`-tier plugin's handler throws an uncaught exception - **THEN** the plugin state remains `started` AND no `crashed` transition is recorded #### Scenario: isolated plugin worker death triggers `crashed` - **WHEN** an `isolated`-tier plugin's worker emits an `exit` event with non-zero code - **THEN** the plugin transitions to `crashed` AND auto-restart fires once after backoff **Verified by:** `src/lifecycle/lifecycle.spec-conformance.test.ts::"trusted tier handler throw does not crash"` AND `src/lifecycle/lifecycle.spec-conformance.test.ts::"isolated tier worker exit transitions to crashed"` --- ### Requirement: Hot reload uses blue-green primitive by default The package SHALL implement the §8.1 blue-green reload primitive. Steps 1–8 of §8.1 MUST execute in order: snapshot to `RollbackJournal`, re-resolve manifest, import new module under `?v=N+1`, run migrations if `manifest.database.schemaVersion` increased, mount shadow router slot, run new `start()` + health probe, atomically flip the live-version pointer, drain the old slot. `host.config.blueGreen: false` MUST opt into the 503-window mode (§8.5) for environments that cannot tolerate two concurrent module instances. Native bindings present in the dep graph MUST refuse reload with `RELOAD_NATIVE_BINDING_HELD`. #### Scenario: blue-green flip is atomic and serves both old and new - **WHEN** a reload is in progress AND the version pointer has been flipped from N to N+1 - **THEN** new requests hit module N+1 AND in-flight requests continue against N until they settle or `drainDeadlineMs` (default 30s) elapses #### Scenario: native binding refuses reload - **WHEN** the dep graph of the new module imports a `.node` file - **THEN** the loader throws `RELOAD_NATIVE_BINDING_HELD` AND the shadow slot is discarded AND the live module continues serving #### Scenario: 503-window mode is opt-in via host config - **WHEN** `host.config.blueGreen === false` - **THEN** reload runs in single-slot drain-then-replace mode AND new requests during the drain window receive 503 + `Retry-After: 1` **Verified by:** `src/hot-reload/blue-green.test.ts::"blue-green flip is atomic and drains old slot"` AND `src/hot-reload/blue-green.test.ts::"native binding refuses reload"` AND `src/hot-reload/blue-green.test.ts::"503-window mode opt-in"` --- ### Requirement: RollbackJournal pins last 3 known-good specifiers and auto-rolls-back The package SHALL maintain a per-plugin ring buffer of depth 3 (configurable). Each entry SHALL match the §8.1a shape: `specifier`, `manifestHash`, `moduleRef: WeakRef | null`, `migrationCursor`, `capturedAt`, `version`. Entries SHALL be persisted to the plugin ledger so that after a host restart the rollback path still works (with one extra module import; the in-memory `moduleRef` is lost on restart). A `start_failed` event during a reload MUST auto-transition the plugin to `rolled_back` by re-activating the most-recent journal entry's specifier — operator action is NOT required for the happy rollback path. #### Scenario: ring buffer truncates to last 3 entries - **WHEN** five successful reloads happen - **THEN** `journal.length === 3` AND only the last three entries remain #### Scenario: auto-rollback on `start_failed` during reload - **WHEN** the new module's `start()` throws during a hot reload - **THEN** the plugin transitions `start_failed → rolled_back` AND the previous specifier from the journal is re-activated AND `plugin.rolled_back` is emitted #### Scenario: persisted cursor survives host restart - **WHEN** the host restarts AND the in-memory `moduleRef` is gone - **THEN** the persisted `specifier` + `migrationCursor` + `manifestHash` from the ledger let the host re-import the previous version with one additional `import(...)` call **Verified by:** `src/hot-reload/rollback-journal.test.ts::"ring buffer truncates to last 3"` AND `src/hot-reload/auto-rollback.test.ts::"start_failed during reload auto-rolls-back"` AND `src/hot-reload/rollback-journal.test.ts::"persisted cursor survives restart"` --- ### Requirement: CapabilityRegistry validates version compat at install The package SHALL expose a `CapabilityRegistry` accepting `defineCapability({ id, version, scope, provider, disposer })` registrations. On `install()` of any plugin, the registry MUST check every entry in the plugin's `requiredCapabilities` against (a) presence in the registry AND (b) version compatibility against the plugin's `apiHost` semver range. Missing → `CAPABILITY_NOT_REGISTERED`. Mismatch → `CAPABILITY_VERSION_MISMATCH`. Duplicate registration of the same `id` → `CAPABILITY_DUPLICATE_REGISTRATION`. Disposers MUST run in reverse-registration order on `stop()`. #### Scenario: missing required capability blocks install - **WHEN** a plugin declares `requiredCapabilities: ["db"]` AND no `db` adapter is registered - **THEN** install throws `CAPABILITY_NOT_REGISTERED` AND the plugin remains `installing` → `install_failed` #### Scenario: disposer order is reverse-registration - **WHEN** capabilities are registered in order `[logger, events, db]` AND `stop()` runs - **THEN** `db.disposer` runs first AND `logger.disposer` runs last **Verified by:** `src/capability-registry/capability-registry.test.ts::"missing required capability throws"` AND `src/capability-registry/capability-registry.test.ts::"disposer order is reverse-registration"` --- ### Requirement: Plugin ledger metadata DB ships its own forward-only migrations The package SHALL own a metadata DB whose schema is created and migrated by this package's own migration runner. Tables `suaveplan_plugin_ledger`, `suaveplan_rollback_journal`, `suaveplan_saga_instances`, `suaveplan_saga_steps`, `suaveplan_2pc_transactions` MUST exist after the runner completes. Migrations MUST be forward-only AND idempotent — running the runner twice MUST be a no-op. The default DB SHALL be SQLite at `host.config.metadataDbPath` (default `./suaveplan-host.db`); operators MAY override to Postgres or Surreal via `@suaveplan/db`. The ledger DB connection MUST NOT be shared with any plugin's own DB connection. #### Scenario: every documented table exists after migration - **WHEN** the migration runner completes against a fresh SQLite file - **THEN** every documented table is present with the documented columns #### Scenario: migration runner is idempotent - **WHEN** the runner is invoked twice in sequence - **THEN** the second run is a no-op AND no error is thrown **Verified by:** `src/plugin-ledger/migrations.test.ts::"every table exists after migration"` AND `src/plugin-ledger/migrations.test.ts::"migration runner is idempotent"` --- ### Requirement: Admin API surface is gated by `host:admin` RBAC and rate-limited The package SHALL mount the §8.4 admin endpoints under `host.config.adminPrefix` (default `/admin`, `null` disables HTTP admin entirely). Every endpoint SHALL require auth (no `publicRoutes` bypass) AND the `host:admin` scope. Per-principal rate limit MUST be 60 ops/min on read endpoints AND 1 op/min on destructive endpoints (`uninstall`, `force-uninstall`, `disable`). Every state-changing call MUST emit a `host.admin_action` audit envelope per §12.5 AND a `host.admin_action_total{action, outcome}` counter. #### Scenario: every documented endpoint is mounted - **WHEN** the host boots with default config - **THEN** every endpoint from §8.4 (`POST /admin/plugins/{name}/reload`, `disable`, `enable`, `install`, `uninstall`, `retry-install`, `retry-start`, `force-uninstall`, `GET /admin/plugins`, `GET /admin/plugins/{name}`, `GET /admin/capabilities`) is mounted #### Scenario: `adminPrefix: null` disables HTTP admin - **WHEN** `host.config.adminPrefix === null` - **THEN** no admin routes are mounted AND `GET /admin/plugins` returns 404 #### Scenario: destructive endpoint rate-limit kicks in at 2 requests / min - **WHEN** a single principal calls `POST /admin/plugins/x/uninstall` twice within 60 seconds - **THEN** the second call returns 429 **Verified by:** `src/admin-api/admin.test.ts::"every documented endpoint is mounted"` AND `src/admin-api/disabled.test.ts::"adminPrefix null disables admin routes"` AND `src/admin-api/admin.test.ts::"destructive rate-limit is 1 per minute"` --- ### Requirement: Error boundary produces the standard envelope and never leaks internals The package SHALL wrap every pipeline step (§6 steps 1–11) in an error boundary. The boundary MUST map: (a) `SuaveplanError` → status from the plugin manifest's `errors[code].status` map; codes not in the map → 500 with the code preserved in the envelope's `code` field; (b) `AbortError` → 499 (client gone); (c) anything else → 500 + log of the cause chain. The response body SHALL be the `ErrorEnvelope` from `api-types/errors` — `{ error: { code, message, status, requestId, traceId, details } }`. The `X-Trace-Id` response header MUST mirror `traceId`. `details` SHALL be redacted per manifest's `errors.redact[code]` policy AND auto-redacted by `@suaveplan/data-masking` for emails / CC shapes / JWT shapes. #### Scenario: unmapped code becomes 500 with code preserved - **WHEN** a handler throws `SuaveplanError` with code `PLUGIN.X.NOT_IN_MAP` - **THEN** the response status is 500 AND `body.error.code === "PLUGIN.X.NOT_IN_MAP"` #### Scenario: AbortError becomes 499 - **WHEN** the client drops the connection mid-request - **THEN** the boundary returns 499 AND no `api.errors_total` counter is incremented #### Scenario: trace id mirrors response header - **WHEN** any error response is produced - **THEN** `res.headers["x-trace-id"] === body.error.traceId` **Verified by:** `src/error-boundary/error-boundary.test.ts::"unmapped code preserves code in envelope"` AND `src/error-boundary/error-boundary.test.ts::"AbortError maps to 499"` AND `src/error-boundary/error-boundary.test.ts::"x-trace-id mirrors envelope traceId"` --- ### Requirement: Per-request pipeline orders the documented 11 steps and exposes OTel spans The package SHALL execute the §6 pipeline in the documented order: HTTP intake → route match → auth resolver → tenant resolver chain → (optional) rate limit → (optional) RBAC → body/params/query parse+validate → (optional) idempotency → capability scoping → plugin handler → response validation → response composition → telemetry close. Plugins MAY insert middleware between steps 7 (capability scoping) and 8 (handler) and NOWHERE ELSE. Streaming routes (`streaming: true`) MUST bypass steps 6 (body buffering) and 10 (response composition's buffering). Cookie-based auth MUST trigger CSRF verification via `Bun.CSRF` on non-GET/HEAD methods unless the route opts out with `csrf: false`. Response-validation strictness SHALL default to `enforce` (production: 500 on violation) and `warn` (development: log + counter, body passes through). #### Scenario: steps execute in documented order - **WHEN** a request flows through an instrumented pipeline - **THEN** the observed span tree matches §12.3 exactly (`api.request > api.route_match > api.auth.resolve > api.tenant.resolve > api.rbac.check > api.body.parse > api.body.validate > plugin.handler > api.response.validate > api.response.compose`) #### Scenario: plugin middleware insertion is restricted - **WHEN** a plugin attempts to register middleware that runs BEFORE auth - **THEN** the host throws `PIPELINE_MIDDLEWARE_SLOT_INVALID` at register time #### Scenario: cookie auth triggers CSRF on POST - **WHEN** an `auth.session` adapter sets a cookie-source flag AND a request arrives as `POST /x` without a CSRF token - **THEN** the pipeline returns 403 before reaching the handler **Verified by:** `src/request-pipeline/pipeline.test.ts::"steps execute in documented order"` AND `src/request-pipeline/pipeline.test.ts::"middleware insertion only between steps 7 and 8"` AND `src/request-pipeline/pipeline.test.ts::"cookie auth triggers CSRF on non-GET"` --- ### Requirement: OTel surface emits every documented metric and span The package SHALL emit every metric listed in §12.3 from at least one production path: `api.requests_total`, `api.request_duration_ms`, `api.body.bytes`, `api.response.bytes`, `api.errors_total`, `api.auth.failures_total`, `api.rbac.denials_total`, `api.tenant.unresolved_total`, `api.validation.failures_total`, `api.idempotency.replays_total`, `api.rate_limit.exceeded_total`, `plugin.lifecycle_transitions_total`, `plugin.reload_total`, `plugin.reload_duration_ms`, `plugin.unsafe_query_total`, `host.admin_action_total`. Each metric SHALL carry the documented label set. Tenant label MUST be opt-in via `host.config.otel.tenantLabelOptIn` to bound cardinality. #### Scenario: every documented metric appears in the in-memory exporter - **WHEN** the test exercises one production path per metric AND collects from `InMemoryMetricExporter` - **THEN** every metric name from §12.3 is present in the collected output with non-empty data points **Verified by:** `src/otel/otel.test.ts::"every documented metric is emitted"` --- ### Requirement: `definePlugin` types the capability map from `requires` literals The package SHALL export `definePlugin(spec)`. The `ctx` parameter passed to user lifecycle / route handlers SHALL have exactly the fields named in `requires` projected onto `ctx.capabilities`, plus the always-present base fields (`plugin`, `logger`, `events`, `config`, `jobs`, `abortSignal`, `instruments`, `secrets`). The orchestration-level `secrets: SecretsLike` slot is always present (the host injects `EmptySecrets` when no adapter is configured — see foundation-fix-gap-3); the capability-projected `ctx.capabilities.secrets` shape is reachable only when `requires` includes `"secrets"`. Accessing an undeclared capability via the capability slice MUST be a compile-time TypeScript error. #### Scenario: declared capability is typed and reachable - **WHEN** a plugin declares `requires: ["db", "auth.session"] as const` - **THEN** `ctx.capabilities.db` is typed as `AdminDatabaseLike` in lifecycle hooks AND `ctx.principal` is typed as `Principal | null` in route handlers #### Scenario: undeclared capability is a compile error - **WHEN** a plugin declares `requires: ["db"] as const` AND references `ctx.capabilities.events.publish(...)` - **THEN** TypeScript reports an error AND `expectTypeOf().not.toHaveProperty("events")` passes **Verified by:** `src/define-plugin/define-plugin.spec-conformance.test.ts::"declared capability is typed in ctx"` AND `src/define-plugin/define-plugin.spec-conformance.test.ts::"undeclared capability is a TS error"` --- ### Requirement: Health and readiness endpoints reflect plugin states The package SHALL mount `GET /internal/livez` (200 if process is responding; no plugin checks) AND `GET /internal/readyz` (200 only if every plugin's state is `started` OR appears in an explicit allowlist; 409 + structured body otherwise). A periodic background health check (default every 10s) SHALL poll each plugin's optional `health` capability hook; a plugin reporting `unhealthy` for longer than `healthFailWindowMs` MUST emit a `plugin.unhealthy` event AND optionally auto-disable per host config (off by default). #### Scenario: livez is independent of plugin state - **WHEN** every plugin is `install_failed` AND `/internal/livez` is called - **THEN** the response is 200 #### Scenario: readyz returns 409 when any plugin is not started - **WHEN** one plugin is `crashed` AND `/internal/readyz` is called - **THEN** the response is 409 AND the body lists the unready plugins with their states **Verified by:** `src/host/health.test.ts::"livez is independent of plugin state"` AND `src/host/health.test.ts::"readyz returns 409 when a plugin is not started"` --- ### Requirement: `./testing` subpath populates the Stage-5 keystone The package SHALL declare `./testing` as a subpath export AND ship: 1. `createTestHost(config?: TestHostConfig): Promise` — Stage-5 keystone composing every in-memory adapter (`api-router-in-memory`, `api-auth-anonymous` / `api-auth-static-principal`, `api-db-sqlite-memory`, `api-events-in-memory`, `api-ws-broker-in-memory`, `api-tenant-provider-memory`, `api-secrets-env`) into a single line of consumer code. 2. `runPipelineRouterConformance(factory)` plus the `PipelineRouterConformanceFactory` + `PipelineRouteRegistration` types. The harness SHALL exercise the host's `PipelineRouter` contract against any adapter via a factory-supplied `(router, register, reload?)` triple, covering: register → match round-trip, `null` on miss, `statusMap` propagation, plugin ownership across reload, `:param` parsing, and collision detection. 3. The legacy `STAGE_2_RESERVED` sentinel, retained for back-compat with a value of `false` (Stage 2 shipped `true`); consumers that branched on `=== true` now branch on `=== false` to opt into the populated helpers. `createTestHost` SHALL resolve each adapter slot in override-beats-default order. Defaults: router from `@suaveplan/api-router-in-memory` (loaded via dynamic specifier to side-step the build-graph cycle the package's runtime dep on `api-host` introduces), auth as a `SessionResolverChain` over `createStaticPrincipalResolver` (when `seed.principal` is set) or `createAnonymousResolver`, tenant chain via `tenantResolverChainFromProvider(createMemoryTenantProvider(seed.tenants ?? []), { headerName: "X-Tenant-Id" })`, secrets via `createEnvSecrets(...)` when `seed.secrets` is set else `new EmptySecrets()`, admin db via `createSqliteMemoryAdapter()`, ledger db via a separate `bun:sqlite :memory:` handle. `capabilities`/`rateLimit`/`idempotency`/`csrf`/`audit`/`span`/`adminPrincipal` default to minimal in-process accept-all stubs. `TestHost` SHALL extend `Host` with `installFixturePlugin(fixture)`, `fetchAs(principal, request)`, `waitForEvent(topic, timeoutMs?)`, `dispose()`, and a `readonly adapters: TestHostAdapters` surface exposing the wired `HostAdapters` bag, the `InMemoryEventsBus`, the `LifecycleEventMap` event bus, and the materialised fixture-route registrations. `waitForEvent` SHALL first inspect the in-memory bus's recorded history then subscribe to future emissions; it SHALL reject the returned promise when `timeoutMs` elapses. `dispose` SHALL stop the wrapped host, dispose the event bus, dispose the ws-broker, close the admin db when `close` is available, and close the ledger-db handle. Calling `dispose` more than once is a no-op. The package's vite build SHALL emit `dist/testing/testing.js` AND `dist/testing/create-test-host/create-test-host.js` AND `dist/testing/pipeline-router-conformance/pipeline-router-conformance.js` so the subpath resolves at runtime. #### Scenario: zero-config boot yields a usable host with every default adapter - **WHEN** a test calls `await createTestHost()` with no arguments - **THEN** the returned `TestHost.plugins()` SHALL be empty AND `host.adapters.host.router` SHALL be defined AND `host.adapters.eventBus` SHALL be defined AND `host.fetch(new Request("http://t/internal/livez"))` SHALL resolve with status 200 #### Scenario: harness drives a reference PipelineRouter through every required case - **WHEN** a test calls `runPipelineRouterConformance({ label, build: () => ({ router, register, reload? }) })` - **THEN** `describe("PipelineRouter conformance — ${label}", ...)` registers six `it()` cases AND all of them pass against any adapter whose `register` populates a backing table consulted by `router.match` #### Scenario: harness asserts collision detection throws - **WHEN** the harness invokes `register({ plugin: "p", method: "GET", path: "/x" })` followed by `register({ plugin: "q", method: "GET", path: "/x" })` - **THEN** the second call SHALL throw — the harness uses `expect(...).toThrow()` without prescribing the error class so any adapter-specific typed error satisfies the contract #### Scenario: harness asserts plugin ownership survives reload - **WHEN** the factory exposes a `reload()` hook, the harness registers a route, calls `reload()`, then calls `router.match(...)` - **THEN** the matched route's `plugin` AND `params` survive the reload cycle #### Scenario: seed.principal reaches `ctx.principal` - **WHEN** a test calls `createTestHost({ seed: { principal } })` then installs a fixture plugin with a route reading `ctx.principal` - **THEN** the handler SHALL observe `ctx.principal.id === principal.id` on every request #### Scenario: seed.tenants + X-Tenant-Id reaches `ctx.tenant` - **WHEN** a test seeds `tenants: [{ id: "t-a", slug: "tenant-a", ... }]` AND issues `fetch(new Request(url, { headers: { "X-Tenant-Id": "t-a" } }))` - **THEN** the matched route handler SHALL observe `ctx.tenant.id === "t-a"` AND `ctx.tenant.slug === "tenant-a"` #### Scenario: seed.secrets exposes `ctx.secrets.require` - **WHEN** a test seeds `secrets: { FOO: "bar" }` AND a route handler calls `await ctx.secrets.require("FOO")` - **THEN** the handler SHALL receive the string `"bar"` #### Scenario: ctx.db round-trips a real bun:sqlite write/read - **WHEN** the smoke server provisions a tenant-aware table via the admin db AND the per-request handler INSERTs + SELECTs through `ctx.db` under an `X-Tenant-Id` header - **THEN** the SELECT SHALL return the inserted row #### Scenario: waitForEvent resolves on bus emission - **WHEN** a test calls `host.waitForEvent("topic.x", 2000)` AND a route handler (or test) calls `host.adapters.eventBus.publish("topic.x", payload)` - **THEN** the `waitForEvent` promise SHALL resolve with the payload #### Scenario: waitForEvent rejects on timeout - **WHEN** a test calls `host.waitForEvent("topic.never", 25)` AND no emission lands within 25ms - **THEN** the promise SHALL reject with an `Error` whose `message` contains `"timed out"` #### Scenario: dispose is idempotent - **WHEN** a test calls `host.dispose()` twice in succession - **THEN** the second call SHALL be a no-op (no thrown error, no double-close on the ledger handle) **Verified by:** `src/testing/create-test-host/create-test-host.test.ts` (13 unit tests against a hand-rolled router adapter), `src/testing/pipeline-router-conformance/pipeline-router-conformance.test.ts`, (downstream) `@suaveplan/api-router-hono`'s `src/pipeline/pipeline.conformance.test.ts`, AND `e2e/foundation-stack-test-host/tests/test-host.spec.ts` (8 Playwright smoke groups against a Bun.serve listener). --- ## ADDED Requirements — Chunk B (Lifecycle + Hot-Reload drivers) ### Requirement: `LifecycleDriver` executes the FSM side-effect descriptors The package SHALL ship `class LifecycleDriver` that orchestrates per-plugin lifecycle through `createPluginFsm` (from `@suaveplan/api-lifecycle`) and executes each `SideEffectOp` in the discriminated union against pluggable adapter surfaces. The driver SHALL expose verbs `install`, `start`, `stop`, `uninstall`, `crash`, `retryInstall`, `retryStart`, `clearRollback`, `forceUninstall`, `repair`, `startForReload`. The driver SHALL emit `plugin.lifecycle_transitions_total{plugin, from, to, trigger, outcome}` on every transition. Per-plugin mutex semantics are inherited from `createPluginFsm`; concurrent triggers queue FIFO. #### Scenario: every documented transition is reachable from operator verbs - **WHEN** a freshly-registered plugin is driven `install → installed → start → started → stop → stopped → uninstall → uninstalled` - **THEN** every transition fires AND every side-effect op (`mount-routes`, `register-jobs`, `subscribe-events`, `inject-capabilities`, `call-user-hook`, `mark-health`, `write-ledger`, `register-scopes`, `unmount-routes`, `pause-jobs`, `unsubscribe-events`, `dispose-capabilities`, `force-cancel-stragglers`, `apply-data-policy`, `drop-ledger`, `drop-scopes`) executes against the configured adapter. #### Scenario: typed events fire on the host bus - **WHEN** the driver completes a lifecycle transition that carries an `emit-event` side-effect - **THEN** the matching topic (`plugin.installed` / `plugin.started` / `plugin.stopped` / `plugin.uninstalled`) is published on the host `EventBus`. #### Scenario: install hook failure parks in `install_failed` - **WHEN** a plugin's `install` hook throws - **THEN** the FSM dispatches `installFailed` AND the plugin lands in `install_failed` AND `lastError` records the cause code + message. #### Scenario: start hook auto-retries before parking - **WHEN** a plugin's `start` hook throws on the first attempt but succeeds on retry - **THEN** the `start_failed` retry budget is consumed AND the FSM reaches `started` via the auto-retry loop. **Verified by:** `src/lifecycle/lifecycle.test.ts::"drives install → installed → starting → started → stopping → stopped → uninstalling → uninstalled"` AND `src/lifecycle/lifecycle.test.ts::"emits typed events on the host bus"` AND `src/lifecycle/lifecycle.test.ts::"install hook throw parks the plugin in install_failed"` AND `src/lifecycle/lifecycle-coverage.test.ts::"retryStart in runStartRetries can succeed before budget exhaustion"` --- ### Requirement: `LifecycleDriver.crash` honours the per-plugin budget The driver SHALL transition `started → crashed` on `crash(plugin, cause)` and auto-restart via `crashRestart` up to `crashRestartMax` (default 3) within the `CrashBudgetTracker` window. After the budget is exhausted, the plugin SHALL remain parked in `crashed` until an operator calls a recovery verb. `plugin.crashed` SHALL be emitted on the host bus. #### Scenario: single crash auto-restarts to started - **WHEN** `crash(plugin, new Error("worker died"))` is invoked on a `started` plugin - **THEN** the FSM transitions `started → crashed → starting → started` AND `plugin.crashed` is emitted. **Verified by:** `src/lifecycle/lifecycle.test.ts::"crash transitions to crashed then auto-restarts to started"` AND `src/lifecycle/lifecycle.test.ts::"crash budget exhaustion parks the plugin"` --- ### Requirement: `ReloadDriver` implements the §8.1 blue-green primitive The package SHALL ship `class ReloadDriver` that orchestrates the design §8.1 blue-green hot-reload. Each `reload(plugin, opts)` call SHALL: 1. Refuse a concurrent reload with `RELOAD.IN_PROGRESS`. 2. Snapshot the active activation into the rollback journal. 3. Apply `requiresReinstall(prev, next)` (from `api-lifecycle`). 4. Import the new module under `bustSpecifier(url, N+1)` via the injected `ModuleResolver`. 5. Walk native bindings and refuse the reload with `RELOAD.NATIVE_BINDING_HELD` when any are present. 6. Stage the new handle in the blue-green shadow slot. 7. Drive `lifecycle.startForReload(plugin, probe)` which fires the FSM `start` and the host-level health probe; failure dispatches `rollbackTriggered` so the FSM lands in `rolled_back`. 8. On success, atomically flip the blue-green slot and drain the prior slot. 9. Emit `plugin.reload_total{plugin, trigger, outcome}` + `plugin.reload_duration_ms` histogram. `blueGreen: false` SHALL opt into the §8.5 503-window mode for environments that cannot host two module instances concurrently. #### Scenario: happy reload flips blue-green and persists the journal - **WHEN** the new module imports cleanly AND `start()` AND `probe()` succeed - **THEN** the blue-green adapter receives `stageShadow → flip → releaseDraining` AND the journal gains an entry AND `lastReloadTrigger` matches the trigger argument. #### Scenario: concurrent reload is rejected - **WHEN** a second `reload()` is dispatched while the first is in-flight - **THEN** the second rejects with `HostError(RELOAD.IN_PROGRESS)`. #### Scenario: native binding refuses reload - **WHEN** the resolver's `walkNativeBindings()` returns a non-empty array - **THEN** the reload throws `HostError(RELOAD.NATIVE_BINDING_HELD)` AND the shadow slot is discarded. #### Scenario: 503-window opt-in skips blue-green slot manipulation - **WHEN** the driver is constructed with `blueGreen: false` - **THEN** the blue-green adapter records no `stage / flip / release` calls AND the reload still completes. **Verified by:** `src/hot-reload/hot-reload.test.ts::"imports, stages, flips, drains, and persists the journal"` AND `src/hot-reload/hot-reload.test.ts::"rejects a concurrent reload with RELOAD.IN_PROGRESS"` AND `src/hot-reload/hot-reload.test.ts::"throws NATIVE_BINDING_HELD when walk reports bindings"` AND `src/hot-reload/hot-reload.test.ts::"skips blue-green slot manipulation when blueGreen=false"` --- ### Requirement: `RollbackJournal` ring buffer + auto-rollback The package SHALL maintain a per-plugin `RollbackJournal` (default depth 3, configurable via `journalDepth`) via the in-memory `createRollbackJournal` from `@suaveplan/api-loader`. Each successful activation SHALL push a `RollbackJournalEntry` carrying `specifier`, `manifestHash`, `moduleRef: WeakRef`, `migrationCursor`, `capturedAt`, `version`. The persisted subset (everything except `moduleRef`) SHALL be written to the configured `RollbackPersistence` adapter so host-restart recovery is possible with one additional `import()` call. On a `start` or health-probe failure during reload, the lifecycle FSM SHALL be auto-driven into `rolled_back` via `startForReload`'s `rollbackTriggered` dispatch AND `plugin.rolled_back` SHALL be emitted on the host bus. #### Scenario: ring buffer truncates to depth on overflow - **WHEN** five successful activations are recorded against a depth-3 journal - **THEN** `journal.length === 3` AND only the last three entries remain. #### Scenario: start failure auto-rolls-back - **WHEN** the new module's `start` hook throws during reload - **THEN** the lifecycle FSM lands in `rolled_back` AND `plugin.rolled_back` is emitted AND the persisted journal preserves the prior known-good specifier. #### Scenario: persisted entries hydrate after restart - **WHEN** `hydrateFromPersistence(plugin)` is called against a populated `RollbackPersistence` - **THEN** the journal is re-populated with entries whose `moduleRef` is `null` (re-imported on demand). #### Scenario: manual rollback flips to the prior journal entry - **WHEN** `rollback(plugin, 0)` is invoked on a populated journal - **THEN** the blue-green adapter restages + flips to the prior specifier AND `plugin.rolled_back` is emitted with `cause.code === "ADMIN"`. **Verified by:** `src/hot-reload/hot-reload.test.ts::"emits plugin.rolled_back and drives lifecycle into rolled_back"` AND `src/hot-reload/hot-reload.test.ts::"rolls back to the prior journal entry"` AND `src/hot-reload/hot-reload.test.ts::"hydrates the journal from the ledger"` AND `src/hot-reload/hot-reload.test.ts::"throws JOURNAL_EMPTY when index exceeds depth"` --- ### Requirement: `FileWatcher` debounces dev-mode reload triggers The package SHALL ship `class FileWatcher` for the §8.3 dev-mode trigger path. `register(plugin, path, trigger)` SHALL wire a watch via an injected `FsWatchAdapter` and collapse a burst of fs events into a single `trigger(plugin)` invocation per debounce window (default 500ms). The watcher SHALL refuse duplicate registration for the same plugin name. `disposeAll()` SHALL cancel every pending debounce timer + tear down every watch. #### Scenario: a burst of fs events fires once - **WHEN** three fs events are emitted in rapid succession - **THEN** `trigger` is invoked exactly once after the debounce window elapses. #### Scenario: duplicate registration throws - **WHEN** `register(plugin, ...)` is called twice with the same `plugin` name - **THEN** the second call throws synchronously. #### Scenario: disposer cancels pending fires - **WHEN** an fs event is debounced AND the disposer is invoked before the timer fires - **THEN** `trigger` is never invoked AND no timers remain. **Verified by:** `src/hot-reload/file-watcher.test.ts::"debounces a burst of events into a single trigger"` AND `src/hot-reload/file-watcher.test.ts::"throws on duplicate registration for the same plugin"` AND `src/hot-reload/file-watcher.test.ts::"disposer cancels pending fires + removes the watch"` --- ### Requirement: `PluginLedger` persists the host-owned plugin record The package SHALL ship `class PluginLedger` backed by a SQLite-shaped `LedgerDb` handle. The ledger SHALL expose `upsertPlugin(record)`, `getPlugin(name)`, `listPlugins()`, `removePlugin(name)`, `pushJournalEntry(plugin, entry)`, `peekJournal(plugin, n)`, `latestJournal(plugin)`, `journalLength(plugin)`. Adapter views `asLifecycleLedger()` and `asRollbackPersistence()` SHALL conform to the lifecycle driver's `LedgerAdapter` and the reload driver's `RollbackPersistence` contracts so production wiring is mechanical. Construction SHALL run host-owned migrations eagerly unless `autoMigrate: false` is passed. #### Scenario: upsert + read round-trip - **WHEN** `upsertPlugin({ name: "billing", ... })` is followed by `getPlugin("billing")` - **THEN** the returned row carries every persisted field (state, version, manifest hash, isolation, counters, capability set). #### Scenario: malformed `capabilities_json` raises `LEDGER.INVALID_ROW` - **WHEN** the underlying row's `capabilities_json` is hand-corrupted to a non-JSON string - **THEN** `getPlugin` throws a `HostError(LEDGER.INVALID_ROW)`. #### Scenario: `asLifecycleLedger.write` preserves existing counters - **WHEN** the lifecycle driver writes a partial row against a plugin already carrying `crashCount: 4` - **THEN** the resulting row carries `crashCount: 4` AND the new `state`, `version`, and `manifestHash`. **Verified by:** `src/plugin-ledger/plugin-ledger.test.ts::"upserts + reads a plugin row"` AND `src/plugin-ledger/plugin-ledger.test.ts::"raises INVALID_ROW for malformed capabilities JSON"` AND `src/plugin-ledger/plugin-ledger.test.ts::"asLifecycleLedger.write preserves existing counters + isolation"` --- ### Requirement: `PluginLedger` ring-buffers the rollback journal The journal subset SHALL be stored in `suaveplan_rollback_journal` keyed on `(plugin_name, position)` with `position ∈ [0, journalDepth)` (default depth 3). `pushJournalEntry` SHALL overwrite the oldest slot (by `captured_at` then `position` for ties) when the depth is exceeded. `peekJournal(plugin, n)` SHALL return at most `n` entries ordered newest-first AND validate every row through `rollbackJournalEntryPersistedSchema` from `@suaveplan/api-loader`. #### Scenario: ring buffer trims oldest on overflow - **WHEN** four entries are pushed against a depth-3 journal - **THEN** the journal contains three entries AND the trimmed slot now holds the latest entry. #### Scenario: malformed entry on push raises a Zod error - **WHEN** `pushJournalEntry` is called with `manifestHash: "bad"` (not matching the `sha256:` shape) - **THEN** the call throws synchronously before any row is written. **Verified by:** `src/plugin-ledger/plugin-ledger.test.ts::"ring-buffer trims oldest on overflow"` AND `src/plugin-ledger/plugin-ledger.test.ts::"rejects malformed entries on push"` AND `src/plugin-ledger/plugin-ledger.test.ts::"ring-buffer breaks ties by lower position"` --- ### Requirement: `HostMigrationRunner` is forward-only and idempotent The package SHALL ship `class HostMigrationRunner` consuming a `LedgerDb` and an ordered `LedgerMigration[]` (default `HOST_LEDGER_MIGRATIONS`). `migrate()` SHALL apply every declared migration whose `id` has no row in `__host_migrations`, in ascending id order. Re-applying SHALL be a no-op. The runner SHALL refuse to apply when applied ids reference undeclared migrations (`LEDGER.MIGRATION_OUT_OF_ORDER`) and when pending ids are ≤ the maximum applied id. A failed `up` callback SHALL raise `LEDGER.MIGRATION_FAILED` AND NOT record the migration id (so a subsequent retry re-runs it). #### Scenario: applying twice is a no-op - **WHEN** `migrate()` is invoked twice against the same DB - **THEN** the second call returns an empty `readonly number[]`. #### Scenario: every host-owned table is created - **WHEN** `migrate()` runs against a fresh DB - **THEN** `suaveplan_plugin_ledger`, `suaveplan_rollback_journal`, `suaveplan_saga_instances`, `suaveplan_saga_steps`, `suaveplan_2pc_transactions`, `__host_migrations` all exist. #### Scenario: migration failure parks the runner - **WHEN** a migration callback throws - **THEN** the runner raises `LEDGER.MIGRATION_FAILED` AND the migration id is NOT recorded as applied. **Verified by:** `src/plugin-ledger/plugin-ledger.test.ts::"re-running migrate is a no-op (idempotent)"` AND `src/plugin-ledger/plugin-ledger.test.ts::"creates every host-owned table"` AND `src/plugin-ledger/plugin-ledger.test.ts::"raises MIGRATION_FAILED when an up callback throws"` --- ### Requirement: Admin API mounts every §8.4 endpoint The package SHALL ship `createAdminApi(options)` returning a `Hono` instance exposing every endpoint listed in design §8.4: `GET /plugins`, `GET /plugins/:name`, `GET /plugins/:name/journal`, `GET /capabilities`, `GET /capabilities/:id`, `GET /health`; `POST /plugins/:name/install`, `POST /plugins/:name/uninstall`, `POST /plugins/:name/start`, `POST /plugins/:name/stop`, `POST /plugins/:name/reload`, `POST /plugins/:name/rollback`, `POST /plugins/:name/restart`. The router SHALL short-circuit the plugin pipeline entirely — every endpoint dispatches against the host-owned `LifecycleDriver`, `ReloadDriver`, `CapabilityRegistry`, and `PluginLedger` references. #### Scenario: `GET /plugins` returns the ledger snapshot - **WHEN** a `host:admin` principal hits `GET /plugins` - **THEN** the response carries `{ plugins: PluginLedgerRow[] }` and 200 status. #### Scenario: `GET /plugins/:name` returns 404 for unknown - **WHEN** the requested plugin has no ledger row - **THEN** the response is `404` with body `{ error: { code: "ADMIN.PLUGIN_NOT_FOUND" } }`. #### Scenario: `GET /capabilities` enumerates registered ids - **WHEN** the registry contains at least one capability - **THEN** the response body's `capabilities` array carries one entry per registered id with `{ id, version, scope }`. **Verified by:** `src/admin-api/admin-api.test.ts::"returns the ledger snapshot"` AND `src/admin-api/admin-api.test.ts::"returns 404 for unknown plugin"` AND `src/admin-api/admin-api.test.ts::"lists registered capabilities"` --- ### Requirement: Admin API enforces `host:admin` scope + per-principal rate limit Every admin endpoint SHALL require an authenticated `AdminPrincipal` carrying the configured `requiredScope` (default `host:admin`). The package SHALL ship `AdminRateLimiter` enforcing a per-principal token bucket — `readRatePerMinute` (default 60) for read endpoints and `destructiveRatePerMinute` (default 1) for mutating endpoints. Missing principal or missing scope SHALL raise `ADMIN.RBAC_DENIED` (HTTP 403); bucket exhaustion SHALL raise `ADMIN.RATE_LIMIT_EXCEEDED` (HTTP 429). #### Scenario: principal without `host:admin` is denied - **WHEN** the resolver returns a principal whose `scopes` lacks `host:admin` - **THEN** the response is `403` with `error.code === "ADMIN.RBAC_DENIED"`. #### Scenario: rate-limit bucket exhaustion rejects with 429 - **WHEN** the configured read RPM is `1` AND the principal makes two `GET /plugins` calls in the same minute - **THEN** the second response is `429` with `error.code === "ADMIN.RATE_LIMIT_EXCEEDED"`. #### Scenario: missing principal short-circuits with audit - **WHEN** the resolver returns `null` - **THEN** the response is `403` AND the audit sink records a `failure` envelope with `cause.code === "ADMIN.RBAC_DENIED"`. **Verified by:** `src/admin-api/admin-api.test.ts::"denies principal without scope"` AND `src/admin-api/admin-api.test.ts::"rejects when read bucket is exhausted"` AND `src/admin-api/admin-api.test.ts::"denies missing principal"` --- ### Requirement: Admin API requires `Idempotency-Key` on mutating endpoints Every mutating endpoint (`install`, `uninstall`, `start`, `stop`, `reload`, `rollback`, `restart`) SHALL require the caller to supply an `Idempotency-Key` header. The header value SHALL be checked against the configured `IdempotencyStore` — a missing header raises `ADMIN.IDEMPOTENCY_KEY_MISSING` (HTTP 400); a replayed key within the TTL window (default 10 minutes) raises `ADMIN.IDEMPOTENCY_KEY_REPLAY` (HTTP 409). The default in-process store SHALL be `InMemoryIdempotencyStore` with lazy TTL pruning. #### Scenario: install without `Idempotency-Key` is rejected - **WHEN** a `POST /plugins/:name/install` is dispatched with no `Idempotency-Key` header - **THEN** the response is `400` with `error.code === "ADMIN.IDEMPOTENCY_KEY_MISSING"`. #### Scenario: replayed key rejects with 409 - **WHEN** the same `Idempotency-Key` is replayed within the TTL window - **THEN** the response is `409` with `error.code === "ADMIN.IDEMPOTENCY_KEY_REPLAY"`. **Verified by:** `src/admin-api/admin-api.test.ts::"install rejects missing Idempotency-Key"` AND `src/admin-api/admin-api.test.ts::"install rejects replayed Idempotency-Key"` --- ### Requirement: Admin API emits audit envelope + OTel instruments Every admin endpoint outcome SHALL emit a structured `AdminAuditRecord` through the operator-supplied `AdminAuditSink`. The envelope SHALL carry `{ at, principal, action, plugin, params, outcome, cause? }`. Successful + failed outcomes SHALL both produce envelopes so the audit trail captures denied requests. Each call SHALL also increment `host.admin_action_total{action, outcome[, plugin]}` and record the elapsed time on `host.admin_call_duration_ms`. A throwing audit sink SHALL NOT fail the underlying admin operation — the failure SHALL be reported via the metric counter under `action: "host.audit.emit", outcome: "fail"`. #### Scenario: success envelope is emitted on the happy path - **WHEN** a `GET /plugins` succeeds - **THEN** the audit sink receives one record with `outcome: "success"`, `action: "host.plugins.list"`, the resolved principal. #### Scenario: failure envelope is emitted on RBAC denial - **WHEN** a principal lacks the required scope - **THEN** the audit sink receives one record with `outcome: "failure"`, `cause.code: "ADMIN.RBAC_DENIED"`. #### Scenario: audit sink throw is recorded as a metric, not a 500 - **WHEN** the audit sink throws on `emit` - **THEN** the admin call still returns 200 AND `host.admin_action_total{action: "host.audit.emit", outcome: "fail"}` increments. **Verified by:** `src/admin-api/admin-api.test.ts::"records a success envelope on a happy path"` AND `src/admin-api/admin-api.test.ts::"records a failure envelope on rate-limit + idempotency + RBAC denials"` AND `src/admin-api/admin-api.test.ts::"emits audit failure when sink throws"` --- ### Requirement: `LEDGER.*` + new `ADMIN.*` codes are published The package SHALL extend the `ADMIN_*` namespace with `IDEMPOTENCY_KEY_MISSING` and `IDEMPOTENCY_KEY_REPLAY`. It SHALL publish a new `LEDGER.*` namespace with `MIGRATION_FAILED`, `MIGRATION_OUT_OF_ORDER`, `ROW_NOT_FOUND`, `INVALID_ROW`, `DB_UNAVAILABLE`. Every new code SHALL have a non-zero `HOST_DEFAULT_STATUS_MAP` entry. #### Scenario: every new code is mapped - **WHEN** every `LEDGER.*` value is looked up via `statusFor` - **THEN** the returned status is a 4xx or 5xx integer. **Verified by:** `src/error-codes/error-codes.test.ts::"LEDGER_CODES carries every documented LEDGER.* reason"` AND `src/error-codes/error-codes.test.ts::"ADMIN_CODES carries every documented ADMIN.* reason"` AND `src/error-codes/error-codes.test.ts::"HOST_DEFAULT_STATUS_MAP maps every published code to a sane HTTP status"` --- ### Requirement: ErrorBoundary projects every captured throw to a stable envelope shape The package SHALL provide an `ErrorBoundary` consumed by every per-request pipeline step. Each invocation SHALL accept a step name, plugin name (`null` for pre-match steps), per-plugin status-map overlay, optional span, and the work function; it SHALL ALWAYS resolve with a `BoundaryOutcome` (`ok: true | false`), never throw. Projections SHALL follow design doc §12.7: - `SuaveplanError` / `HostError` → manifest `statusMap[code]` ?? `HOST_DEFAULT_STATUS_MAP[code]` ?? `500` (code preserved); - `AbortError` (or `code === "ABORT_ERR"`) → `HOST.CLIENT_ABORTED` (499); - anything else → `HOST.INTERNAL_INVARIANT` (500) — `runHandler` substitutes `HOST.HANDLER_THREW` so handler bugs are slice-able in OTel. Every failure SHALL increment `api.errors_total{step, code, status[, plugin]}`. When a span is supplied, the boundary SHALL call `recordException` + `setStatus({code: 2, message: })` but SHALL NOT `.end()` the span — the pipeline owns termination. A structured log line SHALL accompany every failure with a cause chain truncated at depth 5. #### Scenario: manifest statusMap overlays default - **WHEN** the manifest declares `errors.statusMap["HOST.SESSION_REQUIRED"] = 418` - **THEN** a `HostError("HOST.SESSION_REQUIRED")` thrown inside the wrapped step produces a failure with `status === 418`. #### Scenario: unmapped code falls back to 500 with code preserved - **WHEN** a plugin throws `PLUGIN.X.Y` with no entry in either map - **THEN** the boundary returns `status: 500` AND `code: "PLUGIN.X.Y"`. #### Scenario: AbortError → 499 - **WHEN** a step throws `new Error("aborted")` with `name === "AbortError"` - **THEN** the failure carries `code: "HOST.CLIENT_ABORTED"` and `status: 499`. #### Scenario: runHandler distinguishes handler throws - **WHEN** a `runHandler(...)` call captures `throw new Error("plain")` - **THEN** the failure carries `code: "HOST.HANDLER_THREW"`. **Verified by:** `src/error-boundary/error-boundary.test.ts::"manifest statusMap overrides default"` AND `src/error-boundary/error-boundary.test.ts::"unmapped code falls back to 500"` AND `src/error-boundary/error-boundary.test.ts::"AbortError → HOST.CLIENT_ABORTED (499)"` AND `src/error-boundary/error-boundary.test.ts::"runHandler substitutes HOST.HANDLER_THREW on unknown throw"` --- ### Requirement: Request pipeline runs §6 steps in order and is the only entry point for plugin traffic The package SHALL provide a `RequestPipeline.handle(request: Request)` driver that executes design doc §6 steps in the exact documented order: 1. Route match (host) — returns `HOST.ROUTE_NOT_FOUND` (404) when no route matches. 2. Tenant resolve — non-public routes that fail to resolve a tenant return `HOST.TENANT_NOT_RESOLVED` (400). Public (auth-optional) routes that fail to resolve a tenant fall back according to `HostConfig.defaultTenant` (and the equivalent `RequestPipelineOptions.defaultTenant`): when `undefined` or `null` (the default) `ctx.tenant` and `ctx.tenantId` are propagated as `null`; when set to a `TenantContext` value the value is injected verbatim, preserving the pre-foundation-gap-5 `{id: "public", slug: "public"}` semantics for migrators who opt in explicitly. 3. Session resolve via the configured `SessionResolverChain` — resolver throws emit `api.auth.failures_total`. 4. CSRF — required when `route.options.csrf === true`, the request method is not `GET`/`HEAD`, the route is not `streaming`, AND a principal was resolved. Invalid token → `HOST.CSRF_TOKEN_INVALID` (403). 5. RBAC — `null` principal on a non-public route → `HOST.SESSION_REQUIRED` (401); missing scope → `HOST.RBAC_DENIED` (403) + `api.rbac.denials_total`. 6. Rate limit — keyed by principal id (when present) else tenant id; when neither is bound (auth-optional + `defaultTenant` unset + resolver miss) the limiter keys off the literal `"anon"`. Bucket-empty → `HOST.RATE_LIMIT_EXCEEDED` (429) + `api.rate_limit.exceeded_total`. 7. Idempotency — `route.options.idempotent === true` + `Idempotency-Key` header present: replay → `HOST.IDEMPOTENCY_REPLAY` (409) + `api.idempotency.replays_total`. 8. Request validation — Zod body / params / query when `route.options.runtimeValidation !== false` and route is not streaming; failures emit `HOST.REQUEST_VALIDATION_FAILED` (400) + `api.validation.failures_total{where=request}`. 9. Plugin handler — invoked through `ErrorBoundary.runHandler` with a `RequestContext` carrying tenant, principal, capabilities, parsed body/params/query. 10. Response validation — `enforce` (production) → `HOST.RESPONSE_VALIDATION_FAILED` (500) on mismatch; `warn` (development) → success + `api.validation.failures_total{where=response.warn}`. 11. Audit envelope (when `auditable: true`) + OTel telemetry close — every request emits `api.requests_total{route, plugin, status, outcome}` and `api.request_duration_ms` histogram. The pipeline SHALL always set the `X-Request-Id` response header. Streaming routes (`streaming: true`) SHALL bypass CSRF, request validation buffering, and response validation. #### Scenario: 11 steps execute in order on happy path - **WHEN** a `POST /billing/invoices` with valid principal + scope is dispatched - **THEN** the response is 200 AND `X-Request-Id` is set AND an audit envelope is emitted for `auditable: true` routes. #### Scenario: CSRF gates cookie-bound non-GET requests - **WHEN** the route is `csrf: true`, the method is `POST`, the principal is non-null, and the CSRF validator returns `false` - **THEN** the response is 403 with `code: "HOST.CSRF_TOKEN_INVALID"`. #### Scenario: streaming bypasses CSRF - **WHEN** the route is `streaming: true, csrf: true` AND CSRF validator would return `false` - **THEN** the response still succeeds. #### Scenario: response validation enforce → 500 - **WHEN** the handler returns `{wrong: true}` against a `responses["200"].body = z.object({ok: z.boolean()})` - **THEN** the response is 500 with `code: "HOST.RESPONSE_VALIDATION_FAILED"`. #### Scenario: response validation warn → success + counter - **WHEN** the pipeline is constructed with `responseValidation: "warn"` and the handler's body mismatches the response schema - **THEN** the response is 200 AND `api.validation.failures_total{where: "response.warn"}` increments. **Verified by:** `src/request-pipeline/request-pipeline.test.ts::"happy path 200 with X-Request-Id + audit envelope"` AND `src/request-pipeline/request-pipeline.test.ts::"CSRF required on cookie-auth non-GET, invalid → 403"` AND `src/request-pipeline/request-pipeline.test.ts::"streaming routes bypass CSRF"` AND `src/request-pipeline/request-pipeline.test.ts::"response Zod mismatch in enforce mode → 500"` AND `src/request-pipeline/request-pipeline.test.ts::"response Zod mismatch in warn mode → success"` --- ### Requirement: `createHost(config)` composes every internal driver The package SHALL expose a top-level `createHost(config: HostConfig)` entry point that composes the lifecycle driver, reload driver, plugin ledger, capability registry, request pipeline, and admin API into a `Host` whose `fetch(request)` method drives the full §6 pipeline. Config SHALL be Zod-validated at the entry; failures raise `HOST.CONFIG_INVALID`. `host.start()` SHALL topo-sort `config.plugins` via `@suaveplan/api-manifest`'s `resolveInstallOrder` and install each plugin in dependency order. `host.stop(graceMs)` SHALL stop plugins in reverse-topo order with a `HOST.SHUTDOWN_TIMEOUT` deadline. The host SHALL mount the admin API at `config.adminPrefix` (default `/admin`, `null` to disable). It SHALL expose `/internal/livez` (200 when responsive) and `/internal/readyz` (200 + plugin list). `installPlugin`/`uninstallPlugin` SHALL operate at runtime; pre-start fetches SHALL return a 503 envelope. `HostAdapters` SHALL distinguish the host-private narrow SQLite ledger handle from the consumer-facing multi-engine database surface: - `HostAdapters.ledgerDb: LedgerDb` — REQUIRED. Narrow `bun:sqlite` / `better-sqlite3`-style handle (`prepare(sql)` / `run(sql)`) consumed exclusively by the host's internal `PluginLedger`. NOT exposed on `PluginContext` / `RequestContext`. - `HostAdapters.db?: AdminDatabaseLike` — OPTIONAL. The consumer-facing multi-engine façade produced by `createDbAdapter(...).admin` (`@suaveplan/api-db-adapter`). When set, the host SHALL thread the admin surface onto `PluginContext.db` AND per-request SHALL apply `createTenantScopedWrapper(admin, tenantCtx.id)` and expose the result on `RequestContext.db`. When unset, BOTH `PluginContext.db` AND `RequestContext.db` SHALL be `null`. When the per-request tenant resolver chain returns `null`, `RequestContext.db` SHALL be `null` even if `adapters.db` is configured. The two slots are type-incompatible by design and SHALL NOT be unified. #### Scenario: empty host boots; livez/readyz respond - **WHEN** `createHost({adapters, plugins: []})` + `host.start()` runs - **THEN** `host.fetch(GET /internal/livez)` returns 200 AND `host.fetch(GET /internal/readyz)` returns 200 with `plugins: []`. #### Scenario: admin disabled via `adminPrefix: null` - **WHEN** `adminPrefix: null` is configured - **THEN** a fetch to `/admin/plugins` is treated as an unknown plugin route and returns 404. #### Scenario: start twice raises HOST.ALREADY_STARTED - **WHEN** `host.start()` is called twice - **THEN** the second call rejects with `code: "HOST.ALREADY_STARTED"`. #### Scenario: stop before start raises HOST.NOT_STARTED - **WHEN** `host.stop()` is called before `host.start()` - **THEN** the call rejects with `code: "HOST.NOT_STARTED"`. #### Scenario: adapters.db is threaded to ctx.db with tenant-scoped wrapper - **WHEN** the host is booted with `adapters.db = createDbAdapter(...).admin` AND a tenant resolver that binds `{id: "acme", ...}` AND a route handler reads `ctx.db?.query("SELECT * FROM widgets", [])` - **THEN** the SQL the admin surface sees is rewritten to `SELECT * FROM widgets WHERE tenant_id = ?` AND the appended bind value is `"acme"` AND `ctx.db.tenantId === "acme"`. #### Scenario: ctx.db is null when adapters.db is absent - **WHEN** the host is booted WITHOUT `adapters.db` AND a route handler reads `ctx.db` - **THEN** the value is `null` AND the handler runs to completion. #### Scenario: ctx.db is null when no tenant resolved - **WHEN** the host is booted WITH `adapters.db` AND the tenant resolver chain returns `null` AND `defaultTenant` is unset - **THEN** `RequestContext.db === null` at the handler boundary. **Verified by:** `src/host/host.test.ts::"boots with zero plugins + serves /internal/livez 200"` AND `src/host/host.test.ts::"/internal/readyz lists empty plugin set"` AND `src/host/host.test.ts::"adminPrefix: null disables admin surface"` AND `src/host/host.test.ts::"start twice → HOST.ALREADY_STARTED"` AND `src/host/host.test.ts::"stop before start → HOST.NOT_STARTED"` AND `src/host/host.test.ts::"validates config via Zod"` AND `src/host/host.test.ts::"adapters.db threaded into RequestContext via tenant-scoped wrapper"` AND `src/host/host.test.ts::"ctx.db === null when adapters.db is undefined"` AND `src/host/host.test.ts::"ctx.db === null when tenant resolver returns null even if adapters.db is set"` --- ### Requirement: per-request `HOST.*` error codes are published The package SHALL publish the following `HOST.*` codes for the per-request pipeline: `TENANT_NOT_RESOLVED` (400), `SESSION_REQUIRED` (401), `CSRF_TOKEN_INVALID` (403), `RBAC_DENIED` (403), `RATE_LIMIT_EXCEEDED` (429), `REQUEST_VALIDATION_FAILED` (400), `RESPONSE_VALIDATION_FAILED` (500), `IDEMPOTENCY_REPLAY` (409), `ROUTE_NOT_FOUND` (404), `HANDLER_THREW` (500), `CLIENT_ABORTED` (499). Each new code SHALL have a non-zero `HOST_DEFAULT_STATUS_MAP` entry AND SHALL be thrown by at least one production path covered by tests. #### Scenario: every new code is mapped - **WHEN** every Chunk-D `HOST.*` code is looked up via `statusFor` - **THEN** the returned status is the documented HTTP code above. **Verified by:** `src/error-codes/error-codes.test.ts::"HOST_CODES carries every documented HOST.* reason"` AND `src/error-codes/error-codes.test.ts::"HOST_DEFAULT_STATUS_MAP maps every published code to a sane HTTP status"` AND `src/request-pipeline/request-pipeline.test.ts` (each code thrown from a real handler path) --- ### Requirement: `HostAdapters.secrets` slot threads through every context (Added by foundation-fix-gap-3-secrets-slot.) `HostAdapters` SHALL expose an optional `secrets?: SecretsLike` slot. When the operator omits `secrets`, `createHost` SHALL inject `new EmptySecrets()` into both `RequestPipelineOptions.secrets` and `LifecycleDriverOptions.secrets` so every `RequestContext.secrets` and `PluginContext.secrets` access resolves to a defined `SecretsLike` value. The pipeline driver SHALL set `RequestContext.secrets` to the configured value on every constructed handler context; the lifecycle driver SHALL inject the same value onto every user-hook invocation's `PluginContext`. #### Scenario: secrets omitted → EmptySecrets default - **WHEN** `createHost({ adapters: { router, auth, tenant, capabilities, rateLimit, idempotency, csrf, audit, span, db } })` is called without a `secrets` slot - **THEN** every `RequestContext.secrets.require()` and `PluginContext.secrets.require()` throws `SECRETS.SECRET_NOT_FOUND` #### Scenario: secrets supplied → adapter is threaded - **WHEN** `createHost({ adapters: { ..., secrets: createEnvSecrets({ allowedKeys: ["FOO"], source: () => ({ FOO: "bar" }) }) } })` is called - **THEN** a handler's `await ctx.secrets.require("FOO")` resolves to `"bar"` **Verified by:** `src/request-pipeline/request-pipeline-secrets.test.ts::"handler reads a secret via ctx.secrets.require with an env-style adapter"` AND `src/request-pipeline/request-pipeline-secrets.test.ts::"ctx.secrets is the same reference passed via options"` AND `e2e/foundation-stack/tests/foundation.spec.ts::"secrets adapter — redacted retrieval via env-secrets"` --- ### Requirement: `EmptySecrets` is the host default SecretsLike (Added by foundation-fix-gap-3-secrets-slot.) The package SHALL ship `EmptySecrets` implementing `SecretsLike` from `@suaveplan/api-types/secrets`. `get(name)` MUST always resolve to `undefined`. `require(name)` MUST throw `SecretsError` carrying the canonical `SECRETS.SECRET_NOT_FOUND` code (sourced from `@suaveplan/api-secrets`'s `SECRETS_CODES`) and a `context` field of shape `{ name, source: "host.default.empty-secrets" }`. `list()` MUST return a frozen empty `readonly string[]`, stable across calls. The class MUST be reachable via `import { EmptySecrets } from "@suaveplan/api-host/empty-secrets"` AND from the root barrel via `import { EmptySecrets } from "@suaveplan/api-host"`. #### Scenario: get returns undefined - **WHEN** `await new EmptySecrets().get("anything")` is called - **THEN** the resolved value is `undefined` #### Scenario: require throws SECRETS.SECRET_NOT_FOUND - **WHEN** `await new EmptySecrets().require("X_API_KEY")` is called - **THEN** a `SecretsError` is thrown with `code === "SECRETS.SECRET_NOT_FOUND"` AND `context.name === "X_API_KEY"` AND `context.source === "host.default.empty-secrets"` #### Scenario: list is the frozen empty array - **WHEN** `new EmptySecrets().list()` is called twice - **THEN** both calls return the same frozen `readonly string[]` reference whose length is 0 **Verified by:** `src/empty-secrets/empty-secrets.test.ts::"get returns undefined for any key"` AND `src/empty-secrets/empty-secrets.test.ts::"require throws SECRETS.SECRET_NOT_FOUND on every call"` AND `src/empty-secrets/empty-secrets.test.ts::"require attaches the requested key on context"` AND `src/empty-secrets/empty-secrets.test.ts::"list returns an empty readonly array"` ## ADDED Requirements — Reserved-Prefix Contract (2026-05-26) ### Requirement: Publishes the canonical reserved-prefix list `@suaveplan/api-host` SHALL export `HOST_RESERVED_PREFIXES` as a frozen `ReadonlyArray` of every URL path prefix the host (and any first-party engine that has shipped) reserves. The list MUST be normalised (lowercase, leading-slash, no trailing slash) and deduped. Downstream adapters that mount their own routes MUST consult this list (directly or via `isReservedPrefix`) before mounting and refuse to install on collision. #### Scenario: list contains every always-reserved host route - **WHEN** an adapter reads `HOST_RESERVED_PREFIXES` - **THEN** it contains `/_host` and `/internal` (always-reserved); `/admin`, `/graphql`, `/ws`, `/sse` are included once their respective host modules ship #### Scenario: list is frozen - **WHEN** consumer code attempts `HOST_RESERVED_PREFIXES.push("/foo")` - **THEN** the assignment throws `TypeError` **Verified by:** `src/reserved-prefix/reserved-prefix.test.ts::"HOST_RESERVED_PREFIXES contains always-reserved prefixes"` AND `src/reserved-prefix/reserved-prefix.test.ts::"HOST_RESERVED_PREFIXES is frozen"` --- ### Requirement: Exposes a predicate + assertion helper The package SHALL export `isReservedPrefix(prefix: string): boolean` and `assertPrefixAvailable(prefix: string, codeFactory: (collision: string) => Error): void`. The predicate MUST treat input prefixes case-insensitively after normalisation and MUST match both exact prefixes (`/internal` reserves `/internal`) and child paths (`/internal/livez` is also reserved). The assertion MUST throw the error produced by `codeFactory(collision)` when reserved, returning `void` otherwise — letting each adapter surface its own code namespace (`AUTH_BA_*`, `RBAC_*`, …) without coupling. #### Scenario: predicate matches exact + child paths - **WHEN** `isReservedPrefix("/internal")` and `isReservedPrefix("/internal/livez")` are called - **THEN** both return `true` #### Scenario: predicate normalises case and trailing slash - **WHEN** `isReservedPrefix("/Internal/")` is called - **THEN** it returns `true` #### Scenario: assertion throws the factory-supplied error on collision - **WHEN** `assertPrefixAvailable("/_host", () => new MyError("X"))` is called - **THEN** the call throws `MyError("X")` #### Scenario: assertion is a no-op when free - **WHEN** `assertPrefixAvailable("/auth", factory)` is called and `/auth` is not in the list - **THEN** the call returns `void` and `factory` is NOT invoked **Verified by:** `src/reserved-prefix/reserved-prefix.test.ts::"isReservedPrefix matches exact and child paths"` AND `src/reserved-prefix/reserved-prefix.test.ts::"isReservedPrefix normalises case and trailing slash"` AND `src/reserved-prefix/reserved-prefix.test.ts::"assertPrefixAvailable throws supplied error on collision"` AND `src/reserved-prefix/reserved-prefix.test.ts::"assertPrefixAvailable is no-op when prefix is free"` --- ### Requirement: Published via `./reserved-prefix` subpath The contract MUST be importable from `@suaveplan/api-host/reserved-prefix` so adapters can pull only the predicate surface without bundling the full host. The subpath MUST forward types and runtime export of the same set documented in `HOST_RESERVED_PREFIXES`, `isReservedPrefix`, `assertPrefixAvailable`, `normalisePrefix`. #### Scenario: subpath import works in TypeScript and at runtime - **WHEN** `import { isReservedPrefix } from "@suaveplan/api-host/reserved-prefix"` is used - **THEN** the symbol resolves AND TypeScript types it as `(prefix: string) => boolean` **Verified by:** `src/reserved-prefix/reserved-prefix.test.ts::"subpath export resolves and types are forwarded"`