feat(evidence-locker): Decision Capsule sealing pipeline
Builds the previously-aspirational Capsule create/seal/verify/export/replay
pipeline. Unblocks the former CAPSULE-001 task that lived (BLOCKED) in
SPRINT_20260408_005; carried over as CAPSULE-AUDIT-001 inside the new
SPRINT_20260422_002 (created + archived in same pass).
Pipeline:
- CapsuleManifest record: deterministic SBOM+feeds+reachability+policy+VEX
content-address bundle.
- CapsuleManifestCanonicalizer: mirrors AUDIT-007 algorithm byte-for-byte
(ordinal-sorted UTF-8 JSON via JsonDocument round-trip).
- ICapsuleSigner + EcdsaCapsuleSigner + NullCapsuleSigner: DSSE PAE
contract, DSSE payload type application/vnd.stellaops.decision-capsule+json.
Pattern-identical to IAuditBundleManifestSigner; defined locally rather
than cross-referencing IExportAttestationSigner (which lives inside
ExportCenter.WebService, not a shared library — future cleanup noted).
- CapsuleService: create / seal / verify / export (zip) / replay.
- PostgresCapsuleRepository (Dapper) with tenant RLS hookup.
Endpoints (all tenant-scoped, POST):
- POST /api/v1/evidence/capsules
- POST /api/v1/evidence/capsules/{id}/seal
- POST /api/v1/evidence/capsules/{id}/verify
- POST /api/v1/evidence/capsules/{id}/export (application/zip)
- POST /api/v1/evidence/capsules/{id}/replay
Storage: embedded migration 005_decision_capsules.sql creates
evidence_locker.decision_capsules (RLS-enforced) + indexes + CHECK
constraints. Auto-applied by existing EvidenceLockerMigrationRunner.
Audit (CAPSULE-AUDIT-001):
- 5 new AuditActions.Evidence constants (CreateCapsule/Seal/Verify/Export/Replay)
- Each endpoint chained with .Audited(AuditModules.Evidence, ...)
- contentHash surfaced on responses so AuditActionFilter propagates it
into details_jsonb.
Tests: 9 focused tests (determinism x3, sign+verify+tamper x3, null-signer
graceful degradation, pipeline round-trip, 404 on missing). Full
EvidenceLocker namespace sweep: 141/141, 0 failures.
Docs: docs/modules/evidence-locker/architecture.md §9bis (manifest schema,
DSSE payload type, storage, API surface, relationship to
release.run_capsule_replay_linkage).
Runtime curl+Timeline assertion deferred — running container image
predates these changes; rebuild pending. Structural wiring identical to
runtime-verified VerdictEndpoints (AUDIT-002 precedent).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -260,6 +260,59 @@ Bundle N-1 Bundle N Bundle N+1
|
||||
|
||||
---
|
||||
|
||||
## 9bis) Decision Capsule pipeline
|
||||
|
||||
Sprint `SPRINT_20260422_002` CAPSULE-PIPELINE-001 introduces a content-addressed Decision Capsule that binds a release decision's input set (SBOM, vuln feeds, reachability evidence, policy version, derived VEX) into a deterministic, DSSE-signed manifest. The capsule becomes the verifiable replay anchor for a release decision.
|
||||
|
||||
### 9bis.1) Manifest & canonicalisation
|
||||
|
||||
* **Type**: `StellaOps.EvidenceLocker.Capsules.CapsuleManifest` (record).
|
||||
* **apiVersion / kind**: `stella.ops/v1` / `DecisionCapsuleManifest`.
|
||||
* **DSSE payload type**: `application/vnd.stellaops.decision-capsule+json` (distinct from the audit-bundle payload type introduced by AUDIT-007).
|
||||
* **Canonicalisation**: `CapsuleManifestCanonicalizer.Canonicalize(manifest)` — UTF-8, non-indented, keys ordinal-sorted. Algorithm mirrors the audit-bundle canonicaliser (AUDIT-007) so verifiers can share a single canonical-JSON path.
|
||||
* **Content address**: SHA-256 of the canonicalised bytes, surfaced as `contentHash` on every API response and persisted alongside the manifest in `evidence_locker.decision_capsules`.
|
||||
|
||||
### 9bis.2) Storage
|
||||
|
||||
Table `evidence_locker.decision_capsules` (migration `005_decision_capsules.sql`):
|
||||
|
||||
| column | type | notes |
|
||||
|-------------------|---------------|-----------------------------------------------------------------------------------------|
|
||||
| `capsule_id` | `uuid` (PK) | |
|
||||
| `tenant_id` | `uuid` | RLS via `evidence_locker_app.require_current_tenant()` |
|
||||
| `manifest_json` | `text` | **Canonical** manifest bytes (stored as text for byte-exact replay) |
|
||||
| `content_hash` | `text` | lowercase-hex SHA-256 of `manifest_json` |
|
||||
| `dsse_envelope` | `text` / NULL | DSSE envelope JSON (omitted for unsealed or unsigned capsules) |
|
||||
| `signing_status` | `text` | `unsealed` \| `signed` \| `unsigned` \| `invalid` |
|
||||
| `assembly_inputs` | `jsonb` | Opaque input references the capsule was assembled from |
|
||||
| `sealed_at` | `timestamptz` | Non-null once sealed |
|
||||
|
||||
Auto-migration applies on service startup via `EvidenceLockerMigrationRunner` (embedded resource; no manual SQL required).
|
||||
|
||||
### 9bis.3) Signing
|
||||
|
||||
The pipeline reuses the AUDIT-007 pattern: canonicalise the manifest, then delegate DSSE PAE signing to an abstraction (`ICapsuleSigner`). Default implementation is `EcdsaCapsuleSigner` (ECDSA P-256 / SHA-256) which produces an envelope shaped identically to the export / audit-bundle DSSE envelopes. When no signer is registered, `NullCapsuleSigner` returns an unsigned result with `signing_status = "signer_not_registered"` — same graceful degradation contract as AUDIT-007.
|
||||
|
||||
### 9bis.4) API surface
|
||||
|
||||
All endpoints live under `/api/v1/evidence/capsules`, are tenant-scoped, and emit audit events (`module=evidence`) via `StellaOps.Audit.Emission`:
|
||||
|
||||
| Method | Route | Audit action | Scope |
|
||||
|--------|-------------------------------------------|--------------------|-------------------|
|
||||
| POST | `/api/v1/evidence/capsules` | `create_capsule` | `EvidenceCreate` |
|
||||
| POST | `/api/v1/evidence/capsules/{id}/seal` | `seal_capsule` | `EvidenceCreate` |
|
||||
| POST | `/api/v1/evidence/capsules/{id}/verify` | `verify_capsule` | `EvidenceRead` |
|
||||
| POST | `/api/v1/evidence/capsules/{id}/export` | `export_capsule` | `EvidenceRead` |
|
||||
| POST | `/api/v1/evidence/capsules/{id}/replay` | `replay_capsule` | `EvidenceRead` |
|
||||
|
||||
Export returns a `application/zip` bundle with `manifest.json`, `manifest.dsse.json` (if sealed + signed), `assembly-inputs.json`, and `metadata.json`. Replay deserialises the sealed manifest, recomputes its content hash, and returns the reconstructed verdict.
|
||||
|
||||
### 9bis.5) Relationship to `release.run_capsule_replay_linkage`
|
||||
|
||||
The existing `release.run_capsule_replay_linkage` table (SPRINT_20260220_023 B23-RUN-06) continues to link a release run to a capsule by `decision_capsule_id` / `capsule_hash`. The Decision Capsule pipeline is the source of those fields: the `content_hash` this pipeline computes is what the linkage row's `capsule_hash` column references, and `signature_status` mirrors the capsule's `signing_status`.
|
||||
|
||||
---
|
||||
|
||||
## 10) Testing matrix
|
||||
|
||||
* **Sealing tests**: Correct Merkle root computation
|
||||
|
||||
Reference in New Issue
Block a user