diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ff39f4c --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +.PHONY: test check sync + +test: + python3 -m pytest -q + +# Everything the repo can verify about itself, without network. +check: test + @python3 -m json.tool schemas/decision-memo.schema.json > /dev/null && echo "schema: valid JSON" + +sync: + statehub fix-consistency diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 8ad2cd6..36a11c2 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -8,7 +8,7 @@ | Kind | ID | Status | Lane | Source | | --- | --- | --- | --- | --- | -| workplan | INFD-WP-0001 | proposed | — | workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md | +| workplan | INFD-WP-0001 | active | — | workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md | | task | INFD-WP-0001-T01 | done | — | workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md | | task | INFD-WP-0001-T02 | progress | — | workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md | | task | INFD-WP-0001-T03 | done | — | workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md | diff --git a/docs/specs/EvidenceModel.md b/docs/specs/EvidenceModel.md new file mode 100644 index 0000000..714429d --- /dev/null +++ b/docs/specs/EvidenceModel.md @@ -0,0 +1,197 @@ +# Evidence Model + +**Repo:** informed-decision +**Workplan task:** `INFD-WP-0001-T06` +**Status:** draft, pending review +**Companions:** `ProductRequirementsDocument.md`, `UseCaseCatalog.md`, +`ArchitectureBlueprint.md` (gated on `INFD-WP-0001-T02`) +**Normative algorithm:** `history/20260909-initial-exploration/canonicalize.md` +**Governed implementation:** `informed_decision/canonicalize.py` +**Tests:** `tests/test_canonicalize.py` + +--- + +## 1. What this model claims + +> **This person, in this role, was shown this view, and bound this act.** + +Not *"the file was signed"*. The difference is the whole product. A signature +over a document proves a key was used on some bytes; it does not establish what +the signer was looking at when they decided, which is the question an auditor +actually asks and the question no e-signature product answers. + +The mechanism is **dynamic linking**: the human-readable content actually +presented is canonicalized and its digest is bound into the act. + +## 2. What this model does not claim + +Stated first, because an evidence model that oversells is worse than none. + +**E-01 — It does not prove the person understood.** It proves what was +presented and what was explicitly acknowledged. Comprehension is not +observable and this model does not pretend to observe it. + +**E-02 — It does not prove the person read anything.** Deliberately. Evidence of +informedness is the hash of the presented view plus explicit acknowledgment — +never dwell time, scroll depth, or attention telemetry. That choice trades a +weaker claim for the absence of surveillance, and the trade is intentional +(`INTENT.md` principle 12, `PR-70`/`PR-71`). + +**E-03 — It does not survive a compromised surface.** `view_hash` is computed by +the same component that renders. A compromised surface can present X and attest +Y. This is structurally the same residual `approval-engine` names for +adversarial omission at a compromised source, and it is not closed here, not +closable at this layer, and never to be described as closed. + +**E-04 — A hash chain proves records were not altered or truncated after +arrival. It cannot prove a record was never sent.** Carried over from +`approval-engine`'s reasoning rather than rediscovered: a suppressed event +leaves the chain perfectly intact and verification reports intact. Completeness +is a separate obligation met by cadence, not by chaining. + +**E-05 — Stage 1 binding is organizational only.** No AES, no QES. The object +carries `binding_level` so higher levels are expressible without a schema +change; nothing above organizational is implemented or claimed. + +**E-06 — A Stage 1 escalation is an assertion, not a verified claim.** Without +the mandate graph, guard `G_ESC` cannot be fully evaluated. The bundle records +what the approver asserted and marks it unverified. + +## 3. The two hashes + +| | `view_hash` | `awareness_hash` | +| --- | --- | --- | +| Over | the binding document | the awareness document | +| Signed | yes, from organizational level up | **no** | +| Purpose | what the person committed to | what they were oriented with | +| In AES/QES signed attributes | yes | no, unless promotion ran | + +**Binding allow-list:** `memo_id`, `memo_version`, `question`, `requested_act`, +`binding_level`, `brief`, `locale`, `ui_release`, normalized `packet`, +normalized `highlights`, `binding`, and optionally `awareness_promoted`. + +**Awareness allow-list:** proposed hat and its source, available hats and +scopes, last session, situation note. + +Profile: JCS-inspired restricted JSON — UTF-8, keys sorted by code unit, no +insignificant whitespace, integers only, unknown keys stripped, nulls dropped. + +``` +canonical = dumps(document) +hash = SHA-256(UTF-8(canonical)) +``` + +### Why the split exists + +The login case forced it and it now applies everywhere. Defaulting a role to +last-used is **required** for situational awareness — an operator who cannot see +which hat they are about to wear is less safe, not more — and is **forbidden** +from silently entering the signed payload. A person must not be bound to +orientation material they did not commit to. + +Promotion is the only bridge: moving a field from awareness into binding creates +a new memo version, invalidates outstanding presentations, and copies the named +fields into `awareness_promoted` where `view_hash` covers them (guard +`G_PROMOTE`). + +## 4. The four isolation properties + +These are the model. Each is a test in `tests/test_canonicalize.py` and each +must stay green; a failure means `view_hash` no longer means what §1 claims. + +| # | Property | Protects | Test | +| --- | --- | --- | --- | +| 1 | Shuffling object keys does not change either hash | Any verifier, in any language, agrees with us | `test_key_order_does_not_change_*` | +| 2 | Editing an awareness field does not change `view_hash` | The person is bound only to what they committed to | `test_editing_awareness_does_not_change_view_hash` | +| 3 | Changing `binding.target` **does** change `view_hash` | Positive control — without it, 2 could pass vacuously | `test_changing_binding_target_changes_view_hash` | +| 4 | Selecting a hat after login does not rewrite `view_hash` | Post-bind session state is a different object | `test_post_bind_hat_selection_does_not_change_view_hash` | + +Property 3 is not decorative. Properties 1, 2 and 4 are all *negative* — they +assert that things do **not** change the hash. A canonicalizer that returned a +constant would pass all three. Property 3, plus the per-field variants over +`question`, `requested_act`, `binding_level` and `packet`, is what makes the +suite meaningful. + +**Published vectors** reproduce byte for byte and are asserted in CI: + +``` +login view_hash 492d9d31…1b06b8 +login awareness_hash 2be77429…4fe05d1 +ADR-0042 view_hash 1c89ec07…e91f202 +``` + +**Provenance is tested.** `history/20260909-initial-exploration/` is never +edited. `test_governed_vectors_match_the_preserved_history_copy` asserts the +governed fixtures have not drifted from the founding copies, so a silent edit to +a vector to make a failing test pass is itself a test failure. + +## 5. The presentation record + +Every render creates one. It carries: + +- `memo_id`, `memo_version` +- `principal` — who it was shown to +- `locale`, `ui_release` — both inside `view_hash` +- `rendered_at` +- `acked_highlight_ids` +- `view_hash`, `awareness_hash` +- `phase` — `pre_bind` | `bind` | `post_bind` + +A disposition references exactly one presentation, of the same memo version +(guard `G_PRES`). A presentation taken at version *n* cannot bind after the memo +advances to *n+1* — no silent upgrade (`NC-07`). + +Because `locale` is inside `view_hash`, the same memo read in German and in +English produces two different presentations of one binding. Intended, and +documented rather than smoothed over (`PR-61`). + +## 6. The evidence bundle + +An **export**, not a screenshot (`INTENT.md` principle 7). Contains the memo and +all its versions, every presentation with both hashes, every disposition, the +thread, the route as executed, and the append-only hash chain. + +Requirements: + +- **Verifies offline.** No network, no access to this service. A verifier + recomputes `view_hash` from the bundle's own binding document and compares. +- **Detects mutation.** Altering any covered field breaks verification. +- **States its own bound.** The residuals in §2 travel with the bundle as + metadata. An auditor must not have to read this document to learn that E-03 + exists. +- **Marks unverified assertions as such** — see E-06. + +## 7. Relationship to `audit-core` + +`audit-core` holds the archive; this repository holds the operative record and +produces the export. This surface emits its transitions there and does not +duplicate the archive's role. + +Its guarantee is bounded and the bound is inherited, not re-litigated: +`audit-core` is not WORM or object-locked, its tamper evidence is conditional on +live preconditions, and stronger custody was **decided against** in statute §16. +This repository must not plan around a stronger archive, and must not imply the +bundle is more durable than the archive it reconciles against. + +## 8. Open — `view_hash` versus the approval binding digest + +**Unresolved.** Filed as `INFD-IN-0001` R3 with `gate-house`. + +`approval-engine`'s claim carries *"a digest over the same canonical binding the +decision point already computes."* `view_hash` is also a digest over "the +binding". They cover different material: the approval digest exists with no +human in the loop; `view_hash` additionally covers brief, packet, highlights, +locale and UI release. + +Until ruled, this repository computes `view_hash` and **asserts no relationship** +between the two. Three candidate outcomes and their costs are set out in +`docs/gate-house-decision-request-layer-placement.md` §2 R3. The outcome to be +avoided is both digests shipping with no stated authority rule, leaving the +estate with two canonicalizations of one act. + +## 9. Signed attributes (L4+, horizon) + +When AES/QES arrives, the signed attributes carry `memo_id`, `memo_version`, +`disposition.verb` and `presentation.view_hash`. They do **not** carry +`awareness_hash` unless promotion ran. Recorded here so nothing built at Stage 1 +forecloses it. diff --git a/informed_decision/__init__.py b/informed_decision/__init__.py new file mode 100644 index 0000000..f8c3383 --- /dev/null +++ b/informed_decision/__init__.py @@ -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"] diff --git a/informed_decision/__main__.py b/informed_decision/__main__.py new file mode 100644 index 0000000..a609014 --- /dev/null +++ b/informed_decision/__main__.py @@ -0,0 +1,31 @@ +"""CLI: compute a view_hash or awareness_hash over a document. + + python -m informed_decision [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:])) diff --git a/informed_decision/__pycache__/__init__.cpython-312.pyc b/informed_decision/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..d425015 Binary files /dev/null and b/informed_decision/__pycache__/__init__.cpython-312.pyc differ diff --git a/informed_decision/__pycache__/canonicalize.cpython-312.pyc b/informed_decision/__pycache__/canonicalize.cpython-312.pyc new file mode 100644 index 0000000..795f54a Binary files /dev/null and b/informed_decision/__pycache__/canonicalize.cpython-312.pyc differ diff --git a/informed_decision/canonicalize.py b/informed_decision/canonicalize.py new file mode 100644 index 0000000..c4fd240 --- /dev/null +++ b/informed_decision/canonicalize.py @@ -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, + } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..37cde6e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "informed-decision" +version = "0.1.0" +description = "Presentation and binding surface for decisions — the Decision Memo and its evidence." +requires-python = ">=3.11" +dependencies = [] + +[project.optional-dependencies] +dev = ["pytest>=8"] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["informed_decision"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/schemas/decision-memo.example.json b/schemas/decision-memo.example.json new file mode 100644 index 0000000..af5a9b9 --- /dev/null +++ b/schemas/decision-memo.example.json @@ -0,0 +1,150 @@ +{ + "id": "01K4Q8Z3R7V2N6K9M1P5T8W4XC", + "version": 2, + "schema_version": "0.2.0", + "status": "awaiting_actor", + "depth": 2, + "title": "Accept ADR-0042: event-sourced billing ledger", + "question": "Do you formally accept ADR-0042 as the billing architecture for prod?", + "requested_act": "accept", + "binding_level": "aes", + "locale": "en", + "created_at": "2026-09-08T14:02:11Z", + "updated_at": "2026-09-09T07:18:44Z", + "submitted_at": "2026-09-08T15:10:00Z", + "due_at": "2026-09-12T16:00:00Z", + "requester": { + "id": "01K4Q8A1B2C3D4E5F6G7H8J9K0", + "kind": "person", + "display_name": "Lea Hoffmann", + "role": "staff engineer", + "identifiers": [ + { "scheme": "email", "value": "lea.hoffmann@example.com" }, + { "scheme": "idp:oidc-sub", "value": "auth.example.com|a8f21c" } + ] + }, + "parties": [ + { + "id": "01K4Q8MANDATE000000000001", + "kind": "person", + "display_name": "Bernd Worsch", + "role": "principal engineer / ADR acceptor", + "mandate": { + "id": "01K4Q8MANDATE00000000000A", + "scope": "Accept architecture decision records for billing and payments", + "policy_ref": "https://policy.example.com/adr-acceptance" + } + } + ], + "brief": "Replace nightly batch invoicing with an append-only event ledger. Replay is the source of truth for invoices. Dual-run for 60 days. Rollback: freeze the ledger and revert the read model to the last nightly snapshot.", + "consequences": "On-call owns replay tooling. Finance reporting moves from SQL warehouse tables to a projection job. No in-place updates of posted invoices.", + "packet": [ + { + "id": "01K4Q8DOC0000000000000001", + "role": "primary", + "filename": "ADR-0042.md", + "media_type": "text/markdown", + "language": "en", + "bytes": 12880, + "hash": { "alg": "sha256", "hex": "6b1c0f8a9d2e4c7b8a1f0e3d5c6b7a8f9e0d1c2b3a4f5e6d7c8b9a0f1e2d3c4b" }, + "storage_uri": "s3://decisions/adr/0042.md", + "version": 2, + "is_to_be_signed": true, + "label": "ADR-0042" + } + ], + "highlights": [ + { + "id": "01K4Q8HL00000000000000001", + "item_id": "01K4Q8DOC0000000000000001", + "severity": "critical", + "locator": { + "kind": "markdown_heading", + "heading": "Consequences / rollback" + }, + "title": "Rollback is operationally expensive", + "note": "Accepting this ADR commits on-call to ledger freeze drills before go-live.", + "required_ack": true, + "author_id": "01K4Q8A1B2C3D4E5F6G7H8J9K0", + "created_at": "2026-09-08T14:20:00Z", + "source": "requester" + } + ], + "route": { + "mode": "sequential", + "steps": [ + { + "id": "01K4Q8STEP000000000000001", + "ordinal": 1, + "kind": "review", + "assignee": { + "id": "01K4Q8MANDATE000000000002", + "kind": "role", + "display_name": "Billing domain reviewer" + }, + "required_verbs": ["acknowledge"], + "status": "completed", + "completed_at": "2026-09-08T17:40:00Z" + }, + { + "id": "01K4Q8STEP000000000000002", + "ordinal": 2, + "kind": "schlusszeichnung", + "assignee": { + "id": "01K4Q8MANDATE000000000001", + "kind": "person", + "display_name": "Bernd Worsch" + }, + "required_verbs": ["accept", "return", "escalate"], + "min_binding_level": "aes", + "status": "active", + "due_at": "2026-09-12T16:00:00Z" + } + ], + "current_step_ids": ["01K4Q8STEP000000000000002"] + }, + "thread": [ + { + "id": "01K4Q8MSG0000000000000001", + "author_id": "01K4Q8MANDATE000000000002", + "body": "Rollback drill date is missing. Acceptable if added before go-live.", + "created_at": "2026-09-08T17:39:12Z" + } + ], + "presentations": [ + { + "id": "01K4Q8PRES000000000000001", + "actor_id": "01K4Q8MANDATE000000000001", + "memo_version": 2, + "opened_at": "2026-09-09T07:10:00Z", + "ui_release": "informed-decision@0.3.1", + "locale": "en", + "viewport": "brief", + "packet_hashes": [ + { + "item_id": "01K4Q8DOC0000000000000001", + "hash": { "alg": "sha256", "hex": "6b1c0f8a9d2e4c7b8a1f0e3d5c6b7a8f9e0d1c2b3a4f5e6d7c8b9a0f1e2d3c4b" } + } + ], + "highlight_ids": ["01K4Q8HL00000000000000001"], + "highlight_set_hash": { "alg": "sha256", "hex": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "brief_hash": { "alg": "sha256", "hex": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, + "view_hash": { "alg": "sha256", "hex": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" }, + "acked_highlight_ids": ["01K4Q8HL00000000000000001"], + "auth_context": { + "method": "passkey", + "assurance": "substantial", + "idp": "https://auth.example.com" + } + } + ], + "dispositions": [], + "relations": [ + { "kind": "supersedes", "to": "01K4Q8OLDADR0000000000001", "note": "Supersedes ADR-0011 batch invoicing" } + ], + "legal": { + "form_requirement": "none", + "retention_years": 10, + "classification": "internal" + } +} diff --git a/schemas/decision-memo.login.example.json b/schemas/decision-memo.login.example.json new file mode 100644 index 0000000..94ea92c --- /dev/null +++ b/schemas/decision-memo.login.example.json @@ -0,0 +1,153 @@ +{ + "id": "01K4LOGIN00000000000000001", + "version": 1, + "schema_version": "0.2.0", + "status": "awaiting_actor", + "depth": 0, + "title": "Login to Payroll-Prod / ACME", + "question": "Log into Payroll-Prod as Bernd Worsch in tenant ACME?", + "requested_act": "login", + "binding_level": "organizational", + "locale": "en", + "created_at": "2026-09-09T08:01:00Z", + "updated_at": "2026-09-09T08:01:00Z", + "requester": { + "id": "01K4SYSTEM00000000000000001", + "kind": "system", + "display_name": "Payroll-Prod IdP" + }, + "subject": { + "id": "01K4PERSONBERND00000000001", + "kind": "person", + "display_name": "Bernd Worsch" + }, + "brief": "You are entering Payroll-Prod. Sessions are recorded. Privileges at the gate are identity-scoped, not hat-scoped.", + "identity_context": { + "system": "Payroll-Prod", + "environment": "prod", + "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" + }, + { + "id": "01K4PERSONBERNDADMIN000001", + "kind": "person", + "display_name": "Bernd Worsch (break-glass)" + } + ], + "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" + } + }, + "awareness": { + "proposed_hat": { + "id": "hat:finance-controller", + "label": "Finance Controller", + "kind": "access_profile", + "elevates": false, + "scope_id": "tenant:acme" + }, + "proposed_hat_source": "last_used", + "available_hats": [ + { + "id": "hat:finance-controller", + "label": "Finance Controller", + "kind": "access_profile", + "elevates": false + }, + { + "id": "hat:auditor-readonly", + "label": "Auditor (read-only)", + "kind": "perspective", + "elevates": false + }, + { + "id": "hat:payroll-admin", + "label": "Payroll Admin", + "kind": "role", + "elevates": true + } + ], + "available_scopes": [ + { + "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." + } + }, + "packet": [], + "highlights": [], + "route": { + "mode": "sequential", + "steps": [ + { + "id": "01K4LOGINSTEP0000000000001", + "ordinal": 1, + "kind": "acknowledge", + "assignee": { + "id": "01K4PERSONBERND00000000001", + "kind": "person", + "display_name": "Bernd Worsch" + }, + "required_verbs": ["accept", "decline"], + "min_binding_level": "organizational", + "status": "active" + } + ], + "current_step_ids": ["01K4LOGINSTEP0000000000001"] + }, + "session": { + "status": "pending" + } +} diff --git a/schemas/decision-memo.schema.json b/schemas/decision-memo.schema.json new file mode 100644 index 0000000..f4bd0c3 --- /dev/null +++ b/schemas/decision-memo.schema.json @@ -0,0 +1,910 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://informed-decision.dev/schema/decision-memo/v0.2", + "title": "Informed-Decision Decision Memo", + "description": "Portable decision object: context, circulation, disposition, and cryptographic evidence of an informed identity-bound act.", + "$defs": { + "uri": { "type": "string", "format": "uri" }, + "datetime": { "type": "string", "format": "date-time" }, + "ulid": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "description": "ULID. UUID v7 is also acceptable at the API boundary." + }, + "sha256": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "contentHash": { + "type": "object", + "additionalProperties": false, + "required": ["alg", "hex"], + "properties": { + "alg": { "type": "string", "enum": ["sha256", "sha384", "sha3-256"] }, + "hex": { "type": "string", "pattern": "^[a-f0-9]{64,96}$" } + } + }, + "locale": { "type": "string", "pattern": "^[a-z]{2}(-[A-Z]{2})?$" }, + "markdown": { "type": "string", "maxLength": 100000 }, + "shortText": { "type": "string", "minLength": 1, "maxLength": 280 }, + "mediumText": { "type": "string", "maxLength": 4000 }, + + "bindingLevel": { + "type": "string", + "description": "How strongly the resulting act is meant to bind.", + "enum": [ + "none", + "acknowledgment", + "organizational", + "ses", + "aes", + "qes", + "qeseal" + ] + }, + "assuranceLevel": { + "type": "string", + "enum": ["low", "substantial", "high"] + }, + "depthLevel": { + "type": "integer", + "minimum": 0, + "maximum": 5, + "description": "L0 login … L5 multi-party instrument. Advisory; schema is shared." + }, + "requestedAct": { + "type": "string", + "enum": [ + "login", + "elevate", + "acknowledge", + "approve", + "accept", + "decline", + "sign", + "seal", + "countersign", + "ratify" + ] + }, + "memoStatus": { + "type": "string", + "enum": [ + "draft", + "submitted", + "circulating", + "awaiting_actor", + "in_discussion", + "returned", + "completed", + "withdrawn", + "expired", + "superseded" + ] + }, + "outcome": { + "type": "string", + "enum": [ + "accepted", + "declined", + "acknowledged", + "abstained", + "recused", + "cancelled", + "timed_out" + ] + }, + "dispositionVerb": { + "type": "string", + "enum": [ + "comment", + "discuss", + "return", + "forward", + "escalate", + "acknowledge", + "accept", + "decline", + "abstain", + "recuse", + "withdraw", + "stamp", + "configure", + "promote" + ] + }, + "sessionStatus": { + "type": "string", + "enum": ["none", "pending", "active", "ended"] + }, + "awarenessSource": { + "type": "string", + "enum": ["system_default", "last_used", "policy", "explicit", "inferred"] + }, + "scopeKind": { + "type": "string", + "enum": ["system", "tenant", "workspace", "resource", "privilege_set", "environment"] + }, + "partyKind": { + "type": "string", + "enum": ["person", "org", "role", "group", "system", "agent"] + }, + "routeMode": { + "type": "string", + "enum": ["sequential", "parallel", "hybrid"] + }, + "stepKind": { + "type": "string", + "enum": [ + "inform", + "comment", + "review", + "mitzeichnung", + "schlusszeichnung", + "acknowledge", + "approve", + "sign", + "seal", + "deposit" + ] + }, + "documentRole": { + "type": "string", + "enum": [ + "brief", + "primary", + "annex", + "supporting", + "prior_decision", + "diff", + "translation", + "evidence_export" + ] + }, + "highlightSeverity": { + "type": "string", + "enum": ["critical", "material", "informational"] + }, + "evidenceEventType": { + "type": "string", + "enum": [ + "memo.created", + "memo.updated", + "memo.submitted", + "memo.withdrawn", + "memo.superseded", + "memo.expired", + "packet.attached", + "packet.detached", + "highlight.added", + "highlight.updated", + "route.started", + "route.step_assigned", + "route.step_completed", + "route.returned", + "route.forwarded", + "route.escalated", + "presentation.opened", + "presentation.viewed", + "presentation.highlight_acked", + "identity.authenticated", + "identity.step_up", + "disposition.recorded", + "signature.created", + "seal.created", + "timestamp.qualified", + "file.stamped", + "bundle.sealed", + "awareness.shown", + "session.started", + "session.configured", + "session.hat_selected", + "session.ended", + "scope.rebind_required" + ] + }, + + "identifier": { + "type": "object", + "additionalProperties": false, + "required": ["scheme", "value"], + "properties": { + "scheme": { + "type": "string", + "description": "idp:oidc-sub | eudi:pid | eidas:person | email | employee_id | lei | orga_id | did" + }, + "value": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "issuing_authority": { "type": "string" } + } + }, + "mandate": { + "type": "object", + "additionalProperties": false, + "required": ["id", "scope"], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "scope": { + "type": "string", + "description": "Human-readable competence: 'CFO contracts ≤ 250k EUR', 'prod-admin JIT', 'ADR acceptor'." + }, + "policy_ref": { "$ref": "#/$defs/uri" }, + "max_value": { + "type": "object", + "additionalProperties": false, + "properties": { + "amount": { "type": "number" }, + "currency": { "type": "string", "minLength": 3, "maxLength": 3 } + } + }, + "valid_from": { "$ref": "#/$defs/datetime" }, + "valid_until": { "$ref": "#/$defs/datetime" }, + "delegation_of": { "$ref": "#/$defs/ulid" } + } + }, + "partyRef": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "display_name"], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "kind": { "$ref": "#/$defs/partyKind" }, + "display_name": { "type": "string", "minLength": 1, "maxLength": 200 }, + "identifiers": { + "type": "array", + "items": { "$ref": "#/$defs/identifier" } + }, + "org_id": { "$ref": "#/$defs/ulid" }, + "role": { "type": "string" }, + "mandate": { "$ref": "#/$defs/mandate" }, + "contact": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": { "type": "string", "format": "email" }, + "channel": { "type": "string", "enum": ["in_app", "email", "matrix", "teams"] } + } + } + } + }, + "locator": { + "type": "object", + "additionalProperties": false, + "description": "Stable pointer into a packet item. Prefer quote+context over pixel boxes.", + "required": ["kind"], + "properties": { + "kind": { + "type": "string", + "enum": [ + "text_quote", + "pdf_page_region", + "markdown_heading", + "json_pointer", + "xpath", + "sheet_range", + "whole_document" + ] + }, + "quote": { "type": "string", "maxLength": 2000 }, + "prefix": { "type": "string", "maxLength": 200 }, + "suffix": { "type": "string", "maxLength": 200 }, + "page": { "type": "integer", "minimum": 1 }, + "bbox": { + "type": "array", + "items": { "type": "number" }, + "minItems": 4, + "maxItems": 4 + }, + "heading": { "type": "string" }, + "pointer": { "type": "string" }, + "sheet": { "type": "string" }, + "range": { "type": "string" } + } + }, + "packetItem": { + "type": "object", + "additionalProperties": false, + "required": ["id", "role", "filename", "media_type", "hash", "bytes"], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "role": { "$ref": "#/$defs/documentRole" }, + "filename": { "type": "string" }, + "media_type": { "type": "string" }, + "language": { "$ref": "#/$defs/locale" }, + "bytes": { "type": "integer", "minimum": 0 }, + "hash": { "$ref": "#/$defs/contentHash" }, + "storage_uri": { "$ref": "#/$defs/uri" }, + "pages": { "type": "integer", "minimum": 1 }, + "version": { "type": "integer", "minimum": 1 }, + "replaces": { "$ref": "#/$defs/ulid" }, + "is_to_be_signed": { "type": "boolean", "default": false }, + "is_to_be_stamped": { "type": "boolean", "default": false }, + "label": { "type": "string" } + } + }, + "highlight": { + "type": "object", + "additionalProperties": false, + "required": ["id", "item_id", "severity", "locator", "author_id", "created_at"], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "item_id": { "$ref": "#/$defs/ulid" }, + "severity": { "$ref": "#/$defs/highlightSeverity" }, + "locator": { "$ref": "#/$defs/locator" }, + "title": { "$ref": "#/$defs/shortText" }, + "note": { "$ref": "#/$defs/mediumText" }, + "required_ack": { "type": "boolean", "default": false }, + "author_id": { "$ref": "#/$defs/ulid" }, + "created_at": { "$ref": "#/$defs/datetime" }, + "source": { "type": "string", "enum": ["requester", "reviewer", "system", "agent"] } + } + }, + "routeStep": { + "type": "object", + "additionalProperties": false, + "required": ["id", "ordinal", "kind", "assignee", "required_verbs"], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "ordinal": { "type": "integer", "minimum": 1 }, + "parallel_group": { + "type": "string", + "description": "Steps sharing a group may complete in any order. Next ordinal waits for the group." + }, + "kind": { "$ref": "#/$defs/stepKind" }, + "assignee": { "$ref": "#/$defs/partyRef" }, + "required_verbs": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/dispositionVerb" } + }, + "min_binding_level": { "$ref": "#/$defs/bindingLevel" }, + "due_at": { "$ref": "#/$defs/datetime" }, + "started_at": { "$ref": "#/$defs/datetime" }, + "completed_at": { "$ref": "#/$defs/datetime" }, + "status": { + "type": "string", + "enum": ["pending", "ready", "active", "blocked", "completed", "skipped", "returned"] + }, + "completion_disposition_id": { "$ref": "#/$defs/ulid" }, + "instructions": { "$ref": "#/$defs/mediumText" } + } + }, + "route": { + "type": "object", + "additionalProperties": false, + "required": ["mode", "steps"], + "properties": { + "mode": { "$ref": "#/$defs/routeMode" }, + "policy_ref": { "$ref": "#/$defs/uri" }, + "steps": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/routeStep" } + }, + "current_step_ids": { + "type": "array", + "items": { "$ref": "#/$defs/ulid" } + } + } + }, + "commentAnchor": { + "type": "object", + "additionalProperties": false, + "properties": { + "item_id": { "$ref": "#/$defs/ulid" }, + "highlight_id": { "$ref": "#/$defs/ulid" }, + "locator": { "$ref": "#/$defs/locator" } + } + }, + "threadMessage": { + "type": "object", + "additionalProperties": false, + "required": ["id", "author_id", "body", "created_at"], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "author_id": { "$ref": "#/$defs/ulid" }, + "body": { "$ref": "#/$defs/markdown" }, + "anchor": { "$ref": "#/$defs/commentAnchor" }, + "created_at": { "$ref": "#/$defs/datetime" }, + "visibility": { + "type": "string", + "enum": ["all_parties", "internal", "step_only"], + "default": "all_parties" + } + } + }, + "presentationSnapshot": { + "type": "object", + "additionalProperties": false, + "description": "Canonical record of what an actor was shown. The disposition MUST reference a snapshot.", + "required": [ + "id", + "actor_id", + "memo_version", + "opened_at", + "ui_release", + "locale", + "packet_hashes", + "highlight_set_hash", + "view_hash" + ], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "actor_id": { "$ref": "#/$defs/ulid" }, + "memo_version": { "type": "integer", "minimum": 1 }, + "opened_at": { "$ref": "#/$defs/datetime" }, + "closed_at": { "$ref": "#/$defs/datetime" }, + "ui_release": { "type": "string" }, + "locale": { "$ref": "#/$defs/locale" }, + "viewport": { "type": "string", "enum": ["banner", "brief", "packet", "sign", "awareness", "configure"] }, + "phase": { "type": "string", "enum": ["pre_bind", "bind", "post_bind"] }, + "awareness_hash": { + "$ref": "#/$defs/contentHash", + "description": "Hash of the awareness slice as shown. Not part of view_hash unless a promote disposition copied fields into binding." + }, + "packet_hashes": { + "type": "array", + "items": { + "type": "object", + "required": ["item_id", "hash"], + "additionalProperties": false, + "properties": { + "item_id": { "$ref": "#/$defs/ulid" }, + "hash": { "$ref": "#/$defs/contentHash" } + } + } + }, + "highlight_ids": { + "type": "array", + "items": { "$ref": "#/$defs/ulid" } + }, + "highlight_set_hash": { "$ref": "#/$defs/contentHash" }, + "brief_hash": { "$ref": "#/$defs/contentHash" }, + "view_hash": { + "$ref": "#/$defs/contentHash", + "description": "Hash of the canonical presentation document (brief + ordered highlights + visible packet manifests)." + }, + "acked_highlight_ids": { + "type": "array", + "items": { "$ref": "#/$defs/ulid" } + }, + "auth_context": { + "type": "object", + "additionalProperties": false, + "properties": { + "method": { + "type": "string", + "enum": [ + "password", + "mfa", + "passkey", + "oidc", + "saml", + "eudi_wallet", + "national_eid", + "qscd" + ] + }, + "assurance": { "$ref": "#/$defs/assuranceLevel" }, + "idp": { "type": "string" }, + "session_id": { "type": "string" } + } + } + } + }, + "returnPayload": { + "type": "object", + "additionalProperties": false, + "required": ["reasons"], + "properties": { + "reasons": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["code", "text"], + "additionalProperties": false, + "properties": { + "code": { + "type": "string", + "enum": [ + "insufficient_context", + "wrong_scope", + "wrong_assignee", + "highlights_incomplete", + "document_stale", + "legal_issue", + "commercial_issue", + "risk_unaddressed", + "other" + ] + }, + "text": { "$ref": "#/$defs/mediumText" }, + "anchor": { "$ref": "#/$defs/commentAnchor" } + } + } + }, + "required_changes": { "$ref": "#/$defs/markdown" } + } + }, + "transferPayload": { + "type": "object", + "additionalProperties": false, + "required": ["to"], + "properties": { + "to": { "$ref": "#/$defs/partyRef" }, + "reason": { "$ref": "#/$defs/mediumText" }, + "keep_self_informed": { "type": "boolean", "default": true } + } + }, + "signaturePayload": { + "type": "object", + "additionalProperties": false, + "required": ["level", "signed_hash"], + "properties": { + "level": { "$ref": "#/$defs/bindingLevel" }, + "profile": { + "type": "string", + "enum": ["PAdES-B-LT", "PAdES-B-LTA", "XAdES-B-LT", "CAdES-B-LT", "JAdES", "raw"] + }, + "signed_hash": { "$ref": "#/$defs/contentHash" }, + "signed_item_ids": { + "type": "array", + "items": { "$ref": "#/$defs/ulid" } + }, + "presentation_bound": { + "type": "boolean", + "description": "True when view_hash is included in the signed attributes (dynamic linking)." + }, + "certificate_ref": { "type": "string" }, + "tsp": { "type": "string" }, + "qts_token_ref": { "type": "string" }, + "cms_or_jose": { "type": "string", "description": "Detached signature reference or storage URI." } + } + }, + "disposition": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "verb", + "actor", + "step_id", + "presentation_id", + "created_at", + "payload_hash" + ], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "verb": { "$ref": "#/$defs/dispositionVerb" }, + "actor": { "$ref": "#/$defs/partyRef" }, + "step_id": { "$ref": "#/$defs/ulid" }, + "presentation_id": { + "$ref": "#/$defs/ulid", + "description": "Required binding to a presentation snapshot. No snapshot, no binding act." + }, + "body": { "$ref": "#/$defs/markdown" }, + "return": { "$ref": "#/$defs/returnPayload" }, + "transfer": { "$ref": "#/$defs/transferPayload" }, + "signature": { "$ref": "#/$defs/signaturePayload" }, + "configure": { "$ref": "#/$defs/configurePayload" }, + "created_at": { "$ref": "#/$defs/datetime" }, + "payload_hash": { "$ref": "#/$defs/contentHash" }, + "actor_signature": { + "description": "Optional signature over payload_hash + presentation.view_hash + memo.id@version.", + "$ref": "#/$defs/signaturePayload" + } + } + }, + "evidenceEvent": { + "type": "object", + "additionalProperties": false, + "required": ["id", "seq", "type", "at", "payload_hash", "prev_hash", "event_hash"], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "seq": { "type": "integer", "minimum": 0 }, + "type": { "$ref": "#/$defs/evidenceEventType" }, + "at": { "$ref": "#/$defs/datetime" }, + "actor_id": { "$ref": "#/$defs/ulid" }, + "payload": { "type": "object" }, + "payload_hash": { "$ref": "#/$defs/contentHash" }, + "prev_hash": { "$ref": "#/$defs/contentHash" }, + "event_hash": { "$ref": "#/$defs/contentHash" }, + "ip_hash": { + "type": "string", + "description": "Optional HMAC of network address; store raw IP only where lawful." + }, + "device_attestation": { "type": "string" } + } + }, + "evidenceBundle": { + "type": "object", + "additionalProperties": false, + "required": ["id", "memo_id", "memo_version", "events", "head_hash"], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "memo_id": { "$ref": "#/$defs/ulid" }, + "memo_version": { "type": "integer" }, + "events": { + "type": "array", + "items": { "$ref": "#/$defs/evidenceEvent" } + }, + "head_hash": { "$ref": "#/$defs/contentHash" }, + "sealed_at": { "$ref": "#/$defs/datetime" }, + "seal": { "$ref": "#/$defs/signaturePayload" }, + "qualified_timestamp_ref": { "type": "string" }, + "export_uri": { "$ref": "#/$defs/uri" } + } + }, + "relation": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "to"], + "properties": { + "kind": { + "type": "string", + "enum": [ + "supersedes", + "superseded_by", + "relates_to", + "implements", + "triggered_by", + "attachment_of", + "translation_of" + ] + }, + "to": { "$ref": "#/$defs/ulid" }, + "note": { "type": "string" } + } + }, + "scopeRef": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "id", "label"], + "properties": { + "kind": { "$ref": "#/$defs/scopeKind" }, + "id": { "type": "string", "minLength": 1, "maxLength": 200 }, + "label": { "type": "string" }, + "environment": { "type": "string", "enum": ["dev", "test", "stage", "prod", "other"] }, + "requires_new_bind": { + "type": "boolean", + "default": false, + "description": "If true, entering this scope is a new binding act, not a post-sign configure." + } + } + }, + "hat": { + "type": "object", + "additionalProperties": false, + "description": "Post-bind perspective: role, access profile, or UI/work mode inside an already bound scope.", + "required": ["id", "label"], + "properties": { + "id": { "type": "string", "minLength": 1, "maxLength": 200 }, + "label": { "type": "string" }, + "kind": { + "type": "string", + "enum": ["role", "access_profile", "perspective", "duty"] + }, + "permissions_preview": { "type": "array", "items": { "type": "string" } }, + "scope_id": { "type": "string" }, + "elevates": { + "type": "boolean", + "default": false, + "description": "If true, selecting this hat is a privilege act and MUST be promoted into binding or a child memo." + } + } + }, + "bindingSlice": { + "type": "object", + "additionalProperties": false, + "description": "PRE-SIGN. Identity and scope being bound. Included in view_hash. Choosing among identities or bind-scopes happens here.", + "required": ["principal", "target"], + "properties": { + "principal": { "$ref": "#/$defs/partyRef" }, + "available_identities": { + "type": "array", + "items": { "$ref": "#/$defs/partyRef" } + }, + "target": { "$ref": "#/$defs/scopeRef" }, + "available_bind_scopes": { + "type": "array", + "description": "Scopes the actor may enter as part of THIS bind (e.g. pick tenant at login).", + "items": { "$ref": "#/$defs/scopeRef" } + }, + "granted_at_bind": { + "type": "object", + "additionalProperties": false, + "properties": { + "roles": { "type": "array", "items": { "type": "string" } }, + "permissions": { "type": "array", "items": { "type": "string" } }, + "duration_seconds": { "type": "integer", "minimum": 1 } + } + }, + "justification": { "$ref": "#/$defs/mediumText" }, + "blast_radius": { "$ref": "#/$defs/mediumText" }, + "terms": { + "type": "object", + "additionalProperties": false, + "properties": { + "monitoring": { "type": "boolean" }, + "consent_code": { "type": "string" }, + "consent_text_hash": { "$ref": "#/$defs/contentHash" } + } + } + } + }, + "awarenessSlice": { + "type": "object", + "additionalProperties": false, + "description": "Shown before and after bind for situational awareness. Hashed separately as awareness_hash. NOT in view_hash unless promoted.", + "properties": { + "proposed_hat": { "$ref": "#/$defs/hat" }, + "proposed_hat_source": { "$ref": "#/$defs/awarenessSource" }, + "available_hats": { "type": "array", "items": { "$ref": "#/$defs/hat" } }, + "available_scopes": { + "type": "array", + "description": "Other scopes visible for orientation. Those with requires_new_bind=true cannot be configured post-sign.", + "items": { "$ref": "#/$defs/scopeRef" } + }, + "last_session": { + "type": "object", + "additionalProperties": false, + "properties": { + "ended_at": { "$ref": "#/$defs/datetime" }, + "hat_id": { "type": "string" }, + "scope_id": { "type": "string" }, + "principal_id": { "$ref": "#/$defs/ulid" } + } + }, + "situation_note": { "$ref": "#/$defs/mediumText" } + } + }, + "sessionState": { + "type": "object", + "additionalProperties": false, + "description": "POST-SIGN live configuration inside a completed bind (login, elevation). Configure acts mutate this, not the sealed binding slice.", + "required": ["status"], + "properties": { + "status": { "$ref": "#/$defs/sessionStatus" }, + "started_at": { "$ref": "#/$defs/datetime" }, + "ended_at": { "$ref": "#/$defs/datetime" }, + "bound_memo_version": { "type": "integer", "minimum": 1 }, + "bound_presentation_id": { "$ref": "#/$defs/ulid" }, + "active_hat": { "$ref": "#/$defs/hat" }, + "active_hat_source": { "$ref": "#/$defs/awarenessSource" } + } + }, + "configurePayload": { + "type": "object", + "additionalProperties": false, + "properties": { + "hat": { "$ref": "#/$defs/hat" }, + "source": { "$ref": "#/$defs/awarenessSource" }, + "note": { "$ref": "#/$defs/mediumText" } + } + }, + "identityContext": { + "type": "object", + "additionalProperties": false, + "description": "L0/L1 split: binding_slice is pre-sign and signed; awareness_slice is orientation and optional post-sign configure.", + "properties": { + "system": { "type": "string" }, + "environment": { "type": "string", "enum": ["dev", "test", "stage", "prod", "other"] }, + "principal": { "$ref": "#/$defs/partyRef" }, + "requested_roles": { "type": "array", "items": { "type": "string" } }, + "requested_permissions": { "type": "array", "items": { "type": "string" } }, + "duration_seconds": { "type": "integer", "minimum": 1 }, + "justification": { "$ref": "#/$defs/mediumText" }, + "blast_radius": { "$ref": "#/$defs/mediumText" }, + "binding": { "$ref": "#/$defs/bindingSlice" }, + "awareness": { "$ref": "#/$defs/awarenessSlice" } + } + } + }, + + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "version", + "schema_version", + "status", + "created_at", + "updated_at", + "requester", + "question", + "requested_act", + "binding_level", + "brief", + "packet", + "route" + ], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "version": { "type": "integer", "minimum": 1 }, + "schema_version": { "type": "string", "const": "0.2.0" }, + "status": { "$ref": "#/$defs/memoStatus" }, + "outcome": { "$ref": "#/$defs/outcome" }, + "depth": { "$ref": "#/$defs/depthLevel" }, + "title": { "$ref": "#/$defs/shortText" }, + "question": { + "$ref": "#/$defs/shortText", + "description": "Single present-tense decision question." + }, + "requested_act": { "$ref": "#/$defs/requestedAct" }, + "binding_level": { "$ref": "#/$defs/bindingLevel" }, + "locale": { "$ref": "#/$defs/locale" }, + "created_at": { "$ref": "#/$defs/datetime" }, + "updated_at": { "$ref": "#/$defs/datetime" }, + "submitted_at": { "$ref": "#/$defs/datetime" }, + "due_at": { "$ref": "#/$defs/datetime" }, + "completed_at": { "$ref": "#/$defs/datetime" }, + "expires_at": { "$ref": "#/$defs/datetime" }, + + "requester": { "$ref": "#/$defs/partyRef" }, + "subject": { + "$ref": "#/$defs/partyRef", + "description": "Who the decision is about, if different from requester (e.g. the person requesting elevation)." + }, + "parties": { + "type": "array", + "items": { "$ref": "#/$defs/partyRef" } + }, + + "brief": { + "$ref": "#/$defs/markdown", + "description": "Minimum necessary context. Hash of brief is part of presentation.view_hash." + }, + "consequences": { "$ref": "#/$defs/markdown" }, + "identity_context": { "$ref": "#/$defs/identityContext" }, + "session": { "$ref": "#/$defs/sessionState" }, + + "packet": { + "type": "array", + "items": { "$ref": "#/$defs/packetItem" } + }, + "highlights": { + "type": "array", + "items": { "$ref": "#/$defs/highlight" } + }, + "route": { "$ref": "#/$defs/route" }, + "thread": { + "type": "array", + "items": { "$ref": "#/$defs/threadMessage" } + }, + "presentations": { + "type": "array", + "items": { "$ref": "#/$defs/presentationSnapshot" } + }, + "dispositions": { + "type": "array", + "items": { "$ref": "#/$defs/disposition" } + }, + "relations": { + "type": "array", + "items": { "$ref": "#/$defs/relation" } + }, + "evidence": { "$ref": "#/$defs/evidenceBundle" }, + + "legal": { + "type": "object", + "additionalProperties": false, + "properties": { + "governing_law": { "type": "string" }, + "form_requirement": { + "type": "string", + "enum": ["none", "textform", "schriftform", "notarial", "other"] + }, + "retention_years": { "type": "integer", "minimum": 1 }, + "classification": { + "type": "string", + "enum": ["public", "internal", "confidential", "restricted"] + } + } + }, + "extensions": { + "type": "object", + "description": "Namespaced vendor or domain extensions. Must not alter binding semantics.", + "additionalProperties": true + } + } +} diff --git a/tests/__pycache__/test_canonicalize.cpython-312-pytest-7.4.4.pyc b/tests/__pycache__/test_canonicalize.cpython-312-pytest-7.4.4.pyc new file mode 100644 index 0000000..b9c7acf Binary files /dev/null and b/tests/__pycache__/test_canonicalize.cpython-312-pytest-7.4.4.pyc differ diff --git a/tests/test_canonicalize.py b/tests/test_canonicalize.py new file mode 100644 index 0000000..17e637c --- /dev/null +++ b/tests/test_canonicalize.py @@ -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" + ) diff --git a/tests/vectors/adr-binding.json b/tests/vectors/adr-binding.json new file mode 100644 index 0000000..8303883 --- /dev/null +++ b/tests/vectors/adr-binding.json @@ -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" + } + } + ] +} diff --git a/tests/vectors/expected.json b/tests/vectors/expected.json new file mode 100644 index 0000000..b7cea93 --- /dev/null +++ b/tests/vectors/expected.json @@ -0,0 +1,5 @@ +{ + "login_view_hash": "492d9d311bf44ec9de0d0abef28abac7d31df2781527e8fe276a3186ee1b06b8", + "login_awareness_hash": "2be7742970a01e7a879ae5040660659fb8c9a5c024e6c7fb338f3944a4fe05d1", + "adr_view_hash": "1c89ec07c3cc9d16f85a1ba1be5169456b3c55d21161f64037787779ae91f202" +} diff --git a/tests/vectors/login-awareness.json b/tests/vectors/login-awareness.json new file mode 100644 index 0000000..389e45a --- /dev/null +++ b/tests/vectors/login-awareness.json @@ -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." +} diff --git a/tests/vectors/login-binding.json b/tests/vectors/login-binding.json new file mode 100644 index 0000000..70bbb8b --- /dev/null +++ b/tests/vectors/login-binding.json @@ -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" + } + } +} diff --git a/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md b/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md index 983cfaf..0b26cc3 100644 --- a/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md +++ b/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md @@ -237,7 +237,7 @@ are placeholders. ```task id: INFD-WP-0001-T06 -status: todo +status: progress priority: high state_hub_task_id: "47cb3f7a-e349-5c81-a304-86275e058a85" ``` @@ -275,6 +275,30 @@ exercised in CI; the three published expected hashes reproduce byte-for-byte; the implemented-and-first-cut boundary rather than the aspiration, and no longer carries the T01 "nothing is implemented" banner. +2026-09-09 — substantive half done; task stays `progress` because the `SCOPE.md` +rewrite is gated on T02. Delivered: + +- `schemas/decision-memo.schema.json` plus both worked examples; + `informed_decision/canonicalize.py` as the governed canonicalizer, with the + ad-hoc `__main__` block replaced by `python -m informed_decision`; + fixtures under `tests/vectors/`. `history/` is untouched. +- `tests/test_canonicalize.py` — 20 tests, all green. The three published + hashes reproduce byte for byte, and all four isolation properties are pinned. +- `docs/specs/EvidenceModel.md`. +- `pyproject.toml`, `Makefile` (`make test`, `make check`). + +Two things worth recording rather than burying: + +- Isolation properties 1, 2 and 4 are all *negative* — they assert the hash does + **not** change. A canonicalizer returning a constant would pass all three. + Property 3 plus per-field variants over `question`, `requested_act`, + `binding_level` and `packet` are what stop the suite being vacuous. +- `test_governed_vectors_match_the_preserved_history_copy` asserts the governed + fixtures have not drifted from the founding copies, so quietly editing a + vector to make a failing test pass is itself a failure. + +Remaining for `done`: rewrite `SCOPE.md` after the T02 ruling. + ## Publish the OIDC browser-client contract to key-cape ```task