v5.21.0 7 March 2026 Improvement

Kernel Milestone M4: Idempotency Contract (AX-CON-002)

Kernel Milestone: M4 of 6, third delivery Previous: M4 Phase B: Temporal Determinism Standard (v5.20.0) Next: M4 Phase D: Schema-Derived Runtime Validation (AX-BND-001)

System Impact
-------------
Ledger integrity:         strengthened
Ledger mutation:          duplicate financial mutations now blocked at runtime
Tenant isolation:         unchanged
Financial immutability:   unchanged
State machine:            unchanged
Breaking changes:         none
Replay determinism:       preserved
Invariant Change
----------------
Financial mutation endpoints are now idempotency-safe: a client-supplied
Idempotency-Key header activates a single-winner claim pattern that detects
and replays duplicate requests without writing to the ledger a second time.

Why This Matters

A distributed system has no reliable way to distinguish a retry from a new request. A network timeout after a successful write leaves the client uncertain: did the invoice get created? Was the payment posted? Without an idempotency contract, the safe answer is to retry, and the safe assumption is that the retry will double-write.

The consequence is concrete. A client retrying invoice creation after a timeout gets two invoices with two invoice numbers and two sets of journal entries. A payment retry posts twice against the same balance. Both scenarios corrupt the ledger silently, in ways that do not trigger the double-entry conservation trigger and may not be noticed until reconciliation.

AX-CON-002 closes this threat class entirely for the three mutation routes where journal entries are written. The idempotency module is the runtime enforcement layer that converts the existing DB-level claim table into a live, typed, lifecycle-managed invariant.


Proof Anchors

Database layer (migration 081):
  UNIQUE (organisation_id, idempotency_key) — single-winner claim enforced at DB level
  FORCE RLS + 5 tenant isolation policies — claim table is tenant-scoped
  status CHECK constraint {processing, completed, failed} — canonical terminal states
  ALL policy WITH CHECK — prevents cross-tenant claim injection
  expires_at DEFAULT now() + interval '24 hours' — DB-enforced expiry

Runtime layer (idempotency module):
  hashRequestBody() — SHA-256 of serialised request body
  claimIdempotencyKey() — INSERT ON CONFLICT DO NOTHING + SELECT claim race resolution
  resolveIdempotencyConflict() — 409 (processing), 422 (hash mismatch), replay (completed/failed)
  completeIdempotencyKey() / failIdempotencyKey() — WHERE status='processing' guard on all writes

Runtime layer (route integration):
  guardIdempotency() — opt-in activation via Idempotency-Key header presence
  executeIdempotent() — owns claim lifecycle; handler returns plain body for storage and send
  Three mutation routes guarded: invoice create, status transition, payment post

Idempotency Contract

Key Design

The idempotency key stored in the claim table is {organisation_id}:{Idempotency-Key header}. The client supplies the retry identity via the Idempotency-Key header; the server scopes it to the tenant automatically. No key can cross a tenant boundary.

The request body hash (SHA-256) is stored separately as a payload validator, not as part of the key. This preserves the correct Stripe-style semantics:

RequestResult
Same header, same bodyReplay stored response
Same header, different body422 Unprocessable Entity
Different header, same bodyIndependent request
Different header, different bodyIndependent request

Opt-In for v1

The idempotency header is optional at launch. Routes check for its presence: if the header is absent, the module is bypassed entirely and behaviour is unchanged for existing clients. This avoids breaking browsers, direct API calls, and early integrations. Mandatory enforcement is a post-launch upgrade path once SDK adoption is established.

Route Scoping

Idempotency protection applies only to routes that write journal entries. Routes that are naturally idempotent (updating a draft, deleting a draft) are deliberately unwrapped, keeping the claim table focused on financial invariants rather than general CRUD traffic.

Guarded routes:

  • POST /api/v1/invoices: invoice creation writes double-entry journal entries on sent status
  • PATCH /api/v1/invoices/:id/status: status transition to sent creates journal entries
  • POST /api/v1/invoices/:id/payment: payment posting writes to the ledger

Deliberately unwrapped:

  • PUT /api/v1/invoices/:id: draft update, no journal entries
  • DELETE /api/v1/invoices/:id: draft deletion, no journal entries

Lifecycle Discipline

Every guarded route closes the idempotency lifecycle in every exit path:

  • On success: completeIdempotencyKey stores the response body and transitions status to completed
  • On handled failure (validation, ID errors): failIdempotencyKey stores the error body and transitions to failed
  • On unhandled error: failIdempotencyKey stores a masked 500 before re-throwing

The WHERE status='processing' guard on both completion functions prevents races from writing terminal state twice. Permanent processing rows, the most common idempotency implementation bug, cannot occur.


Schema Pre-flight

Migration 081 corrected two pre-existing issues in the idempotency_keys table before the runtime module was activated:

  • Conflicting status constraints. Two CHECK constraints existed with overlapping but inconsistent allowed values: one permitted failed as a terminal state, the other did not. The non-canonical constraint was dropped. {processing, completed, failed} is now the sole status definition.
  • RLS ALL policy type mismatch. The tenant isolation policy for SELECT operations used a text cast for organisation_id comparison, while the four specific operation policies used the correct UUID cast. The ALL policy was replaced with a UUID-cast version including a WITH CHECK clause, bringing it into alignment.

