Files
suaveplan/openspec/changes/api-plugin-example-dual-shape/specs/api-plugin-example/spec.md
2026-05-27 23:29:53 +01:00

5.1 KiB

Capability Spec Delta: api-plugin-example (dual-shape)

Package: @suaveplan/api-plugin-example Status: PROPOSED

ADDED Requirements

Requirement: The package SHALL expose a ./wasm subpath surfacing a WASI-stdio-shaped plugin

@suaveplan/api-plugin-example/wasm SHALL export const plugin whose shape is { routes: readonly SerializedRoute[]; handleRequest(req: SerializedRequest): Promise<SerializedResponse>; start?(): void | Promise<void>; dispose?(): void | Promise<void> }. The wire types are imported from @suaveplan/api-types/sandbox-bridge.

Scenario: wasm subpath imports cleanly under browser-target bundling

  • WHEN Bun.build({ entrypoints: ["packages/api-stack/api/api-plugin-example/src/wasm/index.ts"], target: "browser", format: "esm" }) runs
  • THEN the build succeeds AND the produced ESM bundle imports no node:* specifier AND no Hono/OTel-SDK specifier

Verified by: tests/wasm/browser-bundle.test.ts::"wasm subpath bundles for browser target"


Requirement: The ./wasm subpath SHALL have a constrained dependency graph

A new lint script bun scripts/lint/check-wasm-subpath-deps.ts MUST walk every static import from src/wasm/index.ts transitively and reject any specifier matching the wasm-tier ban list: @suaveplan/api-host, hono, @hono/*, @opentelemetry/sdk-metrics, @opentelemetry/sdk-trace-base, @opentelemetry/sdk-node, any node:* specifier, and any package whose own dependency tree drags one of the above. The lint MUST be wired into bunx turbo lint so CI rejects accidental dependency creep.

Scenario: Adding import "hono" inside src/wasm/ fails the lint

  • WHEN a contributor adds import { Hono } from "hono" to any file reachable from src/wasm/index.ts
  • THEN bun scripts/lint/check-wasm-subpath-deps.ts exits non-zero with a message naming hono and the importing file

Verified by: scripts/lint/check-wasm-subpath-deps.test.ts::"rejects banned specifier in wasm subpath"


Requirement: Trusted-tier and wasm-tier surfaces SHALL share a single domain core

The package SHALL contain a src/domain/ directory with pure (no IO, no Hono, no OTel SDK, no Request/Response) modules: invoice.types.ts, invoice-rules.ts, in-memory-store.ts. Both the trusted-tier routes (src/trusted/) AND the wasm-tier handlers (src/wasm/) MUST call the same rule functions from src/domain/invoice-rules.ts for any business decision (state transitions, validation, etc.).

Scenario: Trusted and wasm handlers produce identical bodies for the same input

  • WHEN the same POST /invoices payload is dispatched through the trusted handler AND through plugin.handleRequest(...)
  • THEN both produce the same HTTP status AND the same JSON response body

Verified by: tests/wasm/shape-conformance.test.ts::"trusted vs wasm parity for every shared route"


Requirement: The existing root export surface SHALL remain unchanged

The shipped @suaveplan/api-plugin-example exports (examplePluginModule, buildRoutes, EXAMPLE_CODES, error class) MUST keep their existing types and runtime semantics after the refactor. The internal git mv from src/{routes,plugin,install,events}/ to src/trusted/{routes,plugin,install,events}/ MUST be invisible to consumers — the root barrel re-exports the same names from the new locations.

Scenario: api-host's existing consumer of api-plugin-example still passes

  • WHEN bunx --bun turbo test:coverage --filter=@suaveplan/api-host runs after the refactor
  • THEN every test that imports @suaveplan/api-plugin-example still passes AND no test file in api-host/ requires source modification

Verified by: tests/trusted/root-export-shape.test.ts::"root exports preserve existing types and names"


Requirement: The wasm subpath SHALL omit endpoints that cannot run under the sandbox tier

The wasm-tier routes array MUST NOT include endpoints that require streaming responses, server-sent events, or any other transport pattern incompatible with the synchronous (SerializedRequest) => SerializedResponse contract. The trusted-tier streamLargeReport endpoint is the canonical example — it stays in the trusted-tier exports and is documented in src/wasm/index.md as a deliberate omission.

Scenario: Wasm routes contain no streaming endpoint

  • WHEN the wasm routes array is inspected
  • THEN no entry's path matches /stream/* or any documented streaming pattern

Verified by: src/wasm/routes.test.ts::"wasm routes omit streaming endpoints"


Requirement: Lifecycle hooks SHALL initialise and tear down the in-memory store deterministically

plugin.start() SHALL prime the module-singleton InMemoryStore with the same seed data the trusted-tier install hook uses. plugin.dispose() SHALL clear the store. Repeated calls to either MUST be idempotent (the second call MUST NOT throw).

Scenario: Dispose then start re-primes the store

  • WHEN plugin.dispose() runs, then plugin.start() runs
  • THEN the wasm-tier GET /invoices route returns the seeded list of invoices

Verified by: src/wasm/start.test.ts::"start re-primes after dispose"