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:
master
2026-04-22 16:04:38 +03:00
parent c5cc11c28f
commit 563079fc69
15 changed files with 1771 additions and 0 deletions

View File

@@ -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