Coverage for node / src / stigmem_node / cid.py: 96%
43 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
1"""Content-addressed fact IDs — spec §25 (CID v2).
3CID = "sha256:" + hex_lowercase(SHA-256(canonical_fact_body))
5Canonicalization is **sorted-key compact ``json.dumps``**, NOT full RFC 8785
6(JCS). Concretely the canonical body is serialized with
7``json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False)``
8and UTF-8 encoded. This gives deterministic key ordering and no insignificant
9whitespace, but does NOT apply RFC 8785's number canonicalization or Unicode
10normalization. Producers and verifiers MUST use this same serialization (see
11``compute_cid``); the documented field set below is what makes it stable.
13**CID v2 (breaking change, 2026-06-06).** The canonical body is a JSON object
14with exactly 8 fields in lexicographic key order:
15 confidence, entity, interpret_as, relation, scope, source, value_type, value_v
17`interpret_as` is now bound (per ADR-003 / threat R-23): flipping a fact's
18interpretation between `content` and `instruction` changes the CID, so it is
19detected on the read path. **v1 CIDs (which omitted `interpret_as`) are NOT
20accepted** — pre-v2 facts must be upgraded via the CID backfill migration;
21until migrated they fail read-path verification (409 cid_mismatch).
23Security-relevant excluded fields (§25.2.1 rev 15):
24 valid_until, derived_from, attestation_chain, source_trust, signature, reason
25 (these require independent validation; CID coverage alone is not sufficient)
27fact_id and cid are also excluded (circular).
28timestamp/created_at is excluded so the same assertion at different times shares one CID.
29"""
31from __future__ import annotations
33import hashlib
34import json
35import re
36from typing import Any
38_CID_PREFIX = "sha256:"
39_CID_HEX_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
42def compute_cid(
43 entity: str,
44 relation: str,
45 value_type: str,
46 value_v: str,
47 source: str,
48 scope: str,
49 confidence: float = 1.0,
50 interpret_as: str = "content",
51) -> str:
52 """Return the CID v2 for a fact's canonical body (spec §25.2.1, §25.2.2).
54 `interpret_as` is part of the canonical body (CID v2); a flip between
55 `content` and `instruction` produces a different CID.
56 """
57 body: dict[str, Any] = {
58 "confidence": confidence,
59 "entity": entity,
60 "interpret_as": interpret_as,
61 "relation": relation,
62 "scope": scope,
63 "source": source,
64 "value_type": value_type,
65 "value_v": value_v,
66 }
67 canonical = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
68 "utf-8"
69 )
70 digest = hashlib.sha256(canonical).hexdigest()
71 return f"{_CID_PREFIX}{digest}"
74def compute_cid_from_row(row: Any) -> str:
75 """Convenience wrapper: compute CID from a facts-table row."""
76 return compute_cid(
77 entity=row["entity"],
78 relation=row["relation"],
79 value_type=row["value_type"],
80 value_v=row["value_v"] or "",
81 source=row["source"],
82 scope=row["scope"],
83 confidence=float(row["confidence"]),
84 interpret_as=(_optional_row_value(row, "interpret_as") or "content"),
85 )
88class CidMismatchError(ValueError):
89 """Raised when a stored fact CID does not match its canonical body."""
91 def __init__(self, *, fact_id: str, stored_cid: str, computed_cid: str) -> None:
92 super().__init__(f"CID mismatch for fact {fact_id}")
93 self.fact_id = fact_id
94 self.stored_cid = stored_cid
95 self.computed_cid = computed_cid
98def _optional_row_value(row: Any, key: str) -> Any:
99 try:
100 keys = row.keys()
101 except AttributeError:
102 return row.get(key) if isinstance(row, dict) else None
103 return row[key] if key in keys else None
106def stored_cid_from_row(row: Any) -> str | None:
107 """Return the stored/projected CID for a fact row, if one is present."""
108 projected = _optional_row_value(row, "projected_cid")
109 if projected is not None:
110 return str(projected)
111 stored = _optional_row_value(row, "cid")
112 return None if stored is None else str(stored)
115def verify_cid_from_row(row: Any) -> None:
116 """Verify a fact row's stored CID, preserving legacy NULL-CID rows."""
117 stored = stored_cid_from_row(row)
118 if stored is None:
119 return
120 computed = compute_cid_from_row(row)
121 if computed != stored:
122 raise CidMismatchError(
123 fact_id=str(row["id"]),
124 stored_cid=stored,
125 computed_cid=computed,
126 )
129def is_valid_cid(s: str) -> bool:
130 """Return True if *s* looks like a well-formed sha256 CID (spec §25.2)."""
131 return bool(_CID_HEX_RE.match(s))
134def is_cid(s: str) -> bool:
135 """Return True if *s* starts with the sha256: prefix (quick pre-filter)."""
136 return s.startswith(_CID_PREFIX)