Initial Commit
This commit is contained in:
142
openspec/specs/api-saga/spec.md
Normal file
142
openspec/specs/api-saga/spec.md
Normal file
@@ -0,0 +1,142 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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"`
|
||||
Reference in New Issue
Block a user