Promote schema and canonicalizer out of history; add EvidenceModel (T06)
Verified the three published hashes reproduce byte for byte before promoting anything, then moved the schema, canonicalizer and vectors into governed assets. history/20260909-initial-exploration/ is untouched and stays the provenance record. - schemas/, informed_decision/, tests/vectors/ populated; the reference canonicalizer's ad-hoc __main__ block replaced by a real `python -m informed_decision` entry point. - tests/test_canonicalize.py — 20 tests, all green. Published vectors, all four isolation properties, canonical-form round-trip, key sorting, and a provenance test asserting the governed fixtures have not drifted from history/. - docs/specs/EvidenceModel.md — the two hashes, the split and why it exists, the four isolation properties, the presentation record, the bundle, and the relationship to audit-core. - pyproject.toml, Makefile. One test of mine was wrong on first run: it scanned for ", " to assert no insignificant whitespace, which fires on prose inside a brief. Replaced with a canonical round-trip comparison, which is the property actually meant. The canonicalizer was correct. EvidenceModel leads with what the model does NOT claim — no proof of comprehension, no proof of reading (deliberately, since the alternative is surveillance), no survival of a compromised surface, and audit-core's inherited bound that a hash chain cannot prove a record was never sent. T06 stays progress: the SCOPE.md rewrite is gated on the T02 ruling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3W1dQG7GFFM9d94jFx7iR Assistant: claude-code Assistant-Model: opus Assistant-Process: 1565372@bnt-lap001 Assistant-Session: 16bb2f25-b34c-49ef-8e94-5fec3567a568
This commit is contained in:
parent
7ae67b2f4e
commit
a8e227851e
19 changed files with 2207 additions and 2 deletions
245
tests/test_canonicalize.py
Normal file
245
tests/test_canonicalize.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"""Canonicalization tests — the four isolation properties that must stay green.
|
||||
|
||||
These are not incidental unit tests. Each one protects a property the evidence
|
||||
model depends on; see ``docs/specs/EvidenceModel.md`` and the negative cases
|
||||
NC-05, NC-06, NC-10 in ``docs/specs/UseCaseCatalog.md``.
|
||||
|
||||
If one of these fails, ``view_hash`` no longer means what ``INTENT.md`` claims
|
||||
it means, and the promise "this person was shown this view" is unsupported.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import pathlib
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from informed_decision.canonicalize import awareness_hash, view_hash
|
||||
|
||||
VECTORS = pathlib.Path(__file__).parent / "vectors"
|
||||
|
||||
|
||||
def load(name: str) -> dict:
|
||||
return json.loads((VECTORS / f"{name}.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def expected() -> dict:
|
||||
return load("expected")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def login_binding() -> dict:
|
||||
return load("login-binding")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def login_awareness() -> dict:
|
||||
return load("login-awareness")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adr_binding() -> dict:
|
||||
return load("adr-binding")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Published vectors — these hashes appear in InitialExploration.md §9 and are
|
||||
# quoted in the founding record. They must reproduce byte for byte.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_login_view_hash_matches_published_vector(login_binding, expected):
|
||||
assert view_hash(login_binding)["hex"] == expected["login_view_hash"]
|
||||
|
||||
|
||||
def test_login_awareness_hash_matches_published_vector(login_awareness, expected):
|
||||
assert awareness_hash(login_awareness)["hex"] == expected["login_awareness_hash"]
|
||||
|
||||
|
||||
def test_adr_view_hash_matches_published_vector(adr_binding, expected):
|
||||
assert view_hash(adr_binding)["hex"] == expected["adr_view_hash"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Isolation 1 — key order is not part of the hash.
|
||||
# Without this, every hash is an artifact of serialisation order and no
|
||||
# verifier written against a different JSON library agrees with us. NC-10.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def shuffled(value):
|
||||
"""Recursively rebuild dicts with their keys in a different order."""
|
||||
if isinstance(value, dict):
|
||||
items = [(k, shuffled(v)) for k, v in value.items()]
|
||||
rng = random.Random(1337)
|
||||
rng.shuffle(items)
|
||||
return dict(items)
|
||||
if isinstance(value, list):
|
||||
return [shuffled(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["login-binding", "adr-binding"])
|
||||
def test_key_order_does_not_change_view_hash(name):
|
||||
doc = load(name)
|
||||
assert view_hash(shuffled(doc))["hex"] == view_hash(doc)["hex"]
|
||||
|
||||
|
||||
def test_key_order_does_not_change_awareness_hash(login_awareness):
|
||||
assert (
|
||||
awareness_hash(shuffled(login_awareness))["hex"]
|
||||
== awareness_hash(login_awareness)["hex"]
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Isolation 2 — awareness never enters view_hash.
|
||||
# This is the property that lets the surface default a role to last-used for
|
||||
# situational awareness without silently signing it. NC-05, PR-40.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_editing_awareness_does_not_change_view_hash(login_binding):
|
||||
before = view_hash(login_binding)["hex"]
|
||||
mutated = copy.deepcopy(login_binding)
|
||||
mutated["awareness"] = {
|
||||
"proposed_hat": {"id": "hat:auditor", "label": "Auditor"},
|
||||
"proposed_hat_source": "last_used",
|
||||
"situation_note": "changed after the presentation was taken",
|
||||
}
|
||||
mutated["proposed_hat_source"] = "policy"
|
||||
assert view_hash(mutated)["hex"] == before
|
||||
|
||||
|
||||
def test_unknown_top_level_keys_are_stripped_from_view_hash(login_binding):
|
||||
before = view_hash(login_binding)["hex"]
|
||||
mutated = copy.deepcopy(login_binding)
|
||||
mutated["not_in_the_allow_list"] = {"anything": "at all"}
|
||||
assert view_hash(mutated)["hex"] == before
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Isolation 3 — the binding slice IS covered.
|
||||
# The positive control for isolation 2: if this passed while 2 also passed
|
||||
# vacuously, view_hash would be covering nothing. NC-06.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_changing_binding_target_changes_view_hash(login_binding):
|
||||
before = view_hash(login_binding)["hex"]
|
||||
mutated = copy.deepcopy(login_binding)
|
||||
target = mutated["binding"]["target"]
|
||||
assert target, "vector must carry a binding target for this test to mean anything"
|
||||
if isinstance(target, dict):
|
||||
key = "id" if "id" in target else next(iter(target))
|
||||
target[key] = f"{target[key]}-BETA"
|
||||
else:
|
||||
mutated["binding"]["target"] = f"{target}-BETA"
|
||||
assert view_hash(mutated)["hex"] != before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["question", "requested_act", "binding_level"])
|
||||
def test_changing_a_binding_field_changes_view_hash(adr_binding, field):
|
||||
before = view_hash(adr_binding)["hex"]
|
||||
mutated = copy.deepcopy(adr_binding)
|
||||
if field not in mutated:
|
||||
pytest.skip(f"vector does not carry {field}")
|
||||
mutated[field] = f"{mutated[field]}-changed"
|
||||
assert view_hash(mutated)["hex"] != before
|
||||
|
||||
|
||||
def test_changing_the_packet_changes_view_hash(adr_binding):
|
||||
before = view_hash(adr_binding)["hex"]
|
||||
mutated = copy.deepcopy(adr_binding)
|
||||
packet = mutated.get("packet")
|
||||
if not packet:
|
||||
pytest.skip("vector carries no packet")
|
||||
packet[0]["hash"] = "sha256:" + "0" * 64
|
||||
assert view_hash(mutated)["hex"] != before
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Isolation 4 — selecting a role after login does not rewrite view_hash.
|
||||
# Post-bind session state is a different object from the signed binding.
|
||||
# NC-04, and the reason `configure` exists as a verb at all.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_post_bind_hat_selection_does_not_change_view_hash(login_binding):
|
||||
before = view_hash(login_binding)["hex"]
|
||||
mutated = copy.deepcopy(login_binding)
|
||||
mutated["session"] = {
|
||||
"status": "active",
|
||||
"hat": {"id": "hat:finance-controller", "elevates": False},
|
||||
"events": [{"kind": "session.hat_selected", "at": "2026-09-09T10:00:00Z"}],
|
||||
}
|
||||
assert view_hash(mutated)["hex"] == before
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Canonical form properties.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["login-binding", "adr-binding"])
|
||||
def test_canonical_form_has_no_insignificant_whitespace(name):
|
||||
"""No whitespace between structural tokens.
|
||||
|
||||
Checked by round-trip rather than substring search: ", " and ": " occur
|
||||
legitimately inside string *values* (a brief is prose), so a naive scan
|
||||
reports a defect that is not there.
|
||||
"""
|
||||
canonical = view_hash(load(name))["canonical"]
|
||||
reserialized = json.dumps(
|
||||
json.loads(canonical),
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
assert canonical == reserialized
|
||||
|
||||
|
||||
def test_canonical_form_keys_are_sorted(login_binding):
|
||||
canonical = view_hash(login_binding)["canonical"]
|
||||
keys = list(json.loads(canonical).keys())
|
||||
assert keys == sorted(keys)
|
||||
|
||||
|
||||
def test_canonical_form_is_utf8_encodable_and_hash_is_over_utf8(login_binding):
|
||||
import hashlib
|
||||
|
||||
result = view_hash(login_binding)
|
||||
assert (
|
||||
hashlib.sha256(result["canonical"].encode("utf-8")).hexdigest()
|
||||
== result["hex"]
|
||||
)
|
||||
|
||||
|
||||
def test_hash_is_stable_across_repeated_calls(login_binding):
|
||||
assert view_hash(login_binding)["hex"] == view_hash(login_binding)["hex"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Provenance — the governed copy must not drift from the founding record.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_governed_vectors_match_the_preserved_history_copy():
|
||||
history = (
|
||||
pathlib.Path(__file__).resolve().parents[1]
|
||||
/ "history"
|
||||
/ "20260909-initial-exploration"
|
||||
/ "vectors"
|
||||
)
|
||||
if not history.is_dir():
|
||||
pytest.skip("history/ not present in this checkout")
|
||||
for governed in sorted(VECTORS.glob("*.json")):
|
||||
original = history / governed.name
|
||||
assert original.is_file(), f"{governed.name} has no provenance original"
|
||||
assert json.loads(governed.read_text()) == json.loads(original.read_text()), (
|
||||
f"{governed.name} drifted from the preserved founding copy"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue