Skip to content

Commit 4f5cb80

Browse files
fix: hash chain concurrency protection (v1.5.0)
Prevents race conditions where concurrent seal_and_store() calls could fork the hash chain by claiming the same sequence number. Defense in depth: - UNIQUE(sequence) constraint on SQLite CapsuleModel - UNIQUE(tenant_id, sequence) on PostgreSQL CapsuleModelPG - Partial unique index for global chain (tenant_id IS NULL) on PG only - Optimistic retry in seal_and_store() (3 attempts, then ChainConflictError) - _is_integrity_error() detects violations across wrapper layers New: ChainConflictError exception, 36 concurrency tests. No public API changes. Backwards compatible.
1 parent 1a8cb94 commit 4f5cb80

11 files changed

Lines changed: 634 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,32 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1111

1212
---
1313

14+
## [1.5.0] - 2026-03-15
15+
16+
Hash chain concurrency protection. Prevents race conditions where concurrent writes could fork the chain.
17+
18+
### Added
19+
20+
- **Optimistic retry in `seal_and_store()`** -- if a concurrent writer claims the same sequence number, the UNIQUE constraint rejects the duplicate and the method retries with the updated chain head. Up to 3 retries before raising `ChainConflictError`. Seal fields (hash, signature, metadata) are properly reset between attempts.
21+
- **`ChainConflictError` exception** -- raised when `seal_and_store()` exhausts all retries due to sustained concurrent writes for the same tenant chain. Subclass of `ChainError`; includes tenant ID in the message.
22+
- **`UNIQUE` constraint on sequence (SQLite)** -- `CapsuleModel` now enforces `UNIQUE(sequence)`, preventing duplicate sequence numbers at the database level. Defense-in-depth: even if application logic fails, the database rejects duplicates.
23+
- **`UNIQUE` constraint on tenant + sequence (PostgreSQL)** -- `CapsuleModelPG` now enforces `UNIQUE(tenant_id, sequence)`, scoped per tenant. Two tenants can independently have sequence 0, but the same tenant cannot have two capsules at the same sequence.
24+
- **Global chain protection (PostgreSQL)** -- a DDL event creates `CREATE UNIQUE INDEX ... WHERE tenant_id IS NULL` exclusively on PostgreSQL, preventing duplicate sequences in the global chain (tenant_id=NULL). Fires via `execute_if(dialect="postgresql")`; does not affect SQLite.
25+
- **`_is_integrity_error()` helper** -- detects `IntegrityError` and `UniqueViolationError` exceptions even when wrapped in `StorageError.__cause__` chains. Supports SQLAlchemy, asyncpg, and aiosqlite error types.
26+
- **36 new concurrency tests** (`test_chain_concurrency.py`) -- exception hierarchy (3), integrity error detection (6), retry behavior with mocks (7), UNIQUE constraint enforcement (2), end-to-end integration (6), model constraint verification (3), retry invariants including capsule identity preservation and warning emission (4), exception hierarchy completeness (3), CLI `_get_version()` (2).
27+
28+
### Security
29+
30+
- **TOCTOU race condition fixed** -- the `add()``seal()``store()` sequence previously had a time-of-check-time-of-use vulnerability where two concurrent writers could both read the same chain head and both store capsules with the same sequence number, silently forking the hash chain. The UNIQUE constraint converts this silent corruption into a detectable conflict, and the retry loop resolves it automatically.
31+
- **Defense in depth** -- the fix operates at two layers: database constraints (cannot be bypassed by application bugs) and application-level retry (handles the race transparently). Non-integrity errors (disk failures, network issues) propagate immediately without retry.
32+
33+
### Migration
34+
35+
- **New installations**: constraints are created automatically by `create_all()`.
36+
- **Existing databases**: run `ALTER TABLE capsules ADD CONSTRAINT uq_capsule_sequence UNIQUE (sequence)` (SQLite) or `ALTER TABLE quantumpipes_capsules ADD CONSTRAINT uq_capsule_tenant_sequence UNIQUE (tenant_id, sequence)` (PostgreSQL). If the table already contains duplicate sequences from a prior race condition, resolve duplicates before adding the constraint.
37+
38+
---
39+
1440
## [1.4.0] - 2026-03-15
1541

1642
Ecosystem expansion: Go verifier, LiteLLM integration, and negative conformance vectors.

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,34 @@ Capsule #0 Capsule #1 Capsule #2
9696

9797
No consensus mechanism. No distributed ledger. SHA3-256 hashes linking one record to the next.
9898

