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.
- EvidenceLocker stores evidence; it does not generate verdicts.
- EvidenceLocker seals bundles and capsules in-process via the StellaOps Cryptography abstraction (
ICryptoProviderRegistry) / configured key material. Signing is not delegated to a separate Signer service — there is no Signer client insrc/EvidenceLocker/. - Evidence is immutable once sealed; UPDATE/DELETE on a sealed bundle row raises
EVIDENCE_BUNDLE_IMMUTABLE(SQLSTATEEL010). A change produces a new sealed row linked viasupersedes/superseded_by. - Supports offline / portable export for air-gapped compliance audits.
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 underStellaOps.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.csis 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 isPOST /evidence/snapshotfollowed byGET /evidence/{bundleId}; the/evidence/qa/assurance-cases/{caseId}/readback-importroute is a QA-only assurance forcing function, not a general portable-bundle importer.
Background work. The
Workerhost 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 byAddEvidenceLockerInfrastructure:EvidenceLockerMigrationHostedService(startup auto-migration),CapsuleRetentionSweepService(opt-in expired-capsule tombstoning), andLegacyCapsulePiiAuditHostedService(startup legacy-PII classification).
2) External dependencies
- PostgreSQL - Metadata + sealed-bundle/capsule storage (schemas
evidence_lockerandevidence_locker_app). Auto-migrated on startup byEvidenceLockerMigrationHostedService→EvidenceLockerMigrationRunner(embedded SQL underInfrastructure/Db/Migrations). - Object store -
IEvidenceObjectStorefor evidence bytes. Two drivers exist:FileSystemEvidenceObjectStore(ObjectStore:Kind=FileSystem) andS3EvidenceObjectStore(ObjectStore:Kind=AmazonS3). AWS regional S3 is selected withAmazonS3:ServiceKind=AmazonS3. RustFS/MinIO compatibility is conditional on the explicitAmazonS3:ServiceKind=S3Compatibletransport contract in §5: endpoint URI, addressing style, and TLS trust must all be configured and pass startup validation. There is no dedicated RustFS or MinIO driver kind. - StellaOps Cryptography (
ICryptoProviderRegistry, BouncyCastle Ed25519 provider) - In-process signing/hashing. Bundle manifests are signed byEvidenceSignatureService; decision capsules by anICapsuleSigner(CryptoRegistryCapsuleSigner/Ed25519CapsuleSigner/EcdsaCapsuleSigner/NullCapsuleSigner). Sealing is not delegated to a separate Signer service — there is no Signer client dependency insrc/EvidenceLocker/. - RFC3161 Timestamp Authority (optional) -
Rfc3161TimestampAuthorityClientwhenSigning:Timestamping:Enabled=true; otherwiseNullTimestampAuthorityClient. - Timeline / TimelineIndexer (optional) -
TimelineIndexerEvidenceTimelinePublisherwhenTimeline:Enabled=true; otherwiseNullEvidenceTimelinePublisher. Audit events are additionally posted viaStellaOps.Audit.Emission(AddAuditEmission). - Authority - OAuth2 resource-server authentication + tenant resolution (
AddStellaOpsResourceServerAuthentication,evidence:*scopes). - Router (Stella) - Service is fronted by the in-platform router as service
evidencelocker(AddRouterMicroservice). - Platform
shared.tenants- Read directly to resolve tenant slug claims (e.g.default) to canonical UUIDs and look up default region; not routed through Router.
3) Contracts & data model
Source of truth:
StellaOps.EvidenceLocker.Core/Domain/*and the embedded SQL migrations. There is nocasUri,eb-YYYY-…bundle id,items[]inline-item array, orchain.sequenceNumberin 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):
| Scope | Const | Used for |
|---|---|---|
evidence:read | EvidenceRead | All reads (also the global fallback/default policy). |
evidence:create | EvidenceCreate | Writes: gate-artifact store, capsule create/seal, verdict store, hold, QA readback. |
evidence:hold | EvidenceHold | Creating an evidence snapshot (POST /evidence/snapshot). |
There is no
evidence:writescope (the legacyRequiredScopes: ["evidence:read","evidence:write"]in older config docs was never implemented). Export endpoints reuse the Export Center scopesexport.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.
AddEvidenceLockerInfrastructureTryAddSingletonsPostgresEvidenceLockerStorage+PostgresEvidenceBundleExporter(EvidenceLockerInfrastructureServiceCollectionExtensions.cs:380/:390). The deterministic in-memory seeded adapters (golden bundlebundle-golden-001) are registered only whenEvidenceLockerRuntimeConfigurationValidator.IsLocalHarness(...)is true (WebService/Program.cs:112-119) — i.e. environmentTestingLocalHarness, orEvidenceLocker:LocalHarness:Enabled=trueunder 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:
| Capability | Required configuration | Null fallback being prevented |
|---|---|---|
| Capsule signing | Preferred (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 timestamping | EvidenceLocker:Signing:Timestamping:Enabled=true and EvidenceLocker:Signing:Timestamping:Endpoint=https://… | NullTimestampAuthorityClient returns null, sealing evidence without an RFC3161 anchor. |
| Timeline anchoring | EvidenceLocker: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(sectionEvidenceLocker). 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/ComplianceDayssetting. A bundle’sexpires_atis set immutably at seal time; extension is viaevidence_holds, regulatory retention records (§9bis.8), and the opt-inRetentionSweep. Authority issuer/audience come from the shared resource-server config, not anEvidenceLocker:Authorityblock.
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:
- a durable provider key addressable by
Signing:ProviderandSigning:KeyId; - configured
Signing:KeyMaterialseeded from secret storage; - an external signer integration that returns a verifiable DSSE envelope.
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):
- Within a sealed bundle —
RootHashis the SHA-256 Merkle root over the bundle’s canonical-path/sha256 artifact entries. Re-computing the root over the stored artifacts detects any tampering of bundle contents. - Across re-seals of a logical bundle — successive sealed rows are linked by the
supersedes/superseded_bypointers (see §6.1), not by aprevHash/sequence field. The chain head is the row withsuperseded_by IS NULL; walkingsupersedesbackwards replays the full sealed history.
Predecessor Successor (chain head)
┌────────────────────┐ ┌────────────────────────┐
│ root_hash: H1 │ │ root_hash: H2 │
│ supersedes: NULL │◄────────│ supersedes: P │
│ superseded_by: S │────────►│ superseded_by: NULL │
└────────────────────┘ └────────────────────────┘
7) Security & compliance
- Immutability: Sealed bundles cannot be modified
- Tamper detection: Merkle tree verification for all items
- Chain validation: Linked bundle verification
- Encryption at rest: Optional bundle encryption
- Access control: Tenant-scoped with Authority tokens
- Audit trail: All access logged
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
- Item ingestion: < 100ms P95 per item
- Sealing: < 500ms P95 per bundle (up to 100 items)
- Verification: < 200ms P95 per bundle
- Export: < 5s P95 for 100 bundles
9) Observability
Status (2026-05-29): roadmap, not yet implemented. No
Meter/Counterregistrations exist insrc/EvidenceLocker/; the metric names below are a target instrumentation set, not live signals. Today the service emits structured audit events throughStellaOps.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:
evidence.bundles.created_total{tenant}evidence.bundles.sealed_total{tenant}evidence.items.ingested_total{type}evidence.verification.duration_secondsevidence.storage.bytes_total
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
- 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. The canonicalizer callsCapsulePiiGuard.AssertNoPiiafter canonicalisation and before the bytes can be signed or persisted. - Content address: SHA-256 of the canonicalised bytes, surfaced as
contentHashon every API response and persisted alongside the manifest inevidence_locker.decision_capsules.
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):
| 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 |
region_tag | varchar(16) | DB-row-bound residency tag stamped from X-Stella-Region, token region claim, or deployment default (unspecified) |
regulatory_retention_record_id | uuid / NULL | Optional link to evidence_locker.regulatory_retention_records for EU regulatory capsules |
sealed_at | timestamptz | Non-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:
Signing:UseCryptoRegistry=true→CryptoRegistryCapsuleSigner(PM-mandated production wiring; resolves anICryptoSignerfromICryptoProviderRegistry).Signing:KeyMaterial:Ed25519PrivateKeyBase64set →Ed25519CapsuleSigner(the deterministic audit-chain default, Sprint SPRINT_20260501_010 C1).Signing:KeyMaterial:EcPrivateKeyPemset →EcdsaCapsuleSigner(ECDSA P-256 / SHA-256; regional-interop fallback only, opt-in).Signing:AllowEphemeralKeyMaterial=true→ ephemeralEd25519CapsuleSigner(local/test harness).- Otherwise →
NullCapsuleSigner, which returns an unsigned result withsigning_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.
| Method | Route | Audit action | Scope |
|---|---|---|---|
| POST | /api/v1/evidence/capsules | create_capsule | EvidenceCreate |
| GET | /api/v1/evidence/capsules/{id} | read only | EvidenceRead |
| 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 |
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:
| Table | Purpose |
|---|---|
evidence_locker.evidence_packs | One 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_artifacts | Ordered 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:
tlpt-scope.v1scope hash and document ref.tlpt-baseline.v1Replay baseline hash and document ref.- Controls inventory artifact refs.
- Runtime instrumentation pre/post snapshot refs.
- Post-test attestation refs.
- White-team sign-off and default 10-year retention metadata.
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:
retentionPolicyId=dora-roi-annual-v1.retentionYears=7.retainUntil=createdAt+7 years.
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 name | Capsule type / table | Why it is there today | Classification | Proposed action |
|---|---|---|---|---|
capsuleId | CapsuleManifest, decision_capsules.capsule_id | Stable capsule identity and DSSE replay anchor. | Opaque | Keep; TASK-051-03 asserts no embedded semantics. |
tenantId | CapsuleManifest, capsule responses, tenant-scoped tables | Tenant isolation and RLS key. | Opaque UUID | Keep; tenant display names remain outside capsules. |
createdAt, sealedAt, updated_at | Capsule contracts and persistence tables | Audit ordering, sealing status, replay evidence. | Non-PII timestamp | Keep; TASK-051-05 adds region_tag without changing timestamp semantics. |
generator | CapsuleManifest, TlptEvidencePackManifest | Identifies the Stella Ops component and version that assembled the manifest. | Non-PII service metadata | Keep. |
sbomRef, reachabilityRef, derivedVexRef, vulnFeedRefs | CapsuleManifest, CreateCapsuleRequest | Content-addressed inputs to a release decision. | Content reference / hash | Keep; TASK-051-03 rejects PII-looking inline values before signing. |
policyId, policyVersion | CapsuleManifest, CreateCapsuleRequest | Binds policy snapshot to the decision. | Non-PII policy reference | Keep. |
pii_policy_version, PiiPolicyVersion | decision_capsules, CapsuleRecord | Tracks whether a capsule row has passed the no-PII write policy or still needs legacy read-side tomb-stoning. | Non-PII policy metadata | Keep; explicitly reviewed so TASK-051-01’s reflection audit can distinguish policy metadata from personal data. |
verdict, verdictSummary | CapsuleManifest, CreateCapsuleRequest | Human-readable release decision summary. | Free-form risk | TASK-051-03 guard rejects emails, IPs, user agents, and name-shaped values before canonical signing. |
manifest_json | evidence_locker.decision_capsules | Byte-exact canonical manifest for verification. | Sealed payload, must be PII-free | TASK-051-03 adds canonical JSON assertion before insert/sign. |
assembly_inputs | evidence_locker.decision_capsules | Structured opaque inputs used to assemble the capsule. | JSON free-form risk | TASK-051-03 validates and strips/blocks PII-shaped strings before persistence. |
reason, notes | evidence_locker.evidence_holds | Operator legal-hold justification and optional notes. | Free-form PII risk | Move through TASK-051-03 write validator; later render through shared.actor_identity redaction where actor data appears. |
description | evidence_locker.evidence_bundles | Optional bundle description. | Free-form PII risk | TASK-051-03 guard or write-path validator before sealed bundle creation. |
actor_ref | regulatory_audit_events, RegulatoryAuditEvent.ActorRef | Opaque actor identifier for audit graph joins. | Opaque actor reference | Keep; already rejects @ via ValidateOpaqueReference; TASK-051-02 maps clear PII in shared.actor_identity. |
approver_id | regulatory_audit_events, RegulatoryAuditEvent.ApproverId | Opaque approver reference for regulatory approval events. | Opaque actor reference | Keep; existing canonicalizer rejects email-shaped values. |
signer_key_id, signer_fingerprint | Regulatory audit events and TLPT responses | Identifies signing key material, not a natural person. | Non-PII key metadata | Keep. |
properties | regulatory_audit_events, RegulatoryClassifiedProperty | Extensible regulatory metadata with classification. | Classified; can be personal/sensitive | Keep only with redaction; existing canonicalizer redacts Personal and Sensitive values. |
scopeHash, baselineHash, artifactHash, attestationHash, documentHash | TLPT evidence pack contracts | Hashes binding TLPT inputs and attestations. | Hash | Keep. |
scopeId, packId, artifactRef, documentRef, approvalRef | TLPT evidence pack contracts | Opaque IDs and content references. | Opaque/reference | Keep; TASK-051-03 guard blocks PII-shaped inline refs. |
approvedByRef | TlptPackSignOff | Opaque white-team approval actor reference. | Opaque actor reference | Keep; use shared.actor_identity for display name/email after TASK-051-02. |
description, reason | TlptPackArtifactRef, TlptPackRetention | Operator TLPT context and retention rationale. | Free-form PII risk | TASK-051-03 guard before TLPT capsule signing. |
canonical_id, artifact_digest, purl, dsse_digest, signer_keyid, rekor_entry_id, rekor_tile | Evidence thread response contracts | Evidence thread identity, package/content address, signature and transparency references. | Non-PII reference/hash | Keep. |
transparency_status.reason | Evidence thread response contracts | Offline transparency skip reason. | Limited free-form risk | Keep with future guard if caller-supplied values become accepted. |
release_id, environment, promotion_run_id, proof_chain_id, manifest_digest | evidence_packs | Dashboard/search metadata for evidence packs. | Non-PII release metadata/hash | Keep; 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
- Sealing tests: Correct Merkle root computation
- Chain tests: Linked bundle verification
- Tamper tests: Detection of modified items
- Export tests: Archive integrity verification
- Retention tests: Policy enforcement
Related Documentation
- Bundle packaging:
./bundle-packaging.md - Attestation contract:
./attestation-contract.md - Evidence bundle spec:
./evidence-bundle-v1.md - Evidence pack schema:
./guides/evidence-pack-schema.md - Promotion gate evidence contract:
./promotion-evidence-contract.md - EU regulatory persistence:
./eu-regulatory-persistence.md - EU regulatory audit contract:
../../contracts/eu-regulatory-audit-events-v1.md - EU regulatory artifact ledger contract:
../../contracts/eu-regulatory-artifact-ledger-v1.md - Audit bundle index schema:
./schemas/audit-bundle-index.schema.json - ExportCenter:
../export-center/architecture.md - Attestor:
../attestor/architecture.md
