EvidenceLocker — Architecture

Audience: compliance, forensic, and platform engineers who need durable proof behind Stella Ops release decisions.

EvidenceLocker is the Stella Ops service for sealed, immutable storage of vulnerability-scan evidence, decision capsules, and audit artifacts. It builds tamper-proof, content-addressed evidence chains with cryptographic sealing and optional RFC 3161 timestamping, so a release decision can be replayed and audited offline.

Scope. Implementation-ready architecture for EvidenceLocker: domain model, REST surface, sealing pipeline, decision-capsule pipeline, configuration, data residency, retention, and the production fail-closed signing/timestamping contract.


0) Mission & boundaries

Mission. Provide immutable, sealed storage for vulnerability scan evidence, audit logs, and compliance artifacts. Ensure tamper-proof evidence chains with cryptographic verification for forensic analysis and regulatory compliance.

Boundaries.

Release-trace identity boundary. The canonical subject and evidence-spine contract is shared with Release Orchestrator and Attestor. EvidenceLocker canonicalId identifies an Artifact Canonical Record and returns its artifact_digest, PURL, and attestations; it is not a replacement for the lowercase OCI sha256:<64-hex> artifact subject. Missing records and an unavailable EvidenceLocker read remain distinct states.


1) Solution & project layout

Verified against src/EvidenceLocker/ (2026-05-29). The host projects (Core, Infrastructure, WebService, Worker, Tests) are nested under StellaOps.EvidenceLocker/; cross-cutting libraries live in __Libraries/ and CLI tooling in __Apps/.

src/EvidenceLocker/
 ├─ StellaOps.EvidenceLocker/                    # Composition root project (HTTP contracts, endpoints, capsules)
 │   ├─ Api/                                      # Minimal-API endpoint groups
 │   │   ├─ Capsules/CapsuleEndpoints.cs          # Decision Capsule + TLPT pack routes
 │   │   ├─ Capsules/DataResidencyEnforcer.cs     # Region-tag write/read enforcement
 │   │   ├─ EvidenceAuditEndpoints.cs             # Pack v2 home/packs/proofs/audit/cvss reads
 │   │   ├─ EvidenceThreadEndpoints.cs            # Artifact Canonical Record API
 │   │   ├─ ExportEndpoints.cs / ExportJobService.cs  # Async bundle export jobs
 │   │   ├─ RegulatoryArtifactLedgerEndpoints.cs  # EU regulatory artifact ledger read API
 │   │   ├─ VerdictEndpoints.cs / VerdictContracts.cs # Verdict attestation API
 │   │   └─ InMemory*/Null* adapters              # Deterministic seeded + null storage/exporter
 │   ├─ Capsules/                                 # Capsule manifest, canonicaliser, signers, repository
 │   │   ├─ CapsuleManifest.cs / CapsuleManifestCanonicalizer.cs / CapsulePiiGuard.cs
 │   │   ├─ CapsuleSigner.cs (EcdsaCapsuleSigner + NullCapsuleSigner)
 │   │   ├─ Ed25519CapsuleSigner.cs / CryptoRegistryCapsuleSigner.cs
 │   │   ├─ CapsuleService.cs / PostgresCapsuleRepository.cs
 │   │   └─ TlptEvidencePackContracts.cs / LegacyCapsuleReadPathSuppressor.cs
 │   ├─ Storage/                                  # Tenant/region/pack/verdict/thread repository contracts + Postgres impls
 │   ├─ Migrations/001_CreateVerdictAttestations.sql  # NOTE: not embedded — superseded by Infrastructure/Db/Migrations
 │   └─ StellaOps.EvidenceLocker.WebService/Program.cs  # WebService host (DI + endpoint wiring + runtime validator)
 │
 ├─ StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Core/    # Domain + abstractions
 │   ├─ Configuration/EvidenceLockerOptions.cs    # Bound options tree (Database/ObjectStore/Quotas/Signing/…)
 │   ├─ Domain/                                   # EvidenceBundle, EvidenceArtifact, EvidenceHold, identifiers, snapshots
 │   ├─ Builders/MerkleTreeCalculator.cs + IEvidenceBundleBuilder
 │   ├─ Repositories/IEvidenceBundleRepository.cs (+ gate-artifact repo)
 │   ├─ Signing/IEvidenceSignatureService.cs + ITimestampAuthorityClient
 │   ├─ Storage/EvidenceObjectStore.cs            # IEvidenceObjectStore + region metadata/exception
 │   ├─ Regulatory/ Incident/ Notifications/ Reindexing/ Timeline/  # Cross-cutting domain
 │   └─ Timeline/IEvidenceTimelinePublisher.cs
 │
 ├─ StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Infrastructure/  # Storage adapters, persistence, hosted services
 │   ├─ Db/                                        # EvidenceLockerMigrationRunner, MigrationLoader, retention sweeper, legacy-PII audit
 │   │   └─ Migrations/                            # Embedded SQL migrations (authoritative): 001_v1_evidencelocker_baseline.sql
 │   │                                              #   (collapsed pre-1.0 001-012) + 017_release_keyed_bundles.sql
 │   │                                              #   + 018_evidence_bundle_region_immutability.sql
 │   │                                              #   + 019_regulatory_retention_horizon.sql; _archived/ NOT embedded
 │   ├─ EfCore/                                    # DbContext + compiled model (read/projection side)
 │   ├─ Repositories/ Services/                    # EvidenceSnapshotService, packaging, portable, gate-artifact, incident
 │   ├─ Signing/ (EvidenceSignatureService, Rfc3161TimestampAuthorityClient, Null…)
 │   ├─ Storage/ (FileSystemEvidenceObjectStore, S3EvidenceObjectStore, StorageKeyGenerator)
 │   ├─ Timeline/ (TimelineIndexerEvidenceTimelinePublisher, Null…)
 │   ├─ Hosting/EvidenceLockerRuntimeConfigurationValidator.cs
 │   └─ DependencyInjection/                       # AddEvidenceLockerInfrastructure + hosted services
 │
 ├─ StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Worker/  # Background host (connectivity probe + hosted services)
 │   ├─ Program.cs                                 # Wires infrastructure (migration/retention-sweep/legacy-PII hosted services)
 │   └─ Worker.cs                                  # Verifies DB connectivity, then idles
 │
 ├─ StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Tests/   # Unit/integration tests
 │
 ├─ __Libraries/
 │   ├─ StellaOps.EvidenceLocker.Export/           # Portable/audit bundle exporter (tar.gz, Merkle, verify-script, KMS backup)
 │   ├─ StellaOps.EvidenceLocker.Import/           # Dormant source-only importer prototype; not compiled or composed
 │   └─ StellaOps.EvidenceLocker.Timestamping/     # RFC3161 (re)timestamp service + offline verifier
 │
 ├─ __Apps/StellaOps.EvidenceLocker.BackupCli/     # Encrypted backup/restore CLI
 │
 └─ __Tests/ (Export.Tests, SchemaEvolution.Tests, WebService.Tests)

