# Capability Spec Delta: api-saga (api-saga) **Package**: `@suaveplan/api-saga` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.) ## ADDED Requirements ### Requirement: Saga ownership versus participation The package SHALL distinguish saga *owners* (plugins whose manifest declares `sagas.owns[]`) from saga *participants* (plugins whose manifest declares `sagas.participatesIn[]` AND export named `actions` / `compensations`). At plugin install, the orchestrator MUST validate that for every owned saga's declared steps the named target plugin exists, exports the named action, and — where the step declares a `compensate` — exports the named compensation. Two plugins declaring `owns` of the same saga name MUST cause install to fail with `SAGA_DUPLICATE_OWNER`. #### Scenario: owner registers a saga its participants can join - **WHEN** plugin A's manifest declares `sagas.owns: [{ name: "X", steps: [{ plugin: "B", action: "a", compensate: "c" }] }]` AND plugin B's manifest declares `sagas.participatesIn: ["A/X"]` AND plugin B exports `actions.a` and `compensations.c` - **THEN** the participant registry validates successfully AND `orchestrator.start("X", input, ctx)` is callable #### Scenario: duplicate owner is rejected at install - **WHEN** plugin A and plugin C both declare `owns: [{ name: "X", ... }]` - **THEN** install fails with `SAGA_DUPLICATE_OWNER` #### Scenario: missing participant action is rejected at install - **WHEN** the saga step names `actions.a` on plugin B AND plugin B does not export `actions.a` - **THEN** install fails with `SAGA_UNKNOWN_PARTICIPANT` **Verified by:** `src/registry/participant-registry.test.ts::"validates owner + participant wiring"` AND `src/registry/participant-registry.test.ts::"rejects duplicate saga owner with SAGA_DUPLICATE_OWNER"` AND `src/registry/participant-registry.test.ts::"rejects missing participant action with SAGA_UNKNOWN_PARTICIPANT"` --- ### Requirement: Step dispatch via event bus with idempotent execution The orchestrator SHALL drive steps in declared order by emitting `saga.step.requested` on the host event bus with `{sagaId, stepName, input}`. The target plugin's `actions[stepName]` handler runs under a `RequestContext` whose `principal` is the saga initiator and whose `tenantId` is the saga's target tenant. Every step invocation MUST be wrapped in `@suaveplan/idempotency` keyed by `{sagaId, stepName}` unless the action declares `idempotency: "manual"`. #### Scenario: step completion advances to next step - **WHEN** an action handler returns success-with-output for step N - **THEN** the orchestrator emits `saga.step.completed` AND immediately requests step N+1 #### Scenario: retried step does not double-fire - **WHEN** the orchestrator retries a step whose handler previously committed under the same `{sagaId, stepName}` key - **THEN** the idempotency wrapper returns the cached outcome AND the handler is NOT re-entered #### Scenario: manual idempotency opts out of the wrapper - **WHEN** the action declares `idempotency: "manual"` - **THEN** the handler is invoked without the idempotency wrapper AND is solely responsible for its own dedup **Verified by:** `src/orchestrator/orchestrator.test.ts::"step completion advances to next step"` AND `src/idempotency/idempotency.test.ts::"retried step returns cached outcome without re-entry"` AND `src/idempotency/idempotency.test.ts::"manual opt-out bypasses idempotency wrapper"` --- ### Requirement: Reverse-order compensation on step failure On any step failure (action returns failure-with-cause OR exceeds `timeoutMs`), the orchestrator SHALL run `compensate` handlers in reverse order over completed steps. Compensation handlers MUST be idempotent + retryable up to `maxCompensationAttempts` (default 5). Successful compensation moves the instance to `compensated`; unrecoverable compensation failure moves to `compensation_failed` AND emits `saga.instance.compensation_failed`. #### Scenario: failure on step 3 compensates steps 2 and 1 in that order - **WHEN** a 3-step saga fails on step 3 AND every compensate succeeds - **THEN** `compensations` for step 2 and then step 1 are invoked in that order AND the instance ends in `compensated` #### Scenario: compensation max-attempts exceeded yields terminal failure - **WHEN** a compensate handler fails on every attempt up to `maxCompensationAttempts` - **THEN** the instance ends in `compensation_failed` AND `SAGA_COMPENSATION_MAX_ATTEMPTS_EXCEEDED` is emitted AND `saga.instance.compensation_failed` fires **Verified by:** `src/compensation/compensation.test.ts::"failure on step 3 compensates steps 2 and 1 in reverse order"` AND `src/compensation/compensation.test.ts::"compensation max-attempts exceeded yields compensation_failed"` --- ### Requirement: Persisted state with replay after host restart Every saga instance and every step transition SHALL be persisted to the host metadata DB (`suaveplan_saga_instances` and `suaveplan_saga_steps`, schema owned by `api-host`). On orchestrator boot the recovery runner MUST query incomplete instances and resume each — completing in-flight actions whose results have not yet been recorded, or re-emitting `saga.step.requested` where the request was never observed. #### Scenario: orchestrator boot resumes an in-flight instance - **WHEN** a saga's step 2 was `in_flight` when the previous orchestrator died AND a new orchestrator boots against the same DB - **THEN** the recovery runner observes the persisted state AND resumes the saga from step 2 (re-emitting `saga.step.requested` is acceptable because the action handler is idempotent) #### Scenario: completed instance is not re-replayed - **WHEN** boot recovery scans the DB - **THEN** instances in terminal states (`completed`, `compensated`, `compensation_failed`, `cancelled`) are NOT re-replayed **Verified by:** `src/persistence/persistence.test.ts::"orchestrator boot resumes in-flight instance from persisted state"` AND `src/persistence/persistence.test.ts::"terminal-state instances are not re-replayed on boot"` --- ### Requirement: Cross-tenant capability gate A saga step whose target tenant differs from the saga's initiator tenant SHALL require the saga owner's manifest to declare the `saga.cross-tenant` capability. Step dispatch without this capability MUST fail with `SAGA_CROSS_TENANT_DENIED`. #### Scenario: same-tenant step proceeds without the capability - **WHEN** a saga's target tenant equals the initiator tenant AND the owner does NOT declare `saga.cross-tenant` - **THEN** step dispatch proceeds normally #### Scenario: cross-tenant step without capability is refused - **WHEN** a saga's target tenant differs from the initiator AND the owner does NOT declare `saga.cross-tenant` - **THEN** dispatch fails with `SAGA_CROSS_TENANT_DENIED` **Verified by:** `src/tenant/cross-tenant.test.ts::"same-tenant saga proceeds without capability"` AND `src/tenant/cross-tenant.test.ts::"cross-tenant step refused without saga.cross-tenant"` --- ### Requirement: OTel instruments cover instance and step lifecycle The orchestrator SHALL emit, against an injected OTel meter and tracer: - `saga.instance` span per saga lifetime (attributes: `saga.name`, `saga.tenant`, `saga.outcome`). - `saga.step` span per step attempt (attributes: `saga.name`, `step.name`, `attempt`, `outcome`). - `saga.compensate` span per compensation invocation. - `saga.instances_total` counter labelled `{ name, outcome }`. - `saga.step_duration_ms` histogram labelled `{ name, step, outcome }`. - `saga.compensations_total` counter labelled `{ name, step }`. - `saga.in_flight` observable gauge of currently-running instances. #### Scenario: full instance emits matching counter + span pair - **WHEN** a saga runs to `completed` - **THEN** an in-memory `MeterProvider` observes one `saga.instances_total{outcome="completed"}` increment AND one `saga.instance` span for the run **Verified by:** `src/instruments/instruments.test.ts::"emits instance span and counter on completed saga"` AND `src/instruments/instruments.test.ts::"emits step_duration_ms histogram per step"` --- ### Requirement: Conformance suite published for participants The package SHALL publish `runSagaParticipantConformance(participantFactory)` on the `./testing` subpath. The suite MUST pin: handler signature shape, idempotency-key contract, compensation-handler signature, error-mapping into `saga.step.failed`, and the `RequestContext` field set visible inside a step handler. #### Scenario: a reference participant passes the suite - **WHEN** the suite runs against the in-package reference participant - **THEN** every invariant passes **Verified by:** `src/orchestrator/orchestrator.spec-conformance.test.ts::"reference participant passes runSagaParticipantConformance"`