Skip to content

Commit a8fb0b2

Browse files
Merge pull request #16 from quantumpipes/feat/sealed-dict-serialization
2 parents a380afa + 3db7dd0 commit a8fb0b2

13 files changed

Lines changed: 424 additions & 25 deletions

File tree

CHANGELOG.md

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

1212
---
1313

14+
## [1.5.2] - 2026-03-18
15+
16+
### Added
17+
18+
- **`Capsule.to_sealed_dict()`** — Serialize a Capsule including the cryptographic seal envelope (`hash`, `signature`, `signature_pq`, `signed_at`, `signed_by`). Returns everything from `to_dict()` plus the five seal fields. Use this when building API responses or exporting complete sealed records. `to_dict()` continues to return only the canonical content (the part that gets hashed).
19+
- **`Capsule.from_sealed_dict(data)`** — Inverse of `to_sealed_dict()`. Deserializes both canonical content and seal envelope from a single dict. Missing seal keys default to empty values, so it also accepts plain `to_dict()` output. Enables full roundtrip: `seal → to_sealed_dict → from_sealed_dict → verify`.
20+
- **21 new tests** across 4 test files — unit tests for both methods (happy path, edge cases, JSON serialization, non-mutation, exact key delta, partial seal fields), real-Seal integration tests (hash stability, verify-after-roundtrip), FastAPI endpoint assertions (seal fields present in list and get responses), and invariant tests (to_sealed_dict superset of to_dict).
21+
22+
### Fixed
23+
24+
- **FastAPI endpoints omitting seal envelope**`GET /capsules/` and `GET /capsules/{id}` were using `to_dict()`, which excludes seal fields by design. Responses now use `to_sealed_dict()` and include `hash`, `signature`, `signature_pq`, `signed_at`, and `signed_by` alongside the capsule content.
25+
26+
---
27+
1428
## [1.5.1] - 2026-03-17
1529

1630
Storage column width fix. Prevents PostgreSQL `StorageError` on every capsule write.
@@ -200,6 +214,7 @@ Initial public release of the Capsule Protocol Specification (CPS) v1.0 referenc
200214

201215
---
202216

217+
[1.5.2]: https://github.com/quantumpipes/capsule/releases/tag/v1.5.2
203218
[1.5.1]: https://github.com/quantumpipes/capsule/releases/tag/v1.5.1
204219
[1.5.0]: https://github.com/quantumpipes/capsule/releases/tag/v1.5.0
205220
[1.4.0]: https://github.com/quantumpipes/capsule/releases/tag/v1.4.0

reference/python/docs/api.md

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
---
22
title: "API Reference"
33
description: "Complete API reference for Capsule: every class, method, parameter, and type."
4-
date_modified: "2026-03-17"
4+
date_modified: "2026-03-18"
55
ai_context: |
6-
Complete Python API reference for the qp-capsule package v1.5.0. Covers Capsule model
7-
(6 sections, 8 CapsuleTypes), Seal (seal, verify, verify_with_key, compute_hash,
8-
keyring integration), Keyring (epoch-based key rotation, NIST SP 800-57),
6+
Complete Python API reference for the qp-capsule package v1.5.1+. Covers Capsule model
7+
(6 sections, 8 CapsuleTypes, to_dict/to_sealed_dict/from_dict/from_sealed_dict),
8+
Seal (seal, verify, verify_with_key, compute_hash, keyring integration),
9+
Keyring (epoch-based key rotation, NIST SP 800-57),
910
CapsuleChain (add, verify, seal_and_store), CapsuleStorageProtocol (7 methods),
1011
CapsuleStorage (SQLite), PostgresCapsuleStorage (multi-tenant), storage schema with
1112
column constraints (signed_at String(40), signed_by String(32)), exception hierarchy,
1213
CLI (verify, inspect, keys, hash), and the high-level API: Capsules class, @audit()
1314
decorator, current() context variable, and mount_capsules() FastAPI integration.
15+
to_sealed_dict() returns canonical content plus seal envelope (hash, signature,
16+
signature_pq, signed_at, signed_by). from_sealed_dict() is the inverse.
17+
FastAPI endpoints use to_sealed_dict() so API responses include the full seal.
1418
---
1519

1620
# API Reference
@@ -63,7 +67,7 @@ from qp_capsule.integrations.fastapi import mount_capsules
6367

6468
The atomic record. Every action creates one.
6569

66-
<!-- VERIFIED: reference/python/src/qp_capsule/capsule.py:315-373 -->
70+
<!-- VERIFIED: reference/python/src/qp_capsule/capsule.py:315-374 -->
6771

