# Capability Spec: api-2pc **Package**: `@suaveplan/api-2pc` **Status**: SHIPPED (0.1.0) > Every requirement below carries a `**Verified by:**` line at authoring time. ## ADDED Requirements ### Requirement: Two-phase commit driven against Postgres `PREPARE TRANSACTION` The package SHALL expose `run2pc({ participants, timeoutMs? }, ctx)`. Phase 1 sends `prepare` to every participant; each participant runs its work inside a transaction and issues `PREPARE TRANSACTION ''`, returning `vote_commit` or `vote_abort`. Phase 2: if all participants vote commit, the coordinator issues `COMMIT PREPARED` everywhere; if any participant votes abort OR the prepare-phase deadline expires, the coordinator issues `ROLLBACK PREPARED` everywhere. #### Scenario: unanimous commit completes successfully - **WHEN** every participant votes commit - **THEN** the coordinator issues `COMMIT PREPARED` on every participant AND the transaction ends in `committed` #### Scenario: any vote-abort rolls back everywhere - **WHEN** at least one participant votes abort - **THEN** the coordinator issues `ROLLBACK PREPARED` on every participant that returned `vote_commit` AND emits `TWOPC_VOTE_ABORT` **Verified by:** `src/coordinator/coordinator.test.ts::"unanimous commit issues COMMIT PREPARED on every participant"` AND `src/coordinator/coordinator.test.ts::"any vote-abort rolls back everywhere with TWOPC_VOTE_ABORT"` --- ### Requirement: SQLite and SurrealDB cannot participate as writers The participant registry SHALL refuse registration of a writing participant whose bound DB engine does not advertise the `db.preparable` capability. SQLite and SurrealDB plugins MAY register as read-only voters that contribute a `vote_commit`/`vote_abort` based on their own consistency check but MUST NOT be sent prepare/commit SQL. Attempting to bind one as a writer fails at install with `TWOPC_PARTICIPANT_NOT_PREPARABLE`. #### Scenario: SQLite plugin is rejected as a writer - **WHEN** a plugin bound to SQLite declares `twoPhaseCommit.participates: true` AND is registered as a write participant - **THEN** install fails with `TWOPC_PARTICIPANT_NOT_PREPARABLE` #### Scenario: SurrealDB plugin participates as a read-only voter - **WHEN** a SurrealDB-bound plugin is registered with `voterMode: "read-only"` - **THEN** registration succeeds AND the coordinator collects its vote in phase 1 but never sends commit/rollback SQL #### Scenario: read-only voter that attempts a write is refused - **WHEN** a read-only voter's prepare callback returns a write-intent payload - **THEN** the coordinator fails with `TWOPC_READ_ONLY_PARTICIPANT_CANNOT_WRITE` **Verified by:** `src/registry/participant-registry.test.ts::"rejects SQLite writer with TWOPC_PARTICIPANT_NOT_PREPARABLE"` AND `src/registry/participant-registry.test.ts::"accepts SurrealDB plugin as read-only voter"` AND `src/registry/participant-registry.test.ts::"refuses write-intent from read-only voter"` --- ### Requirement: Timeout default and hard ceiling The default `timeoutMs` SHALL be 5000ms. The hard ceiling SHALL be 30000ms — calls supplying `timeoutMs > 30000` MUST be refused at call time with `TWOPC_TIMEOUT_CEILING_EXCEEDED` (NOT silently clamped — the operational warning in §9.2 is that long prepares hold row-level locks; the coordinator surfaces the misuse, not hides it). Exceeding the runtime deadline during phase 1 triggers rollback with `TWOPC_TIMEOUT`. #### Scenario: default timeout applies when none is supplied - **WHEN** `run2pc` is called without `timeoutMs` - **THEN** the effective deadline is 5000ms #### Scenario: timeoutMs above ceiling is refused - **WHEN** `run2pc({ ..., timeoutMs: 30001 })` is called - **THEN** the call rejects with `TWOPC_TIMEOUT_CEILING_EXCEEDED` BEFORE any participant is contacted #### Scenario: deadline expiry during prepare triggers rollback - **WHEN** any participant has not returned a vote before the deadline - **THEN** the coordinator issues `ROLLBACK PREPARED` to every prepared participant AND emits `TWOPC_TIMEOUT` **Verified by:** `src/limits/timeout-ceiling.test.ts::"default timeoutMs is 5000"` AND `src/limits/timeout-ceiling.test.ts::"timeoutMs above 30000 rejects with TWOPC_TIMEOUT_CEILING_EXCEEDED"` AND `src/coordinator/coordinator.test.ts::"deadline expiry during prepare triggers rollback with TWOPC_TIMEOUT"` --- ### Requirement: Outstanding-prepared cap The coordinator SHALL enforce a default cap of 256 outstanding prepared transactions across all in-flight calls. This default is the spec'd implementation choice for design doc §14's open question on coordinator throttling — it is operator-tunable via host config but the default ships at 256. Exceeding the cap SHALL fail new `run2pc` calls with `TWOPC_OUTSTANDING_LIMIT_EXCEEDED` until in-flight transactions complete. #### Scenario: 257th concurrent transaction is refused - **WHEN** 256 transactions are in phase 1 or phase 2 AND a 257th call begins - **THEN** the 257th call rejects with `TWOPC_OUTSTANDING_LIMIT_EXCEEDED` #### Scenario: cap recovers as transactions complete - **WHEN** an in-flight transaction completes AND a new call arrives - **THEN** the new call is admitted **Verified by:** `src/limits/outstanding-cap.test.ts::"257th concurrent transaction is refused"` AND `src/limits/outstanding-cap.test.ts::"slot recovers as transactions complete"` --- ### Requirement: Persisted state and boot-time orphan resolution Every coordinator transition SHALL be persisted to `suaveplan_2pc_transactions` (schema owned by `api-host`). On coordinator boot the recovery runner MUST query every participant's `pg_prepared_xacts` for transactions tagged with the host's instance ID. For each orphan: if the ledger row records `all-voted-commit`, issue `COMMIT PREPARED`; otherwise issue `ROLLBACK PREPARED`. Transactions without a corresponding ledger row MUST be rolled back (safe default — no commit without explicit log). #### Scenario: orphan with "all-voted-commit" ledger row is committed - **WHEN** the coordinator boots AND finds a prepared transaction whose ledger row is `all-voted-commit` - **THEN** `COMMIT PREPARED` is issued AND the row is moved to `committed` #### Scenario: orphan with no ledger row is rolled back - **WHEN** the coordinator boots AND finds a prepared transaction with no matching ledger row - **THEN** `ROLLBACK PREPARED` is issued **Verified by:** `src/recovery/recovery.test.ts::"orphan with all-voted-commit ledger row is committed"` AND `src/recovery/recovery.test.ts::"orphan with no ledger row is rolled back"` --- ### Requirement: OTel instruments expose prepare and commit durations The coordinator SHALL emit, against an injected OTel meter and tracer: - `2pc.transaction` span per transaction lifetime (attributes: `tx.id`, `outcome`, `participant_count`). - `2pc.prepare`, `2pc.commit`, `2pc.rollback` spans per participant phase. - `2pc.transactions_total` counter labelled `{ outcome }`. - `2pc.prepare_duration_ms` histogram labelled `{ outcome }`. - `2pc.commit_duration_ms` histogram labelled `{ outcome }`. - `2pc.outstanding_prepared` observable gauge. Operators rely on `prepare_duration_ms` to spot lock-holding times before they cause throughput regressions. #### Scenario: successful commit emits both prepare and commit histograms - **WHEN** a 2PC run completes successfully - **THEN** an in-memory `MeterProvider` observes one observation in `2pc.prepare_duration_ms` AND one in `2pc.commit_duration_ms` AND the outstanding gauge briefly rises then falls **Verified by:** `src/instruments/instruments.test.ts::"emits prepare_duration_ms and commit_duration_ms on successful commit"` --- ### Requirement: Conformance suite for participants The package SHALL publish `run2pcParticipantConformance(participantFactory)` on the `./testing` subpath, pinning prepare-callback signature, vote semantics, idempotent commit/rollback contract, and the requirement that participants advertise `db.preparable`. #### Scenario: reference participant passes the suite - **WHEN** the suite runs against the in-package reference Postgres participant - **THEN** every invariant passes **Verified by:** `src/coordinator/coordinator.spec-conformance.test.ts::"reference Postgres participant passes run2pcParticipantConformance"`