approval-engine replied to INFD-IN-0001 with docs/approver-surface-requirements.md (31da1af, 5203f46) and two corrections. Several of my requirements were wrong or incomplete; revised rather than appended to. Corrected: - PR-02 listed only approval:approve. Wrong — the surface also needs approval:read to fetch what it renders. As drafted it would have shipped a client able to submit an entry it could never display. That changes a registration key-cape has already implemented, so it is their call (open question A). - NC-03 implied approval-engine refuses non-human approver entries. It does not; only /consume is principal-restricted, and the operator service client holds approval:approve. Enforcement of "humans bind, agents draft" is therefore ours alone, and is auditable via schema v4's entries[].principal_type — never from the shape of subject_id. Added: - PR-04 assurance shape. approval-engine persists it verbatim and accepts an empty object, so it is the only place MFA survives into the approval record. Needs auth method, acr/amr, auth_time, agreed with key-cape. - PR-05 entitlement. A 200 from the engine is not permission to view; we owe access-engine a check before rendering. Consuming a decision, not making one. - PR-06 response mapping, including 409 duplicate_approver rendered as SUCCESS (a browser double-submit is routine and the first entry stands) and 503 as fail-closed. - PR-07 and a matching INTENT wrongness condition: never render `approved` as permission to act. That is a PDP in the browser wearing UI copy. - L-05, L-06 and EvidenceModel 8b: view_hash cannot ride into the entry — the POST discards its body by design — so Stage 1 correlates by (approval_id, subject, approved_at). DoD-3 is satisfied by the triple, not by a stored hash. PRD open question 1 is answered by construction: there is no inbox endpoint and there will not be one, so the approvals-inbox shape is foreclosed upstream. 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
244 lines
11 KiB
Markdown
244 lines
11 KiB
Markdown
# 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.
|
|
|
|
## 8b. Where `view_hash` actually lives (Stage 1)
|
|
|
|
**It is not in the approval entry, by the engine's deliberate design.**
|
|
|
|
`POST /v1/approvals/{id}/entries` reads the request body and **discards it**.
|
|
Approver identity, `assurance` and `evidence_ref` come only from the verified
|
|
token (`evidence_ref` is `jwt-sha256:<digest of the presented token>`). The
|
|
reasoning is sound and we adopt it rather than argue it: a caller-supplied
|
|
approver field is provenance, not evidence, and that entry is currently the one
|
|
record containing no caller-supplied data.
|
|
|
|
Stage 1 arrangement, requiring no change to `approval-engine`:
|
|
|
|
1. this repository holds the presentation record and computes both hashes;
|
|
2. it emits that record to `audit-core` as its own evidence;
|
|
3. correlation to the approval entry is by **`(approval_id, subject,
|
|
approved_at)`** — all three returned by the engine after a successful entry.
|
|
|
|
**Consequence for an auditor, stated plainly:** someone holding only the
|
|
approval object cannot reach the presentation. They need this surface's record
|
|
or `audit-core` as well. `GOAL.md` DoD-3 — "the approval entry is
|
|
reconstructable from a `view_hash`" — is satisfied by the correlation triple,
|
|
not by a hash stored on the entry.
|
|
|
|
Putting the binding *inside* the approval object would be a real change request
|
|
against `approval-engine` (an optional, verified-alongside-token `view_hash` on
|
|
the entry) and it is doctrine before it is a change request, so it goes to
|
|
`gate-house` first. Not raised at Stage 1; recorded here so the option is not
|
|
lost.
|
|
|
|
### `assurance` is the only carrier of authentication facts
|
|
|
|
`approval-engine` persists the token's `assurance` claim verbatim and
|
|
interprets nothing — an empty `{}` is *accepted* and evidences nothing. It is
|
|
therefore the single place where "this was bound under MFA" survives into the
|
|
approval record. The shape must carry authentication method, `acr`/`amr` and
|
|
`auth_time` at minimum, and `informed-decision` and `key-cape` own it between
|
|
them. Nothing downstream reconstructs it.
|
|
|
|
### Principal type is verified evidence; `subject_id` is not
|
|
|
|
Schema v4 records `entries[].principal_type` from the verified token. Design
|
|
principle 10 (humans bind, agents draft) is enforceable from the evidence chain
|
|
by reading that field. Never infer it from the shape of `subject_id` — a naming
|
|
convention is not a verified claim. Entries written before v4 are `null` and
|
|
must not be read as `human`.
|
|
|
|
## 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.
|