From a8e227851e508e253fe39f68b801e138af734eb2 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 9 Sep 2026 14:16:28 +0200 Subject: [PATCH] Promote schema and canonicalizer out of history; add EvidenceModel (T06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- Makefile | 11 + WORK-RECORDS.md | 2 +- docs/specs/EvidenceModel.md | 197 ++++ informed_decision/__init__.py | 9 + informed_decision/__main__.py | 31 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 514 bytes .../__pycache__/canonicalize.cpython-312.pyc | Bin 0 -> 12182 bytes informed_decision/canonicalize.py | 284 ++++++ pyproject.toml | 19 + schemas/decision-memo.example.json | 150 +++ schemas/decision-memo.login.example.json | 153 +++ schemas/decision-memo.schema.json | 910 ++++++++++++++++++ ..._canonicalize.cpython-312-pytest-7.4.4.pyc | Bin 0 -> 21578 bytes tests/test_canonicalize.py | 245 +++++ tests/vectors/adr-binding.json | 31 + tests/vectors/expected.json | 5 + tests/vectors/login-awareness.json | 63 ++ tests/vectors/login-binding.json | 73 ++ ...ounding-specs-and-approver-ui-ownership.md | 26 +- 19 files changed, 2207 insertions(+), 2 deletions(-) create mode 100644 Makefile create mode 100644 docs/specs/EvidenceModel.md create mode 100644 informed_decision/__init__.py create mode 100644 informed_decision/__main__.py create mode 100644 informed_decision/__pycache__/__init__.cpython-312.pyc create mode 100644 informed_decision/__pycache__/canonicalize.cpython-312.pyc create mode 100644 informed_decision/canonicalize.py create mode 100644 pyproject.toml create mode 100644 schemas/decision-memo.example.json create mode 100644 schemas/decision-memo.login.example.json create mode 100644 schemas/decision-memo.schema.json create mode 100644 tests/__pycache__/test_canonicalize.cpython-312-pytest-7.4.4.pyc create mode 100644 tests/test_canonicalize.py create mode 100644 tests/vectors/adr-binding.json create mode 100644 tests/vectors/expected.json create mode 100644 tests/vectors/login-awareness.json create mode 100644 tests/vectors/login-binding.json 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 0000000000000000000000000000000000000000..d4250157085ed2dbbb177751fa1ad87d21f20c63 GIT binary patch literal 514 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 NX3f7YMM1Uge*h7#b{GHv literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b9c7acfbccb6f62153da5b06f6c823441fa32009 GIT binary patch literal 21578 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 literal 0 HcmV?d00001 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