oc_chat
Loading...
Searching...
No Matches
hashing.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/source/hashing.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/source/test_hashing.py"
6"""
7The three AuthoredSource hash projections (+ envelope nullability).
8
9Purpose
10-------
11Implement Registry Source & Storage Spec V0.22 §3.2: (1) the canonical
12source-tree hash, (2) the source semantic projection, (3) the canonical
13logical-state hash over the RuntimeStore durable roadmap rows (surrogate →
14natural-key resolution + canonical ``query_params_json``), plus the §10 raw
15source-tree hash and the failure-envelope hash nullability rule.
16
17Responsibilities
18----------------
19- ``raw_source_tree_hash`` — sha256 over raw observed bytes (incl. files that
20 later fail validation; computable before parse; null only on scan failure).
21- ``source_hash`` — sha256 over canonical record bytes (projection 1).
22- ``semantic_hash`` — sha256 over the source semantic projection (projection 2).
23- ``logical_state_hash`` — sha256 over the §3.2 store projection (projection 3).
24- ``hash_preimage`` — the printed preimage (with ``hash_preimage_schema_version``).
25- ``stage_hashes`` — the §10 nullability of the three hashes per failure stage.
26
27Diagnostics
28-----------
29Domain: OC_CHAT.MAIN
30Levels:
31 L2 - hash lifecycle
32 L3 - projection details
33 L4 - per-row projection tracing
34
35Contracts
36---------
37- All hashes are sha256 hex over ``canonical_json`` of a preimage that begins
38 with ``hash_preimage_schema_version`` (starts at integer 1).
39- The logical projection includes/excludes exactly the §3.2 columns; surrogate
40 keys are resolved to natural keys; ``query_params_json`` is re-emitted canonical.
41- ``logical_state_hash`` reads an already-built store; it derives nothing
42 (version status/lineage derive-verify is the P1.4 step-7 publish-gate).
43
44Dependencies
45------------
46- oc_diagnostics (structured debug logging via _dbg)
47- oc_chat.registry.canonical (canonical_json)
48- oc_chat.registry.source.scanner (ScannedRecord and observed_files - the records and files the hashes cover)
49- oc_chat.registry.source.canonical (canonical_record_bytes - the canonical bytes behind the source hash)
50- oc_chat.registry.source.errors (SRC_BAD_QUERY_PARAMS and SourceValidationError - an invalid query_params_json)
51- option_c_registry_contracts (RegistryModel - the loaded record models behind the semantic hash)
52"""
53from __future__ import annotations
54
55import hashlib
56import json
57import sqlite3
58from pathlib import Path
59from typing import Any, Optional
60
61from oc_diagnostics import _dbg
62
63from option_c_registry_contracts import RegistryModel
64
65from oc_chat.registry.canonical import canonical_json
66from oc_chat.registry.source.canonical import canonical_record_bytes
68 SRC_BAD_QUERY_PARAMS,
69 SourceValidationError,
70)
71from oc_chat.registry.source.scanner import ScannedRecord, observed_files
72
73_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
74
75#: Bumps only when a projection definition changes (§3.2; Opus 4.7 V9 L-V9-1).
76HASH_PREIMAGE_SCHEMA_VERSION = 1
77
78# Failure stages for the §10 envelope hash nullability rule (AC §8.48).
79STAGE_SCAN = "scan"
80STAGE_PARSE = "parse"
81STAGE_CROSS_RECORD = "cross_record"
82STAGE_BUILD = "build"
83STAGE_CLEAN = "clean"
84
85
86def _sha256_hex(data: bytes) -> str:
87 return hashlib.sha256(data).hexdigest()
88
89
90def _hash_preimage(preimage: dict[str, Any]) -> str:
91 return _sha256_hex(canonical_json(preimage).encode("utf-8"))
92
93
94# --- Projection 1 (raw + canonical source-tree) -----------------------------
95def raw_source_tree_preimage(root: Path) -> dict[str, Any]:
96 """``[(relative_posix_path, sha256(raw_file_bytes))]`` over observed files."""
97 entries = [
98 [path.relative_to(root).as_posix(), _sha256_hex(path.read_bytes())]
99 for path in observed_files(Path(root))
100 ]
101 entries.sort()
102 return {
103 "hash_preimage_schema_version": HASH_PREIMAGE_SCHEMA_VERSION,
104 "files": entries,
105 }
106
107
108def raw_source_tree_hash(root: Path) -> str:
110
111
112def source_hash_preimage(records: list[ScannedRecord]) -> dict[str, Any]:
113 """``[(relative_posix_path, sha256(canonical_record_bytes))]`` (projection 1).
114
115 [OP-5] promoted parity: promoted items are now included (the earlier
116 model-gap skip is removed), so ``source_hash`` is a complete fingerprint of
117 the authored tree — an edit to a promoted file moves ``source_hash``.
118 """
119 entries: list[list[str]] = []
120 for scanned in records:
121 entries.append(
122 [scanned.rel_path, _sha256_hex(canonical_record_bytes(scanned))]
123 )
124 entries.sort()
125 return {
126 "hash_preimage_schema_version": HASH_PREIMAGE_SCHEMA_VERSION,
127 "records": entries,
128 }
129
130
131def source_hash(records: list[ScannedRecord]) -> str:
132 return _hash_preimage(source_hash_preimage(records))
133
134
135# --- Projection 2 (source semantic) -----------------------------------------
136def semantic_preimage(loaded: list[tuple[ScannedRecord, RegistryModel]]) -> dict[str, Any]:
137 """Authored records normalized to canonical JSON using source field names."""
138 records = [
139 {
140 "kind": scanned.kind,
141 "rel_path": scanned.rel_path,
142 "fields": model.model_dump(mode="json"),
143 }
144 for scanned, model in loaded
145 ]
146 records.sort(key=lambda entry: entry["rel_path"])
147 return {
148 "hash_preimage_schema_version": HASH_PREIMAGE_SCHEMA_VERSION,
149 "records": records,
150 }
151
152
153def semantic_hash(loaded: list[tuple[ScannedRecord, RegistryModel]]) -> str:
154 return _hash_preimage(semantic_preimage(loaded))
155
156
157# --- Projection 3 (canonical logical-state) ---------------------------------
158def _table_exists(conn: sqlite3.Connection, name: str) -> bool:
159 row = conn.execute(
160 # OCT-LINT: disable=no_hardcoded_secrets reason="Column/table names are schema constants, not secrets"
161 "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)
162 ).fetchone()
163 return row is not None
164
165
166def _rows(conn: sqlite3.Connection, table: str, columns: str) -> list[sqlite3.Row]:
167 if not _table_exists(conn, table):
168 return []
169 return conn.execute(f"SELECT {columns} FROM {table}").fetchall()
170
171
172def _canonical_query_params(value: Optional[str], section_path: str) -> Optional[str]:
173 if value is None or value == "":
174 return None
175 try:
176 parsed = json.loads(value)
177 except (ValueError, TypeError) as error:
179 f"render section {section_path!r}: invalid query_params_json: {error}",
180 code=SRC_BAD_QUERY_PARAMS,
181 records=[section_path],
182 ) from error
183 return canonical_json(parsed)
184
185
186def logical_state_projection(conn: sqlite3.Connection) -> dict[str, Any]:
187 """Build the §3.2 projection-3 preimage over the durable roadmap rows."""
188 conn.row_factory = sqlite3.Row
189 phase_code_by_id = {
190 row["phase_id"]: row["phase_code"]
191 for row in _rows(conn, "roadmap_phases", "phase_id, phase_code")
192 }
193 track_code_by_id = {
194 row["track_id"]: row["track_code"]
195 for row in _rows(conn, "roadmap_tracks", "track_id, track_code")
196 }
197
198 eras = [
199 dict(row)
200 for row in _rows(
201 conn,
202 "roadmap_eras",
203 "era_id, era_label, major_bump_reason, archived_under, "
204 "sort_order, started_at, ended_at",
205 )
206 ]
207 eras.sort(key=lambda row: row["era_id"])
208
209 versions = [
210 dict(row)
211 for row in _rows(
212 conn,
213 "roadmap_versions",
214 "version_id, era_id, bump_kind, notes, source_round_no, released_at",
215 )
216 ]
217 versions.sort(key=lambda row: row["version_id"])
218
219 phases = []
220 for row in _rows(
221 conn,
222 "roadmap_phases",
223 "phase_id, phase_code, label, parent_phase_id, branch_suggestion, "
224 "commit_prefix, target_version, priority_label, parallel_track, "
225 "full_spec_url, sort_order, purpose, introduced_in_version_id, "
226 "retired_in_version_id",
227 ):
228 record = dict(row)
229 record["parent_phase_code"] = phase_code_by_id.get(record.pop("parent_phase_id"))
230 record.pop("phase_id")
231 phases.append(record)
232 phases.sort(key=lambda row: row["phase_code"])
233
234 tracks = [
235 dict(row)
236 for row in _rows(
237 conn,
238 "roadmap_tracks",
239 "track_code, label, description, sort_order, active",
240 )
241 ]
242 tracks.sort(key=lambda row: row["track_code"])
243
244 items = []
245 for row in _rows(
246 conn,
247 "roadmap_items_v3",
248 "item_id, track_kind, phase_id, parent_item_id, title, prose, status, "
249 "priority, sort_order, target_version, superseded_by_item_id, "
250 "successor_item_id, purpose, introduced_in_version_id, closed_in_version_id",
251 ):
252 record = dict(row)
253 record["phase_code"] = phase_code_by_id.get(record.pop("phase_id"))
254 items.append(record)
255 items.sort(key=lambda row: row["item_id"])
256
257 item_tracks = []
258 for row in _rows(conn, "roadmap_item_tracks", "item_id, track_id"):
259 item_tracks.append(
260 {"item_id": row["item_id"], "track_code": track_code_by_id.get(row["track_id"])}
261 )
262 item_tracks.sort(key=lambda row: (row["item_id"], row["track_code"] or ""))
263
264 dependencies = [
265 {"from_item_id": row["from_item_id"], "to_item_id": row["to_item_id"], "kind": row["kind"]}
266 for row in _rows(
267 conn, "roadmap_item_dependencies", "from_item_id, to_item_id, kind"
268 )
269 ]
270 dependencies.sort(key=lambda row: (row["from_item_id"], row["to_item_id"], row["kind"]))
271
272 render_sections = []
273 for row in _rows(
274 conn,
275 "roadmap_render_sections",
276 "file_kind, section_path, sort_order, query_kind, query_params_json, "
277 "required_min_version, active",
278 ):
279 record = dict(row)
280 record["query_params_json"] = _canonical_query_params(
281 record["query_params_json"], record["section_path"]
282 )
283 render_sections.append(record)
284 render_sections.sort(key=lambda row: (row["file_kind"], row["section_path"]))
285
286 return {
287 "hash_preimage_schema_version": HASH_PREIMAGE_SCHEMA_VERSION,
288 "roadmap_eras": eras,
289 "roadmap_versions": versions,
290 "roadmap_phases": phases,
291 "roadmap_tracks": tracks,
292 "roadmap_items_v3": items,
293 "roadmap_item_tracks": item_tracks,
294 "roadmap_item_dependencies": dependencies,
295 "roadmap_render_sections": render_sections,
296 }
297
298
299def logical_state_hash(conn: sqlite3.Connection) -> str:
301
302
303# --- §10 failure-envelope hash nullability (AC §8.48) ------------------------
305 stage: str,
306 *,
307 raw: Optional[str] = None,
308 source: Optional[str] = None,
309 logical: Optional[str] = None,
310) -> dict[str, Optional[str]]:
311 """Return the three envelope hash fields with the §10 nullability for ``stage``.
312
313 scan failure → raw null; parse/local/canonicalisation → source null, raw
314 present; cross-record / pre-build → logical null, source present; clean →
315 all three present.
316 """
317 if stage == STAGE_SCAN:
318 return {"raw_source_tree_hash": None, "source_hash": None, "logical_state_hash": None}
319 if stage == STAGE_PARSE:
320 return {"raw_source_tree_hash": raw, "source_hash": None, "logical_state_hash": None}
321 if stage in (STAGE_CROSS_RECORD, STAGE_BUILD):
322 return {"raw_source_tree_hash": raw, "source_hash": source, "logical_state_hash": None}
323 return {"raw_source_tree_hash": raw, "source_hash": source, "logical_state_hash": logical}
324
325
326# --- hash-preimage debug surface (§3.2 / §13) -------------------------------
328 which: str,
329 *,
330 root: Optional[Path] = None,
331 records: Optional[list[ScannedRecord]] = None,
332 loaded: Optional[list[tuple[ScannedRecord, RegistryModel]]] = None,
333 conn: Optional[sqlite3.Connection] = None,
334) -> dict[str, Any]:
335 """Return the canonical preimage for ``which`` projection (debug surface)."""
336 if which == "source-tree":
337 if records is None:
338 raise ValueError("source-tree preimage requires records")
339 return source_hash_preimage(records)
340 if which == "raw-source-tree":
341 if root is None:
342 raise ValueError("raw-source-tree preimage requires root")
343 return raw_source_tree_preimage(root)
344 if which == "semantic":
345 if loaded is None:
346 raise ValueError("semantic preimage requires loaded records")
347 return semantic_preimage(loaded)
348 if which == "logical-state":
349 if conn is None:
350 raise ValueError("logical-state preimage requires a store connection")
351 return logical_state_projection(conn)
352 raise ValueError(f"unknown preimage kind: {which!r}")
str _hash_preimage(dict[str, Any] preimage)
Definition hashing.py:90
str _sha256_hex(bytes data)
Definition hashing.py:86
dict[str, Any] semantic_preimage(list[tuple[ScannedRecord, RegistryModel]] loaded)
Definition hashing.py:136
Optional[str] _canonical_query_params(Optional[str] value, str section_path)
Definition hashing.py:172
bool _table_exists(sqlite3.Connection conn, str name)
Definition hashing.py:158
str raw_source_tree_hash(Path root)
Definition hashing.py:108
dict[str, Any] logical_state_projection(sqlite3.Connection conn)
Definition hashing.py:186
dict[str, Optional[str]] stage_hashes(str stage, *, Optional[str] raw=None, Optional[str] source=None, Optional[str] logical=None)
Definition hashing.py:310
dict[str, Any] hash_preimage(str which, *, Optional[Path] root=None, Optional[list[ScannedRecord]] records=None, Optional[list[tuple[ScannedRecord, RegistryModel]]] loaded=None, Optional[sqlite3.Connection] conn=None)
Definition hashing.py:334
str logical_state_hash(sqlite3.Connection conn)
Definition hashing.py:299
str semantic_hash(list[tuple[ScannedRecord, RegistryModel]] loaded)
Definition hashing.py:153
list[sqlite3.Row] _rows(sqlite3.Connection conn, str table, str columns)
Definition hashing.py:166
str source_hash(list[ScannedRecord] records)
Definition hashing.py:131
dict[str, Any] source_hash_preimage(list[ScannedRecord] records)
Definition hashing.py:112
dict[str, Any] raw_source_tree_preimage(Path root)
Definition hashing.py:95