Canonicalization Version Migration Guide

Audience: Stella Ops platform engineers and operators responsible for attestations and content-addressed evidence. Purpose: Explain how Stella Ops content-addressed identifiers move from legacy (unversioned) canonicalization to versioned canonicalization (stella:canon:v1), and what the planned migration phases will require of operators.

Version: 1.0 Status: Active (Phase 1 only — see note below) Last Updated: 2025-12-24

Implementation status (reconciled against src/). The versioned-canonicalization primitives are implemented and shipping: CanonVersion (V1 = "stella:canon:v1", VersionFieldName = "_canonVersion", Current = V1, IsVersioned, ExtractVersion) in src/__Libraries/StellaOps.Canonical.Json/CanonVersion.cs, and the hashing/canonicalization helpers CanonJson.CanonicalizeVersioned<T> / CanonJson.HashVersioned<T> in src/__Libraries/StellaOps.Canonical.Json/CanonJson.Versioned.cs and CanonJson.Hashing.cs. Content-addressed ID generation (EvidenceId, ReasoningId, VexVerdictId, ProofBundleId) already emits versioned hashes via ContentAddressedIdGenerator in src/Attestor/__Libraries/StellaOps.Attestor.ProofChain/Identifiers/.

The operator-facing tooling and lifecycle controls described below are not yet implemented and are retained as the planned migration design:

  • There is no stella rehash, stella inspect --show-versionCLI command, and stella verify does not expose a version flag (verified against src/Cli/).
  • There is no canon_legacy_hash_verified_total metric and no deprecation-warning logging for legacy hashes.
  • There is no dual-mode verifier that falls back from versioned to legacy hashing; generation is versioned-only and no consumer of IsVersioned/ExtractVersion performs the accept-both-formats flow shown here.

Treat the phase timeline, CLI snippets, SQL, and metrics in this guide as roadmap/Draft until the corresponding code lands. Flagged items are marked NOT IMPLEMENTED inline.


Overview

This guide describes the migration path for content-addressed identifiers from legacy (unversioned) canonicalization to versioned canonicalization (stella:canon:v1). Versioned canonicalization embeds algorithm version markers in the canonical JSON before hashing, ensuring forward compatibility and verifier clarity.

Content-addressed identifiers covered: EvidenceId, ReasoningId, VexVerdictId, and ProofBundleId (defined under src/Attestor/__Libraries/StellaOps.Attestor.ProofChain/Identifiers/). All four are computed by ContentAddressedIdGenerator, and the three single-predicate IDs (EvidenceId, ReasoningId, VexVerdictId) already canonicalize with the stella:canon:v1 version marker. ProofBundleId is a Merkle root over the other IDs and SBOM entry ID rather than a direct versioned-canonicalization hash.

Why Versioning?

Problem

Legacy content-addressed IDs (EvidenceId, ReasoningId, VexVerdictId, ProofBundleId) were computed as:

hash = SHA256(canonicalize(payload))

If the canonicalization algorithm ever changed (bug fix, specification update, optimization), existing hashes would become invalid with no way to detect which algorithm produced them.

Solution

Versioned content-addressed IDs embed a version marker:

hash = SHA256(canonicalize_with_version(payload, "stella:canon:v1"))

The canonical JSON now includes _canonVersion as the first field:

{
  "_canonVersion": "stella:canon:v1",
  "sbomEntryId": "sha256:91f2ab3c:pkg:npm/lodash@4.17.21",
  "vulnerabilityId": "CVE-2021-23337"
}

Migration Phases

Phase 1: Dual-Mode (Current — partially implemented)

Timeline: Now
Behavior:

Impact:

Phase 2: Deprecation Warning (planned — NOT IMPLEMENTED)

Timeline: +6 months from Phase 1
Behavior:

Operator Action:

Phase 3: Legacy Rejection (planned — NOT IMPLEMENTED)

Timeline: +12 months from Phase 1
Behavior:

Operator Action:


Detection and Verification

Detecting Versioned Hashes

