Promote schema and canonicalizer out of history; add EvidenceModel (T06)

Verified the three published hashes reproduce byte for byte before promoting
anything, then moved the schema, canonicalizer and vectors into governed assets.
history/20260909-initial-exploration/ is untouched and stays the provenance
record.

- schemas/, informed_decision/, tests/vectors/ populated; the reference
  canonicalizer's ad-hoc __main__ block replaced by a real
  `python -m informed_decision` entry point.
- tests/test_canonicalize.py — 20 tests, all green. Published vectors, all four
  isolation properties, canonical-form round-trip, key sorting, and a provenance
  test asserting the governed fixtures have not drifted from history/.
- docs/specs/EvidenceModel.md — the two hashes, the split and why it exists, the
  four isolation properties, the presentation record, the bundle, and the
  relationship to audit-core.
- pyproject.toml, Makefile.

One test of mine was wrong on first run: it scanned for ", " to assert no
insignificant whitespace, which fires on prose inside a brief. Replaced with a
canonical round-trip comparison, which is the property actually meant. The
canonicalizer was correct.

EvidenceModel leads with what the model does NOT claim — no proof of
comprehension, no proof of reading (deliberately, since the alternative is
surveillance), no survival of a compromised surface, and audit-core's inherited
bound that a hash chain cannot prove a record was never sent.

T06 stays progress: the SCOPE.md rewrite is gated on the T02 ruling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3W1dQG7GFFM9d94jFx7iR

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1565372@bnt-lap001
Assistant-Session: 16bb2f25-b34c-49ef-8e94-5fec3567a568
This commit is contained in:
tegwick 2026-09-09 14:16:28 +02:00
parent 7ae67b2f4e
commit a8e227851e
19 changed files with 2207 additions and 2 deletions

11
Makefile Normal file
View file

@ -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

View file

@ -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 |

197
docs/specs/EvidenceModel.md Normal file
View file

@ -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.

View file

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

View file

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

Binary file not shown.

View file

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

19
pyproject.toml Normal file
View file

@ -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"]

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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
}
}
}

245
tests/test_canonicalize.py Normal file
View file

@ -0,0 +1,245 @@
"""Canonicalization tests — the four isolation properties that must stay green.
These are not incidental unit tests. Each one protects a property the evidence
model depends on; see ``docs/specs/EvidenceModel.md`` and the negative cases
NC-05, NC-06, NC-10 in ``docs/specs/UseCaseCatalog.md``.
If one of these fails, ``view_hash`` no longer means what ``INTENT.md`` claims
it means, and the promise "this person was shown this view" is unsupported.
"""
from __future__ import annotations
import copy
import json
import pathlib
import random
import pytest
from informed_decision.canonicalize import awareness_hash, view_hash
VECTORS = pathlib.Path(__file__).parent / "vectors"
def load(name: str) -> dict:
return json.loads((VECTORS / f"{name}.json").read_text(encoding="utf-8"))
@pytest.fixture(scope="module")
def expected() -> dict:
return load("expected")
@pytest.fixture
def login_binding() -> dict:
return load("login-binding")
@pytest.fixture
def login_awareness() -> dict:
return load("login-awareness")
@pytest.fixture
def adr_binding() -> dict:
return load("adr-binding")
# --------------------------------------------------------------------------
# Published vectors — these hashes appear in InitialExploration.md §9 and are
# quoted in the founding record. They must reproduce byte for byte.
# --------------------------------------------------------------------------
def test_login_view_hash_matches_published_vector(login_binding, expected):
assert view_hash(login_binding)["hex"] == expected["login_view_hash"]
def test_login_awareness_hash_matches_published_vector(login_awareness, expected):
assert awareness_hash(login_awareness)["hex"] == expected["login_awareness_hash"]
def test_adr_view_hash_matches_published_vector(adr_binding, expected):
assert view_hash(adr_binding)["hex"] == expected["adr_view_hash"]
# --------------------------------------------------------------------------
# Isolation 1 — key order is not part of the hash.
# Without this, every hash is an artifact of serialisation order and no
# verifier written against a different JSON library agrees with us. NC-10.
# --------------------------------------------------------------------------
def shuffled(value):
"""Recursively rebuild dicts with their keys in a different order."""
if isinstance(value, dict):
items = [(k, shuffled(v)) for k, v in value.items()]
rng = random.Random(1337)
rng.shuffle(items)
return dict(items)
if isinstance(value, list):
return [shuffled(v) for v in value]
return value
@pytest.mark.parametrize("name", ["login-binding", "adr-binding"])
def test_key_order_does_not_change_view_hash(name):
doc = load(name)
assert view_hash(shuffled(doc))["hex"] == view_hash(doc)["hex"]
def test_key_order_does_not_change_awareness_hash(login_awareness):
assert (
awareness_hash(shuffled(login_awareness))["hex"]
== awareness_hash(login_awareness)["hex"]
)
# --------------------------------------------------------------------------
# Isolation 2 — awareness never enters view_hash.
# This is the property that lets the surface default a role to last-used for
# situational awareness without silently signing it. NC-05, PR-40.
# --------------------------------------------------------------------------
def test_editing_awareness_does_not_change_view_hash(login_binding):
before = view_hash(login_binding)["hex"]
mutated = copy.deepcopy(login_binding)
mutated["awareness"] = {
"proposed_hat": {"id": "hat:auditor", "label": "Auditor"},
"proposed_hat_source": "last_used",
"situation_note": "changed after the presentation was taken",
}
mutated["proposed_hat_source"] = "policy"
assert view_hash(mutated)["hex"] == before
def test_unknown_top_level_keys_are_stripped_from_view_hash(login_binding):
before = view_hash(login_binding)["hex"]
mutated = copy.deepcopy(login_binding)
mutated["not_in_the_allow_list"] = {"anything": "at all"}
assert view_hash(mutated)["hex"] == before
# --------------------------------------------------------------------------
# Isolation 3 — the binding slice IS covered.
# The positive control for isolation 2: if this passed while 2 also passed
# vacuously, view_hash would be covering nothing. NC-06.
# --------------------------------------------------------------------------
def test_changing_binding_target_changes_view_hash(login_binding):
before = view_hash(login_binding)["hex"]
mutated = copy.deepcopy(login_binding)
target = mutated["binding"]["target"]
assert target, "vector must carry a binding target for this test to mean anything"
if isinstance(target, dict):
key = "id" if "id" in target else next(iter(target))
target[key] = f"{target[key]}-BETA"
else:
mutated["binding"]["target"] = f"{target}-BETA"
assert view_hash(mutated)["hex"] != before
@pytest.mark.parametrize("field", ["question", "requested_act", "binding_level"])
def test_changing_a_binding_field_changes_view_hash(adr_binding, field):
before = view_hash(adr_binding)["hex"]
mutated = copy.deepcopy(adr_binding)
if field not in mutated:
pytest.skip(f"vector does not carry {field}")
mutated[field] = f"{mutated[field]}-changed"
assert view_hash(mutated)["hex"] != before
def test_changing_the_packet_changes_view_hash(adr_binding):
before = view_hash(adr_binding)["hex"]
mutated = copy.deepcopy(adr_binding)
packet = mutated.get("packet")
if not packet:
pytest.skip("vector carries no packet")
packet[0]["hash"] = "sha256:" + "0" * 64
assert view_hash(mutated)["hex"] != before
# --------------------------------------------------------------------------
# Isolation 4 — selecting a role after login does not rewrite view_hash.
# Post-bind session state is a different object from the signed binding.
# NC-04, and the reason `configure` exists as a verb at all.
# --------------------------------------------------------------------------
def test_post_bind_hat_selection_does_not_change_view_hash(login_binding):
before = view_hash(login_binding)["hex"]
mutated = copy.deepcopy(login_binding)
mutated["session"] = {
"status": "active",
"hat": {"id": "hat:finance-controller", "elevates": False},
"events": [{"kind": "session.hat_selected", "at": "2026-09-09T10:00:00Z"}],
}
assert view_hash(mutated)["hex"] == before
# --------------------------------------------------------------------------
# Canonical form properties.
# --------------------------------------------------------------------------
@pytest.mark.parametrize("name", ["login-binding", "adr-binding"])
def test_canonical_form_has_no_insignificant_whitespace(name):
"""No whitespace between structural tokens.
Checked by round-trip rather than substring search: ", " and ": " occur
legitimately inside string *values* (a brief is prose), so a naive scan
reports a defect that is not there.
"""
canonical = view_hash(load(name))["canonical"]
reserialized = json.dumps(
json.loads(canonical),
separators=(",", ":"),
sort_keys=True,
ensure_ascii=False,
)
assert canonical == reserialized
def test_canonical_form_keys_are_sorted(login_binding):
canonical = view_hash(login_binding)["canonical"]
keys = list(json.loads(canonical).keys())
assert keys == sorted(keys)
def test_canonical_form_is_utf8_encodable_and_hash_is_over_utf8(login_binding):
import hashlib
result = view_hash(login_binding)
assert (
hashlib.sha256(result["canonical"].encode("utf-8")).hexdigest()
== result["hex"]
)
def test_hash_is_stable_across_repeated_calls(login_binding):
assert view_hash(login_binding)["hex"] == view_hash(login_binding)["hex"]
# --------------------------------------------------------------------------
# Provenance — the governed copy must not drift from the founding record.
# --------------------------------------------------------------------------
def test_governed_vectors_match_the_preserved_history_copy():
history = (
pathlib.Path(__file__).resolve().parents[1]
/ "history"
/ "20260909-initial-exploration"
/ "vectors"
)
if not history.is_dir():
pytest.skip("history/ not present in this checkout")
for governed in sorted(VECTORS.glob("*.json")):
original = history / governed.name
assert original.is_file(), f"{governed.name} has no provenance original"
assert json.loads(governed.read_text()) == json.loads(original.read_text()), (
f"{governed.name} drifted from the preserved founding copy"
)

View file

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

View file

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

View file

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

View file

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

View file

@ -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