Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

---

## [1.5.2] - 2026-03-18

### Added

- **`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).
- **`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`.
- **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).

### Fixed

- **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.

---

## [1.5.1] - 2026-03-17

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

---

[1.5.2]: https://github.com/quantumpipes/capsule/releases/tag/v1.5.2
[1.5.1]: https://github.com/quantumpipes/capsule/releases/tag/v1.5.1
[1.5.0]: https://github.com/quantumpipes/capsule/releases/tag/v1.5.0
[1.4.0]: https://github.com/quantumpipes/capsule/releases/tag/v1.4.0
Expand Down
50 changes: 41 additions & 9 deletions reference/python/docs/api.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
---
title: "API Reference"
description: "Complete API reference for Capsule: every class, method, parameter, and type."
date_modified: "2026-03-17"
date_modified: "2026-03-18"
ai_context: |
Complete Python API reference for the qp-capsule package v1.5.0. Covers Capsule model
(6 sections, 8 CapsuleTypes), Seal (seal, verify, verify_with_key, compute_hash,
keyring integration), Keyring (epoch-based key rotation, NIST SP 800-57),
Complete Python API reference for the qp-capsule package v1.5.1+. Covers Capsule model
(6 sections, 8 CapsuleTypes, to_dict/to_sealed_dict/from_dict/from_sealed_dict),
Seal (seal, verify, verify_with_key, compute_hash, keyring integration),
Keyring (epoch-based key rotation, NIST SP 800-57),
CapsuleChain (add, verify, seal_and_store), CapsuleStorageProtocol (7 methods),
CapsuleStorage (SQLite), PostgresCapsuleStorage (multi-tenant), storage schema with
column constraints (signed_at String(40), signed_by String(32)), exception hierarchy,
CLI (verify, inspect, keys, hash), and the high-level API: Capsules class, @audit()
decorator, current() context variable, and mount_capsules() FastAPI integration.
to_sealed_dict() returns canonical content plus seal envelope (hash, signature,
signature_pq, signed_at, signed_by). from_sealed_dict() is the inverse.
FastAPI endpoints use to_sealed_dict() so API responses include the full seal.
---

# API Reference
Expand Down Expand Up @@ -63,7 +67,7 @@ from qp_capsule.integrations.fastapi import mount_capsules

The atomic record. Every action creates one.

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

```python
@dataclass
Expand Down Expand Up @@ -98,7 +102,7 @@ class Capsule:

### Methods

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

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

**`to_dict() -> dict[str, Any]`**
Serialize to dictionary. Produces the canonical representation used for hashing and storage.
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()`.

**`to_sealed_dict() -> dict[str, Any]`**
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.

```python
seal.seal(capsule)
d = capsule.to_sealed_dict()

d["id"] # "a1b2c3d4-..."
d["trigger"] # {...}
d["hash"] # "e21819859fce83ea..." (64-char SHA3-256 hex)
d["signature"] # "db37397b068c79..." (Ed25519 hex)
d["signature_pq"] # "" (empty if PQ disabled)
d["signed_at"] # "2026-03-18T02:52:03+00:00"
d["signed_by"] # "qp_key_a1b2"
```

**`Capsule.from_dict(data: dict) -> Capsule`** *(classmethod)*
Deserialize from dictionary. Restores all 6 sections.
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()`.

**`Capsule.from_sealed_dict(data: dict) -> Capsule`** *(classmethod)*
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.

```python
# Full roundtrip with seal preservation
d = capsule.to_sealed_dict()
restored = Capsule.from_sealed_dict(d)
assert seal.verify(restored) # True — signature survives the roundtrip
```

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

## mount_capsules() (FastAPI Integration)

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

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

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`).

FastAPI is not a hard dependency. Raises `CapsuleError` if not installed.

**Security note:** These endpoints are read-only and do not add authentication. Protect them with your application's auth middleware in production.
Expand Down
17 changes: 15 additions & 2 deletions reference/python/docs/high-level-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ mount_capsules(app, capsules, prefix="/api/v1/capsules")

### Endpoints

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`).

#### `GET {prefix}/`

List capsules with pagination and filtering.
Expand All @@ -219,7 +221,18 @@ List capsules with pagination and filtering.