6872
```python
6973
@dataclass
@@ -98,7 +102,7 @@ class Capsule:
98102

99103
### Methods
100104

101-
<!-- VERIFIED: reference/python/src/qp_capsule/capsule.py:375-611 -->
105+
<!-- VERIFIED: reference/python/src/qp_capsule/capsule.py:376-656 -->
102106

103107
**`is_sealed() -> bool`**
104108
Returns `True` if the Capsule has a hash and Ed25519 signature.
@@ -107,10 +111,36 @@ Returns `True` if the Capsule has a hash and Ed25519 signature.
107111
Returns `True` if the Capsule also has an ML-DSA-65 post-quantum signature.
108112

109113
**`to_dict() -> dict[str, Any]`**
110-
Serialize to dictionary. Produces the canonical representation used for hashing and storage.
114+
Serialize the canonical content of this Capsule. Returns only the content fields — the part that gets hashed. Seal envelope fields (`hash`, `signature`, `signature_pq`, `signed_at`, `signed_by`) are deliberately excluded to avoid circular dependency during hash computation. For a complete representation including the seal, use `to_sealed_dict()`.
115+
116+
**`to_sealed_dict() -> dict[str, Any]`**
117+
Serialize this Capsule including the cryptographic seal envelope. Returns everything from `to_dict()` plus five additional keys: `hash`, `signature`, `signature_pq`, `signed_at` (ISO 8601 string or `null`), and `signed_by`. Use this when serializing capsules for API responses, exports, or any context where the complete sealed record is needed.
118+
119+
```python
120+
seal.seal(capsule)
121+
d = capsule.to_sealed_dict()
122+
123+
d["id"] # "a1b2c3d4-..."
124+
d["trigger"] # {...}
125+
d["hash"] # "e21819859fce83ea..." (64-char SHA3-256 hex)
126+
d["signature"] # "db37397b068c79..." (Ed25519 hex)
127+
d["signature_pq"] # "" (empty if PQ disabled)
128+
d["signed_at"] # "2026-03-18T02:52:03+00:00"
129+
d["signed_by"] # "qp_key_a1b2"
130+
```
111131

112132
**`Capsule.from_dict(data: dict) -> Capsule`** *(classmethod)*
113-
Deserialize from dictionary. Restores all 6 sections.
133+
Deserialize from a canonical content dictionary. Restores all 6 sections. Seal envelope fields, if present in *data*, are ignored. To restore a complete sealed record, use `from_sealed_dict()`.
134+
135+
**`Capsule.from_sealed_dict(data: dict) -> Capsule`** *(classmethod)*
136+
Deserialize from a sealed dictionary. Restores both the canonical content and the seal envelope fields (`hash`, `signature`, `signature_pq`, `signed_at`, `signed_by`). This is the inverse of `to_sealed_dict()`. Missing seal keys default to empty values.
137+
138+
```python
139+
# Full roundtrip with seal preservation
140+
d = capsule.to_sealed_dict()
141+
restored = Capsule.from_sealed_dict(d)
142+
assert seal.verify(restored) # True — signature survives the roundtrip
143+
```
114144

115145
**`Capsule.create(capsule_type=, trigger=, context=, reasoning=, authority=, execution=, outcome=, *, domain=, parent_id=) -> Capsule`** *(classmethod)*
116146
Factory method that accepts plain dicts instead of section dataclasses. Unknown keys are silently ignored.
@@ -708,7 +738,7 @@ async def run_agent(task: str, *, site_id: str):
708738

709739
## mount_capsules() (FastAPI Integration)
710740

711-
<!-- VERIFIED: reference/python/src/qp_capsule/integrations/fastapi.py:36-113 -->
741+
<!-- VERIFIED: reference/python/src/qp_capsule/integrations/fastapi.py:36-119 -->
712742

713743
```python
714744
from qp_capsule.integrations.fastapi import mount_capsules
@@ -730,6 +760,8 @@ mount_capsules(app, capsules, prefix="/api/v1/capsules")
730760
| GET | `{prefix}/verify` | Verify chain integrity (query: `tenant_id`) |
731761
| GET | `{prefix}/{capsule_id}` | Get capsule by ID (404 if missing) |
732762

763+
All capsule endpoints serialize using `to_sealed_dict()`, so responses include both the canonical content and the cryptographic seal envelope (`hash`, `signature`, `signature_pq`, `signed_at`, `signed_by`).
764+
733765
FastAPI is not a hard dependency. Raises `CapsuleError` if not installed.
734766

735767
**Security note:** These endpoints are read-only and do not add authentication. Protect them with your application's auth middleware in production.

reference/python/docs/high-level-api.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ mount_capsules(app, capsules, prefix="/api/v1/capsules")
204204

205205
### Endpoints
206206

207+
All capsule endpoints serialize using `capsule.to_sealed_dict()`, so responses include both the canonical content (the 6 sections) and the cryptographic seal envelope (`hash`, `signature`, `signature_pq`, `signed_at`, `signed_by`).
208+
207209
#### `GET {prefix}/`
208210

209211
List capsules with pagination and filtering.
@@ -219,7 +221,18 @@ List capsules with pagination and filtering.
219221

220222
```json
221223
{
222-
"capsules": [...],
224+
"capsules": [
225+
{
226+
"id": "a1b2c3d4-...",
227+
"type": "agent",
228+
"trigger": { "..." : "..." },
229+
"hash": "e21819859fce83ea...",
230+
"signature": "db37397b068c79...",
231+
"signature_pq": "",
232+
"signed_at": "2026-03-18T02:52:03+00:00",
233+
"signed_by": "qp_key_a1b2"
234+
}
235+
],
223236
"total": 42,
224237
"limit": 20,
225238
"offset": 0
@@ -230,7 +243,7 @@ List capsules with pagination and filtering.
230243

231244
Get a single capsule by UUID.
232245

233-
Returns the full capsule dict, or 404 if not found.
246+
Returns the full sealed capsule dict (content + seal envelope), or 404 if not found.
234247

235248
#### `GET {prefix}/verify`
236249

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.5.1"
7+
version = "1.5.2"
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: 1 addition & 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.5.1"
39+
__version__ = "1.5.2"
4040
__author__ = "Quantum Pipes Technologies, LLC"
4141
__license__ = "Apache-2.0"
4242

reference/python/src/qp_capsule/capsule.py

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -392,12 +392,15 @@ def has_pq_seal(self) -> bool:
392392

393393
def to_dict(self) -> dict[str, Any]:
394394
"""
395-
Serialize Capsule for hashing.
395+
Serialize the canonical content of this Capsule.
396396
397-
This produces the canonical representation used for:
398-
- Computing the SHA3-256 hash
399-
- Verifying integrity
400-
- Storage
397+
Returns only the content fields — the part that gets hashed.
398+
Seal envelope fields (hash, signature, signed_at, etc.) are
399+
deliberately excluded to avoid circular dependency during
400+
hash computation.
401+
402+
For a complete representation including the seal, use
403+
:meth:`to_sealed_dict`.
401404
"""
402405
return {
403406
"id": str(self.id),
@@ -464,12 +467,33 @@ def to_dict(self) -> dict[str, Any]:
464467
},
465468
}
466469

470+
def to_sealed_dict(self) -> dict[str, Any]:
471+
"""
472+
Serialize this Capsule including the cryptographic seal envelope.
473+
474+
Returns everything from :meth:`to_dict` plus the seal fields:
475+
``hash``, ``signature``, ``signature_pq``, ``signed_at``, and
476+
``signed_by``.
477+
478+
Use this when serializing capsules for API responses, exports,
479+
or any context where the complete sealed record is needed.
480+
"""
481+
d = self.to_dict()
482+
d["hash"] = self.hash
483+
d["signature"] = self.signature
484+
d["signature_pq"] = self.signature_pq
485+
d["signed_at"] = self.signed_at.isoformat() if self.signed_at else None
486+
d["signed_by"] = self.signed_by
487+
return d
488+
467489
@classmethod
468490
def from_dict(cls, data: dict[str, Any]) -> Capsule:
469491
"""
470-
Deserialize Capsule from dictionary.
492+
Deserialize Capsule from a canonical content dictionary.
471493
472-
Used when loading from storage.
494+
Restores only the content fields. Seal envelope fields, if
495+
present in *data*, are ignored. To restore a complete sealed
496+
record, use :meth:`from_sealed_dict`.
473497
"""
474498
capsule = cls(
475499
id=UUID(data["id"]),
@@ -562,6 +586,26 @@ def from_dict(cls, data: dict[str, Any]) -> Capsule:
562586

563587
return capsule
564588

589+
@classmethod
590+
def from_sealed_dict(cls, data: dict[str, Any]) -> Capsule:
591+
"""
592+
Deserialize a Capsule from a sealed dictionary.
593+
594+
Restores both the canonical content **and** the seal envelope
595+
fields (``hash``, ``signature``, ``signature_pq``, ``signed_at``,
596+
``signed_by``). This is the inverse of :meth:`to_sealed_dict`.
597+
"""
598+
capsule = cls.from_dict(data)
599+
capsule.hash = data.get("hash", "")
600+
capsule.signature = data.get("signature", "")
601+
capsule.signature_pq = data.get("signature_pq", "")
602+
signed_at = data.get("signed_at")
603+
capsule.signed_at = (
604+
datetime.fromisoformat(signed_at) if signed_at else None
605+
)
606+
capsule.signed_by = data.get("signed_by", "")
607+
return capsule
608+
565609
@classmethod
566610
def create(
567611
cls,

reference/python/src/qp_capsule/integrations/fastapi.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ async def list_capsules(
8888
tenant_id=tenant_id,
8989
)
9090
return {
91-
"capsules": [c.to_dict() for c in items],
91+
"capsules": [c.to_sealed_dict() for c in items],
9292
"total": total,
9393
"limit": limit,
9494
"offset": offset,
@@ -114,6 +114,6 @@ async def get_capsule(
114114
capsule = await capsules.storage.get(capsule_id, tenant_id=tenant_id)
115115
if capsule is None:
116116
raise HTTPException(status_code=404, detail="Capsule not found")
117-
return capsule.to_dict()
117+
return capsule.to_sealed_dict()
118118

119119
app.include_router(router, prefix=prefix)

0 commit comments

Comments
 (0)