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

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 (migration 003_add_artifact_canonical_record_view.sql, owned by Attestor) and the read API is exposed by EvidenceLocker at GET /api/v1/evidence/thread/{canonicalId}. The implemented record contains canonical_id, format, artifact_digest, purl, attestations[], and created_at. The sbom_ref, referrers, vex_refs, updated_at, and record_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:

  1. The Evidence Thread API (GET /api/v1/evidence/thread/{canonicalId}, EvidenceLocker — implemented for attestations)
  2. Audit pack generation (export all evidence for an artifact — design target)
  3. 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 by canonical_id. The Platform service also exposes an unrelated graph-oriented Evidence Thread API at GET /api/v1/evidence/{artifactDigest} (ReleaseOrchestrator-backed StellaOps.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 / EvidenceThreadAttestation in StellaOps.EvidenceLocker.Api.EvidenceThreadContracts) are: canonical_id, format, artifact_digest, purl, attestations[] (each with predicate_type, dsse_digest, signer_keyid, rekor_entry_id, rekor_tile, signed_at), an optional transparency_status, and created_at. sbom_ref, referrers, vex_refs, and updated_at are 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_id field 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:

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:

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):

Determinism

The implemented attestation ordering is deterministic and performed in application code (EvidenceThreadEndpoints.ParseAttestations), not in the SQL view:

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

Implementing source (ground truth, 2026-05-30)