8.7 KiB
Capability Spec: api-saga
Package: @suaveplan/api-saga
Status: SHIPPED (Stage 6.1, 2026-05-26)
Every requirement below carries a
**Verified by:**line. (Seeopenspec/AGENTS.mdSection 13.)
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 declaressagas.participatesIn: ["A/X"]AND plugin B exportsactions.aandcompensations.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.aon plugin B AND plugin B does not exportactions.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.completedAND 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
compensationsfor step 2 and then step 1 are invoked in that order AND the instance ends incompensated
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_failedANDSAGA_COMPENSATION_MAX_ATTEMPTS_EXCEEDEDis emitted ANDsaga.instance.compensation_failedfires
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_flightwhen 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.requestedis 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.instancespan per saga lifetime (attributes:saga.name,saga.tenant,saga.outcome).saga.stepspan per step attempt (attributes:saga.name,step.name,attempt,outcome).saga.compensatespan per compensation invocation.saga.instances_totalcounter labelled{ name, outcome }.saga.step_duration_mshistogram labelled{ name, step, outcome }.saga.compensations_totalcounter labelled{ name, step }.saga.in_flightobservable gauge of currently-running instances.
Scenario: full instance emits matching counter + span pair
- WHEN a saga runs to
completed - THEN an in-memory
MeterProviderobserves onesaga.instances_total{outcome="completed"}increment AND onesaga.instancespan 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"