Files
suaveplan/openspec/archive/2026-05-26-foundation-fix-gap-3-secrets-slot/proposal.md
2026-05-27 23:29:53 +01:00

5.2 KiB

foundation-fix-gap-3-secrets-slot

  • Status: PROPOSED
  • Spec deltas:
    • ./specs/api-host/spec.md
    • ./specs/api-types/spec.md

What & why

The post-Stage-4 foundation-coherence audit (Gap #3) flagged that HostAdapters had no secrets slot, so even though @suaveplan/api-secrets-env builds a SecretsLike cleanly, the host never threaded it through to plugin handlers. The e2e foundation fixture worked around this by closing over a createEnvSecrets(...) binding inside the route module (buildSecrets() in e2e/foundation-stack/src/fixture-plugin.ts). Real plugins would have to do the same thing — there was no canonical wiring.

This change closes the gap by:

  1. HostAdapters gains optional secrets?: SecretsLike. When omitted, the host injects EmptySecrets — a no-op default whose get() returns undefined, require() throws SECRETS.SECRET_NOT_FOUND, and list() returns the frozen empty array. Optional so legacy bootstraps stay source-compatible.
  2. PluginContext<Caps> and (by inheritance) RequestContext<Caps>, JobContext<Caps>, ConnectionContext<Caps> gain secrets: SecretsLike. Plugin authors can destructure ctx.secrets without a null guard at every call-site; the host always supplies a value.
  3. RequestPipelineOptions gains required secrets: SecretsLike. createHost wires config.adapters.secrets ?? new EmptySecrets() into the pipeline options and into the lifecycle driver, so both route handlers (RequestContext.secrets) and lifecycle hooks (PluginContext.secrets) see the same instance.
  4. @suaveplan/api-host/empty-secrets subpath export. The class ships under a dedicated subpath so the host's EmptySecrets is importable for explicit fallback wiring (e.g. mode-conditional choice between createEnvSecrets and EmptySecrets).
  5. e2e/foundation-stack drops the closure-bound buildSecrets helper; the route handler now reads via ctx.secrets.require(...) directly and the secrets adapter is wired into HostAdapters.secrets once at boot.

Impact

Affected dependents

  • @suaveplan/api-types — PluginContext.secrets is a new REQUIRED field on every context flavour. The 6 in-tree consumers that reference RequestContext via cast-narrowing or generic parameter (api-graphql, api-router, api-router-hono — as RequestContext, Parameters<R>[2] typing) are unaffected because they do not construct context literals. The only literal-construction site is @suaveplan/api-host/request-pipeline-helpers.ts's buildRequestContext, which is updated atomically.
  • @suaveplan/api-host — gains EmptySecrets class + ./empty-secrets subpath export. HostAdapters.secrets is optional. Pipeline options
    • lifecycle driver options both gain a threaded secrets slot. Dependency on @suaveplan/api-secrets is added so the host can reuse the SECRETS_CODES.SECRET_NOT_FOUND error code namespace and SecretsError subclass — no new error namespaces are introduced.
  • @suaveplan/e2e-foundation-stack — buildAdapters wires createEnvSecrets({ allowedKeys: ["FOUNDATION_SECRET"], source: ... }) into HostAdapters.secrets. The fixture plugin's /api/secret-key route handler now resolves via ctx.secrets.require("FOUNDATION_SECRET") instead of a hand-rolled closure.

Backward compatibility

HostAdapters.secrets is optional — bootstraps that do not set it receive the EmptySecrets default. RequestContext.secrets / PluginContext.secrets are new REQUIRED fields on the public type, but the only literal-construction site in the monorepo is owned by api-host itself (updated atomically). All other consumers use type-narrowing casts or generic parameter typing and are unaffected at compile time.

The new pipeline secrets option is REQUIRED on RequestPipelineOptions so the host cannot accidentally construct a pipeline without threading the slot. Tests + the lifecycle driver construct an EmptySecrets() explicitly in the same commit.

Parity Checklist

This is a genesis-original fix; no upstream parity table required.

Verification

bunx turbo lint typecheck test build --filter=@suaveplan/api-host --filter=@suaveplan/api-types --filter=@suaveplan/e2e-foundation-stack → green.

bunx turbo test:coverage --filter=@suaveplan/api-host --filter=@suaveplan/api-types → touched files at 100% line coverage; empty-secrets.ts has 75% bun function coverage (3 of 4 — the constructor counts as a function under bun:test's naive counter even though every test instantiates it). Vitest/istanbul function coverage is 100%.

E2E (Playwright) — all 10 foundation-stack tests pass post-shim removal, including the secrets-redaction test that now exercises the pipeline-threaded ctx.secrets.require(...) path.

Tests added:

  • request-pipeline-secrets.test.ts — handler reads ctx.secrets.require("FOO") via a wired adapter (round-trip); default EmptySecrets throws SECRETS.SECRET_NOT_FOUND on require; ctx.secrets is the same reference passed via options.
  • empty-secrets.test.ts — get returns undefined, require throws SECRETS.SECRET_NOT_FOUND with { name, source } context, list returns a frozen empty array stable across calls.