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:
parent
7ae67b2f4e
commit
a8e227851e
19 changed files with 2207 additions and 2 deletions
11
Makefile
Normal file
11
Makefile
Normal 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
|
||||
|
|
@ -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
197
docs/specs/EvidenceModel.md
Normal 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.
|
||||
9
informed_decision/__init__.py
Normal file
9
informed_decision/__init__.py
Normal 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"]
|
||||
31
informed_decision/__main__.py
Normal file
31
informed_decision/__main__.py
Normal 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:]))
|
||||
BIN
informed_decision/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
informed_decision/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
informed_decision/__pycache__/canonicalize.cpython-312.pyc
Normal file
BIN
informed_decision/__pycache__/canonicalize.cpython-312.pyc
Normal file
Binary file not shown.
284
informed_decision/canonicalize.py
Normal file
284
informed_decision/canonicalize.py
Normal 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
19
pyproject.toml
Normal 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"]
|
||||
150
schemas/decision-memo.example.json
Normal file
150
schemas/decision-memo.example.json
Normal 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"
|
||||
}
|
||||
}
|
||||
153
schemas/decision-memo.login.example.json
Normal file
153
schemas/decision-memo.login.example.json
Normal 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"
|
||||
}
|
||||
}
|
||||
910
schemas/decision-memo.schema.json
Normal file
910
schemas/decision-memo.schema.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
tests/__pycache__/test_canonicalize.cpython-312-pytest-7.4.4.pyc
Normal file
BIN
tests/__pycache__/test_canonicalize.cpython-312-pytest-7.4.4.pyc
Normal file
Binary file not shown.
245
tests/test_canonicalize.py
Normal file
245
tests/test_canonicalize.py
Normal 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"
|
||||
)
|
||||
31
tests/vectors/adr-binding.json
Normal file
31
tests/vectors/adr-binding.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
5
tests/vectors/expected.json
Normal file
5
tests/vectors/expected.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"login_view_hash": "492d9d311bf44ec9de0d0abef28abac7d31df2781527e8fe276a3186ee1b06b8",
|
||||
"login_awareness_hash": "2be7742970a01e7a879ae5040660659fb8c9a5c024e6c7fb338f3944a4fe05d1",
|
||||
"adr_view_hash": "1c89ec07c3cc9d16f85a1ba1be5169456b3c55d21161f64037787779ae91f202"
|
||||
}
|
||||
63
tests/vectors/login-awareness.json
Normal file
63
tests/vectors/login-awareness.json
Normal 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."
|
||||
}
|
||||
73
tests/vectors/login-binding.json
Normal file
73
tests/vectors/login-binding.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue