Contract: Artifact Canonical Record (v1)
The Artifact Canonical Record is the single query target that aggregates every piece of evidence StellaOps holds for one artifact, keyed by its canonical_id. It lets the Evidence Thread API, audit-pack export, and the Console proof-chain view answer “show me everything signed about this artifact” with one lookup instead of fanning out across Attestor, Scanner, VexLens, and the OCI registry.
Audience: Attestor/EvidenceLocker implementers and consumers of the Evidence Thread API. Read the implementation-status note below before relying on any field — the shipped v1 is a read projection over attestation evidence only.
Status
- Status: PARTIALLY IMPLEMENTED (schema v1 verified against code 2026-05-30; draft authored 2026-02-19)
- Owners: Attestor Guild, EvidenceLocker Guild
- Consumers: Evidence Thread API, Audit Pack Export, Console UI
- Sprint: SPRINT_20260219_009 (CID-03 schema, CID-04 implementation)
Implementation status (2026-05-30 reconciliation). The v1 record as actually implemented is a read projection over attestation evidence only. The backing store is the PostgreSQL materialized view
proofchain.artifact_canonical_records(migration003_add_artifact_canonical_record_view.sql, owned by Attestor) and the read API is exposed by EvidenceLocker atGET /api/v1/evidence/thread/{canonicalId}. The implemented record containscanonical_id,format,artifact_digest,purl,attestations[], andcreated_at. Thesbom_ref,referrers,vex_refs,updated_at, andrecord_id(content-addressing) fields described below are NOT IMPLEMENTED in the v1 view or API and are retained here as the forward design target. Each unimplemented surface is annotated inline.
Purpose
Define a unified document that aggregates all evidence references for a single artifact identified by canonical_id. Today this information is distributed across Attestor (dsse_envelopes), Scanner (SBOM refs), VexLens (consensus records), and OCI registries (referrers). The Artifact Canonical Record provides a single query target for:
- The Evidence Thread API (
GET /api/v1/evidence/thread/{canonicalId}, EvidenceLocker — implemented for attestations) - Audit pack generation (export all evidence for an artifact — design target)
- Console UI proof chain visualization (design target)
NOTE — two distinct “Evidence Thread” surfaces exist. This contract is backed by the EvidenceLocker endpoint
GET /api/v1/evidence/thread/{canonicalId}(StellaOps.EvidenceLocker.Api.EvidenceThreadEndpoints), keyed bycanonical_id. The Platform service also exposes an unrelated graph-oriented Evidence Thread API atGET /api/v1/evidence/{artifactDigest}(ReleaseOrchestrator-backedStellaOps.Platform.WebService.Endpoints.EvidenceThreadEndpoints) keyed by artifact digest. That second API returns thread nodes/links/verdict/transcripts and is governed by a different contract; do not conflate the two.
Record Schema
The schema below is the v1 design schema. Fields actually returned by the implemented API (
GetEvidenceThreadResponse/EvidenceThreadAttestationinStellaOps.EvidenceLocker.Api.EvidenceThreadContracts) are:canonical_id,format,artifact_digest,purl,attestations[](each withpredicate_type,dsse_digest,signer_keyid,rekor_entry_id,rekor_tile,signed_at), an optionaltransparency_status, andcreated_at.sbom_ref,referrers,vex_refs, andupdated_atare NOT IMPLEMENTED.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Artifact Canonical Record v1",
"type": "object",
"required": ["canonical_id", "format", "attestations", "created_at"],
"properties": {
"canonical_id": {
"type": "string",
"description": "IMPLEMENTED. Maps to proofchain.sbom_entries.bom_digest. Per canonical-sbom-id-v1.md. NOTE: the view stores bom_digest as VARCHAR(64) (bare hex, no 'sha256:' prefix); the documented 'sha256:<hex>' pattern reflects the intended wire shape, not the stored column."
},
"format": {
"type": "string",
"description": "IMPLEMENTED. Canonicalization format identifier. The view emits the literal constant 'cyclonedx-jcs:1'.",
"const": "cyclonedx-jcs:1"
},
"artifact_digest": {
"type": "string",
"description": "IMPLEMENTED. SHA-256 of the component artifact if available. Maps to proofchain.sbom_entries.artifact_digest (nullable, VARCHAR(64)). Omitted from the response when null.",
"pattern": "^[a-f0-9]{64}$"
},
"purl": {
"type": "string",
"description": "IMPLEMENTED. Package URL of the component. Maps to proofchain.sbom_entries.purl. Omitted from the response when null. Used by the PURL-lookup endpoint."
},
"sbom_ref": {
"type": "string",
"description": "NOT IMPLEMENTED (forward design target). Content-addressable reference to the SBOM (CAS URI or OCI ref). The v1 materialized view exposes 'purl' and 'artifact_digest' instead of a CAS/OCI sbom_ref.",
"examples": [
"cas://sbom/inventory/abc123.json",
"oci://registry/repo@sha256:abc123"
]
},
"attestations": {
"type": "array",
"description": "IMPLEMENTED. All DSSE attestations referencing this artifact, aggregated from proofchain.dsse_envelopes (joined to proofchain.rekor_entries). Maps to EvidenceThreadAttestation. Suppressed when the request sets include_attestations=false.",
"items": {
"type": "object",
"required": ["predicate_type", "dsse_digest", "signed_at"],
"properties": {
"predicate_type": {
"type": "string",
"description": "Predicate type URI. Maps to proofchain.dsse_envelopes.predicate_type."
},
"dsse_digest": {
"type": "string",
"description": "SHA-256 of the DSSE envelope body. Maps to proofchain.dsse_envelopes.body_hash (VARCHAR(64), bare hex). The documented 'sha256:<hex>' pattern reflects intended wire shape, not the stored column.",
"pattern": "^sha256:[a-f0-9]{64}$"
},
"signer_keyid": {
"type": "string",
"description": "Key ID of the signer. Maps to proofchain.dsse_envelopes.signer_keyid. Omitted from the response when null."
},
"rekor_entry_id": {
"type": "string",
"description": "Rekor transparency log entry UUID. Maps to proofchain.rekor_entries.uuid (null/omitted if offline or no entry)."
},
"rekor_tile": {
"type": "string",
"description": "Maps to proofchain.rekor_entries.log_id (the Rekor log identifier). NOTE: despite the name, the view populates this from log_id, not a tile URL; treat as a log/tile identifier. Omitted from the response when null."
},
"signed_at": {
"type": "string",
"format": "date-time",
"description": "Maps to proofchain.dsse_envelopes.signed_at."
}
}
}
},
"transparency_status": {
"type": "object",
"description": "IMPLEMENTED (DTO present, currently always omitted by the read endpoint — reserved for offline/air-gapped deployments). Maps to TransparencyStatus in EvidenceThreadContracts.",
"required": ["mode"],
"properties": {
"mode": {
"type": "string",
"enum": ["online", "offline"],
"description": "Transparency log mode."
},
"reason": {
"type": "string",
"description": "Explanation when mode is offline. Omitted when null."
}
}
},
"referrers": {
"type": "array",
"description": "NOT IMPLEMENTED (forward design target). OCI referrers (symbol bundles, attestation manifests). Not present in the v1 materialized view, the repository row (ArtifactCanonicalRecord), or the API response.",
"items": {
"type": "object",
"required": ["media_type", "descriptor_digest"],
"properties": {
"media_type": {
"type": "string",
"description": "OCI media type",
"examples": [
"application/vnd.stella.symbols+tar",
"application/vnd.in-toto+json"
]
},
"descriptor_digest": {
"type": "string",
"pattern": "^sha256:[a-f0-9]{64}$"
},
"registry": {
"type": "string",
"description": "Registry hostname"
}
}
}
},
"vex_refs": {
"type": "array",
"description": "NOT IMPLEMENTED in this contract (forward design target). VEX consensus records targeting this artifact. The v1 artifact_canonical_records view and EvidenceThread API do not surface VEX refs. (A separate, unrelated table proofchain... is not used; EvidenceLocker's evidence_gate_artifacts table has its own 'vex_refs' jsonb column under a different contract — do not conflate.)",
"items": {
"type": "object",
"required": ["vulnerability_id", "consensus_status"],
"properties": {
"vulnerability_id": {
"type": "string",
"description": "CVE or advisory ID"
},
"consensus_status": {
"type": "string",
"enum": ["affected", "not_affected", "under_investigation", "fixed"]
},
"confidence_score": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"consensus_digest": {
"type": "string",
"description": "SHA-256 of the VexLens consensus record"
},
"dsse_digest": {
"type": "string",
"description": "SHA-256 of the VEX attestation DSSE (if signed)"
},
"rekor_tile": {
"type": "string",
"description": "Rekor tile URL (if anchored)"
}
}
}
},
"created_at": {
"type": "string",
"format": "date-time",
"description": "IMPLEMENTED. Maps to proofchain.sbom_entries.created_at."
},
"updated_at": {
"type": "string",
"format": "date-time",
"description": "NOT IMPLEMENTED (forward design target). The v1 view/record has no updated_at column; it is a read projection with no mutable record state."
}
}
}
Content Addressing
NOT IMPLEMENTED (forward design target). The v1 read projection does not compute a record-level digest; there is no
record_idfield in the view, the repository row, or the API response, and no JCS canonicalization of the record is performed in code.
The record is intended to be content-addressed:
record_id := sha256(JCS(record_json))
This would enable deduplication and integrity verification of the record.
Storage Design
Option A: Materialized View (IMPLEMENTED for v1)
This is the shipped v1 storage. The materialized view is created by Attestor migration 003_add_artifact_canonical_record_view.sql and applied on startup via AddStartupMigrations(schemaName: "attestor", moduleName: "Attestor.Persistence", …) in AttestorWebServiceComposition.cs (the SQL is an embedded resource in StellaOps.Attestor.Persistence). EvidenceLocker reads from it via PostgresEvidenceThreadRepository.
The actual migration matches the SQL below (uses IF NOT EXISTS and adds a partial PURL index):
CREATE MATERIALIZED VIEW IF NOT EXISTS proofchain.artifact_canonical_records AS
SELECT
se.bom_digest AS canonical_id,
'cyclonedx-jcs:1'::text AS format,
se.artifact_digest,
se.purl,
se.created_at,
COALESCE(
jsonb_agg(
DISTINCT jsonb_build_object(
'predicate_type', de.predicate_type,
'dsse_digest', de.body_hash,
'signer_keyid', de.signer_keyid,
'rekor_entry_id', re.uuid,
'rekor_tile', re.log_id,
'signed_at', de.signed_at
)
) FILTER (WHERE de.env_id IS NOT NULL),
'[]'::jsonb
) AS attestations
FROM proofchain.sbom_entries se
LEFT JOIN proofchain.dsse_envelopes de ON de.entry_id = se.entry_id
LEFT JOIN proofchain.rekor_entries re ON re.env_id = de.env_id
GROUP BY se.entry_id, se.bom_digest, se.artifact_digest, se.purl, se.created_at;
-- Unique index for CONCURRENTLY refresh and fast canonical_id lookup.
CREATE UNIQUE INDEX IF NOT EXISTS idx_acr_canonical_id
ON proofchain.artifact_canonical_records (canonical_id);
-- Partial index for PURL-based lookup (Evidence Thread by PURL).
CREATE INDEX IF NOT EXISTS idx_acr_purl
ON proofchain.artifact_canonical_records (purl)
WHERE purl IS NOT NULL;
The underlying tables (proofchain.sbom_entries, proofchain.dsse_envelopes, proofchain.rekor_entries) are created by Attestor migration 001_initial_schema.sql in the proofchain schema.
Refresh strategy: NOT IMPLEMENTED. The migration creates the view but no service issues REFRESH MATERIALIZED VIEW [CONCURRENTLY] for proofchain.artifact_canonical_records. There is no DSSE-submission trigger, no Rekor-confirmation trigger, and no periodic refresh job for this view in Attestor or EvidenceLocker. (The only REFRESH MATERIALIZED VIEW CONCURRENTLY callers in the codebase target the unrelated analytics.mv_* views in PlatformAnalyticsMaintenanceService.) Consequently the view returns only the rows present at creation time until a refresh is run manually. The intended strategy remains:
REFRESH MATERIALIZED VIEW CONCURRENTLYon new DSSE envelope submission- on new Rekor entry confirmation
- on a periodic schedule (e.g. every 5 minutes)
This refresh-automation gap is the highest-priority follow-up for the v1 store to be operationally correct.
Option B: Dedicated Table (for v2 if write patterns emerge)
NOT IMPLEMENTED (forward design target). If the record needs to be written to directly (e.g., for referrers and VEX refs that come from different services), migrate to a dedicated table with trigger-based population from upstream sources.
API Integration
Owned by EvidenceLocker (StellaOps.EvidenceLocker.Api.EvidenceThreadEndpoints, mapped via MapEvidenceThreadEndpoints() in EvidenceLocker.WebService Program.cs). Both endpoints are grouped under /api/v1/evidence/thread, require the evidence:read scope (StellaOpsResourceServerPolicies.EvidenceRead = StellaOpsScopes.EvidenceRead, the literal scope string evidence:read), and require a resolved tenant (.RequireTenant()).
Evidence Thread Endpoint (IMPLEMENTED)
GET /api/v1/evidence/thread/{canonicalId}
Returns the Artifact Canonical Record for the given canonicalId. The response (GetEvidenceThreadResponse) includes canonical_id, format, artifact_digest, purl, attestations[], optional transparency_status, and created_at. It does not include referrers, vex_refs, sbom_ref, or updated_at (none of those are in the backing view).
Responses: 200 OK with the record, 404 Not Found ({ "error": ..., "canonical_id": ... }) when no row matches, 500 Internal Server Error on failure.
Query parameters:
include_attestations(boolean, default true) — IMPLEMENTED. When false, theattestationsarray is returned empty.include_vex(boolean) — NOT IMPLEMENTED (no such parameter).include_referrers(boolean) — NOT IMPLEMENTED (no such parameter).
Evidence Thread by PURL (IMPLEMENTED)
GET /api/v1/evidence/thread/?purl={purl}
Lists Artifact Canonical Records whose purl exactly equals the query value, ordered by created_at descending and capped at 100 rows (PostgresEvidenceThreadRepository.GetByPurlAsync). The purl parameter is required; a missing/blank value returns 400 Bad Request. The response (ListEvidenceThreadsResponse) is a list of summaries (canonical_id, format, purl, attestation_count, created_at) plus a pagination object. NOTE: the pagination block is currently a static placeholder (limit: 100, offset: 0, total = number of returned rows); true paging is not implemented. The lookup is a direct purl = match on the view, not a resolve-then-fetch through sbom_entries.
Offline Mode
When Rekor is unavailable (air-gapped deployment):
rekor_entry_idandrekor_tilearenull/ omitted on the affected attestations (IMPLEMENTED — these come from theLEFT JOINtoproofchain.rekor_entries, so they are naturally absent when no Rekor entry exists).- A
transparency_statusobject withmode="offline"and an optionalreasonsub-field. PARTIALLY IMPLEMENTED: theTransparencyStatusDTO (fieldsmode,reason) exists inEvidenceThreadContracts, butGetThreadByCanonicalIdAsyncnever populates it, so the field is currently always omitted from the response. There is no logic that setsmode = "offline"yet. - Verification uses bundled checkpoints instead of live Rekor queries (forward design target; not wired into this read API).
Determinism
The implemented attestation ordering is deterministic and performed in application code (EvidenceThreadEndpoints.ParseAttestations), not in the SQL view:
- Attestation arrays sorted by
signed_atascending, thenpredicate_typeascending (ordinal string comparison). IMPLEMENTED. - VEX refs sorted by
vulnerability_idascending — NOT IMPLEMENTED (no VEX refs in v1). - Referrers sorted by
media_typeascending, thendescriptor_digestascending — NOT IMPLEMENTED (no referrers in v1).
NOTE: the materialized view aggregates attestations with jsonb_agg(DISTINCT …), whose in-JSONB ordering is not guaranteed; deterministic ordering is therefore re-applied in ParseAttestations after reading. The PURL-list endpoint orders rows by created_at descending.
References
- Canonical SBOM Identifier v1 —
canonical_idcomputation - Attestor architecture — DSSE envelope storage
- EvidenceLocker attestation contract — Evidence Bundle v1
Implementing source (ground truth, 2026-05-30)
- View / schema:
src/Attestor/__Libraries/StellaOps.Attestor.Persistence/Migrations/003_add_artifact_canonical_record_view.sql(underlying tables:…/Migrations/001_initial_schema.sql) - Migration wiring (auto-migrate on startup):
src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService/AttestorWebServiceComposition.cs(AddStartupMigrations) - API endpoints:
src/EvidenceLocker/StellaOps.EvidenceLocker/Api/EvidenceThreadEndpoints.cs - DTOs:
src/EvidenceLocker/StellaOps.EvidenceLocker/Api/EvidenceThreadContracts.cs(GetEvidenceThreadResponse,EvidenceThreadAttestation,TransparencyStatus,ListEvidenceThreadsResponse,EvidenceThreadSummary) - Repository:
src/EvidenceLocker/StellaOps.EvidenceLocker/Storage/PostgresEvidenceThreadRepository.cs,…/Storage/IEvidenceThreadRepository.cs - DI registration:
src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Infrastructure/DependencyInjection/EvidenceLockerInfrastructureServiceCollectionExtensions.cs - Scope:
evidence:read—src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs(EvidenceRead), policy inStellaOps.Auth.ServerIntegration/StellaOpsResourceServerPolicies.cs - Endpoint contract reference S00-T05-EVID-01 — UNVERIFIED (no matching identifier found in the codebase; retained from the original draft and flagged for confirmation).
