Files
2026-05-27 23:29:53 +01:00

65 KiB
Raw Permalink Blame History

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<Module> | 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<const Requires extends readonly CapabilityId[]>(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<typeof ctx["capabilities"]>().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<TestHost> — 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<LifecycleEventMap>.

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<object>, 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: <code>}) 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.
  • 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(<name>) and PluginContext.secrets.require(<name>) 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<string> 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"