Files
suaveplan/openspec/specs/api-saga/spec.md
2026-05-27 23:29:53 +01:00

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. (See openspec/AGENTS.md Section 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 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"