# Capability Spec Delta: api-plugin-example (api-plugin-example) **Package**: `@suaveplan/api-plugin-example` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. This spec doubles as an integration-test specification — every requirement names a concrete integration `it(...)`. ## ADDED Requirements ### Requirement: Plugin deploys cleanly under every isolation tier The example plugin SHALL boot to `started` state under `isolation: "trusted"`, `isolation: "isolated"`, `isolation: "sandboxed"` (vm), AND `isolation: "sandboxed"` (wasm) configurations. The full integration suite runs in each. #### Scenario: trusted-tier boot - WHEN the host installs the plugin with `isolation: "trusted"` - THEN the plugin reaches `started` AND a request to `/example/health` returns 200 #### Scenario: isolated-tier boot - WHEN the host installs the plugin with `isolation: "isolated"` - THEN the worker thread starts AND a request to `/example/health` returns 200 #### Scenario: sandboxed-vm boot - WHEN the host installs the plugin with `isolation: "sandboxed"` and `host.defaultSandbox = "vm"` - THEN the plugin runs inside `api-sandbox-vm` AND a request to `/example/health` returns 200 #### Scenario: sandboxed-wasm boot - WHEN the host installs the plugin with `isolation: "sandboxed"` and `host.defaultSandbox = "wasm"` - THEN the plugin runs inside `api-sandbox-wasm` AND a request to `/example/health` returns 200 **Verified by:** `tests/integration/full-flow.integration.test.ts::"plugin boots and serves /example/health in "` (parameterised over four modes) --- ### Requirement: REST surface covers every Section 6 pipeline step The plugin SHALL expose routes that exercise every step of the per-request pipeline: public, private+RBAC-gated, validated, streaming, idempotent, rate-limited. #### Scenario: public route bypasses auth - WHEN a request hits `/example/public/ping` - THEN response is 200 with no `auth.session` resolution required #### Scenario: private RBAC-gated route enforces scope - WHEN a request hits `/example/invoices` with a principal lacking `example:read` - THEN response is 403 with `code === "RBAC_MISSING_SCOPE"` #### Scenario: validated body rejects malformed payload - WHEN a request hits `/example/invoices` with `body.amountCents` missing - THEN response is 400 with the Zod-validated error envelope #### Scenario: streaming route streams chunks - WHEN a request hits `/example/stream/large-report` - THEN response uses chunked transfer-encoding AND every chunk is delivered before the handler returns #### Scenario: idempotency replays original response - WHEN two requests with the same `Idempotency-Key` hit `/example/invoices` - THEN the second response is byte-identical to the first AND only one DB row is created #### Scenario: rate-limited route returns 429 with Retry-After - WHEN burst is exceeded on `/example/rate-limited` - THEN response is 429 with `Retry-After` set **Verified by:** `tests/integration/full-flow.integration.test.ts::"REST surface — public bypasses auth"` AND `...::"REST surface — RBAC denies missing scope"` AND `...::"REST surface — Zod rejects malformed body"` AND `...::"REST surface — streaming delivers chunked"` AND `...::"REST surface — idempotency replays response"` AND `...::"REST surface — rate-limited returns 429 with Retry-After"` --- ### Requirement: GraphQL surface — types, resolvers, federation, subscription The plugin SHALL contribute GraphQL types AND resolvers AND a subscription channel. A sibling test plugin SHALL be installed in the integration test to exercise federation. #### Scenario: federated query crosses plugins - WHEN a GraphQL query touches types from both `api-plugin-example` and the sibling plugin - THEN the result contains both AND no field is `null` due to federation failure #### Scenario: GraphQL subscription delivers events - WHEN a subscription to `exampleInvoiceCreated` is open AND an invoice is created via REST - THEN the subscription delivers exactly one message **Verified by:** `tests/integration/graphql-federation.integration.test.ts::"federated query crosses plugins"` AND `tests/integration/graphql-federation.integration.test.ts::"GraphQL subscription delivers exampleInvoiceCreated"` --- ### Requirement: WebSocket surface — subscription fan-out single + multi-host The plugin SHALL expose `/example/ws` with subscription fan-out. The integration test exercises single-host fan-out AND a multi-host topology via the broker adapter. #### Scenario: single-host fan-out delivers to every subscriber - WHEN two subscribers connect to `/example/ws` and the plugin publishes `example.invoice.created` - THEN both subscribers receive the message exactly once #### Scenario: multi-host fan-out via broker - WHEN the broker is wired across two host instances AND a subscriber connects to host A - THEN a publish on host B reaches the subscriber on host A **Verified by:** `tests/integration/ws-broadcast.integration.test.ts::"single-host fan-out delivers to every subscriber"` AND `tests/integration/ws-broadcast.integration.test.ts::"multi-host fan-out via broker"` --- ### Requirement: SSE surface with Last-Event-Id resume The plugin SHALL expose an SSE endpoint that supports `Last-Event-Id` resume per design doc §11.6. #### Scenario: client reconnects with Last-Event-Id - WHEN a client reconnects to `/example/sse` with `Last-Event-Id: 42` - THEN the server replays events with id > 42 **Verified by:** `tests/integration/full-flow.integration.test.ts::"SSE replays events after Last-Event-Id"` --- ### Requirement: Owned saga with compensations + 2PC participation The plugin SHALL own a 3-step saga AND participate in a host-managed 2PC transaction. Both happy and failure paths are exercised. #### Scenario: saga happy path commits every step - WHEN the saga runs to completion - THEN every step's effect is observable AND no compensations run #### Scenario: saga failure triggers compensations - WHEN step 2 throws - THEN step 1's compensation runs AND no step 3 effect is observable #### Scenario: 2PC prepare→commit cycle - WHEN the host issues prepare to the plugin AND every other participant votes yes - THEN the host issues commit AND the plugin observes commit before responding #### Scenario: 2PC abort cycle - WHEN any participant votes no - THEN the plugin observes abort AND no committed state is observable **Verified by:** `tests/integration/saga-2pc.integration.test.ts::"saga happy path commits every step"` AND `...::"saga failure triggers compensations"` AND `...::"2PC prepare→commit cycle"` AND `...::"2PC abort cycle"` --- ### Requirement: Background jobs — cron, per-tenant cron, queued The plugin SHALL register a global cron job, a per-tenant cron job, and a queued job. The integration test advances `FakeClock` to fire them. #### Scenario: global cron fires on schedule - WHEN `FakeClock` advances past the cron expression - THEN `reconcile-invoices` runs exactly once for that tick #### Scenario: per-tenant cron fans out - WHEN three tenants exist AND `FakeClock` advances past the schedule - THEN `send-monthly-statements` runs three times — once per tenant — each with a tenant-scoped context #### Scenario: queued job retries on failure up to maxAttempts - WHEN `send-receipt` throws on attempt 1 - THEN attempt 2 runs after backoff AND after `maxAttempts: 5` the job is parked in DLQ **Verified by:** `tests/integration/full-flow.integration.test.ts::"cron fires on schedule"` AND `...::"per-tenant cron fans out"` AND `...::"queued job retries up to maxAttempts then DLQ"` --- ### Requirement: Event publish + subscribe The plugin SHALL publish `example.invoice.created` AND subscribe to `users.user.deleted`. #### Scenario: publishing increments the event bus - WHEN an invoice is created - THEN exactly one `example.invoice.created` event is observed on the bus #### Scenario: subscriber handler runs on incoming event - WHEN `users.user.deleted` is published by a sibling plugin - THEN the example's handler runs exactly once for that delivery **Verified by:** `tests/integration/full-flow.integration.test.ts::"publish increments event bus"` AND `tests/integration/full-flow.integration.test.ts::"subscriber handler runs on incoming event"` --- ### Requirement: Custom error codes mapped to HTTP statuses The plugin SHALL declare `PLUGIN.EXAMPLE.*` codes in `error-codes.ts` AND map them in `errors.statusMap` in the manifest. Each code MUST be thrown by at least one production path AND surface with the manifested HTTP status. #### Scenario: SUBSCRIPTION_INACTIVE returns 409 - WHEN a request triggers `PLUGIN.EXAMPLE.SUBSCRIPTION_INACTIVE` - THEN response status is 409 AND error envelope includes that code **Verified by:** `tests/integration/full-flow.integration.test.ts::"custom error code surfaces with manifest-mapped status"` --- ### Requirement: Custom metrics declared in manifest emit values The plugin SHALL declare `example.invoices_finalised_total`, `example.stripe.call_duration_ms`, and `example.outstanding_invoice_count`. Each MUST emit values during the integration test, asserted against a real in-memory `MeterProvider`. #### Scenario: counter emits on invoice finalise - WHEN an invoice is finalised - THEN `example.invoices_finalised_total` increments by 1 **Verified by:** `tests/integration/full-flow.integration.test.ts::"custom metrics emit values against MeterProvider"` --- ### Requirement: Multiple secrets read at runtime The plugin SHALL declare ≥3 secrets in `secrets[]` AND read each at runtime via `ctx.secrets`. #### Scenario: every declared secret reads successfully - WHEN the plugin starts - THEN each declared secret returns its configured test value AND no `SECRETS_*_NOT_ALLOWED` error is raised **Verified by:** `tests/integration/full-flow.integration.test.ts::"every declared secret reads successfully"` --- ### Requirement: Schema migrations across two versions with auto-rollback The plugin SHALL ship migrations at `schemaVersion: 1` and `schemaVersion: 2`. The integration test forces the v2 migration to fail and asserts the host auto-rolls back to v1. #### Scenario: v1 → v2 upgrade succeeds on happy path - WHEN a host with v1 reloads to v2 with the v2 migration green - THEN the plugin reaches `started` with v2 schema active #### Scenario: v2 migration failure auto-rolls back - WHEN the v2 migration throws - THEN the host transitions the plugin to `rolled_back` AND the prior known-good v1 module serves traffic AND a structured audit log entry is emitted **Verified by:** `tests/integration/migration-rollback.integration.test.ts::"v1 → v2 upgrade succeeds on happy path"` AND `tests/integration/migration-rollback.integration.test.ts::"v2 migration failure auto-rolls back to v1"` --- ### Requirement: Intentional schema-violation route exercises response validation The plugin SHALL expose `/_/test/fail-response?failResponse=1` returning a body that violates the declared response schema. The integration test runs it in `enforce` (expects 500) and `warn` (expects body + log + counter increment). #### Scenario: enforce mode returns 500 - WHEN `host.responseValidation = "enforce"` AND the route is hit with `?failResponse=1` - THEN response is 500 #### Scenario: warn mode returns body but logs and counts - WHEN `host.responseValidation = "warn"` AND the route is hit with `?failResponse=1` - THEN response carries the (invalid) body AND `api.response_validation.failures_total` increments by 1 **Verified by:** `tests/integration/response-validation.integration.test.ts::"enforce mode returns 500"` AND `tests/integration/response-validation.integration.test.ts::"warn mode returns body but increments counter"`