Both issues were latent: the table was not in active use before this release. Migration 081 corrected them before any production claims were written.


Threat Closure

ThreatStatus BeforeStatus AfterEnforcement Layer
Duplicate invoice creation from network retryOpenClosed (Class M)Runtime: claim module + DB UNIQUE constraint
Double payment posting from retryOpenClosed (Class M)Runtime: claim module + DB UNIQUE constraint
Duplicate journal entries from status transition retryOpenClosed (Class M)Runtime: claim module + DB UNIQUE constraint
Cross-tenant idempotency key collisionOpenClosed (Class M)DB: FORCE RLS + UNIQUE (organisation_id, key)
Payload substitution via key reuseOpenClosed (Class M)Runtime: SHA-256 hash mismatch detection (422)
Permanent processing rows from incomplete lifecycleOpenClosed (Class M)Runtime: WHERE status=‘processing’ guard on all terminal writes

Kernel Closure Statement

Axiom: AX-CON-002 — Idempotency Contract

Status: CLOSED as of SpeyBooks v5.21.0

Enforcement layers verified:
  Database layer    — UNIQUE (organisation_id, idempotency_key), FORCE RLS,
                      status CHECK constraint, expires_at 24h default
                      [migration 081-idempotency-preflight.sql]
  Runtime layer     — claim/resolve/complete/fail lifecycle module,
                      guardIdempotency and executeIdempotent route helpers,
                      three financial mutation routes guarded

CI verification:
  pnpm build                    — PASS (clean compile)
  constraint-coverage-check.sh  — PASS (74/74 constraints mapped)
  axiom-coverage-check          — PASS (18/18 axioms, registry v1.8)
  verify_081()                  — PASS (7/7 schema checks)

Residual risk:
  Routes outside the invoice domain (dividends, bank import confirm,
  opening balance commit) are not yet guarded. These are post-launch
  coverage extensions. The idempotency module is designed for drop-in
  adoption: guardIdempotency and executeIdempotent require no modification
  to apply to additional routes.

Next dependent axiom:
  AX-BND-001 — Schema-Derived Runtime Validation

Security Posture Change

The idempotency claim table existed since the initial tenant hardening milestone but was unused. This release activates it as a live enforcement surface. All claim table operations are tenant-scoped at the database level via FORCE RLS, so the idempotency system inherits the same isolation guarantees as the rest of the financial kernel. A claim key from one tenant cannot interfere with, observe, or replay against another tenant’s claim space.


Verification Record

Pre-flight (migration 081):
  7/7 schema validation checks clean
  Conflicting status constraint identified and removed
  RLS ALL policy type mismatch identified and corrected

Build:
  pnpm build — clean (0 TypeScript errors)

Post-deploy CI:
  constraint-coverage-check.sh  — 74/74 constraints mapped
  axiom-coverage-check           — 18/18 axioms (registry v1.8)
  verify_081()                   — 7/7 PASS

Adversarial review:
  10 / 10 Production Gold
  Key construction corrected: header value included in stored key
  Optional chain corrected: request.apiKeyId (not request.user.apiKeyId)
  P1 resolved: executeIdempotent owns reply.send, handler returns plain body

Architectural Context

AX-CON-001 (v5.19.0) established that financial document numbers are sequential and collision-free. AX-TMP-001 (v5.20.0) established that all temporal values are deterministic and DB-authoritative. AX-CON-002 now establishes that financial mutations are retry-safe.

Together these three axioms close the provenance domain: every financial event has a unique identifier, a deterministic timestamp, and a single-winner creation guarantee. The ledger cannot contain duplicate entries, ambiguous times, or collision-prone identifiers.

The remaining M4 axioms (AX-BND-001, AX-QRY-001) address the read boundary and ordering determinism, which are prerequisites for the cryptographic ledger in M6.


Operational Impact

  • Duplicate invoice creation from a network retry now returns the original response rather than creating a second invoice.
  • Double payment posting is blocked: a retry returns the recorded payment result, not a second ledger entry.
  • The idempotency module is composable: applying it to additional mutation routes requires adding two lines to a route handler.
  • Zero behaviour change for clients not sending the Idempotency-Key header.

Kernel Status

MilestoneDescriptionStatus
M1Tenant IsolationComplete
M2Financial Immutability and State MachinesComplete
M3Monetary Domain MigrationComplete
M4ProvenanceActive (AX-CON-001, AX-TMP-001, AX-CON-002 delivered)
M5Schema-Derived Categorical BoundaryPending
M6Append-Only Cryptographic LedgerPending

Files Changed

Backend:

  • api/db/migrations/081-idempotency-preflight.sql: schema pre-flight, status constraint correction, RLS ALL policy WITH CHECK, UUID cast alignment
  • api/src/lib/idempotency.ts: idempotency runtime module (claim, resolve, complete, fail lifecycle)
  • api/src/routes/invoices.ts: route integration, guardIdempotency and executeIdempotent helpers, three mutation routes guarded. AX-TMP-001 cleanup: normaliseDate applied, local date helper removed.

Registry:

  • docs/kernel/axioms.yml: v1.8, AX-CON-002 delivered, M4 delivers updated, post-launch list corrected

The idempotency contract completes the provenance core of the financial kernel. The remaining M4 work addresses how the kernel reads and orders data before M5 closes the categorical boundary.