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:
tegwick 2026-09-09 14:16:28 +02:00
parent 7ae67b2f4e
commit a8e227851e
19 changed files with 2207 additions and 2 deletions

245
tests/test_canonicalize.py Normal file
View 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"
)

View file

@ -0,0 +1,31 @@
{
"memo_id": "01K4Q8Z3R7V2N6K9M1P5T8W4XC",
"memo_version": 2,
"question": "Do you formally accept ADR-0042 as the billing architecture for prod?",
"requested_act": "accept",
"binding_level": "aes",
"brief": "Replace nightly batch invoicing with an append-only event ledger.",
"locale": "en",
"ui_release": "informed-decision@0.3.1",
"packet": [
{
"item_id": "01K4Q8DOC0000000000000001",
"hash": {
"alg": "sha256",
"hex": "6b1c0f8a9d2e4c7b8a1f0e3d5c6b7a8f9e0d1c2b3a4f5e6d7c8b9a0f1e2d3c4b"
}
}
],
"highlights": [
{
"id": "01K4Q8HL00000000000000001",
"item_id": "01K4Q8DOC0000000000000001",
"severity": "critical",
"required_ack": true,
"locator": {
"kind": "markdown_heading",
"heading": "Consequences / rollback"
}
}
]
}

View file

@ -0,0 +1,5 @@
{
"login_view_hash": "492d9d311bf44ec9de0d0abef28abac7d31df2781527e8fe276a3186ee1b06b8",
"login_awareness_hash": "2be7742970a01e7a879ae5040660659fb8c9a5c024e6c7fb338f3944a4fe05d1",
"adr_view_hash": "1c89ec07c3cc9d16f85a1ba1be5169456b3c55d21161f64037787779ae91f202"
}

View file

@ -0,0 +1,63 @@
{
"memo_id": "01K4LOGIN00000000000000001",
"memo_version": 1,
"locale": "en",
"ui_release": "informed-decision@0.4.0",
"proposed_hat": {
"id": "hat:finance-controller",
"label": "Finance Controller",
"kind": "access_profile",
"elevates": false,
"permissions_preview": ["invoice.read", "invoice.export"],
"scope_id": "tenant:acme"
},
"proposed_hat_source": "last_used",
"available_hats": [
{
"id": "hat:finance-controller",
"label": "Finance Controller",
"kind": "access_profile",
"elevates": false,
"permissions_preview": ["invoice.read", "invoice.export"],
"scope_id": "tenant:acme"
},
{
"id": "hat:auditor-readonly",
"label": "Auditor (read-only)",
"kind": "perspective",
"elevates": false,
"permissions_preview": ["invoice.read"],
"scope_id": "tenant:acme"
},
{
"id": "hat:payroll-admin",
"label": "Payroll Admin",
"kind": "role",
"elevates": true,
"permissions_preview": ["payroll.run", "employee.export-all"],
"scope_id": "tenant:acme"
}
],
"available_scopes": [
{
"kind": "tenant",
"id": "tenant:acme",
"label": "ACME Corp",
"environment": "prod",
"requires_new_bind": true
},
{
"kind": "tenant",
"id": "tenant:beta",
"label": "Beta GmbH",
"environment": "prod",
"requires_new_bind": true
}
],
"last_session": {
"ended_at": "2026-09-08T16:12:00Z",
"hat_id": "hat:finance-controller",
"scope_id": "tenant:acme"
},
"situation_note": "Last session Tuesday 18:12 CEST as Finance Controller in ACME. Payroll Admin is an elevating hat and needs its own bind."
}

View file

@ -0,0 +1,73 @@
{
"memo_id": "01K4LOGIN00000000000000001",
"memo_version": 1,
"question": "Log into Payroll-Prod as Bernd Worsch in tenant ACME?",
"requested_act": "login",
"binding_level": "organizational",
"brief": "You are entering Payroll-Prod. Sessions are recorded. Privileges at the gate are identity-scoped, not hat-scoped.",
"locale": "en",
"ui_release": "informed-decision@0.4.0",
"packet": [],
"highlights": [],
"binding": {
"principal": {
"id": "01K4PERSONBERND00000000001",
"kind": "person",
"display_name": "Bernd Worsch",
"role": "employee",
"identifiers": [
{ "scheme": "email", "value": "bernd.worsch@example.com" },
{ "scheme": "idp:oidc-sub", "value": "auth.example.com|bernd" }
]
},
"available_identities": [
{
"id": "01K4PERSONBERND00000000001",
"kind": "person",
"display_name": "Bernd Worsch",
"identifiers": [
{ "scheme": "idp:oidc-sub", "value": "auth.example.com|bernd" }
]
},
{
"id": "01K4PERSONBERNDADMIN000001",
"kind": "person",
"display_name": "Bernd Worsch (break-glass)",
"identifiers": [
{ "scheme": "idp:oidc-sub", "value": "auth.example.com|bernd-bg" }
]
}
],
"target": {
"kind": "tenant",
"id": "tenant:acme",
"label": "ACME Corp",
"environment": "prod",
"requires_new_bind": true
},
"available_bind_scopes": [
{
"kind": "tenant",
"id": "tenant:acme",
"label": "ACME Corp",
"environment": "prod",
"requires_new_bind": true
},
{
"kind": "tenant",
"id": "tenant:beta",
"label": "Beta GmbH",
"environment": "prod",
"requires_new_bind": true
}
],
"granted_at_bind": {
"roles": ["authenticated"],
"permissions": ["session.create"]
},
"terms": {
"monitoring": true,
"consent_code": "LOGIN-PROD-2026"
}
}
}