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:
| Request | Result |
|---|---|
| Same header, same body | Replay stored response |
| Same header, different body | 422 Unprocessable Entity |
| Different header, same body | Independent request |
| Different header, different body | Independent 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 onsentstatusPATCH /api/v1/invoices/:id/status: status transition tosentcreates journal entriesPOST /api/v1/invoices/:id/payment: payment posting writes to the ledger
Deliberately unwrapped:
PUT /api/v1/invoices/:id: draft update, no journal entriesDELETE /api/v1/invoices/:id: draft deletion, no journal entries
Lifecycle Discipline
Every guarded route closes the idempotency lifecycle in every exit path:
- On success:
completeIdempotencyKeystores the response body and transitions status tocompleted - On handled failure (validation, ID errors):
failIdempotencyKeystores the error body and transitions tofailed - On unhandled error:
failIdempotencyKeystores 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
failedas 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
SELECToperations used a text cast fororganisation_idcomparison, 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
| Threat | Status Before | Status After | Enforcement Layer |
|---|---|---|---|
| Duplicate invoice creation from network retry | Open | Closed (Class M) | Runtime: claim module + DB UNIQUE constraint |
| Double payment posting from retry | Open | Closed (Class M) | Runtime: claim module + DB UNIQUE constraint |
| Duplicate journal entries from status transition retry | Open | Closed (Class M) | Runtime: claim module + DB UNIQUE constraint |
| Cross-tenant idempotency key collision | Open | Closed (Class M) | DB: FORCE RLS + UNIQUE (organisation_id, key) |
| Payload substitution via key reuse | Open | Closed (Class M) | Runtime: SHA-256 hash mismatch detection (422) |
| Permanent processing rows from incomplete lifecycle | Open | Closed (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-Keyheader.
Kernel Status
| Milestone | Description | Status |
|---|---|---|
| M1 | Tenant Isolation | Complete |
| M2 | Financial Immutability and State Machines | Complete |
| M3 | Monetary Domain Migration | Complete |
| M4 | Provenance | Active (AX-CON-001, AX-TMP-001, AX-CON-002 delivered) |
| M5 | Schema-Derived Categorical Boundary | Pending |
| M6 | Append-Only Cryptographic Ledger | Pending |
Files Changed
Backend:
api/db/migrations/081-idempotency-preflight.sql: schema pre-flight, status constraint correction, RLS ALL policy WITH CHECK, UUID cast alignmentapi/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.