99+
### Concurrency Protection (v1.5.0+)
100+
101+
Concurrent writes to the same chain are handled automatically by `seal_and_store()`:
102+
103+
1. **UNIQUE constraint** — the database rejects duplicate sequence numbers (`UNIQUE(sequence)` on SQLite, `UNIQUE(tenant_id, sequence)` on PostgreSQL)
104+
2. **Optimistic retry** — on conflict, `seal_and_store()` re-reads the chain head, recomputes the sequence, re-seals, and retries (up to 3 attempts)
105+
3. **`ChainConflictError`** — raised if all retries are exhausted under extreme contention
106+
107+
```python
108+
from qp_capsule import Capsule, CapsuleChain, CapsuleStorage, Seal
109+
from qp_capsule.exceptions import ChainConflictError
110+
111+
storage = CapsuleStorage()
112+
chain = CapsuleChain(storage)
113+
seal = Seal()
114+
115+
# Safe for concurrent use — retries automatically on sequence conflict
116+
capsule = await chain.seal_and_store(
117+
Capsule(trigger=TriggerSection(source="agent", request="deploy")),
118+
seal=seal,
119+
tenant_id="org-123",
120+
)
121+
122+
# Multi-tenant: each tenant has an independent chain
123+
await chain.seal_and_store(capsule_a, seal=seal, tenant_id="tenant-a")
124+
await chain.seal_and_store(capsule_b, seal=seal, tenant_id="tenant-b")
125+
```
126+
99127
---
100128

101129
## Cryptographic Seal

reference/python/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "qp-capsule"
7-
version = "1.4.0"
7+
version = "1.5.0"
88
description = "Capsule Protocol Specification (CPS) — tamper-evident audit records for AI operations. Create, seal, verify, and chain Capsules in Python."
99
readme = "README.md"
1010
license = "Apache-2.0"

reference/python/src/qp_capsule/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
Spec: https://github.com/quantumpipes/capsule
3737
"""
3838

39-
__version__ = "1.4.0"
39+
__version__ = "1.5.0"
4040
__author__ = "Quantum Pipes Technologies, LLC"
4141
__license__ = "Apache-2.0"
4242

@@ -56,6 +56,7 @@
5656
)
5757
from qp_capsule.exceptions import (
5858
CapsuleError,
59+
ChainConflictError,
5960
ChainError,
6061
KeyringError,
6162
SealError,
@@ -109,6 +110,7 @@
109110
"CapsuleError",
110111
"SealError",
111112
"ChainError",
113+
"ChainConflictError",
112114
"StorageError",
113115
"KeyringError",
114116
# High-Level API

reference/python/src/qp_capsule/chain.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,31 @@
1616
- Integrity verification: Anyone can verify the chain
1717
1818
The chain is the memory of the system made immutable.
19+
20+
Concurrency:
21+
seal_and_store() uses optimistic retry — if a concurrent writer claims
22+
the same sequence number, the UNIQUE constraint rejects the duplicate
23+
and the method retries with the updated chain head.
1924
"""
2025

2126
from __future__ import annotations
2227

28+
import logging
2329
from dataclasses import dataclass
2430
from typing import TYPE_CHECKING
2531

32+
from qp_capsule.exceptions import ChainConflictError
2633
from qp_capsule.seal import compute_hash
2734

2835
if TYPE_CHECKING:
2936
from qp_capsule.capsule import Capsule
3037
from qp_capsule.protocol import CapsuleStorageProtocol
3138
from qp_capsule.seal import Seal
3239

40+
_log = logging.getLogger(__name__)
41+
42+
_MAX_CHAIN_RETRIES = 3
43+
3344

3445
@dataclass
3546
class ChainVerificationResult:
@@ -238,10 +249,57 @@ async def seal_and_store(
238249
seal: Seal | None = None,
239250
tenant_id: str | None = None,
240251
) -> Capsule:
241-
"""Chain, seal, and store a capsule in one call."""
252+
"""
253+
Chain, seal, and store a Capsule in one call.
254+
255+
Uses optimistic retry to handle concurrent writes: if another writer
256+
claims the same sequence number, the UNIQUE constraint on the storage
257+
backend rejects the duplicate and this method retries with the updated
258+
chain head. Retries up to ``_MAX_CHAIN_RETRIES`` times.
259+
260+
Raises:
261+
ChainConflictError: If all retries are exhausted.
262+
"""
242263
from qp_capsule.seal import Seal as SealCls
243264

244-
capsule = await self.add(capsule, tenant_id=tenant_id)
245265
seal_instance = seal or SealCls()
246-
capsule = seal_instance.seal(capsule)
247-
return await self.storage.store(capsule, tenant_id=tenant_id)
266+
267+
for attempt in range(_MAX_CHAIN_RETRIES):
268+
capsule = await self.add(capsule, tenant_id=tenant_id)
269+
capsule = seal_instance.seal(capsule)
270+
271+
try:
272+
return await self.storage.store(capsule, tenant_id=tenant_id)
273+
except Exception as exc:
274+
if not _is_integrity_error(exc):
275+
raise
276+
277+
_log.warning(
278+
"Chain sequence conflict (attempt %d/%d, seq=%d, tenant=%s), retrying",
279+
attempt + 1,
280+
_MAX_CHAIN_RETRIES,
281+
capsule.sequence,
282+
tenant_id,
283+
)
284+
capsule.hash = ""
285+
capsule.signature = ""
286+
capsule.signature_pq = ""
287+
capsule.signed_at = None
288+
capsule.signed_by = ""
289+
290+
raise ChainConflictError(
291+
f"Failed to add Capsule to chain after {_MAX_CHAIN_RETRIES} retries "
292+
f"(concurrent writes for tenant={tenant_id!r})"
293+
)
294+
295+
296+
def _is_integrity_error(exc: BaseException) -> bool:
297+
"""Check if an exception is a database integrity/unique-constraint violation."""
298+
from qp_capsule.exceptions import StorageError
299+
300+
name = type(exc).__name__
301+
if name in ("IntegrityError", "UniqueViolationError"):
302+
return True
303+
if isinstance(exc, StorageError) and exc.__cause__ is not None:
304+
return _is_integrity_error(exc.__cause__)
305+
return False

reference/python/src/qp_capsule/cli.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@
4141
_NO_COLOR = False
4242

4343

44+
def _get_version() -> str:
45+
from qp_capsule import __version__
46+
return __version__
47+
48+
4449
# ---------------------------------------------------------------------------
4550
# ANSI helpers (stdlib only -- no rich, no click)
4651
# ---------------------------------------------------------------------------
@@ -563,7 +568,7 @@ def _build_parser() -> argparse.ArgumentParser:
563568
)
564569
parser.add_argument(
565570
"--version", action="version",
566-
version=f"capsule {__import__('qp_capsule').__version__}",
571+
version=f"capsule {_get_version()}",
567572
)
568573
sub = parser.add_subparsers(dest="command")
569574

reference/python/src/qp_capsule/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ class ChainError(CapsuleError):
2121
"""Hash chain integrity error."""
2222

2323

24+
class ChainConflictError(ChainError):
25+
"""Concurrent write detected — sequence conflict after max retries."""
26+
27+
2428
class StorageError(CapsuleError):
2529
"""Capsule storage operation failed."""
2630

reference/python/src/qp_capsule/storage.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from typing import Any
2626
from uuid import UUID
2727

28-
from sqlalchemy import Integer, String, Text, desc, func, select
28+
from sqlalchemy import Integer, String, Text, UniqueConstraint, desc, func, select
2929
from sqlalchemy.ext.asyncio import (
3030
AsyncEngine,
3131
AsyncSession,
@@ -53,6 +53,9 @@ class CapsuleModel(Base):
5353
"""
5454

5555
__tablename__ = "capsules"
56+
__table_args__ = (
57+
UniqueConstraint("sequence", name="uq_capsule_sequence"),
58+
)
5659

5760
id: Mapped[str] = mapped_column(String(36), primary_key=True)
5861
type: Mapped[str] = mapped_column(String(20))

reference/python/src/qp_capsule/storage_pg.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from datetime import datetime
2828
from uuid import UUID
2929

30-
from sqlalchemy import Integer, String, Text, desc, select
30+
from sqlalchemy import DDL, Integer, String, Text, UniqueConstraint, desc, event, select
3131
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
3232
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
3333

@@ -49,6 +49,9 @@ class CapsuleModelPG(PGBase):
4949
"""
5050

5151
__tablename__ = "quantumpipes_capsules"
52+
__table_args__ = (
53+
UniqueConstraint("tenant_id", "sequence", name="uq_capsule_tenant_sequence"),
54+
)
5255

5356
id: Mapped[str] = mapped_column(String(36), primary_key=True)
5457
type: Mapped[str] = mapped_column(String(20))
@@ -65,6 +68,16 @@ class CapsuleModelPG(PGBase):
6568
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
6669

6770

71+
event.listen(
72+
CapsuleModelPG.__table__,
73+
"after_create",
74+
DDL(
75+
"CREATE UNIQUE INDEX IF NOT EXISTS uq_capsule_global_sequence "
76+
"ON quantumpipes_capsules (sequence) WHERE tenant_id IS NULL"
77+
).execute_if(dialect="postgresql"),
78+
)
79+
80+
6881
class PostgresCapsuleStorage:
6982
"""
7083
Capsule storage using PostgreSQL.

0 commit comments

Comments
 (0)