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

View file

@ -0,0 +1,9 @@
"""informed-decision — presentation and binding surface for decisions.
This package must never contain an authorization decision. See ``INTENT.md``
and ``AGENTS.md``: ``access-engine`` is the only policy decision point.
"""
from .canonicalize import awareness_hash, view_hash
__all__ = ["view_hash", "awareness_hash"]

View file

@ -0,0 +1,31 @@
"""CLI: compute a view_hash or awareness_hash over a document.
python -m informed_decision <path.json> [view|awareness]
"""
from __future__ import annotations
import json
import pathlib
import sys
from .canonicalize import awareness_hash, view_hash
def main(argv: list[str]) -> int:
if not argv:
print(__doc__, file=sys.stderr)
return 2
data = json.loads(pathlib.Path(argv[0]).read_text(encoding="utf-8"))
kind = argv[1] if len(argv) > 1 else "view"
if kind not in ("view", "awareness"):
print(f"unknown kind {kind!r}; expected 'view' or 'awareness'", file=sys.stderr)
return 2
result = view_hash(data) if kind == "view" else awareness_hash(data)
print(result["canonical"])
print(result["hex"])
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

Binary file not shown.

View file

@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""Canonicalization for informed-decision view_hash and awareness_hash.
Governed copy. The provenance original is
``history/20260909-initial-exploration/canonicalize.py`` and is never edited;
this file is the one that changes. Promoted by INFD-WP-0001-T06.
Profile: JCS-inspired restricted JSON (RFC 8785 subset).
- UTF-8
- objects with keys sorted by UTF-8 code units (equivalent to UTF-16 for our ASCII keys)
- no insignificant whitespace
- integers only (no floats)
- strings escaped as in RFC 8259 / JCS
"""
from __future__ import annotations
import hashlib
import json
from typing import Any
ALLOWED_BINDING_KEYS = (
"awareness_promoted",
"binding",
"binding_level",
"brief",
"highlights",
"locale",
"memo_id",
"memo_version",
"packet",
"question",
"requested_act",
"ui_release",
)
ALLOWED_AWARENESS_KEYS = (
"available_hats",
"available_scopes",
"last_session",
"locale",
"memo_id",
"memo_version",
"proposed_hat",
"proposed_hat_source",
"situation_note",
"ui_release",
)
BINDING_SLICE_KEYS = (
"available_bind_scopes",
"available_identities",
"blast_radius",
"granted_at_bind",
"justification",
"principal",
"target",
"terms",
)
def _is_int(n: Any) -> bool:
return isinstance(n, int) and not isinstance(n, bool)
def escape_string(s: str) -> str:
out = ['"']
for ch in s:
o = ord(ch)
if ch == '"':
out.append('\\"')
elif ch == "\\":
out.append("\\\\")
elif ch == "\b":
out.append("\\b")
elif ch == "\f":
out.append("\\f")
elif ch == "\n":
out.append("\\n")
elif ch == "\r":
out.append("\\r")
elif ch == "\t":
out.append("\\t")
elif o < 0x20:
out.append(f"\\u{o:04x}")
else:
out.append(ch)
out.append('"')
return "".join(out)
def dumps(value: Any) -> str:
if value is None:
return "null"
if value is True:
return "true"
if value is False:
return "false"
if _is_int(value):
return str(value)
if isinstance(value, str):
return escape_string(value)
if isinstance(value, list):
return "[" + ",".join(dumps(v) for v in value) + "]"
if isinstance(value, dict):
items = []
for key in sorted(value.keys()):
if not isinstance(key, str):
raise TypeError("object keys must be strings")
items.append(escape_string(key) + ":" + dumps(value[key]))
return "{" + ",".join(items) + "}"
raise TypeError(f"unsupported type for canonicalization: {type(value)!r}")
def pick(obj: dict, allowed: tuple[str, ...]) -> dict:
return {k: obj[k] for k in allowed if k in obj}
def strip_nulls(value: Any) -> Any:
if isinstance(value, dict):
return {k: strip_nulls(v) for k, v in value.items() if v is not None}
if isinstance(value, list):
return [strip_nulls(v) for v in value]
return value
def normalize_hash(h: Any) -> str:
if isinstance(h, str):
return h if ":" in h else f"sha256:{h}"
if isinstance(h, dict):
return f"{h['alg']}:{h['hex']}"
raise TypeError("hash must be string or {alg,hex}")
def normalize_packet(packet: list) -> list:
items = []
for item in packet:
items.append(
{
"hash": normalize_hash(item["hash"]),
"item_id": item["item_id"],
}
)
items.sort(key=lambda i: i["item_id"])
return items
def normalize_highlights(highlights: list) -> list:
items = []
for hl in highlights:
loc = hl.get("locator") or {}
items.append(
{
"id": hl["id"],
"item_id": hl["item_id"],
"locator": strip_nulls(loc),
"required_ack": bool(hl.get("required_ack", False)),
"severity": hl.get("severity", "informational"),
}
)
items.sort(key=lambda i: i["id"])
return items
def normalize_party(party: dict) -> dict:
out = {
"display_name": party["display_name"],
"id": party["id"],
"kind": party["kind"],
}
if party.get("identifiers"):
ids = []
for ident in party["identifiers"]:
ids.append({"scheme": ident["scheme"], "value": ident["value"]})
ids.sort(key=lambda i: (i["scheme"], i["value"]))
out["identifiers"] = ids
if party.get("role"):
out["role"] = party["role"]
return out
def normalize_scope(scope: dict) -> dict:
out = {
"id": scope["id"],
"kind": scope["kind"],
"label": scope["label"],
}
if "environment" in scope:
out["environment"] = scope["environment"]
if "requires_new_bind" in scope:
out["requires_new_bind"] = bool(scope["requires_new_bind"])
return out
def normalize_hat(hat: dict) -> dict:
out = {"id": hat["id"], "label": hat["label"]}
if hat.get("kind"):
out["kind"] = hat["kind"]
if "elevates" in hat:
out["elevates"] = bool(hat["elevates"])
if hat.get("permissions_preview"):
out["permissions_preview"] = sorted(hat["permissions_preview"])
if hat.get("scope_id"):
out["scope_id"] = hat["scope_id"]
return out
def normalize_binding(binding: dict) -> dict:
raw = pick(binding, BINDING_SLICE_KEYS)
out: dict[str, Any] = {}
if "principal" in raw:
out["principal"] = normalize_party(raw["principal"])
if raw.get("available_identities"):
ids = [normalize_party(p) for p in raw["available_identities"]]
ids.sort(key=lambda p: p["id"])
out["available_identities"] = ids
if "target" in raw:
out["target"] = normalize_scope(raw["target"])
if raw.get("available_bind_scopes"):
scopes = [normalize_scope(s) for s in raw["available_bind_scopes"]]
scopes.sort(key=lambda s: s["id"])
out["available_bind_scopes"] = scopes
if raw.get("granted_at_bind"):
g = dict(raw["granted_at_bind"])
if g.get("roles"):
g["roles"] = sorted(g["roles"])
if g.get("permissions"):
g["permissions"] = sorted(g["permissions"])
out["granted_at_bind"] = strip_nulls(g)
for k in ("justification", "blast_radius", "terms"):
if k in raw:
out[k] = strip_nulls(raw[k])
return out
def binding_document(src: dict) -> dict:
doc = pick(src, ALLOWED_BINDING_KEYS)
if "packet" in doc:
doc["packet"] = normalize_packet(doc["packet"])
if "highlights" in doc:
doc["highlights"] = normalize_highlights(doc["highlights"])
if "binding" in doc:
doc["binding"] = normalize_binding(doc["binding"])
if "awareness_promoted" in doc:
doc["awareness_promoted"] = strip_nulls(doc["awareness_promoted"])
return strip_nulls(doc)
def awareness_document(src: dict) -> dict:
doc = pick(src, ALLOWED_AWARENESS_KEYS)
if doc.get("proposed_hat"):
doc["proposed_hat"] = normalize_hat(doc["proposed_hat"])
if doc.get("available_hats"):
hats = [normalize_hat(h) for h in doc["available_hats"]]
hats.sort(key=lambda h: h["id"])
doc["available_hats"] = hats
if doc.get("available_scopes"):
scopes = [normalize_scope(s) for s in doc["available_scopes"]]
scopes.sort(key=lambda s: s["id"])
doc["available_scopes"] = scopes
return strip_nulls(doc)
def sha256_hex(canonical: str) -> str:
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def view_hash(src: dict) -> dict:
canonical = dumps(binding_document(src))
return {
"alg": "sha256",
"hex": sha256_hex(canonical),
"canonical": canonical,
}
def awareness_hash(src: dict) -> dict:
canonical = dumps(awareness_document(src))
return {
"alg": "sha256",
"hex": sha256_hex(canonical),
"canonical": canonical,
}