```json
{
"capsules": [...],
"capsules": [
{
"id": "a1b2c3d4-...",
"type": "agent",
"trigger": { "..." : "..." },
"hash": "e21819859fce83ea...",
"signature": "db37397b068c79...",
"signature_pq": "",
"signed_at": "2026-03-18T02:52:03+00:00",
"signed_by": "qp_key_a1b2"
}
],
"total": 42,
"limit": 20,
"offset": 0
Expand All @@ -230,7 +243,7 @@ List capsules with pagination and filtering.

Get a single capsule by UUID.

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

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

Expand Down
2 changes: 1 addition & 1 deletion reference/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "qp-capsule"
version = "1.5.1"
version = "1.5.2"
description = "Capsule Protocol Specification (CPS) — tamper-evident audit records for AI operations. Create, seal, verify, and chain Capsules in Python."
readme = "README.md"
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion reference/python/src/qp_capsule/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
Spec: https://github.com/quantumpipes/capsule
"""

__version__ = "1.5.1"
__version__ = "1.5.2"
__author__ = "Quantum Pipes Technologies, LLC"
__license__ = "Apache-2.0"

Expand Down
58 changes: 51 additions & 7 deletions reference/python/src/qp_capsule/capsule.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,12 +392,15 @@ def has_pq_seal(self) -> bool:

def to_dict(self) -> dict[str, Any]:
"""
Serialize Capsule for hashing.
Serialize the canonical content of this Capsule.

This produces the canonical representation used for:
- Computing the SHA3-256 hash
- Verifying integrity
- Storage
Returns only the content fields — the part that gets hashed.
Seal envelope fields (hash, signature, signed_at, etc.) are
deliberately excluded to avoid circular dependency during
hash computation.

For a complete representation including the seal, use
:meth:`to_sealed_dict`.
"""
return {
"id": str(self.id),
Expand Down Expand Up @@ -464,12 +467,33 @@ def to_dict(self) -> dict[str, Any]:
},
}

def to_sealed_dict(self) -> dict[str, Any]:
"""
Serialize this Capsule including the cryptographic seal envelope.

Returns everything from :meth:`to_dict` plus the seal fields:
``hash``, ``signature``, ``signature_pq``, ``signed_at``, and
``signed_by``.

Use this when serializing capsules for API responses, exports,
or any context where the complete sealed record is needed.
"""
d = self.to_dict()
d["hash"] = self.hash
d["signature"] = self.signature
d["signature_pq"] = self.signature_pq
d["signed_at"] = self.signed_at.isoformat() if self.signed_at else None
d["signed_by"] = self.signed_by
return d

@classmethod
def from_dict(cls, data: dict[str, Any]) -> Capsule:
"""
Deserialize Capsule from dictionary.
Deserialize Capsule from a canonical content dictionary.

Used when loading from storage.
Restores only the content fields. Seal envelope fields, if
present in *data*, are ignored. To restore a complete sealed
record, use :meth:`from_sealed_dict`.
"""
capsule = cls(
id=UUID(data["id"]),
Expand Down Expand Up @@ -562,6 +586,26 @@ def from_dict(cls, data: dict[str, Any]) -> Capsule:

return capsule

@classmethod
def from_sealed_dict(cls, data: dict[str, Any]) -> Capsule:
"""
Deserialize a Capsule 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 :meth:`to_sealed_dict`.
"""
capsule = cls.from_dict(data)
capsule.hash = data.get("hash", "")
capsule.signature = data.get("signature", "")
capsule.signature_pq = data.get("signature_pq", "")
signed_at = data.get("signed_at")
capsule.signed_at = (
datetime.fromisoformat(signed_at) if signed_at else None
)
capsule.signed_by = data.get("signed_by", "")
return capsule

@classmethod
def create(
cls,
Expand Down
4 changes: 2 additions & 2 deletions reference/python/src/qp_capsule/integrations/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ async def list_capsules(
tenant_id=tenant_id,
)
return {
"capsules": [c.to_dict() for c in items],
"capsules": [c.to_sealed_dict() for c in items],
"total": total,
"limit": limit,
"offset": offset,
Expand All @@ -114,6 +114,6 @@ async def get_capsule(
capsule = await capsules.storage.get(capsule_id, tenant_id=tenant_id)
if capsule is None:
raise HTTPException(status_code=404, detail="Capsule not found")
return capsule.to_dict()
return capsule.to_sealed_dict()

app.include_router(router, prefix=prefix)
Loading