Evidence — architecture
Source ownership, 2026-10-03. PCD-253 withdrew the uncompiled
StellaOps.EvidenceLocker.Import/EvidenceBundleImporter.cspredecessor draft. No project or DI consumer used it. Evidence custody/export and OfflineKit import ownership remain unchanged; no import capability is inferred from the frozen draft. See the per-file source disposition.
Verified against commit
942a18727con 2026-09-26 (UTC). Re-verify:git grep -n "Map[A-Za-z]*Endpoints\|UseAttestorWebService" -- src/Evidence/StellaOps.Evidence.WebService/Program.csandls src/Evidence/__Libraries/StellaOps.Evidence.Persistence/Migrations.
This is the module dossier for the consolidated Evidence service (ADR-039 D14). It absorbed the retired attestor host and the retired evidence-locker-web/evidence-locker-worker hosts. The predecessor EvidenceLocker dossier is archived at docs-archive/modules/evidence-locker/; its still-current format and contract documents live in locker/. Signer is not part of this family and never joins it: key custody is a separate service with a separate database.
Timestamp Assurance is composed in source: the web role registers installation checks and the tenant API; EvidenceWorkerComposition registers the same sources plus the schedules. Runtime availability depends on the deployed image and the configured provider and trust inputs.
Further re-verification commands for specific claims:
# the family's own database, and only its own
rg -n "ConnectionEnvironmentVariable|refuses to start" \
src/Evidence/__Libraries/StellaOps.Evidence.Persistence/Extensions/ConsolidatedEvidencePersistenceExtensions.cs
# the removed trusted-network bypass
rg -n "StellaOpsBypassMode|BypassNetworks" src/Evidence/StellaOps.Evidence.WebService/Program.cs
# the published route surface, pinned row by row
cat src/Evidence/__Tests/StellaOps.Evidence.WebService.Tests/Fixtures/consolidated-route-inventory.tsv
# the release bundle members
grep -nE "^ (evidence-web|evidence-worker|attestor-tileproxy|tsa):" devops/release/bundle/docker-compose.yml
1. One family, two roles
src/Evidence/ ships one deployable family with two replica roles. They are not two services: they share one database, one migration set and one source graph, and neither project references the other (P19, stated in both csprojs).
| Role | Source | Image | Host kind | Serves |
|---|---|---|---|---|
evidence-web | src/Evidence/StellaOps.Evidence.WebService/ | stellaops/evidence-web | WebApplication.CreateBuilder | the whole HTTP surface in §3 |
evidence-worker | src/Evidence/StellaOps.Evidence.Worker/ | stellaops/evidence-worker | WebApplication.CreateSlimBuilder | the carried background loops and worker health endpoints |
Both roles are in the canonical compose model and in the release bundle (devops/release/bundle/docker-compose.yml services evidence-web and evidence-worker, each with STELLAOPS_POSTGRES_EVIDENCE_CONNECTION).
The worker serves the shared liveness and startup-readiness endpoints, with matching Compose healthcheck paths. Its loops are composed in EvidenceWorkerComposition.cs (AddAttestorBackgroundWorkers and AddEvidenceLockerInfrastructure(..., EvidenceLockerHostedServiceOptions.ConsolidatedWorker)), and the object store is resolved eagerly after Build() so a misconfigured store fails at startup rather than on the first job (Worker/Program.cs, pinned by EagerStoreResolutionConformanceTests).
Verified-by: src/Evidence/StellaOps.Evidence.WebService/Program.cs; src/Evidence/StellaOps.Evidence.Worker/Program.cs; src/Evidence/StellaOps.Evidence.Worker/EvidenceWorkerComposition.cs; devops/compose/docker-compose.evidence.yml.
What the tree holds
Libraries under src/Evidence/__Libraries/, in three groups. The grouping says which code was written for this family and which was carried into it:
- Born new:
StellaOps.Evidence.Persistence(the family’s one DAL) and theStellaOps.Evidence.TimestampAssurance*projects (base,.Adapters,.Hosting,.Remediation,.Scheduling,.TrustBundle). - Carried from Attestor:
StellaOps.Attestor.Application,.Bundle,.Bundling,.CapsuleProjection,.Core,.Infrastructure,.Persistence,.ProofChain,.Spdx3,.StandardPredicates,.TrustRepo,.Verify,.Watchlist. - Carried from EvidenceLocker:
StellaOps.EvidenceLocker,.Core,.Application,.Infrastructure.
A residual predecessor tree, src/EvidenceLocker/, still holds live shared libraries under src/EvidenceLocker/__Libraries/ (StellaOps.EvidenceLocker.Contracts, .Export, .Import, .Timestamping) and the backup tool src/EvidenceLocker/__Apps/StellaOps.EvidenceLocker.BackupCli (see encrypted backup and restore). It has no host: its WebService and Worker projects are frozen under src/__Obsoleted/EvidenceLocker/.
The carried application types use StellaOps.Evidence.Attestation.* and StellaOps.Evidence.Locker.Api.*. The latter includes the carried audit, security and contract types. Assembly names and wire contracts are unchanged. The retained verdict-ledger endpoint library also uses the consolidated attestation namespace; its historical project filename is not a host. EvidenceApplicationNamespaceTests pins the emitted types and source declarations against predecessor namespace reintroduction. Rebuild each consuming role with its matching application libraries; these internal CLR type names have no compatibility aliases.
2. The database boundary
The family owns one physical database, stellaops_evidence, on the shared PostgreSQL installation, with its own role (CoC §8.2, ADR-039). Not a per-service PostgreSQL container: the scanner precedent e01c4a43ce retired exactly that over-realization.
One connection knob, no fallback. The DAL resolves STELLAOPS_POSTGRES_EVIDENCE_CONNECTION, then the equally specific configuration key Postgres:Evidence:ConnectionString, and otherwise throws:
Consolidated Evidence persistence refuses to start without its own database. Set
STELLAOPS_POSTGRES_EVIDENCE_CONNECTION(or configuration keyPostgres:Evidence:ConnectionString) to thestellaops_evidenceconnection string. There is no generic shared-connection fallback (CoC §8.2 / ADR-039 database-per-service).
Both roles then assign that one resolved string into the carried compatibility keys (EvidenceLocker:Database:ConnectionString, attestor:postgres:connectionString and the rest) rather than binding them, and set EvidenceLocker:Database:ApplyMigrationsAtStartup = false. That is the mechanism that makes a second way into a different database impossible: a carried key cannot point somewhere else, because nothing reads it from configuration any more.
| Schema | Carries |
|---|---|
evidence | the migration ledger, Timestamp Assurance and retention machinery |
attestor | entries, recorded verification receipts, verdict ledger, watchlist |
proofchain | merkle spines, receipts, CAS index |
evidence_locker | capsules, materials, holds, exports, the regulatory artifact ledger |
evidence_locker_app | the tenant-context helper functions |
Every evidence_locker table and every evidence table except the migration ledger carries FORCE ROW LEVEL SECURITY; doctor.evidence.data-integrity.tenant-isolation-forced checks this on the running database.
That is why the owning role must be neither superuser nor BYPASSRLS: the service connects as the owner, which is precisely the account FORCE exists to subject to the policy. The locker policies call evidence_locker_app.require_current_tenant().
Migrations are forward-only (ADR-004), embedded, and applied on startup by both roles through AddStartupMigrations(schemaName: "evidence", moduleName: "Evidence.Consolidated", ...), with the ledger in evidence.schema_migrations. The chain in src/Evidence/__Libraries/StellaOps.Evidence.Persistence/Migrations/ is the consolidated baseline (001) and forward Timestamp Assurance and retention migrations. The embedded files in this directory are the authoritative chain; the hosts apply them through their own startup ledger.
011 implements the owner ruling on RFC-15 Q-38. The two timestamp trust tables are reclaimed only after counsel’s retention period R, resolved from the per-tenant retention profile. The regulatory artifact ledger honours evidence_locker.regulatory_retention_records.retention_years. Nothing with a nonzero reference or hold count is reclaimed. The worker schedule is CounselRetentionReclamationService (src/Evidence/__Libraries/StellaOps.Evidence.TimestampAssurance.Hosting/Scheduling/), and the backlog is reported by doctor.evidence.retention.counsel-retention-backlog.
012_capsule_retention_tenant_registry.sql repairs capsule retention enumeration under the required non-superuser NOBYPASSRLS owner (source verified against f0f4301e6e, 2026-10-05). capsule_retention_tenants stores tenant ids and the first registered capsule’s creation time, with no capsule content or age-based deletion. Its insert/update trigger registers candidate tenants. The enumerator reads this RLS-free registry, checks live capsules under each tenant’s app.current_tenant, and restores the caller context. Fully tombstoned tenants are excluded; the existing sweep still preserves capsule ids, hashes and regulatory linkage, and keeps its retention and legal-hold rules.
Startup backfills existing real capsule keys under the migration transaction’s exclusive table lock. It temporarily removes FORCE for the table owner’s backfill, restores FORCE before the ledger commit, and never grants BYPASSRLS. Runtime reads keep FORCE enabled. Re-verify the fresh and upgrade paths with CapsuleRetentionUnderNoBypassRlsOwnerTests in the Evidence web-service test project and the consolidated schema tests in its persistence project, using tools/scripts/test-targeted-xunit.ps1 -BuildProjectReferences. These are source and isolated database proofs; deployment currency requires checking the running images and migration ledger.
EST-8 renamed the assembly to StellaOps.Evidence.Persistence; the persistent module name, SQL leaf names and SQL bytes are unchanged.
Correction of record — the applied baseline’s DC-26 note is stale and is NOT edited in place. .../Migrations/001_evidence_consolidated_baseline.sql:76-77,223-224 still says that platform-web’s AttestorMigrationModulePlugin (schema proofchain) and EvidenceLockerMigrationModulePlugin (schema evidence_locker) run and must stay “until the EVD-9 cutover”. That cutover executed on 2026-09-05 and SPRINT_20260722_026 CM-2 (85290801d1) deleted both plugins the same day, so the sentence is false at HEAD. The SQL is an APPLIED migration and ADR-004 is forward-only, so it must not be corrected in place by anyone — this paragraph is the correction the note points to. Nothing else in the baseline is affected: the note is a comment, not a statement the migration acts on. The same retirement also makes this assembly the family’s sole migration authority, because the consolidated host composes with RegisterLegacyMigrationAuthority false and therefore does not register attestor-web’s own AddStartupMigrations either.
Verified-by: src/Platform/__Libraries/StellaOps.Platform.Persistence/MigrationModulePlugins.cs:34-41,111-114 (the removal notes stood where the two plugins were, and the file no longer declared either type; 2026-09-14 (SPRINT_20260722_021 PLT-4): that file and the whole IMigrationModulePlugin mechanism are now deleted, so the verification point is the absence of the mechanism itself); src/Evidence/StellaOps.Evidence.WebService/Program.cs:114; src/Evidence/__Libraries/StellaOps.Attestor.Application/AttestorWebServiceComposition.cs:682-688; DatabaseOwnershipConformanceTests.CentralMigratorPluginFile_ShrinksOnly (src/__Tests/architecture/StellaOps.Architecture.Contracts.Tests/DatabaseOwnershipConformanceTests.cs:1127).
Three named Npgsql pools are built from that one string — Evidence.DoctorStandard, Evidence.TenantLifecycle, Evidence.Doctor — so pg_stat_activity attributes a session to the subsystem that opened it.
Verified-by: src/Evidence/__Libraries/StellaOps.Evidence.Persistence/Extensions/ConsolidatedEvidencePersistenceExtensions.cs:31,37,40,49,56-57,89-92,120-141; .../Migrations/001_evidence_consolidated_baseline.sql:229-232,964,1848-1860; src/Evidence/StellaOps.Evidence.WebService/Doctor/EvidenceSchemaTopology.cs:33; src/Evidence/StellaOps.Evidence.WebService/Program.cs:72-73,88-91,217-229,257-264; DatabaseOwnershipConformanceTests, WebServiceStartupMigrationsConformanceTests.
Decision capsule ledger writes
The consolidated baseline defines attestor.verdict_decision. The verdict writer binds the lowercase decision label using the destination column’s declared PostgreSQL type; it does not require attestor on the connection search path. Invalid enum labels are rejected by PostgreSQL.
CapsuleVerdictLedgerPersistenceTests runs the production baseline and writer as a restricted role, verifies all four decisions, tenant-specific chain continuity and foreign-tenant refusal, and confirms invalid labels cannot append a row. Re-run it through tools/scripts/test-targeted-xunit.ps1 in the consolidated Evidence host test project.
Backup boundary
Source qualified at d8fc7e65d7; re-verify with python devops/compose/scripts/test_backup_evidence.py -v.
The service database, Evidence filesystem (evidence-data) and proof-chain CAS (attestor-proofchain-cas) must describe the same application quiesce for recovery. Object bytes include the filesystem region sidecars and CAS metadata sidecars. The supported DevOps archive source includes both file volumes, resolves their physical identities and refuses running consumers; its embedded member inventory checks bytes, type, mode and numeric ownership. Its raw PostgreSQL tar remains crash-consistent: retain the matching backup-postgres.sh artifact and stop LSN. See backup and recovery operations.
Verify frozen storage bytes before a materializing native/portable download can rebuild missing objects or create successor rows, then verify the recorded owner subjects with their independent public trust. Archive integrity and fixture round trips do not establish actual installation recovery. The approved disposable Evidence recovery and CCP-053 CA-14 acceptance remain separate runtime criteria.
3. The canonical HTTP surface
The canonical prefix is /api/evidence/v1. It is served natively — there is no rewrite, no alias and no compatibility route group, because AGENTS.md §2.11 forbids one before the first external install and EVD-HTTP-00 ruled it out explicitly.
The published surface is pinned row by row in src/Evidence/__Tests/StellaOps.Evidence.WebService.Tests/Fixtures/consolidated-route-inventory.tsv by EvidenceRouteInventoryTests.WebHost_PublishedRouteInventoryMatchesTheReviewedFixture, which enumerates the real host’s endpoint data source. Regenerating the fixture (STELLAOPS_EVIDENCE_ROUTE_INVENTORY_UPDATE=1) rewrites it and still fails, so a regeneration can never read as a green run.
| Prefix | What it is |
|---|---|
/api/evidence/v1 | the canonical surface |
/api/v1 | the predicate registry’s absolute route; proof and verdict retrieval use the canonical prefix |
/anchors, /proofs, /verify | absolute controller routes inherited from the proof surface |
/internal/api/v1 | POST /internal/api/v1/attestations/verdict, service-to-service |
/openapi, /health, /doctor | host infrastructure |
Some rows remain outside the canonical prefix because attribute-routed MVC templates do not take the minimal-API routePrefix argument. ProofChainController declares only [Route("api/evidence/v1/proofs")], and its four GET operations keep that canonical root. Verdict creation stays at POST /internal/api/v1/attestations/verdict and returns a canonical /api/evidence/v1/verdicts/{verdictId} retrieval URI. That GET is the existing native VerdictEndpoints owner read, mapped by Program.cs; the controller does not publish a second lookup endpoint. Stored verdict reads require evidence:read and the authenticated tenant, independently of whether verdict creation is enabled. The predecessor proof and verdict retrieval aliases are withdrawn.
Re-verify the source route contract with EvidenceRouteInventoryTests.WebHost_PublishedRouteInventoryMatchesTheReviewedFixture. Gateway static-hint retirement and running-image currency are separate checks.
The Attestor half is mapped by app.UseAttestorWebService(..., routePrefix: "/api/evidence/v1", mapHealthEndpoints: false); the legacy health routes are suppressed so the host keeps a single readiness truth. The locker half maps nine native endpoint groups (evidence core, capsules, capsule signing keys, evidence threads, exports, verdicts, audit, regulatory artifact ledger, legacy capsule erasure).
Four retrieval routes that the retired evidence-locker-web mapped inline (/evidence/{bundleId}/chain, /download, /portable, and /evidence/by-release/{releaseId}/portable) are served under the canonical prefix and pinned by EvidenceCarriedPredecessorRoutesTests, which also pins that the withdrawn POST /evidence route does not reappear. The Timestamp Assurance tenant API is mapped by MapTimestampAssuranceEndpoints under the same prefix.
Verified-by: src/Evidence/StellaOps.Evidence.WebService/Program.cs (UseAttestorWebService and the Map*Endpoints block); src/Evidence/__Libraries/StellaOps.Attestor.Application/Controllers/ProofChainController.cs; src/Evidence/__Libraries/StellaOps.Attestor.Application/Controllers/VerdictController.cs; src/Evidence/__Libraries/StellaOps.EvidenceLocker.Application/Api/VerdictEndpoints.cs; src/Evidence/__Tests/StellaOps.Evidence.WebService.Tests/EvidenceRouteInventoryTests.cs.
Retired with the predecessors
The predecessor gateway rows were retired by RAR-7 in SPRINT_20260809_001; the predecessor hostnames attestor.stella-ops.local and evidencelocker.stella-ops.local were deleted with their compose keys and are not claimed by the successor. Handing a caller a silently working alias would re-open the tokenless submit path ROA-4 closed.
4. Identity, scopes and the removed bypass
The host registers as a resource server and then clears the global required-scope set: a single required scope would make either the Attestor half or the Locker half unreachable by construction. Authorization is per-endpoint instead, with a fallback policy of “authenticated, and holds evidence:read”, so an unpolicied endpoint is closed rather than open.
| Half | Policies |
|---|---|
| Attestor | attestor:write → claim attest:create; attestor:read / attestor:verify → attest:read or attest:create; watchlist:read / watchlist:write → trust:* plus the legacy watchlist:* literals |
| Locker | EvidenceRead, EvidenceCreate, EvidenceHold, ExportViewer, ExportOperator |
| Host | /doctor/evidence/checks requires the estate-wide ops.health, deliberately not an Evidence domain scope |
Anonymous by design: /health/ready, /openapi/{documentName}.json, and the capsule signing-key JWKS.
There is no trusted-network bypass on this host, by construction. ROA-4 sets options.BypassMode = StellaOpsBypassMode.Disabled and clears BypassNetworks in code, not only by omitting the networks from compose, so a stray BypassNetworks entry cannot re-open the door. The Attestor policies lost their bypass branch in the same change: the scope set is the whole decision on the direct path as well as through the gateway. The measured failure this closes is on record — during the EVD-9 window a token carrying no attest:* scope got HTTP 200 on POST /api/evidence/v1/rekor/verify through the gateway. The Rekor routes (/rekor/entries, /rekor/verify) are mapped by AttestorWebServiceEndpoints.MapAttestorEndpoints under the host’s consolidated prefix /api/evidence/v1 (src/Evidence/StellaOps.Evidence.WebService/Program.cs:75,351), not under /api/v1; a probe of this host must use that prefix. The dated live measurement that earlier stood here was removed as not re-verified on 2026-09-26.
watchlist:admin is deliberately not registered: it guarded zero routes, and AttestorPolicyClaimPublicationTests.WatchlistAdminPolicy_IsNotRegistered_AndGuardsNoRoute keeps it that way.
Verified-by: src/Evidence/StellaOps.Evidence.WebService/Program.cs:157,159,162,165-173,186-195; src/Evidence/__Libraries/StellaOps.Attestor.Application/Security/AttestorPolicies.cs:21-33,75-80,81-130; .../Security/AttestorScopeRequirement.cs:35-42,80; src/Evidence/__Tests/StellaOps.Evidence.WebService.Tests/AttestorPolicyClaimPublicationTests.cs:172.
5. Transparency, timestamping and the verification context
Local transparency log. LocalTransparencyRekorClient is selected only when an NpgsqlDataSource is present and attestor:transparency:local:enabled is true; otherwise the host falls back to HttpRekorClient. It signs the checkpoint (signed tree head) with the key named by attestor:transparency:local:signingKeyId, falling back to the sole enabled entry in attestor:signing:keys. If that resolution is ambiguous or empty the checkpoint is left unsigned and SignCheckpointAsync swallows it — which is why a proof backfill run without those keys looks successful and the entries still fail verification with checkpoint_signature_missing.
The local log’s state is served by GET /api/evidence/v1/transparency/local (attestor:read): enabled, origin and logUrl from attestor:transparency:local, checkpointSigned from the same key resolution the signer uses, and treeSize and lastCheckpointAt from attestor.local_transparency_entries, which is installation-wide. A store failure answers 503 (local_transparency_unavailable), never a disabled log. The field contract is in the Attestor transparency document.
The verifier does not use the signing key registry. It authenticates a checkpoint against the public key configured for the log backend, attestor:rekor:primary:publicKeyBase64 (or publicKeyPath; attestor:rekor:mirror:* for the mirror backend). That setting is the trust anchor: without it every entry fails with checkpoint_public_key_missing, and with a key other than the signing key’s public half every entry fails with checkpoint_signature_invalid.
Producer and verifier path
Recording. Producers record an attestation with POST /api/evidence/v1/rekor/entries (attest:create, through authorization policy attestor:write): Scanner (scan-result, report, sbom), Release Orchestrator (deployment), Findings (vex-override) and JobEngine (offline-seed-import). The route admits only the kinds in AttestorSubmissionValidator.AllowedKinds. slsa-provenance uses the reported-CI SLSA v1 contract described below; policy-verdict uses the authenticated release-set contract. stella attest build uses attestations:sign, then rekor/entries, then attestations:export to return the exact retained envelope. Policy Engine’s historical finding/drift attestations keep their distinct verdict-store contract (/api/evidence/v1/verdicts). New pack evaluations use the generic verified submission and archive path, without also writing the same outcome into that store.
Policy release-set evidence (CCP-085). One signed statement binds the actual authenticated component-to-image input set, pack/version/digest, run and outcome. The existing verdict authority roster remains an independent admission boundary. Membership is not an independent per-image verdict. Only the archive-derived subject index is new; attestor.entries and the exact-byte archive remain the existing custody parent. Generic import cannot manufacture a verified policy identity. Missing/no-rule inputs and failed signing/recording do not become evaluated-clean evidence. See the release-set contract for scope, replay, retention and the separate runtime boundary. Source qualification is recorded by CCP-085; no deployed provider/key or live acceptance is inferred.
Reported-CI provenance (CCP-083). The CLI composes an in-toto Statement v1 with SLSA provenance v1, one exact SHA-256 subject, repository/full Git commit in one resolvedDependencies descriptor, a builder URI and a required run reference. Build type https://stellaops.org/build-types/reported-ci/v1 explicitly records caller-supplied lineage. It does not prove that the named CI job executed or assign a SLSA assurance level. The signing service adds the reserved authenticated producer byproduct with the token issuer, subject, client ID and tenant plus the actual selected key ID, algorithm, provider and mode. Submission checks provider and mode against the configured verifier, so unsigned bundle metadata cannot relabel a keyful signature as KMS. This separates reported lineage, authenticated transport producer and cryptographic signer; JWT audience is never an issuer. For these entries the signer descriptor contains the signing key, without claiming that the caller’s issuer/SAN belongs to that key. The authenticated producer remains inside the signed statement. Source verified against f87a08736519a20970fb1d39f4e78bddadc53a18; commands, counts and runtime limits are in the CCP-083 source receipt. Caller-provided byproducts are refused by this narrow build profile, so reported content is not silently replaced by the server-owned identity binding.
Submission requires the exact artifact/statement subject, that authenticated producer binding, one signature verified by the existing configured-key verifier, primary log recording and archive retention. Missing keys, unsupported/malformed statements, bad signatures and attribution mismatches fail before recording. The existing archive adapter must retain and return byte-identical signed envelope bytes before the entry becomes visible. A disabled or failed archive cannot yield recorded success. No new evidence ledger or private-key custody is introduced.
Migration 015_attestor_provenance_identity.sql adds a nullable statement identity and tenant-scoped unique index to existing attestor.entries; historical rows remain unchanged. Identity is the canonical statement SHA-256 through central HashPurpose.Interop, bound to the existing SHA-256/Rekor submission contract. It includes the authenticated producer and signer but excludes signature randomness. A PostgreSQL transaction-scoped advisory lease serializes retries. Re-signing the same statement returns the original recorded UUID and exported bytes; changed lineage or signer is a different statement, not a retry. An attempt that fails after log submission but before archive/row persistence can leave a log entry or archived object; retry guarantees one recorded semantic result, not exactly-once external log execution. Missing or corrupt original archive bytes on a later retry refuse success rather than substitute a new envelope under the old receipt. An identical envelope previously recorded or imported as a generic artifact is not verified provenance: admission refuses provenance_identity_conflict without changing that historical row. Dedupe-cache UUIDs are hints and must resolve to the requested bundle digest before they can select an existing result. The check is repeated under the archive lease after log submission, so concurrent generic and provenance submissions cannot rewrite the winner’s kind or verified identity.
The source/test contract is independent of live CI identity enrollment, client scope grants, key/trust admission and deployment. Read-only discovery remains the existing Release Orchestrator resolver; presence/attribution are distinct from independent build-execution and signature verification.
The list and bundle-export paths resolve only the authenticated tenant claim, including explicit UUID exports; missing tenant claims return tenant_required. Caller headers cannot widen that boundary. When the optional archive is absent, the existing export can read retained local-log components only after that owner entry fence, reconcile the complete local identity, and return shared-canonicalizer bytes whose SHA-256 equals both recorded bundle hashes. Original algorithms remain separate retained metadata; they are not bound by that canonical hash. Fixed parser, lookup and complete successful-item JSON budgets fail visibly. This is an export, not signature verification or evidence of current producer trust; the exact contract and source re-verification are in retained local envelope export. The existing offline bundle importer does not verify authenticated producer signatures, so it refuses slsa-provenance with provenance_import_requires_verified_admission; it cannot substitute for this producer path or overwrite an existing provenance row under a disguised kind. Generic import also validates every entry’s tenant against the authenticated claim before any write, including mixed-tenant packages, and uses tenant-bound UUID lookups. Internal archive-target ownership checks return only a collision decision, not foreign records. Sorted database leases serialize physical UUID/bundle aliases across submission and import; a caller cannot gain ownership by relabeling a foreign package. Both fields occupy the same proof/metadata object-key namespace, so a UUID equal to another entry’s bundle digest is also a collision. Migration 015 adds indexes for those collision checks without changing historical ownership or imposing global uniqueness on existing rows. Aliases of a distinct verified-provenance entry are protected inside the same tenant as well. A retained generic row, including one whose original archive failed, cannot use its mutable import contract to replace those proof/metadata objects. The server-owned non-null provenance identity establishes this protection; generic same-entry refresh and the existing replica/mirror descriptor remain supported.
The S3 adapter conditionally creates envelope objects and new proof/metadata, accepting existing bytes only when they match. New/orphan targets cannot be overwritten without an owner row. A fenced import of an existing same-tenant, non-SLSA entry retains the explicit proof/metadata update contract; it never replaces envelope bytes or binds an existing UUID to a different bundle digest. That rebind is refused as bundle_identity_conflict. SLSA import remains refused. Current proof refresh also remains the separate mutable repository operation, with verification receipts capturing the evaluated proof; it does not rewrite the original archive. Other kinds retain their existing signature/import policy. Archive object addresses remain the existing content-addressed bundle/UUID contract; this change does not migrate historical object keys.
Verifying. POST /api/evidence/v1/rekor/verify with { "uuid": … }, the request the Console sends, checks the stored inclusion proof (the RFC 6962 leaf SHA-256(0x00 || bundleSha256) and an L:/R:-oriented audit path that reaches the checkpoint root) and the checkpoint signature against the configured anchor. It reads the stored entry and changes nothing in it (the route records an audit event); only refreshProof on a verification, or refresh=true on the entry read, re-derives and persists a proof. A request without a bundle skips the DSSE signature section, so a passing result proves log inclusion under the configured log key, not the producer’s signature. The route resolves the entry only inside the caller’s tenant claim, for a uuid, bundle or artifact lookup alike: a principal without a tenant claim gets 400 tenant_required, and a request body whose tenantId names another tenant gets 403 tenant_forbidden. The entry reads (by uuid on the rekor/entries and attestations paths) are bound the same way, so an entry of another tenant is not found and is never refreshed.
Each non-success names its cause in its issue codes, and the codes stay distinct. The Console source classifies known missing material separately from demonstrated integrity failures:
| Cause | Issue codes | Console |
|---|---|---|
| Missing or unoriented path | proof_missing, proof_inclusion_missing, proof_path_orientation_missing | Unverifiable |
| Missing trust | checkpoint_public_key_missing, checkpoint_signature_missing, checkpoint_signature_invalid | Unverifiable |
| Missing witness | witness_missing, or witness_status_<status> with a nonempty status for an unendorsed witness: a warning (policy_warn:transparency) unless attestor:verification:requireWitnessEndorsement is true | Proven when the owner returns ok:true with a warning; Unverifiable for the required-witness refusal unless a decisive integrity failure is also present (deployment boundary in known limit 6) |
| Mismatched proof or bundle | proof_root_mismatch on a walkable proof, witness_root_mismatch, bundle_hash_mismatch; proof_leafhash_mismatch | Root and bundle mismatches remain Tampered even beside missing material. A leaf mismatch stays Tampered with witness absence alone; an existing missing-path or missing-trust explanation can make it Unverifiable. |
policy_blocked:<section> names the section that refused; policy_warn:<section> names one that only warned. A verifier the Console cannot reach is reported as “verification unavailable” and leaves the earlier state in place; it is never shown as a refusal. policy_blocked:<section> alone and unknown issues do not acquire a missing-material exception. The classifiers retain the original reasons and the response’s non-success; they do not change the verifier or its trust policy. Source verified against b824a1b3b1ba5496e72b2e1a1bb515b9826efe87; recheck from src/Web/StellaOps.Web with npx vitest run --config vitest.codex.config.ts src/app/core/api/attestor-verification-verdict.spec.ts src/app/features/evidence-custody/evidence-custody.component.spec.ts src/app/features/evidence-transparency/evidence-transparency.component.spec.ts.
Recorded verification receipts
Each served verification appends one receipt to attestor.verification_receipts (migration 013). A receipt states:
| Field | Content |
|---|---|
| Attribution | tenant, the authenticated subject that asked (requestedBy), and the verification time |
| Evidence and subject | the evidence digest (sha256: of the canonical attestation bundle); the entry uuid, artifact digest and kind, image digest, subject URI, log backend, URL and index |
| Verifier | stellaops-attestor-verify and the StellaOps.Attestor.Verify assembly version, which carries the source commit |
| Trust context | verification policy id and version, every verification policy switch, the time-skew policy (enabled, fail on reject, and the warn, reject and future-skew thresholds), the checkpoint backend, the state and SHA-256 of its configured key, and a digest over all of them |
| Proof material | the checkpoint origin, tree size and root hash of the proof the verdict was computed on, a digest over that checkpoint (with its signed note), leaf hash and audit path, and a digest over the witness observation (aggregator, status, root, statement, signature, key id) |
| Inputs | the request as received: uuid, artifact digest, subject, envelope id, policy version, refresh and offline flags, whether a bundle was supplied, and the digest of the canonical form of a supplied bundle, which equals the evidence digest exactly when the bundle matches the entry |
| Outcome | pass or fail, the verifier’s reason codes, each check with its outcome (pass, warn, fail) and issue codes, and each check that was not run with the reason |
A request without a bundle records signatures and issuer as not checked and says that the producer signature was not verified, so a passing receipt never reads as a verified producer signature. The verify response returns the receipt with the result. A result served from the verification cache is not a new verification and is not recorded (receiptNotRecordedReason is cached_result). The entry reads return the newest 50 receipts of the entry, newest first, with verificationReceiptCount; reading them runs no verification. A later session with read access to the tenant therefore retrieves the same receipt and its exact inputs.
A re-verification, including one under a changed trust context, appends a new receipt; the trust context digest tells the two apart. A verification with refreshProof (the Console’s Transparency page sends it on every run) first replaces the entry’s stored proof with one against the log’s current checkpoint; its receipt names the refreshed proof, and every earlier receipt still names the proof it was computed on by checkpoint and digest (known limit 7). Nothing updates or deletes a receipt: the table refuses UPDATE and DELETE by trigger (SQLSTATE EV011), its foreign key keeps the entry it names (ON DELETE RESTRICT), and it is source-of-truth, which tenant offboarding keeps like every other source-of-truth table of this database while legal holds stay binding.
Verified-by: src/Evidence/__Libraries/StellaOps.Evidence.Persistence/Migrations/013_attestor_verification_receipts.sql; src/Evidence/__Libraries/StellaOps.Attestor.ProofChain/Receipts/VerificationReceipt.cs; src/Evidence/__Libraries/StellaOps.Attestor.Core/Verification/AttestorVerificationBinding.cs; src/Evidence/__Libraries/StellaOps.Attestor.Application/Contracts/AttestationVerificationReceiptContracts.cs; src/Evidence/__Libraries/StellaOps.Attestor.Application/Services/PostgresVerificationReceiptStore.cs; src/Evidence/__Tests/StellaOps.Evidence.WebService.Tests/VerificationReceiptRecordingTests.cs. Re-verify with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Evidence/__Tests/StellaOps.Evidence.WebService.Tests/StellaOps.Evidence.WebService.Tests.csproj -Class '*VerificationReceiptRecordingTests' -BuildProjectReferences.
Carried entries that cannot verify. The verifier does not check an entry’s log backend label: it authenticates the checkpoint against the primary anchor for every backend other than mirror, so an entry fails on its proof data, not on its backend. Carried demo rows on backend stellaops-transparency (a log URL this installation does not serve, unsigned checkpoints) carry three-node unoriented paths where RFC 6962 requires nine for their index and tree size. Their leaf hashes do not derive from their own bundles, no orientation of the nodes reaches the stored root, and five rows claim five different roots for one tree size. They verify with policy_blocked:transparency, proof_leafhash_mismatch, proof_path_orientation_missing and witness_missing. That refusal is correct, and neither a proof refresh nor an orientation backfill can change it. Keep them as recorded. Three carried rows on the local log have checkpoint notes signed by a development key that was retired because it had been committed; they fail with checkpoint_signature_invalid, and making them pass would mean trusting the retired key (EVD-9 data move runbook).
Verified-by: src/Evidence/__Libraries/StellaOps.Attestor.Core/Submission/AttestorSubmissionValidator.cs:25,112-115; src/Evidence/__Libraries/StellaOps.Attestor.Verify/AttestorVerificationEngine.cs:564-776,886-912; src/Evidence/__Libraries/StellaOps.Attestor.Infrastructure/Verification/AttestorVerificationService.cs:313-401; src/Evidence/__Libraries/StellaOps.Attestor.Application/AttestorWebServiceEndpoints.cs:863-926,1159-1195; src/Evidence/__Libraries/StellaOps.Attestor.Persistence/Repositories/PostgresAttestorEntryRepository.cs:93-118; src/Web/StellaOps.Web/src/app/core/api/attestor-verification-verdict.ts:51-141; src/Evidence/__Tests/StellaOps.Evidence.WebService.Tests/EvidenceProducerVerifierPathTests.cs. Re-verify with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Evidence/__Tests/StellaOps.Evidence.WebService.Tests/StellaOps.Evidence.WebService.Tests.csproj -Class '*EvidenceProducerVerifierPathTests' -BuildProjectReferences.
RFC 3161 timestamping. Capsule sealing POSTs a DER timestamp query to the in-repo tsa service and embeds the returned token as an existence-time anchor, never folded into the deterministic bundle digest (ADR-036 §Determinism). The runtime configuration validator refuses to start outside the local harness if the client resolves to the null implementation, “which seals evidence without an RFC3161 anchor”.
The carried verification context is a compose contract, not a default. The EVD-9 window measured 26 of 26 carried attestations verifying ok:false because the consolidated overlay restated none of the 74 attestor__ keys the predecessor carried, so product defaults applied: mTLS-only writes and no checkpoint key. Twenty-eight keys per role are now restated in docker-compose.evidence.yml and asserted key by key by EvidenceConsolidationComposeConformanceTests.StagedCompose_CarriesTheTransparencyVerificationContextAndTheCarriedWritePosture. Delete any one of them and that test fails naming the key, while docker compose config still renders green — which is exactly how they went missing.
Verified-by: src/Evidence/__Libraries/StellaOps.Attestor.Infrastructure/Rekor/LocalTransparencyRekorClient.cs:18,188-194,391-414,441-458,466-507; src/Evidence/__Libraries/StellaOps.Attestor.Infrastructure/Transparency/LocalTransparencyLogStateReader.cs; src/Evidence/__Libraries/StellaOps.Attestor.Infrastructure/ServiceCollectionExtensions.cs:111-120; src/Evidence/__Libraries/StellaOps.EvidenceLocker.Infrastructure/Signing/Rfc3161TimestampAuthorityClient.cs:22,39-48; .../Hosting/EvidenceLockerRuntimeConfigurationValidator.cs:224-237.
6. Tenancy: the replica, residency and lifecycle
The consolidated host resolves tenant slugs from its own replica of Authority’s tenants catalog, held in stellaops_evidence. It does not read Platform’s shared.tenants, and it refuses to fall back to one:
Consolidated Evidence tenant resolution requires
Catalog:Replication:Tenants:Enabled=true. The target owns noshared.tenantsfallback and refuses to register the predecessor’s cross-database resolver.
The dual-mode registration’s generic argument is a fail-closed tripwire, not a usable fallback: the type it names throws. CatalogReplicaUnavailableException is deliberately not caught — an unknown or inactive tenant is a measured null, but an undrained replica stays a loud fault.
Residency works the same way: the carried default-region resolver is removed and replaced by one over a setup-owned value that is resolved fail-closed at startup, so the installation owns the default and no developer-selected fallback exists. Requests carrying an explicit region still override it.
Tenant lifecycle (SCR-4) registers EvidenceTenantLifecycleHandler with a legal-hold override keyed on evidence_locker.evidence_holds; the drain is opt-in and report-only until both Catalog:Replication:TenantLifecycle:Enabled and :DryRun say otherwise. Its eleven tests run as a non-superuser role, so row-level security actually evaluates.
Verified-by: src/Evidence/StellaOps.Evidence.WebService/Tenancy/EvidenceTenantCatalog.cs:24,65-132,72-79,122-123,141-153; src/Evidence/StellaOps.Evidence.WebService/Program.cs:59-60,124-126,266-269,331-338; src/Evidence/__Tests/StellaOps.Evidence.WebService.Tests/EvidenceTenantLifecycleHandlerTests.cs:160-325.
Export tenant isolation
Export creation resolves the authenticated tenant through the same tenant catalog used by the owner APIs. That UUID travels through bundle metadata, archive construction, job status and download. Storage uses a tenant-scoped connection under the constrained Evidence role; it never discovers a bundle owner by querying an RLS table without tenant context. A bundle or export job belonging to another tenant is unavailable. Archive SHA-256 is the format-defined digest computed through the shared Stella Ops cryptography library.
Native snapshot export uses the existing package contract: manifest.json, signature.json, bundle.json, checksums.txt and instructions.txt. It preserves the original signed manifest and detached signature. Snapshot materials are signed references; original material bytes absent from the snapshot are not synthesized. An empty artifact-table projection is not a snapshot export.
The package writer honors the immutable locator already sealed into its owning bundle. Both storage implementations validate the exact tenant, bundle and canonical artifact name and require write-once storage. The complete native locator is used verbatim; the configured S3-compatible material prefix continues to apply to generated content-addressed material keys. Actual digest metadata is retained. No sealed row is rewritten. Export jobs copy the cached native package to separate job-owned output directories, so requests for the same bundle cannot overwrite each other’s output.
Regression coverage uses the non-superuser evidence_app role, real filesystem storage and existing Stella Ops purpose-bound signature verification, including payload tampering and cross-tenant refusal. Source validation and a subsequent live export are tracked separately during the rollout.
Capsule public trust before the first seal
The CapsuleSeal JWKS lookup resolves its configured purpose key before the first signing operation. The owner key service delegates profile, algorithm and provider selection to Stella Ops cryptography. A provider-held key takes precedence; when that key is absent, the shared configured-key codec loads only the existing installation material. This read never generates an ephemeral key or creates a snapshot. Missing material or a missing explicitly selected provider returns 503. A subsequent seal uses the same key identity. The existing rotation source continues to publish its public roots and statuses; public JWK operations are restricted to verification.
Source verification: CapsuleSigningKeyEndpointTests covers cold Ed25519 and EC lookup, identity before and after a real seal, provider-held custody, missing configuration, explicit provider failure, and rotation roots. Live cold-start verification is recorded separately from those tests.
7. Doctor checks
evidence-web registers the family checks through AddEvidenceDoctorChecks, the SDK’s standard checks through AddServiceDoctorChecks, and the Timestamp Assurance installation checks through AddTimestampAssuranceInstallationChecks. They are served at GET /doctor/evidence/checks behind ops.health. The worker adopts none: it is a replica role over the same database, so the web role answers for the family.
| Check id | What it proves |
|---|---|
doctor.evidence.database.own-database | the host is connected to stellaops_evidence, not to a shared control-plane database |
doctor.evidence.data-integrity.append-only-guard | the append-only guards on the ledger are in place |
doctor.evidence.data-integrity.tenant-isolation-forced | every armed RLS table is also FORCEd, which catches a superuser or BYPASSRLS owner |
doctor.evidence.data-integrity.verdict-chain-linkage | the hash-chained verdict ledger links |
doctor.evidence.data-integrity.legal-hold-integrity | holds cannot be orphaned from what they hold |
doctor.evidence.retention.rate-counter-budget | the rate-counter retention sweep keeps within budget |
doctor.evidence.retention.rekor-queue-backlog | the Rekor submission queue retention backlog |
doctor.evidence.retention.retimestamp-queue-budget | the completed re-timestamp retention budget |
doctor.evidence.retention.counsel-retention-backlog | the RFC-15 Q-38 reclamation backlog (migration 011) |
doctor.evidence.timestamp.* | Timestamp Assurance: TSA, revocation, eIDAS trust list, time correlation and evidence staleness |
The family checks are registered by concrete implementation type through TryAddEnumerable; the interface-only overload would de-duplicate them away. The explicit ServiceDoctorContext is registered before the standard checks, because an implicit bind produces a false green (“no database configured”) or binds the wrong pool.
Registration with the Platform doctor registry is default-off and fails closed on a blank Authority, client id or client secret — compose passes unset variables through as empty strings, so “missing” and “blank” had to be the same failure.
Verified-by: src/Evidence/StellaOps.Evidence.WebService/Doctor/EvidenceDoctorChecks.cs (AddEvidenceDoctorChecks); src/Evidence/StellaOps.Evidence.WebService/Program.cs (AddServiceDoctorChecks, AddTimestampAssuranceInstallationChecks, MapServiceDoctorEndpoints); src/Evidence/__Tests/StellaOps.Evidence.WebService.Tests/EvidenceDoctorAdoptionTests.cs.
The worker exposes /health/liveness and /health/readiness through the shared worker health library. Readiness verifies host startup; it does not claim that every queue or schedule has processed work. These endpoints are local operational probes and do not register a second Doctor surface.
8. Compose home and the infrastructure members
The two roles are defined in devops/compose/docker-compose.evidence.yml, with the reviewed immutable digests pinned in the committed devops/compose/docker-compose.local-evidence.yml. The pin lives in a committed file on purpose: a container whose recorded compose chain names a tmp/ path cannot be recreated once that directory is swept.
Two members of the family keep their own compose keys in the canonical docker-compose.stella-services.yml and are family members by ownership, not by key. Both are also release-bundle services (attestor-tileproxy, tsa):
| Key | Why it is not re-keyed |
|---|---|
attestor-tileproxy | a stateless transparency-tile edge: its own ASP.NET host (src/Attestor/StellaOps.Attestor.TileProxy/) and image, no database. Offline by default: no upstream, no sync job, serving only its local content-addressed cache. |
tsa | the in-repo openssl RFC 3161 responder built from devops/docker/tsa/. No .NET, no database. Its tsa-data volume is carried by staying attached to this unchanged key. |
The canonical wrapper merges infrastructure, application services and docker-compose.evidence.yml in one include.path list. Compose merges the shared network declaration before importing the application model, so the default stack and generated release bundle both contain evidence-web and evidence-worker. The standalone family declaration remains available for isolated rehearsals. The compliance lane imports this canonical model once and then applies its disposable-estate override.
Re-verify: docker compose --env-file NUL -f devops/compose/docker-compose.stella-ops.yml config --no-interpolate --services (use /dev/null on Unix), then bash tools/scripts/validate/check-bundle-generated.sh. The topology does not imply a live deployment or a successful compliance journey.
Carried volumes, with their physical names, which is what makes the carry real rather than nominal: compose_evidence-data at /data/evidence, compose_attestor-proofchain-cas at /app/data/proofchain-cas, compose_tsa-data at /var/lib/tsa.
The service base creates /data/evidence with the application UID/GID before a fresh named volume is initialized.
Verified-by: devops/compose/docker-compose.evidence.yml; devops/compose/docker-compose.local-evidence.yml; devops/compose/docker-compose.stella-services.yml; EvidenceConsolidationComposeConformanceTests.
9. Predecessors
| Predecessor | State |
|---|---|
attestor (host + compose key) | retired. Compose key deleted 2026-09-05 (EVD-8/EVD-10b); the host project under src/Attestor/StellaOps.Attestor.WebService/ no longer has a Program.cs. |
evidence-locker-web | retired. Compose key deleted 2026-09-05; the host project is frozen at src/__Obsoleted/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.WebService/ (its AGENTS.md records the withdrawn route). |
evidence-locker-worker | retired. Compose key deleted 2026-09-05; frozen at src/__Obsoleted/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Worker/. |
attestor-tileproxy | alive, unchanged, now an Evidence-family member by ownership (§8). |
tsa | alive, unchanged, now an Evidence-family member by ownership (§8). |
signer | not a predecessor. Key custody is a separate service with a separate database and does not join this family. src/Attestor/ still exists because the StellaOps.Signer.* persistence libraries live there. |
10. Known limits
- Timestamp Assurance is composed in both roles; runtime assurance remains conditional on the installed provider and trust inputs.
- No pass reclaims orphaned shared content-addressed objects in
evidence.timestamp_assurance_objects. Migration003’s trigger refuses to delete one while a reference or active hold remains; RFC-15 Q-38 scoped only the three tables011reclaims. - Reported build provenance has a source/test producer contract (section 5, “Producer and verifier path”). Live CI attribution, client/key/trust enrollment and rollout remain separate acceptance inputs. Image-bound policy verdict production remains a separate task.
- A verification by uuid does not check the DSSE signature. The verifier evaluates signatures only for a bundle supplied with the request and does not load an archived one. A uuid-only result proves inclusion in the configured log, not the producer’s signature.
- Bulk verification is not bound to the job’s tenant. The list, export, import, verify route and entry reads enforce the authenticated tenant as described in section 5. The separate bulk worker still verifies its items without the job’s tenant. Bulk results are kept on the job and are not recorded as receipts; the worker is outside the reported-CI producer change.
- Required-witness presentation needs a qualified Console deployment. CCP-086 source
b824a1b3b1classifiespolicy_blocked:transparencywithwitness_missing, or a knownwitness_status_reason with a nonempty suffix, as Unverifiable while retaining the owner’s refusal and reasons. The earlier classifier fell through to Tampered. Decisive signature, root and bundle mismatches remain Tampered; witness absence alone does not explain a walkable leaf mismatch. Source tests do not establish which Console is served or complete the separately gated CCP-010/011 changed-trust and R13 acceptance. - A proof refresh replaces the entry’s stored proof.
refreshProofon a verification andrefresh=trueon an entry read overwrite the entry’s inclusion proof in place (and its witness when a new witness observation is fetched). A receipt keeps the checkpoint origin, tree size and root hash and the digests of the proof and witness it was computed on, not the audit path itself, so after a refresh the entry no longer holds the path an earlier receipt verified. The attestation bundle, which the evidence digest names, is not changed by a refresh.
11. Related documents
attestor/consolidation-design.md— the consolidation design and the direct-HTTP-caller census. It stays at that path becauseEvidenceDirectHttpCallerConformanceTestsparses it there.attestor/README.md: predecessor pointer.docs-archive/modules/evidence-locker/: the archived EvidenceLocker dossier (host-era architecture and API reference).locker/: the EvidenceLocker format and contract documents that remain current.../../runbooks/evidence/evidence-evd9-data-move.md— the cutover runbook, including the isolated scratch rehearsal.../../architecture/database-ownership-matrix.md— the ownership tiebreaker.../signer/README.md— the separate key-custody service.
Proof/verdict retrieval route contract verified against d456ec1a2c (2026-10-07). Re-verify this scoped contract with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Evidence/__Tests/StellaOps.Evidence.WebService.Tests/StellaOps.Evidence.WebService.Tests.csproj -Class '*EvidenceAliasWithdrawalTests*' -BuildProjectReferences.
