fix: multi-process-safe audit integrity chain via atomic per-org append#35
Conversation
… (multi-pod safe)
The integrity audit chain held `sequence` and `previousHash` as process-local
state, resumed from the durable head only once at boot. Behind multiple pods
sharing one Postgres database, each process advanced its own counter, so two
pods derived the same sequence for the same org — one INSERT won and the other
lost to the UNIQUE (COALESCE(organization_id,''), integrity_sequence) index
(Postgres 23505), silently dropping that governance decision's tamper-evident
record. The per-process previousHash also forked the chain.
Sequence allocation and previous-hash derivation are now atomic against the
database, per org, on every write:
- New optional GovernanceStorage.appendToAuditChain(event, computeIntegrity):
reads the org's durable head, invokes computeIntegrity(head) (signing key
stays in the SDK core), and persists event + integrity as one operation.
- Postgres: transaction with pg_advisory_xact_lock(hashtext(org)) → head SELECT
→ INSERT → COMMIT, serialising each org's chain against itself across all
writers; unrelated orgs proceed in parallel. Pools exposing only query()
fall back to a bounded read-head → insert → retry-on-23505 loop.
- Memory adapter: per-org async lock spanning head-read → compute → write.
- createGovernance() prefers appendToAuditChain; the previous
createAuditEventWithIntegrity + process-local sequence path remains as a
single-writer fallback for third-party adapters.
API surface unchanged for consumers — createGovernance({ integrityAudit })
picks up the safe path automatically. Canonical hash form, per-org scoping,
verify(), export(), and stats() are unchanged.
Tests: two governance instances over one shared storage (two-pod model) fire
N concurrent writes → N rows, contiguous 1..N sequences, standalone-verifiable
chain; Postgres path asserts BEGIN/advisory-lock/SELECT/INSERT/COMMIT shape and
DB-head-derived sequencing; retry fallback recovers from a 23505.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1724a65 to
4c0fca0
Compare
…append path createdAt is stamped before the append lock allocates the sequence, so under concurrent writers a lower sequence can carry a later timestamp (lock-wait inversion, cross-pod clock skew). verifyAuditIntegrity() and export() sorted createdAt-first and could report a valid multi-writer chain as tampered. Both now order by the HMAC-covered sequence (createdAt tiebreaks); forging a sequence still breaks the hash/previous-hash checks. Also on the transactional append path: - a failed ROLLBACK destroys the pooled connection (release(err)) instead of returning a dead/aborted client to the pool - the advisory lock uses the namespaced two-arg form under a fixed classid so other advisory-lock users of the same database can't contend - the chain-head SELECT scopes by COALESCE(organization_id,''), the exact partition of the unique index and lock key, so the head read can never disagree with the uniqueness/lock scope Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
bugbot run |
…ly append fallback Every unique-violation on the fallback path means a competitor committed the derived sequence, so a writer loses at most once per concurrent same-org contender. Raise the cap to 12 (was 8) and desynchronise contenders with jittered backoff so re-reads rarely re-collide; a 10-way contention test completes deterministically under the bound. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 42c4a20. Configure here.
…warn createIntegrityAudit is single-process
The atomic-append fix hardens the durable createGovernance({ integrityAudit })
path but leaves two things undocumented for SDK consumers who aren't the one
large multi-process deployment that surfaced the bug:
- appendToAuditChain is the storage contract that makes the chain
multi-process-safe, but it was documented only in the interface JSDoc. Add a
"Multi-process deployments" section to the README: what the method
guarantees, a per-adapter support table (Postgres = advisory-lock
transaction; memory = single-process by design; third-party = single-writer
fallback), and how a custom adapter should implement it.
- The standalone createIntegrityAudit() wrapper keeps its chain in process
memory and never persists integrity metadata, so it forks across processes
and loses verifiability across restarts — the same unsoundness class, on a
separate entry point. It cannot reuse the storage contract (it holds no
storage handle), so it is documented rather than rewired: a prominent
single-process warning in its JSDoc, the README note, and a regression test
asserting a second wrapper over the same governance starts its own sequence
at 1 instead of resuming the durable head. Consumers needing durable,
multi-process audit are pointed at createGovernance({ integrityAudit }).
Also neutralise process-specific wording (multi-pod -> multi-process,
cross-pod -> cross-process) in the CHANGELOG, and record that DB-backed
stats() is deferred because it would turn a sync method async.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dcaa5f9 to
738b433
Compare
… append fallback Two capability-detection gaps for custom storage adapters that implement a partial subset of the integrity contract (shipped Postgres/memory adapters implement all methods, so they are unaffected): - export()/verify() gated durable integrity reads on the legacy createAuditEventWithIntegrity. An adapter implementing the new appendToAuditChain write contract plus getAuditIntegrity — but not the legacy write method — persisted integrity durably yet exported an empty chain, since the read fell back to the (empty) in-process index. Gate the durable read on getAuditIntegrity alone (storageCanReadIntegrity). - An adapter with durable integrity but no appendToAuditChain used process-local sequence allocation (single-writer-safe only) with no signal, contradicting the README's "falls back safely and warns". Emit a one-time onAuditError advisory; the pure-legacy session-local path keeps its per-write warn. Adds audit-chain-partial-adapter.test.ts covering both subsets (each fails without its fix); full suite 1450 pass, tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit fadddbd. Configure here.
scotty595
left a comment
There was a problem hiding this comment.
Approving — this is a correct, well-tested fix for a real soundness bug in the 0.18.0 per-org chains under multi-process writers (silent 23505 drops + chain fork). Reviewed the load-bearing logic, not just the description:
appendToAuditChaincontract keeps the HMAC key in SDK core via thecomputeIntegritycallback — clean separation.- Postgres advisory-xact-lock +
READ COMMITTEDreasoning is correct (a fixed-snapshot level would re-read a stale head and re-collide); poisoned-connectionrelease(rollbackErr)on ROLLBACK failure is proper hygiene. - Sorting export/verify by HMAC-covered
sequenceinstead ofcreatedAtis a genuine fix — the old createdAt-first sort could flag a valid multi-writer chain as tampered. - Real-Postgres 60-writer before/after (30-dropped → 0-dropped) is the right evidence.
Action items on the consumer side (lua-core, multi-pod — the exact vulnerable case):
- Bump lua-core to 0.18.2 once published; our 0.18.0 chains in staging are currently unsound under concurrency.
- Our pnpm patch re-applies cleanly — this diff doesn't touch
createAgentormastra-processor.js(the only things we patch); just re-point the patch key to 0.18.2 andgit apply --check. - Rolling-deploy: all pods must reach 0.18.2 together; expect transient
23505warnings until the last 0.18.0/0.18.1 pod drains.
Watch (non-blocking): per-org audit writes now serialize through the advisory lock and hold a pooled connection per write; since enforce awaits the chain write with integrityAudit on, worth a load check on the governance pool under high per-org volume.
Problem
The tamper-evident integrity audit (
createGovernance({ integrityAudit })) held the chain'ssequenceandpreviousHashas process-local state, resumed from the durable head only once at boot (loadChainHead, guarded bystate.loaded), then advanced an in-process counter under an in-process lock.Under any multi-process deployment — Kubernetes replicas, a
pm2cluster, or serverless instances all writing to one shared database — that assumption breaks:sequencefor the same org. OneINSERTwins; the other loses to theUNIQUE (COALESCE(organization_id,''), integrity_sequence)index (Postgres23505). With the defaultonFailure: 'allow', that governance decision's tamper-evident record is silently dropped.previousHashlinks to that process's last write, not the true durable head, so the hash chain forks across processes and isn't sound under multi-process regardless of collisions.storage-postgres.tscreateAuditEventWithIntegrity()was a singleINSERTwith the pre-computed sequence — noON CONFLICT, no retry, no head re-read.Fix
Sequence allocation and previous-hash derivation are now atomic against the database, per org, on every write — never from process-local state.
GovernanceStorage.appendToAuditChain(event, computeIntegrity)— the documented storage contract for atomic chain appends. The adapter, under a per-org lock spanning the whole operation, reads the org's durable head, callscomputeIntegrity(head)(the HMAC signing key stays in the SDK core, never in storage), and persists event + integrity as one indivisible write.BEGIN ISOLATION LEVEL READ COMMITTED→pg_advisory_xact_lock(<classid>, hashtext(org))→ headSELECT→INSERT→COMMIT. The advisory lock (released atCOMMIT) serialises each org's chain against itself across all writers, including separate processes; unrelated orgs proceed in parallel.READ COMMITTEDis pinned deliberately: it's what guarantees the post-lock headSELECTsees the prior writer's just-committed row (a fixed-snapshot isolation level would re-read stale and re-collide). The two-arg lock form namespaces the key under a fixed classid so other advisory-lock users of the same database can't contend. A pool exposing onlyquery()(no transaction handle) falls back to a bounded read-head → insert → retry-on-23505loop with jittered backoff, which is also multi-writer-safe.createGovernance()prefersappendToAuditChainwhen present; the previouscreateAuditEventWithIntegrity+ process-local sequence path remains only as a single-writer fallback for third-party adapters that predate this method.Why advisory lock over
ON CONFLICT-retryRetry alone doesn't fix the head-read race or the chain fork: each retry still needs to re-read the durable head and re-hash from it, so a naive retry that reuses process-local
previousHashproduces a forked chain. The advisory lock makes head-read → hash → insert indivisible in one pass. The retry-on-23505path is kept only as the fallback for pools withoutconnect()— there it does re-read the head each attempt, so it's correct; the bound is deterministic, not probabilistic (every23505means a competitor committed the derived sequence, so a writer loses at most once per concurrent same-org contender).Verification order = sequence, not wall clock
createdAtis stamped before the append lock allocates the sequence, so under concurrent writers a lower sequence can carry a later timestamp (lock-wait inversion, cross-process clock skew).verifyAuditIntegrity()andintegrityChain.export()now order by the HMAC-coveredsequence(createdAtonly tiebreaks legacy forked rows) — the oldcreatedAt-first sort could report a valid multi-writer chain as tampered. Tamper-safe: the sequence is inside the signed hash, so forging it still fails the hash / previous-hash checks.API surface unchanged
Existing consumers need no code change beyond upgrading —
createGovernance({ integrityAudit })picks up the safe path automatically once its storage adapter (a realpg.Pool) exposesconnect(). Canonical hash form, per-org scoping,verify(),export(), andstats()behaviour are unchanged.One behavioural note for reviewers: the transactional path now holds a pooled connection across
BEGIN → lock-wait → SELECT → hash → INSERT → COMMIT(the old path was grab/insert/release per statement). A same-instant burst across many orgs would queue on the pool and degrade toconnectionTimeout/statement_timeout→ the sameonAuditErrorpath (not a silent drop).Multi-process guidance + docs (this PR)
The fix is generalised for the SDK's whole audience, not only the deployment that surfaced it:
appendToAuditChainguarantees, a per-adapter support table (Postgres / memory / third-party fallback), and how a custom storage adapter should implement atomic append (row lock, advisory lock, serializable txn, or CAS-retry on its uniqueness constraint).createIntegrityAudit()wrapper (audit-integrity.ts) is the other entry point that keeps its chain in process memory — the same unsoundness class on a separate surface. It holds no storage handle to append against, so it cannot reuse the contract; it is now documented as single-process only (prominent JSDoc + README + a regression test asserting a second wrapper starts its own sequence at 1 rather than resuming the durable head), with consumers pointed atcreateGovernance({ integrityAudit })for durable multi-process audit.integrityChain.stats()still reports this process's last-written sequence/hash (a process-local cache);export()+verifyAuditIntegrity()are the authoritative durable readers. A DB-backedstats()is deferred — it would turn a sync method async (a breaking signature change) — and noted in the CHANGELOG.Tests
npm test— 1448 pass,tsc --noEmitclean.audit-chain-multiwriter.test.ts— the load-bearing regression test: twocreateGovernanceinstances sharing one storage (two-writer model) fire N concurrent interleaved writes → exactly N rows, contiguous 1..N sequences (no duplicates/gaps), and the interleaved chain verifies standalone. Also covers two writers × two orgs, and a timestamp-inverted-vs-sequence chain (fails on the oldcreatedAtsort).audit-chain-append-postgres.test.ts— asserts the Postgres SQL shape (BEGIN ISOLATION→ advisory-lock → headSELECT→INSERT→COMMIT), DB-head-derived sequencing across sequential appends, an orchestration run of two instances over one pool, retry-fallback recovery from a23505, and dead-connection release onROLLBACKfailure.audit-integrity.test.ts— new: documents thecreateIntegrityAudit()single-process boundary.Verified against a real Postgres (not just the mock)
The committed pg test models
pg_advisory_xact_lockas an in-process mutex (the repo harness has no Postgres), so it validates orchestration + SQL shape but can't prove real cross-connection locking. The two-writer scenario was additionally run out-of-band against an ephemeral real Postgres 14 with two independentpg.Pools (realconnect(), real advisory lock, real unique index), 60 concurrent writes to one org:23505)The "before" row reproduces the failure mode; the "after" row demonstrates the fix on real cross-connection locks. (Throwaway harness — not committed.)
Rollout note
The advisory lock only protects writers that take it. During a mixed-version rolling deploy, pre-
0.18.2processes still allocate from their process-local counters and can collide with or fork past locked writers. Replace all writers together; expect residual23505warnings until the last old process drains.Compatibility
Backward compatible.
appendToAuditChainis an optional interface method — adapters that omit it fall back to the existing single-writer path. Canonical hash form and per-org scoping are unchanged, so existing chains keep verifying. Versioned as a patch (0.18.2) per the repo's convention (fix + optional interface addition).🤖 Generated with Claude Code
Note
High Risk
Changes audit integrity under concurrent production writers (silent event loss / chain fork before); rolling deploys need all writers on 0.18.2+ or collisions can persist during mixed versions.
Overview
Release 0.18.2 fixes tamper-evident audit (
integrityAudit) when several processes share one store: process-local sequence/previousHashused to collide on the per-org unique index (silent drops) and fork the HMAC chain.New storage contract
appendToAuditChain— adapters read the durable head under a per-org lock, call back into the SDK for HMAC metadata, and insert atomically.createGovernance()uses it when present; legacycreateAuditEventWithIntegrity+ in-process counters remain for single-writer adapters, with a one-timeonAuditErrorwarning if durable integrity exists without atomic append.Postgres implements transactional append (
READ COMMITTED,pg_advisory_xact_lock, headSELECTscoped withCOALESCE(organization_id,'')) plus a query-only pool fallback (bounded23505retries). Memory gets per-org async locks. Export/verify now sort by integritysequence(notcreatedAtfirst), read integrity whenevergetAuditIntegrityexists, and document thatcreateIntegrityAudit()stays in-process only.README adds a Multi-process deployments section; extensive regression tests cover multi-writer chains, partial adapters, and Postgres orchestration.
Reviewed by Cursor Bugbot for commit fadddbd. Bugbot is set up for automated code reviews on this repo. Configure here.