Versioned canonical JSON always starts with {"_canonVersion": due to lexicographic sorting (underscore sorts before all ASCII letters).

using StellaOps.Canonical.Json;

// Check if canonical JSON is versioned
byte[] canonicalJson = GetCanonicalPayload();
bool isVersioned = CanonVersion.IsVersioned(canonicalJson);

// Extract version if present
string? version = CanonVersion.ExtractVersion(canonicalJson);
if (version == CanonVersion.V1)
{
    // Use V1 verification algorithm
}

Verifying Both Formats

During Phase 1, verifiers should accept both formats. The pattern below is the planned dual-mode verifier; it is NOT IMPLEMENTED as a shipping component today.

API note. CanonJson.Hash<T>(T obj) and CanonJson.HashVersioned<T>(T obj, string version = CanonVersion.Current) are generic helpers that canonicalize the object before hashing — they do not accept already-canonical bytes. To re-hash a payload that is already canonical UTF-8 JSON, hash the bytes directly with CanonJson.Sha256Hex(byte[]), or compare against the bytes recovered by IsVersioned/ExtractVersion. Signatures live in src/__Libraries/StellaOps.Canonical.Json/CanonJson.Hashing.cs. Hash/HashVersioned return a 64-character lowercase hex string (no sha256: prefix); use HashPrefixed/HashVersionedPrefixed for the prefixed form.

// Illustrative (proposed) dual-mode verifier — not a shipping API.
public bool VerifyContentAddressedId(byte[] canonicalPayload, string expectedHash)
{
    // Both branches re-hash the already-canonical bytes directly.
    // CanonVersion.IsVersioned only inspects the leading bytes for the
    // {"_canonVersion":" prefix; it does not re-canonicalize.
    var hash = CanonJson.Sha256Hex(canonicalPayload);
    return hash == expectedHash;
}

Re-Attestation Procedure

When to Re-Attest

Re-attestation is required when:

CLI Re-Attestation (planned — NOT IMPLEMENTED)

NOT IMPLEMENTED. No stella rehash command exists in src/Cli/ (verified). The snippet below is the proposed command surface for the bulk re-attestation tooling and will become accurate only once that tooling lands.

# Re-attest a single attestation bundle
stella rehash --input attestation.json --output attestation-v1.json

# Bulk re-attest all attestations in a directory
stella rehash --input-dir /var/stella/attestations \
              --output-dir /var/stella/attestations-v1 \
              --version stella:canon:v1

# Dry-run to preview changes
stella rehash --input attestation.json --dry-run

Database Migration

Illustrative — schema does not match the live Evidence Locker. The Evidence Locker has no attestations table and no content column. Content-addressed records live in tables such as evidence_locker.decision_capsules, evidence_locker.verdict_attestations, and evidence_locker.regulatory_artifact_ledger, and the digest column is not uniformly named (verified against 005_decision_capsules.sql, 006_verdict_attestations.sql, 007_regulatory_audit_retention_ledger.sql):

  • evidence_locker.decision_capsules — digest in content_hash (^[0-9a-f]{64}$), and it does persist the canonical body in manifest_json text (stored as text, not jsonb, for byte-identical replay). A manifest_json LIKE '{"_canonVersion":%' scan is therefore structurally possible here, but it will not match: the stored body is the capsule manifest, not a versioned-canon predicate payload.
  • evidence_locker.verdict_attestationsno content_hash column; the digest is predicate_digest and the signed body is envelope jsonb (a DSSE envelope, not the bare canonical predicate).
  • evidence_locker.regulatory_artifact_ledger — digest in content_hash (sha256:-prefixed); the canonical body is not stored (only source_evidence_refs).

The bare content LIKE '{"_canonVersion":%' predicate below cannot be evaluated against verdict_attestations or regulatory_artifact_ledger (no body column), and is misleading against decision_capsules (the body column is manifest_json, and capsule manifests are not the predicates whose IDs carry the version marker). Detecting legacy content-addressed records in PostgreSQL therefore means recomputing a versioned hash from the source predicate and comparing it against the stored digest (content_hash / predicate_digest), not a LIKE scan of stored JSON. Adapt the query below to your actual table once a real migration job is defined.

-- ILLUSTRATIVE ONLY: no `attestations` table exists in the Evidence Locker schema.
-- Find legacy attestations (those without version marker)
SELECT id, content_hash, created_at
FROM attestations
WHERE NOT content LIKE '{"_canonVersion":%'
ORDER BY created_at;

-- Export for re-processing
COPY (
  SELECT id, content
  FROM attestations
  WHERE NOT content LIKE '{"_canonVersion":%'
) TO '/tmp/legacy_attestations.csv' WITH CSV HEADER;

Troubleshooting

Hash Mismatch Errors

Symptom: Verification fails with “hash mismatch” error.

Diagnosis:

  1. Check if the stored hash was computed with legacy or versioned canonicalization
  2. Check the current verification mode (Phase 1/2/3)

Resolution (planned — NOT IMPLEMENTED):

NOT IMPLEMENTED. There is no stella inspect ... --show-version command in src/Cli/ (verified). To check a payload’s format programmatically today, use CanonVersion.IsVersioned(bytes) / CanonVersion.ExtractVersion(bytes) against the canonical JSON.

# Inspect the attestation format
stella inspect attestation.json --show-version

# Output:
# Canonicalization Version: stella:canon:v1
# Hash Algorithm: SHA-256
# Computed Hash: sha256:7b8c9d0e...

Legacy Hash in Phase 3

Symptom: Verification rejected with “legacy hash not accepted” error.

Phase 3 rejection is NOT IMPLEMENTED (no verifier rejects legacy hashes today). This section applies only after the phase timeline lands.

Resolution:

  1. Re-attest the content with versioned canonicalization
  2. Update any references to the old hash
stella rehash --input old.json --output new.json  # NOT IMPLEMENTED (no `stella rehash`)
stella verify new.json  # Should pass

Performance Considerations

Versioned canonicalization adds ~35 bytes to each canonical payload (the prefix {"_canonVersion":"stella:canon:v1", is exactly 35 bytes — consistent with CanonVersion.IsVersioned, which requires a minimum length of 35 bytes). This has negligible impact on:


Version History

VersionIdentifierStatusNotes
V1stella:canon:v1CurrentRFC 8785 JSON canonicalization
Legacy(none)DeprecatedPre-versioning; no version marker