Importer boundary (verified 2026-07-18). __Libraries/StellaOps.EvidenceLocker.Import/EvidenceBundleImporter.cs is a source-only prototype: the directory has no project, no production project references it, no DI registration or HTTP/CLI surface consumes it, and its artifact-store write method is a no-op. It is therefore not a supported evidence-bundle import path. The supported EvidenceLocker write/read path is POST /evidence/snapshot followed by GET /evidence/{bundleId}; the /evidence/qa/assurance-cases/{caseId}/readback-import route is a QA-only assurance forcing function, not a general portable-bundle importer.

Background work. The Worker host does not perform sealing or archival; sealing is synchronous on the write path. The Worker verifies database connectivity and then idles. The real recurring background jobs are the hosted services registered by AddEvidenceLockerInfrastructure: EvidenceLockerMigrationHostedService (startup auto-migration), CapsuleRetentionSweepService (opt-in expired-capsule tombstoning), and LegacyCapsulePiiAuditHostedService (startup legacy-PII classification).


2) External dependencies


3) Contracts & data model

Source of truth: StellaOps.EvidenceLocker.Core/Domain/* and the embedded SQL migrations. There is no casUri, eb-YYYY-… bundle id, items[] inline-item array, or chain.sequenceNumber in the implementation — those were illustrative and have been corrected to the real records below.

3.1 EvidenceBundle (EvidenceBundleMetadata.cs)

A sealed bundle row. After the append-only refactor (SPRINT_20260501_050) only Sealed (3) and Archived (5) are persisted; Pending (1), Assembling (2), and Failed (4) are in-memory only. Materials are stored as separate EvidenceArtifact rows / object-store blobs, not inline.

public enum EvidenceBundleKind { Evaluation = 1, Job = 2, Export = 3 }
public enum EvidenceBundleStatus { Pending = 1, Assembling = 2, Sealed = 3, Failed = 4, Archived = 5 }

public sealed record EvidenceBundle(
    EvidenceBundleId Id,                 // uuid (content-addressed at seal time)
    TenantId TenantId,
    EvidenceBundleKind Kind,
    EvidenceBundleStatus Status,
    string RootHash,                     // lowercase-hex SHA-256 Merkle root (^[0-9a-f]{64}$)
    string StorageKey,                   // object-store key (see §3.4)
    DateTimeOffset CreatedAt,
    DateTimeOffset UpdatedAt,
    string? Description = null,
    DateTimeOffset? SealedAt = null,
    DateTimeOffset? ExpiresAt = null,    // immutable; retention extension via evidence_holds
    string? PortableStorageKey = null,
    DateTimeOffset? PortableGeneratedAt = null,
    EvidenceBundleId? Supersedes = null,    // re-seal predecessor
    EvidenceBundleId? SupersededBy = null,  // re-seal successor (chain head when null)
    string RegionTag = "unspecified");      // DB-row data-residency tag

EvidenceArtifact (table evidence_locker.evidence_artifacts) holds one row per material: Id, BundleId, TenantId, Name, ContentType, SizeBytes, StorageKey, Sha256, CreatedAt.

EvidenceHold (table evidence_locker.evidence_holds) holds legal/retention holds: Id, TenantId, optional BundleId, CaseId, Reason, CreatedAt, optional ExpiresAt/ReleasedAt, optional Notes. The effective retention envelope of a bundle is MAX(bundle.expires_at, MAX(active hold.expires_at)).

3.2 Material kinds

Materials are arbitrary content-typed blobs (EvidenceArtifact.ContentType, SHA-256, size). The locker does not enforce a fixed enum of evidence item types; producers (Scanner, Policy, Attestor, promotion gates) decide the content. Common material classes include SBOMs, scan results / findings, policy verdict attestations, VEX, audit logs, and DSSE attestation envelopes, but these are carried as opaque content references rather than a locker-owned type taxonomy. Decision Capsules (§9bis) and verdict attestations (§7.1) are the structured, schema-bound records the locker does own.

3.3 EvidenceBundleSignature (EvidenceBundleSignature.cs)

The seal is a DSSE-style signature row (evidence_locker.evidence_bundle_signatures), not a bespoke SealManifest record:

public sealed record EvidenceBundleSignature(
    EvidenceBundleId BundleId,
    TenantId TenantId,
    string PayloadType,                  // default "application/vnd.stella.evidence.manifest+json"
    string Payload,                      // base64 canonical manifest payload
    string Signature,
    string? KeyId,
    string Algorithm,                    // default ES256 (SignatureAlgorithms.Es256)
    string Provider,
    DateTimeOffset SignedAt,
    DateTimeOffset? TimestampedAt = null,
    string? TimestampAuthority = null,
    byte[]? TimestampToken = null);      // RFC3161 token when timestamping enabled

GET /evidence/{bundleId} returns EvidenceBundleDetails = (EvidenceBundle, EvidenceBundleSignature?), plus any metadata map extracted from the signed manifest payload.

3.4 Storage keys (content addressing)

Object-store keys are generated by StorageKeyGenerator.BuildObjectKey as:

[<prefix>/]tenants/<tenantGuid:N>/bundles/<bundleGuid:N>/<sha256>[-<sanitized-name>]

Addressing is content-derived (SHA-256) but tenant/bundle-namespaced; there is no cas:// URI scheme. Every stored object also carries an x-stella-region user-metadata header (residency tag).


4) REST API (EvidenceLocker.WebService)

Authn is OAuth2 resource-server (Authority OpTok). Every business endpoint is .RequireTenant() and is bound to a StellaOpsResourceServerPolicies scope. There is no item-by-item POST /bundles/{id}/items / POST /bundles/{id}/seal / GET /bundles?tenant= listing — sealing is a single atomic operation (POST /evidence/snapshot). Routes are grouped as follows.

The three scopes in play (constants in StellaOps.Auth.Abstractions.StellaOpsScopes):

ScopeConstUsed for
evidence:readEvidenceReadAll reads (also the global fallback/default policy).
evidence:createEvidenceCreateWrites: gate-artifact store, capsule create/seal, verdict store, hold, QA readback.
evidence:holdEvidenceHoldCreating an evidence snapshot (POST /evidence/snapshot).

There is no evidence:write scope (the legacy RequiredScopes: ["evidence:read","evidence:write"] in older config docs was never implemented). Export endpoints reuse the Export Center scopes export.operator / export.viewer.

4.1 Evidence bundles, snapshots, holds (Program.cs, prefix /evidence)

POST /evidence                              gate-artifact ingest → 201 { evidenceId, evidenceScore, stored }   [evidence:create]
GET  /evidence/score?artifact_id={id}       deterministic evidence score → { evidenceScore, status }            [evidence:read]
POST /evidence/snapshot                     create+seal a bundle → 201 { bundleId, rootHash, signature }        [evidence:hold]
GET  /evidence/{bundleId:guid}              bundle details + signature + manifest metadata                      [evidence:read]
GET  /evidence/{bundleId:guid}/chain        supersede chain, oldest-first                                       [evidence:read]
GET  /evidence/{bundleId:guid}/download     sealed bundle .tgz (object-store residency enforced)                [evidence:read]
GET  /evidence/{bundleId:guid}/portable     portable/air-gapped bundle .tgz                                     [evidence:read]
POST /evidence/verify                       { bundleId, rootHash } → { trusted: bool }                          [evidence:read]
POST /evidence/hold/{caseId}                create a legal hold → 201                                           [evidence:create]
POST /evidence/qa/assurance-cases/{caseId}/readback-import   QA-only readback proof (not a compliance claim)    [evidence:create]

4.2 Evidence & Audit (Pack v2) reads (EvidenceAuditEndpoints.cs, prefix /api/v1/evidence) — all [evidence:read]

GET  /api/v1/evidence                       home summary + quick stats
GET  /api/v1/evidence/packs                 tenant-scoped evidence pack list
GET  /api/v1/evidence/packs/{id}            evidence pack detail (404 if not tenant-owned)
GET  /api/v1/evidence/proofs/{subjectDigest}  proof chain by subject digest
GET  /api/v1/evidence/audit                 unified audit slice — DEPRECATED, Sunset 2027-10-19, successor /api/v1/audit/events?modules=evidencelocker
GET  /api/v1/evidence/receipts/cvss/{id}    CVSS scoring receipt

4.3 Decision Capsules + TLPT packs (CapsuleEndpoints.cs, prefix /api/v1/evidence/capsules)

See §9bis for semantics. Routes: POST / (create, evidence:create), POST /{id}/seal (evidence:create), POST /{id}/verify (evidence:read), POST /{id}/export (evidence:read), POST /{id}/replay (evidence:read), POST /tlpt-packs (assemble DORA TLPT pack → 201, evidence:create).

4.4 Evidence Threads (EvidenceThreadEndpoints.cs, prefix /api/v1/evidence/thread) — [evidence:read]

GET  /api/v1/evidence/thread/{canonicalId}[?include_attestations=]   Artifact Canonical Record
GET  /api/v1/evidence/thread?purl={purl}                            list ACRs by PURL

4.5 Verdict attestations (VerdictEndpoints.cs, prefix /api/v1/verdicts)

POST /api/v1/verdicts                       store verdict attestation → 201           [evidence:create]
GET  /api/v1/verdicts/{verdictId}           full verdict record                       [evidence:read]
GET  /api/v1/runs/{runId}/verdicts          list verdicts for a policy run            [evidence:read]
POST /api/v1/verdicts/{verdictId}/verify    fail-closed signature/Rekor diagnostics   [evidence:read]
GET  /api/v1/verdicts/{verdictId}/envelope  raw DSSE envelope JSON                     [evidence:read]

4.6 Async bundle export (ExportEndpoints.cs, prefix /api/v1/bundles)

POST /api/v1/bundles/{bundleId}/export                       → 202 { exportId, statusUrl }   [export.operator]
GET  /api/v1/bundles/{bundleId}/export/{exportId}            status (200 ready / 202 in-progress) [export.viewer]
GET  /api/v1/bundles/{bundleId}/export/{exportId}/download   .tgz (409 if not ready)         [export.viewer]

Export job state is held in an in-process ConcurrentDictionary (ExportJobService, singleton) — an in-flight export does not survive a restart and is not visible to sibling replicas.

The backing store is Postgres/object-store by default. AddEvidenceLockerInfrastructure TryAddSingletons PostgresEvidenceLockerStorage + PostgresEvidenceBundleExporter (EvidenceLockerInfrastructureServiceCollectionExtensions.cs:380 / :390). The deterministic in-memory seeded adapters (golden bundle bundle-golden-001) are registered only when EvidenceLockerRuntimeConfigurationValidator.IsLocalHarness(...) is true (WebService/Program.cs:112-119) — i.e. environment TestingLocalHarness, or EvidenceLocker:LocalHarness:Enabled=true under Development/Testing. Outside the harness the same validator (ValidateStorageBackend) throws at startup if an in-memory storage adapter is active, so a production host cannot silently serve golden-bundle fixtures.

4.7 EU regulatory artifact ledger (RegulatoryArtifactLedgerEndpoints.cs)

GET  /api/v1/regulatory/artifact-ledger[?regime=&artifactType=&reportingYear=&requireMatch=&cursor=&limit=]   [evidence:read]

4.8 Operational endpoints (anonymous)

GET  /healthz        liveness (always-true predicate)
GET  /readyz         readiness
GET  /health/ready   legacy readiness alias
GET  /buildinfo.json build/drift info
GET  /openapi/*      OpenAPI document

There is no /metrics endpoint exposed by this service; metrics are emitted through the platform OpenTelemetry pipeline.


4bis) Required production configuration

EvidenceLocker now refuses to start outside the local harness when the dependency graph would silently resolve to the null fallbacks for capsule signing, RFC3161 timestamping, or Timeline anchoring. The startup validator (EvidenceLockerRuntimeConfigurationValidator — Sprint 20260501-051, audit finding L2; migrated to the shared IRuntimeConfigurationValidatorpipeline in Sprint 20260513_018 URCV-003) inspects the bound EvidenceLockerOptions before the host accepts traffic and throws InvalidOperationException with the exact configuration key required to recover.

Production deployments must configure all three of:

CapabilityRequired configurationNull fallback being prevented
Capsule signingPreferred (Sprint 20260513_016, PM directive): EvidenceLocker:Signing:UseCryptoRegistry=true — the capsule signer flows through the StellaOps Cryptography abstraction (ICryptoProviderRegistry → regional plugin: EIDAS / FIPS / GOST / SM / HSM). Set EvidenceLocker:Signing:Algorithm, EvidenceLocker:Signing:KeyId, and EvidenceLocker:Signing:Provider to bind to the active plugin’s key material. Legacy (preserved for backward compatibility): EvidenceLocker:Signing:KeyMaterial:Ed25519PrivateKeyBase64 (audit-chain default) or EvidenceLocker:Signing:KeyMaterial:EcPrivateKeyPem (regional FIPS interop) — ingest raw key material from configuration.NullCapsuleSigner ships unsigned decision capsules with status signer_not_registered.
RFC3161 timestampingEvidenceLocker:Signing:Timestamping:Enabled=true and EvidenceLocker:Signing:Timestamping:Endpoint=https://…NullTimestampAuthorityClient returns null, sealing evidence without an RFC3161 anchor.
Timeline anchoringEvidenceLocker:Timeline:Enabled=true and EvidenceLocker:Timeline:Endpoint=https://…NullEvidenceTimelinePublisher skips bundle-sealed, hold, and incident-mode publish events.

Crypto abstraction routing (Sprint 20260513_016). When UseCryptoRegistry=true, the capsule signer is CryptoRegistryCapsuleSigner (in src/EvidenceLocker/StellaOps.EvidenceLocker/Capsules/CryptoRegistryCapsuleSigner.cs). It resolves an ICryptoSigner from the registered ICryptoProviderRegistry using the configured KeyId/Algorithm/Provider triple, mirroring the pattern already established by EvidenceSignatureService (bundle manifest signer). Regional crypto plugins own key material; the EvidenceLocker process never sees raw private bytes from configuration. This is the PM-mandated production wiring.

EvidenceLocker:Signing:AllowUnsignedCapsules=true is treated as a harness-only flag: enabling it outside the local harness raises the same startup error as a null signer. There is no legitimate production reason to ship unsigned decision capsules.

Local harness opt-in (Development / Testing only)

For developer machines, integration tests, and Worker-only deployments where timeline publishing is intentionally disabled, set:

EvidenceLocker:
  LocalHarness:
    Enabled: true

…or set the host environment to TestingLocalHarness. The validator returns without error in either case. The flag is restricted to Development/Testing environments — Production hosts ignore it (defense-in-depth: a misdeployed Development image still trips the guard).

A worked example lives at samples/appsettings.LocalHarness.json. Container deployments can set the equivalent environment variable:

STELLAOPS_EVIDENCELOCKER__LOCALHARNESS__ENABLED=true

The validator runs from both EvidenceLocker.WebService/Program.cs and EvidenceLocker.Worker/Program.cs — there is no path to start either host with a silent null implementation. Removing the validator call without also removing the null DI registrations reintroduces the L2 finding.


5) Configuration

Source of truth: EvidenceLockerOptions (section EvidenceLocker). The keys below are the real bound options; the previous example used non-existent keys (Postgres, Storage:Provider=rustfs, Sealing:Policy, Retention:*, Export:*, Authority:RequiredScopes). None of those bind.

EvidenceLocker:
  Database:
    ConnectionString: "Host=postgres;Database=stellaops_platform;Search Path=evidence_locker;..."
    ApplyMigrationsAtStartup: true        # default true

  ObjectStore:
    Kind: FileSystem                       # FileSystem | AmazonS3
    EnforceWriteOnce: true                 # WORM — reject overwrite of an existing object key
    FileSystem:
      RootPath: "/data/evidence"
    AmazonS3:                              # only when Kind=AmazonS3
      BucketName: "stellaops-evidence"
      Region: "us-east-1"
      ServiceKind: AmazonS3                # AmazonS3 | S3Compatible
      Endpoint: null                       # required absolute URI for S3Compatible
      ForcePathStyle: null                 # required explicit true/false for S3Compatible
      Tls:
        VerifyServerCertificate: null      # explicit in S3Compatible; production requires true
        CustomCaCertificatePath: null      # optional absolute PEM/DER custom root CA path
        ClientCertificatePath: null        # optional absolute PKCS#12 or PEM certificate path
        ClientCertificateKeyPath: null     # optional absolute PEM private-key path
        ClientCertificatePassword: null    # optional PKCS#12 secret; inject, never commit
      Prefix: null
      UseIntelligentTiering: false
      ObjectLock:                          # optional S3 Object Lock (WORM)
        Enabled: false
        Mode: Governance                   # Governance | Compliance
        DefaultRetentionDays: 90
        DefaultLegalHold: false

  Quotas:
    MaxMaterialCount: 128
    MaxTotalMaterialSizeBytes: 536870912   # 512 MiB
    MaxMetadataEntries: 64
    MaxMetadataKeyLength: 128
    MaxMetadataValueLength: 512

  Signing:
    Enabled: true
    Algorithm: "ES256"                     # default SignatureAlgorithms.Es256
    KeyId: ""
    Provider: null
    PayloadType: "application/vnd.stella.evidence.manifest+json"
    UseCryptoRegistry: false               # true = route through ICryptoProviderRegistry (PM-preferred prod wiring)
    AllowEphemeralKeyMaterial: false       # local/test harness only
    AllowUnsignedCapsules: false           # local/test harness only
    KeyMaterial:                           # legacy raw-key paths (backward compat)
      Ed25519PrivateKeyBase64: null        # audit-chain default
      EcPrivateKeyPem: null                # regional FIPS interop (ECDSA P-256)
      EcPublicKeyPem: null
    Timestamping:                          # RFC3161 anchor (optional)
      Enabled: false
      Endpoint: null
      HashAlgorithm: "SHA256"
      RequireTimestamp: false
      RequestTimeoutSeconds: 30
      Authentication: { Username: null, Password: null }

  Timeline:                                # optional event anchoring
    Enabled: false
    Endpoint: null
    RequestTimeoutSeconds: 15
    Source: "stellaops.evidence-locker"
    Authentication: { HeaderName: "Authorization", Scheme: "Bearer", Token: null }

  Portable:
    Enabled: true
    ArtifactName: "portable-bundle-v1.tgz"
    InstructionsFileName: "instructions-portable.txt"
    OfflineScriptFileName: "verify-offline.sh"
    MetadataFileName: "bundle.json"

  Incident:
    Enabled: false
    RetentionExtensionDays: 30
    CaptureRequestSnapshot: true

  DataResidency:
    DefaultRegion: "unspecified"
    AllowedRegions: []                     # when non-empty, writes outside the list are rejected

  Crypto:
    HashAlgorithm: "SHA256"                # Merkle hash; SHA256/384/512, GOST3411-2012-256/512
    PreferredProvider: null

  RetentionSweep:                          # opt-in background tombstoning of expired non-RoI capsules
    Enabled: false
    DryRun: false
    DefaultRetentionDays: 3650             # 10 years (conservative)
    InitialDelay: "00:05:00"
    Interval: "12:00:00"

  # LocalHarness / environment TestingLocalHarness relaxes the production
  # startup validator (see §4bis). Production hosts ignore LocalHarness:Enabled.
  LocalHarness:
    Enabled: false

5.1 S3-compatible endpoint contract

ServiceKind=AmazonS3 preserves the AWS SDK regional endpoint and credential chain. An external RustFS, MinIO, or other S3-compatible service must instead set ServiceKind=S3Compatible and provide all of Endpoint, ForcePathStyle, and Tls:VerifyServerCertificate; the service validates these options on startup. Region remains required because it is the SigV4 request-signing region even when the endpoint is not AWS-hosted.

Outside the Development/Testing local harness, an external endpoint must use HTTPS and must verify the server certificate. CustomCaCertificatePath adds an explicit private trust root while retaining hostname validation. ClientCertificatePath loads a PKCS#12 client identity, or a PEM certificate when paired with ClientCertificateKeyPath. Referenced certificate paths must be absolute and exist in the EvidenceLocker host/container. Credentials still come from the AWS SDK credential chain; they are not part of this transport block.

The offline contract tests prove endpoint/signing-region selection, path-style selection, custom-root validation, client-certificate loading, and the production startup guard. A dated local SeaweedFS run additionally proves basic bucket and object write/read/delete interoperability through this adapter. Neither result constitutes RustFS/MinIO version certification or proves HTTPS/mTLS or server-side Object Lock; operators must validate those capabilities against their deployed product and version.

The default local Compose infrastructure exposes that SeaweedFS endpoint under the legacy service name rustfs and environment variable RUSTFS_IMAGE. Those identifiers are not a provider claim or a generic image plug-in point: the service entrypoint, command, mounted provisioning assets, and health check are SeaweedFS-specific. Private CAS/testing Compose files separately reference a RustFS image, but that image is not part of the local development runtime.

When ObjectLock:DefaultLegalHold=true, rejection of the S3 PutObjectLegalHold operation fails StoreAsync; EvidenceLocker does not silently downgrade a requested legal hold to an unprotected successful write. The environment-gated live probe requires explicit provider and version labels before it creates an Object-Lock-enabled disposable bucket and verifies Governance retention, legal-hold readback, and protected-version delete denial.

Retention is not a flat DefaultDays/ComplianceDays setting. A bundle’s expires_at is set immutably at seal time; extension is via evidence_holds, regulatory retention records (§9bis.8), and the opt-in RetentionSweep. Authority issuer/audience come from the shared resource-server config, not an EvidenceLocker:Authority block.


6) Sealing Process

6.1 Sealing Flow (append-only, single-insert)

After SPRINT_20260501_050 (audit finding L1, BUSL-1.1 pre-prod break) the EvidenceLocker enforces strict append-only storage. The previous Pending → Assembling → Sealed lifecycle that mutated a single row is replaced by a single-insert seal: the bundle row exists on disk only in Sealed (or Archived) state.

1. EvidenceSnapshotService receives a request
   ├─ Validates quotas (materials count, total size, metadata limits)
   └─ Captures incident-mode snapshot if required

2. EvidenceBundleBuilder builds the manifest *in memory*
   ├─ Computes the Merkle root over canonical-path/sha256 entries
   └─ Returns (rootHash, manifest); NO database write

3. IEvidenceSignatureService signs the canonical manifest
   └─ Produces an EvidenceBundleSignature (DSSE envelope + RFC3161 stamp)

4. IEvidenceBundleRepository.SealBundleAsync(bundle, signature, ct)
   ├─ Single transactional INSERT of the bundle row + signature row
   ├─ Bundle.Status MUST be Sealed (3) or Archived (5)
   └─ Pending (1), Assembling (2), Failed (4) are in-memory states only

5. Timeline publishes the bundle-sealed event

Re-seal (chain on payload change)

A subsequent re-seal of the same logical bundle (e.g. when the portable artifact is materialised) writes a NEW sealed row whose supersedes column points at the predecessor. The predecessor’s superseded_by is then linked via the SECURITY DEFINER SQL function evidence_bundle_set_superseded_by — the only UPDATE the row-level trigger permits on a sealed row. Any other UPDATE or DELETE attempt on evidence_bundles raises EVIDENCE_BUNDLE_IMMUTABLE (SQLSTATE EL010).

Portable download is idempotent across this append-only chain. A request for the predecessor follows superseded_by while the root hash remains unchanged and reuses the first existing portable_storage_key; it never attempts a second write-once object-store insert. A missing successor, cycle, or root-hash change fails visibly instead of returning evidence for a different snapshot.

Predecessor                    Successor
┌────────────────────┐         ┌────────────────────────┐
│ bundle_id: P       │         │ bundle_id: S           │
│ supersedes: NULL   │◄────────│ supersedes: P          │
│ superseded_by: S   │────────►│ superseded_by: NULL    │
│ portable_storage_  │         │ portable_storage_key:  │
│   key: NULL        │         │   tenants/.../v1.tgz   │
└────────────────────┘         └────────────────────────┘
   ▲                                ▲
   └─ original sealed row           └─ chain head

GET /evidence/{bundleId:guid}/chain (live; backed by IEvidenceBundleRepository.GetSupersedesChainAsync, scope evidence:read) returns the chain oldest-first so an auditor can replay the full sealed history of a bundle. Each entry carries the per-row bundle_id, root_hash, storage_key, sealed_at, signature key_id, and supersedes/superseded_by pointers. Default reindex / cross-reference queries return only chain heads (superseded_by IS NULL); pass includeSuperseded=true to walk the full history.

Retention

Bundle expires_at is immutable (set at seal time). Hold-driven retention extension lives in the evidence_holds table; the effective retention envelope for a bundle is computed as MAX(bundle.expires_at, MAX(active hold.expires_at)) by read-side queries. Operators extending retention must therefore create a hold record rather than rewriting the bundle row.

Rollback (intentionally removed)

Reindex checkpoint rollback is incompatible with append-only evidence storage and now throws NotSupportedException. To “revert” to a prior state, produce a new sealed bundle that captures the desired state and link it via SupersedeBundleAsync; the predecessor rows remain in the chain unchanged.

6.1.1 Protected Signing And Timestamp Fail-Closed Rules

Protected EvidenceLocker signing does not accept placeholder or best-effort time anchors. When EvidenceLocker:Signing:Timestamping:RequireTimestamp=true, sealing fails unless the configured timestamp authority returns a non-null RFC3161 timestamp result. A disabled, null, or no-op timestamp client is valid only for explicit local/test harnesses where timestamping is not required.

Protected evidence also requires durable signing material. If the configured crypto provider cannot resolve Signing:KeyId, EvidenceLocker loads configured key material; it no longer generates a transient key unless Signing:AllowEphemeralKeyMaterial=true is set for a local/test harness. Decision Capsules follow the same fail-closed posture: sealing cannot persist unsigned capsules unless Signing:AllowUnsignedCapsules=true is explicitly configured for a local/test harness.

Production configuration must therefore provide one of:

Decision Capsule signing follows the same rule. The runtime no longer registers a process-local capsule signer by default: it uses configured EC private key material when present, allows generated key material only when Signing:AllowEphemeralKeyMaterial=true is explicitly set for local/test harnesses, and otherwise treats capsule signing as unavailable. Protected capsule sealing then fails closed unless unsigned capsules are explicitly enabled for a local/test harness.

6.2 Chain Integrity

Two distinct integrity mechanisms apply (do not conflate them):

Predecessor                    Successor (chain head)
┌────────────────────┐         ┌────────────────────────┐
│ root_hash: H1      │         │ root_hash: H2           │
│ supersedes: NULL   │◄────────│ supersedes: P           │
│ superseded_by: S   │────────►│ superseded_by: NULL     │
└────────────────────┘         └────────────────────────┘

7) Security & compliance

7.1 Verdict attestation trust boundary

EvidenceLocker stores verdict attestations but does not own policy verdict generation. Verdict API tenant scope is derived from authenticated tenant context. Authority tenant slugs (for example default) are resolved through IEvidenceLockerTenantResolver; verdict rows and repository reads/lists use only the canonical tenant UUID. A store body may repeat either the authenticated slug or that mapped UUID, while any other tenant_id is rejected. Attestor’s direct store hop carries a short-lived signed identity envelope with only the authenticated request tenant and evidence:create. EvidenceLocker verifies that envelope before its normal authentication, scope, and tenant middleware; raw tenant headers or an unsigned service request cannot authorize the write.

POST /api/v1/verdicts/{verdictId}/verify is fail-closed until the deployment provides verdict trust roots, key resolution, and bundled/offline Rekor proof material. In that state the endpoint may parse signature metadata for diagnostics, but it returns signature_valid=false and inclusion_proof_valid=false with stable error codes instead of claiming cryptographic success.

The evidence_locker.verdict_attestations table is created by the embedded startup migration path and has row-level security enabled using app.current_tenant, matching the rest of the EvidenceLocker schema.

As verified on 2026-07-18, this production verdict-attestation contract does not extract, persist, or query SBOM component bom-refs. Attestor has separate bounded verdict-ledger primitives with one nullable bom_ref value and an unconsumed CycloneDX/SPDX component-reference extractor, but the production Attestor host does not compose a bom-ref query route. Treat component-level verdict indexing as unimplemented until one owner provides a tenant-scoped one-to-many schema, ingestion composition, API contract, and PostgreSQL forcing function.


8) Performance targets


9) Observability

Status (2026-05-29): roadmap, not yet implemented. No Meter/Counter registrations exist in src/EvidenceLocker/; the metric names below are a target instrumentation set, not live signals. Today the service emits structured audit events through StellaOps.Audit.Emission (module=evidence / module=evidencelocker) — including capsule create/seal/verify/export, snapshot, hold, verdict store/verify, TLPT pack assembly, DORA RoI retention, capsule download, and PII-rejection events — plus standard request logging.

Planned metrics:

Tracing (planned): Spans for ingestion, sealing, verification, export.


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

9bis.2) Storage

Table evidence_locker.decision_capsules (baseline 001_v1_evidencelocker_baseline.sql:242; folded in from the pre-1.0 005_decision_capsules.sql, now archived and not embedded):

columntypenotes
capsule_iduuid (PK)
tenant_iduuidRLS via evidence_locker_app.require_current_tenant()
manifest_jsontextCanonical manifest bytes (stored as text for byte-exact replay)
content_hashtextlowercase-hex SHA-256 of manifest_json
dsse_envelopetext / NULLDSSE envelope JSON (omitted for unsealed or unsigned capsules)
signing_statustextunsealed | signed | unsigned | invalid
assembly_inputsjsonbOpaque input references the capsule was assembled from
region_tagvarchar(16)DB-row-bound residency tag stamped from X-Stella-Region, token region claim, or deployment default (unspecified)
regulatory_retention_record_iduuid / NULLOptional link to evidence_locker.regulatory_retention_records for EU regulatory capsules
sealed_attimestamptzNon-null once sealed

Auto-migration applies on service startup via EvidenceLockerMigrationRunner (embedded resource; no manual SQL required).

9bis.2.1) Region Tagging and Residency Enforcement

Sprint SPRINT_20260518_051 adds region_tag varchar(16) NOT NULL DEFAULT 'unspecified' to decision_capsules, evidence_bundles, regulatory_audit_events, and evidence_packs. EvidenceLocker capsule write endpoints stamp the tag from the request’s explicit region claim (stellaops:region, stellaops:region_tag, region, or region_tag), then X-Stella-Region, then Platform’s tenant default in shared.tenants.default_region, then the EvidenceLocker deployment default (STELLAOPS_DEFAULT_REGION / EvidenceLocker:DataResidency:DefaultRegion), then unspecified. The tenant default lookup reads the Platform-owned shared.tenants table directly by tenant UUID or slug; it does not route through Router.

Capsule reads and writes are hard fail-closed for region mismatch. A request from region=us against a capsule row tagged eu-only receives HTTP 403 with region_forbidden, the attempted region, and the only allowed row region. If EvidenceLocker:DataResidency:AllowedRegions is configured, writes from regions outside that list are rejected before persistence. The object-store path is not yet region-tagged by code; the current residency proof is DB-row-bound only.

9bis.3) Signing

The pipeline reuses the AUDIT-007 pattern: canonicalise the manifest, assert the canonical JSON is PII-free, then delegate DSSE PAE signing to an abstraction (ICapsuleSigner). The concrete signer is selected at DI time (AddEvidenceLockerInfrastructure) in this precedence order:

  1. Signing:UseCryptoRegistry=trueCryptoRegistryCapsuleSigner (PM-mandated production wiring; resolves an ICryptoSigner from ICryptoProviderRegistry).
  2. Signing:KeyMaterial:Ed25519PrivateKeyBase64 set → Ed25519CapsuleSigner (the deterministic audit-chain default, Sprint SPRINT_20260501_010 C1).
  3. Signing:KeyMaterial:EcPrivateKeyPem set → EcdsaCapsuleSigner (ECDSA P-256 / SHA-256; regional-interop fallback only, opt-in).
  4. Signing:AllowEphemeralKeyMaterial=true → ephemeral Ed25519CapsuleSigner (local/test harness).
  5. Otherwise → NullCapsuleSigner, which returns an unsigned result with signing_status = "signer_not_registered" — same graceful-degradation contract as AUDIT-007. (The production startup validator forbids this state outside the local harness — see §4bis.)

All signers produce an envelope (DSSE payload type application/vnd.stellaops.decision-capsule+json, the CapsuleManifest.DssePayloadType constant) shaped identically to the export / audit-bundle DSSE envelopes, and assert pre-canonicalised payload bytes in SignPayloadAsync before constructing the envelope.

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. Caller tenant claims may arrive as slugs such as default; the capsule endpoints resolve those slugs through shared.tenants to the canonical UUID before calling CapsuleService or querying capsule rows. Unknown slugs fail closed with HTTP 403, while already-canonical UUID claims continue to work without a database lookup.

MethodRouteAudit actionScope
POST/api/v1/evidence/capsulescreate_capsuleEvidenceCreate
GET/api/v1/evidence/capsules/{id}read onlyEvidenceRead
POST/api/v1/evidence/capsules/{id}/sealseal_capsuleEvidenceCreate
POST/api/v1/evidence/capsules/{id}/verifyverify_capsuleEvidenceRead
POST/api/v1/evidence/capsules/{id}/exportexport_capsuleEvidenceRead
POST/api/v1/evidence/capsules/{id}/replayreplay_capsuleEvidenceRead

All five {id} routes require a non-empty canonical D-format UUID. Malformed, whitespace, empty-UUID, and non-D values return typed HTTP 400 invalid_capsule_id before repository access and do not echo the attacker-controlled path value. GET reads decision_capsules directly and returns 404 for an unknown id. For pii_policy_version = 0, its manifestJson is always a marked, non-canonical ordinary-read projection (legacyPiiSuppressed=true), never the raw stored signed bytes. Successful verification memory (lastVerifiedStatus=ok) may restore PROVEN after reload; every other or missing status remains unproven/unknown.

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.

Successful capsule export/download also emits a direct Timeline-ingest audit payload for the anomaly v2 feed: module=evidencelocker, action=download, resource.type=evidence_pack, resource.id=<capsuleId>, actor from the current identity envelope, and correlation_id from ICorrelationIdAccessor. This is additive to the existing export_capsule endpoint-filter audit event so legacy capsule audit consumers keep the old action while the cross-service anomaly engine receives the normalized EvidenceLocker download signal.

Capsule create, seal, and TLPT assembly reject PII-shaped canonical payloads with HTTP 400 capsule_pii_rejected. EvidenceLocker emits evidence.capsule.pii_rejected with the offending JSON path and violation kind only; the rejected value is not logged or returned.

9bis.5) Evidence pack catalog persistence

Sprint SPRINT_20260517_016 replaces the temporary dashboard fixture behind GET /api/v1/evidence/packs and GET /api/v1/evidence/packs/{id} with a tenant-scoped PostgreSQL catalog:

TablePurpose
evidence_locker.evidence_packsOne dashboard/search row per evidence pack, keyed by (tenant_id, pack_id) and carrying release ID, environment, bundle/manifest version, seal status, manifest digest, decision, promotion run, proof chain, and timestamps.
evidence_locker.evidence_pack_artifactsOrdered artifact summary rows for pack detail responses, keyed by (tenant_id, pack_id, ordinal).

Both tables have row-level security enabled and compare tenant_id to evidence_locker_app.require_current_tenant(). The API handlers also resolve the authenticated tenant through IStellaOpsTenantAccessor and call IPackRepository.ListByTenantAsync / GetByTenantAsync; there is no header-only tenant fallback and no static demo data path. The catalog table evidence_locker.evidence_packs is created by the startup baseline 001_v1_evidencelocker_baseline.sql (line 944; folded in from the pre-1.0 009_evidence_packs.sql) through the existing EvidenceLocker migration runner.

9bis.6) 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.

9bis.7) DORA TLPT evidence pack capsules

Sprint SPRINT_20260430_132 adds tlpt-evidence-pack.v1 as a second capsule manifest kind under the same repository and signer pipeline. The TLPT pack is a long-lived signed capsule that binds:

The manifest kind is TlptEvidencePackManifest, and the DSSE payload type is application/vnd.stellaops.tlpt-evidence-pack+json. CapsuleService assembles and signs TLPT packs through ICapsuleSigner.SignPayloadAsync; protected assembly fails closed when no durable signer is configured. Capsule verification selects the DSSE payload type from the stored manifest kind so existing Decision Capsules and TLPT packs can both verify offline.

Contract: docs/contracts/tlpt-evidence-pack-v1.md.

Audit trail: POST /api/v1/evidence/capsules/tlpt-packs assembles the same service-level TLPT capsule and emits evidence.assemble_tlpt_pack with the pack hash/content hash, signer key id, signing status, retention policy/expiry, capsule id, and ledger id. The endpoint is additive; local CLI pack assembly continues to use the existing capsule service path and does not change the fail-closed signing rules.

9bis.8) DORA RoI capsule retention metadata

Sprint SPRINT_20260430_124 wires annual Register of Information retention metadata into the existing Decision Capsule path. Callers may include a regulatoryRetention block on POST /api/v1/evidence/capsules with artifactType=dora-roi-snapshot. EvidenceLocker resolves missing DORA RoI retention values to the approved platform default:

Tenant overrides are accepted only in the 5 to 10 year range enforced by RegulatoryRetentionRules, and any non-default DORA RoI retention value must carry overrideAuditEventId. The service persists the reusable evidence_locker.regulatory_retention_records row, links decision_capsules.regulatory_retention_record_id, and returns the retention metadata on capsule responses and export metadata.json.

Every new or updated regulatory retention record must retain through at least createdAt.ToUniversalTime().AddYears(retentionYears). This uses calendar-year arithmetic, including .NET/PostgreSQL leap-day behavior, rather than a fixed 365-day multiplier. Legal hold and shortening approval may extend or authorize a change relative to an earlier commitment; neither permits a record below its own declared horizon. Core validation rejects a missing/overflowing createdAt, while migration 019_regulatory_retention_horizon.sql enforces the same relationship against the persisted created_at, closing update-time timestamp spoofing. The database constraint is introduced NOT VALID: it enforces every subsequent insert/update without asserting that historical rows have already been audited. Operators must audit any legacy violations before a future VALIDATE CONSTRAINT.

Audit trail: capsule creation emits dora.roi.snapshot.retention.applied with the capsule id, content hash, regulatory artifact hash, retention record id, retention policy id, retention years, retain-until timestamp, legal hold timestamp, and override audit event id when DORA RoI retention metadata is applied.

9bis.9) PII Field Inventory

Sprint SPRINT_20260518_051 records the capsule privacy boundary: evidence capsules carry opaque references, hashes, timestamps, and content references. Raw PII such as email addresses, personal names, IP addresses, user-agent strings, external employee IDs, and free-form usernames must live outside the sealed capsule payload in the mutable shared.actor_identity side table planned by TASK-051-02.

Findings ledger — migrated (SPRINT_20260519_078). The Findings Ledger now persists only an opaque actor_ref (UUID) alongside the opaque actor_id (OIDC sub); it never stored actor_email/actor_ip/actor_user_agent columns, and it reuses this section’s CapsulePiiGuard (via a project reference to StellaOps.EvidenceLocker.Core) to reject any ledger event whose canonical hash-chained envelope contains an email/IP/user-agent/name-shaped value (emitting findings.actor_pii_rejected). The opaque actor_ref is the join key the Subject Access Request aggregator uses to surface Findings events, so the documented findings_actor_pii_lift_pending SAR gap from sprint SPRINT_20260518_051 is closed.

The current inventory shows the core Decision Capsule contract is already PII-light. The material risks are the free-form text columns and JSON payloads that can bind operator-entered text into immutable records. TASK-051-03 owns the write-time guard and canonical-payload assertion that prevents those risks from becoming sealed PII.

Rows marked Keep are validated by TASK-051-01’s reflection audit. Rows that need behaviour changes name the owning follow-on task in the Proposed action column.

Field nameCapsule type / tableWhy it is there todayClassificationProposed action
capsuleIdCapsuleManifest, decision_capsules.capsule_idStable capsule identity and DSSE replay anchor.OpaqueKeep; TASK-051-03 asserts no embedded semantics.
tenantIdCapsuleManifest, capsule responses, tenant-scoped tablesTenant isolation and RLS key.Opaque UUIDKeep; tenant display names remain outside capsules.
createdAt, sealedAt, updated_atCapsule contracts and persistence tablesAudit ordering, sealing status, replay evidence.Non-PII timestampKeep; TASK-051-05 adds region_tag without changing timestamp semantics.
generatorCapsuleManifest, TlptEvidencePackManifestIdentifies the Stella Ops component and version that assembled the manifest.Non-PII service metadataKeep.
sbomRef, reachabilityRef, derivedVexRef, vulnFeedRefsCapsuleManifest, CreateCapsuleRequestContent-addressed inputs to a release decision.Content reference / hashKeep; TASK-051-03 rejects PII-looking inline values before signing.
policyId, policyVersionCapsuleManifest, CreateCapsuleRequestBinds policy snapshot to the decision.Non-PII policy referenceKeep.
pii_policy_version, PiiPolicyVersiondecision_capsules, CapsuleRecordTracks whether a capsule row has passed the no-PII write policy or still needs legacy read-side tomb-stoning.Non-PII policy metadataKeep; explicitly reviewed so TASK-051-01’s reflection audit can distinguish policy metadata from personal data.
verdict, verdictSummaryCapsuleManifest, CreateCapsuleRequestHuman-readable release decision summary.Free-form riskTASK-051-03 guard rejects emails, IPs, user agents, and name-shaped values before canonical signing.
manifest_jsonevidence_locker.decision_capsulesByte-exact canonical manifest for verification.Sealed payload, must be PII-freeTASK-051-03 adds canonical JSON assertion before insert/sign.
assembly_inputsevidence_locker.decision_capsulesStructured opaque inputs used to assemble the capsule.JSON free-form riskTASK-051-03 validates and strips/blocks PII-shaped strings before persistence.
reason, notesevidence_locker.evidence_holdsOperator legal-hold justification and optional notes.Free-form PII riskMove through TASK-051-03 write validator; later render through shared.actor_identity redaction where actor data appears.
descriptionevidence_locker.evidence_bundlesOptional bundle description.Free-form PII riskTASK-051-03 guard or write-path validator before sealed bundle creation.
actor_refregulatory_audit_events, RegulatoryAuditEvent.ActorRefOpaque actor identifier for audit graph joins.Opaque actor referenceKeep; already rejects @ via ValidateOpaqueReference; TASK-051-02 maps clear PII in shared.actor_identity.
approver_idregulatory_audit_events, RegulatoryAuditEvent.ApproverIdOpaque approver reference for regulatory approval events.Opaque actor referenceKeep; existing canonicalizer rejects email-shaped values.
signer_key_id, signer_fingerprintRegulatory audit events and TLPT responsesIdentifies signing key material, not a natural person.Non-PII key metadataKeep.
propertiesregulatory_audit_events, RegulatoryClassifiedPropertyExtensible regulatory metadata with classification.Classified; can be personal/sensitiveKeep only with redaction; existing canonicalizer redacts Personal and Sensitive values.
scopeHash, baselineHash, artifactHash, attestationHash, documentHashTLPT evidence pack contractsHashes binding TLPT inputs and attestations.HashKeep.
scopeId, packId, artifactRef, documentRef, approvalRefTLPT evidence pack contractsOpaque IDs and content references.Opaque/referenceKeep; TASK-051-03 guard blocks PII-shaped inline refs.
approvedByRefTlptPackSignOffOpaque white-team approval actor reference.Opaque actor referenceKeep; use shared.actor_identity for display name/email after TASK-051-02.
description, reasonTlptPackArtifactRef, TlptPackRetentionOperator TLPT context and retention rationale.Free-form PII riskTASK-051-03 guard before TLPT capsule signing.
canonical_id, artifact_digest, purl, dsse_digest, signer_keyid, rekor_entry_id, rekor_tileEvidence thread response contractsEvidence thread identity, package/content address, signature and transparency references.Non-PII reference/hashKeep.
transparency_status.reasonEvidence thread response contractsOffline transparency skip reason.Limited free-form riskKeep with future guard if caller-supplied values become accepted.
release_id, environment, promotion_run_id, proof_chain_id, manifest_digestevidence_packsDashboard/search metadata for evidence packs.Non-PII release metadata/hashKeep; do not add actor display fields here.

Regression coverage: PiiFieldInventoryAuditTests fails if a new capsule-bearing contract adds an email, ip, username, fullName, address, phone, or userAgent shaped field without an explicit inventory review. That test is a tripwire, not the final runtime guard; TASK-051-03 owns the canonical payload validator.

Runtime guard: CapsulePiiGuard.AssertNoPii walks canonical JSON in stable property order and rejects email addresses, IPv4 literals, IPv6 literals, browser user-agent strings, and strings longer than 64 characters in fields named name, displayName, fullName, or username. The guard allows sha256:<64 hex> hashes, UUIDs, and opaque actor/content references. It is currently wired into Decision Capsule and TLPT canonicalisation/signing, decision_capsules.manifest_json, decision_capsules.assembly_inputs, RegulatoryAuditEventCanonicalizer, and evidence_holds.reason/notes.

Read-side actor identity redaction is not yet wired in EvidenceLocker because TASK-051-02 delivered the shared.actor_identity schema and Platform-local store only; the cross-service Platform.ActorIdentity.Client package and EvidenceLocker consumer dependency are still pending. Until that client exists, capsule read endpoints must not synthesize display names, email addresses, IP addresses, or user agents from capsule payloads.

9bis.10) Legacy Capsule Erasure Audit

Sprint SPRINT_20260518_051 TASK-051-07 adds a legacy classification layer for capsules written before the no-PII guard became mandatory. The baseline 001_v1_evidencelocker_baseline.sql (line 1175 onward; folded in from the pre-1.0 011_legacy_pii_audit.sql) adds pii_policy_version to decision_capsules, evidence_bundles, regulatory_audit_events, and evidence_packs. Existing rows start at version 0; new rows default to version 1.

On startup, LegacyCapsulePiiAuditService scans legacy decision capsules whose policy version is still 0. It runs CapsulePiiGuard against the stored canonical manifest_json. Rows that pass are upgraded to version 1; rows that fail remain version 0 and are recorded in evidence_locker.legacy_pii_capsules with deterministic field paths and action erasure_refused_signature_integrity. The scanner never rewrites manifest_json or dsse_envelope, preserving DSSE verification for historical capsules.

LegacyCapsuleErasureRefusalService provides the EvidenceLocker-side erasure refusal primitive for callers that erase actor identity data. If a requested actor reference appears in a ledgered legacy capsule, it emits actor.erasure.legacy_capsule_refused and returns a partial result with the capsule IDs, pii_policy_version, field paths, and the GDPR Art. 23 / DORA / NIS2 / CRA audit-retention basis. Platform SAR/erasure endpoint adoption is a cross-service follow-on; EvidenceLocker currently supplies the persistence, scan, and refusal/audit behavior.

Operator-facing policy: legacy-capsule-erasure-policy.md.


10) Testing matrix