diff --git a/GOAL.md b/GOAL.md new file mode 100644 index 0000000..9f4eba3 --- /dev/null +++ b/GOAL.md @@ -0,0 +1,130 @@ +--- +repo: informed-decision +repo_flavor: project +project_status: active +stage: 1 +started: "2026-09-09" +--- + +# Goal — informed-decision, Stage 1 + +## Outcome + +**The approver UI has an owner, and it is this repository.** + +Stage 1 is complete when a human approver can be shown a `approval-engine` +approval request on a surface this repository owns, understand it, act on it +with a vocabulary richer than approve/reject, and leave behind a +reconstructable record of *what they were shown when they bound themselves* — +and when `key-cape` holds the browser-client registration it has been waiting +for since 2026-09-08. + +Two things are being settled at once, and they are the same thing: + +1. **The ownership question.** `approval-engine` disowns the inbox by design + and named the gap in `docs/keycape-service-registrations.md`. Nobody picked + it up. Stage 1 picks it up, in writing, with a catalog placement agreed by + `gate-house` rather than asserted. +2. **The capability.** A browser-facing approver surface that presents + `approval:approve` tokens to `approval-engine` and records the presentation. + +## Why this is the first stage + +The founding exploration reaches L5 — multi-party international instruments. +Stage 1 deliberately does not. It takes the **narrowest real consumer with a +live blocking dependency**, because a decision surface with no consumer is a +design document, and this estate already has enough of those. + +The approval approver flow is L3 on the depth spectrum. It is the right first +cut because it is the only level currently costing another repository progress. + +## What Stage 1 delivers + +- `INTENT.md`, `GOAL.md`, `SCOPE.md`, and a `layer.yaml` derived from a + `gate-house` answer. +- The four specs: `docs/specs/ProductRequirementsDocument.md`, + `docs/specs/UseCaseCatalog.md`, `docs/specs/ArchitectureBlueprint.md`, + `docs/specs/EvidenceModel.md`. +- The Decision Memo schema promoted out of `history/` into `schemas/`, with the + canonicalizer and the isolation vectors under test in CI. +- A published OIDC browser-client contract — `client_id` and callback URI — + handed to `key-cape`, closing `KEY-WP-0013-T02`. +- A working L3 approver surface against a deployed `approval-engine`. + +## What Stage 1 explicitly does not deliver + +- L4/L5, QES, QTSP integration, or qualified archival retention. +- Multi-party parallel sign-off (`Mitzeichnung` across several parties at once). +- The mandate graph. Stage 1 routes to a named approver; it does not maintain a + living map of who may bind what. Without that map, `escalate` is only + forwarding — this is a **known and accepted** Stage 1 limitation, recorded + here so it is not discovered later as a defect. +- EUDI Wallet / eID identity import. Identity comes from `key-cape` only. +- A general workflow engine, a notification transport, or a ticketing system. + +## Invariants + +These hold for Stage 1 and for every stage after it. + +- **`access-engine` remains the only policy decision point.** This repository + renders questions and records answers. No endpoint here answers "may this + actor do X". A disposition recorded on a memo is evidence of a human act, not + an authorization verdict. +- **`approval-engine` remains the sole owner of approval state.** This surface + calls its mutations and reads its introspection claim. It never caches + validity, never infers consumption, and never holds approval current-state. + `GH-DEC-2026-003` ordering is honoured: decision, then consume, then act. +- **Identity is imported.** Every principal on this surface is authenticated by + `key-cape`. No local credential, no self-issued assurance level. Human + principals carry `principal_type: human` and are refused `approval:consume` by + the engine — this surface must never attempt it. +- **The binding/awareness split survives contact with the first consumer.** + `view_hash` covers only what the person committed to. Proposed roles, + other-tenant orientation and last-session summaries are hashed separately and + are not signed unless explicitly promoted into `awareness_promoted`. +- **One question per memo.** An approval that bundles three unrelated acts is + three memos. +- **Humans bind, agents draft.** An agent may assemble a memo and may not + complete a disposition. +- **The schema does not fork.** Any Stage 1 field added for approvals must be + expressible for an L0 login banner, or it does not go in the shared object. + +## The question Stage 1 must answer in writing + +> **Who owns the approver UI?** + +The answer this repository proposes, to be ratified rather than assumed: + +- **informed-decision owns it** — the surface, the presentation record, the + browser client, and the evidence of informedness. +- It is **PEP-shaped**, not an Engine: it is browser-facing and causes a + protected side effect on the far side of a decision, so it owes companion §5 + and statute §6.4. +- It supplies exactly one PIP-like fact — *what was presented* — as a claim + carrying `view_hash`, issuer and freshness, and it never evaluates that fact. +- The catalog row does not exist yet. `INFD-WP-0001-T02` asks `gate-house` and + writes `layer.yaml` from the ruling. If `gate-house` places this component + elsewhere, `INTENT.md` and this file change to match — the ruling wins. + +## Definition of done + +1. `gate-house` has ruled on the layer placement and `layer.yaml` reflects it. +2. `key-cape` has the `client_id` and callback URI, and `KEY-WP-0013-T02` is + unblocked with the answer traceable to this repository. +3. The four specs exist, are reviewed against the current `approval-engine`, + `access-engine`, `key-cape` and `audit-core` contracts, and name their + external gates. +4. `view_hash` / `awareness_hash` are computed by code in this repository, and + the four isolation vectors from the exploration are green in CI. +5. One approval is approved end to end by a human through this surface against + a deployed `approval-engine`, and the resulting approval entry is + reconstructable from a stored presentation. +6. `return` and `discuss` are demonstrated on a real approval and are + distinguishable from `decline` in the record. + +## Stage 2 and beyond — direction only, not commitment + +L0 informed login/elevation in front of `key-cape`, then L2 ADR accept against +the estate's own decision records. Those two prove scale invariance downward and +sideways. L4 contract paths and any QES work wait for a named buyer and a +retention answer, both open questions in the founding exploration. diff --git a/INTENT.md b/INTENT.md new file mode 100644 index 0000000..5223b8a --- /dev/null +++ b/INTENT.md @@ -0,0 +1,245 @@ +--- +project: informed-decision +product: Informed Decision +status: draft +intent_version: 0.1.0 +date: "2026-09-09" +domain: infotech +topic_slug: netkingdom +layer: surface +role: pep-shaped # PROVISIONAL — see "Layer Placement", INFD-WP-0001-T02 +standard: net-kingdom/canon/standards/security-layer-model_v0.7.md +companion: net-kingdom/SECURITY-COMPANION.md +--- + +# INTENT + +> This file captures why this repository exists and the kind of system it is +> meant to become. It is aspirational and stable, not a description of current +> implementation. Implemented boundary belongs in `SCOPE.md`; the current stage +> belongs in `GOAL.md`; current work belongs in `workplans/`. + +## Purpose + +> **informed-decision owns the surface on which a human is shown a decision and +> binds their identity to it — and owns the evidence that the presentation +> actually happened.** + +It answers one question, totally and decidably: + +> **What was this person shown, in what role, at the moment they bound +> themselves to this act — and can that be proven afterwards?** + +Nothing else. It does not decide whether the act is permitted (`access-engine`), +does not own the approval object (`approval-engine`), does not author the +doctrine that requires an approval (`gate-house`), does not authenticate the +person (`key-cape`), and does not archive the trail (`audit-core`). + +## Why This Exists + +Every layer of the NetKingdom estate has an owner except the one a human +actually touches. + +`approval-engine`'s `INTENT.md` names it under Non-Goals, twice and +deliberately: *"Not a workflow or ticketing system"*, *"Not an approvals inbox +or notification surface."* That exclusion is correct — an engine that renders +its own object is self-dealing — but it leaves a hole, and the hole is now +load-bearing: + +- `approval-engine` is a bearer-token resource server with no Ingress, no + redirect endpoint and no browser client + (`deploy/approval-engine.yaml`: Namespace + ClusterIP + StatefulSet). +- `key-cape` (`KEY-WP-0013-T02`) asked approval-engine for the human approver + client's `client_id` and callback URI on 2026-09-08. +- approval-engine correctly refused to invent them, recording in + `docs/keycape-service-registrations.md`: *"The human approver flow therefore + belongs to whichever browser-facing approver UI presents `approval:approve` + tokens to this engine. That component is not in this repo."* + +So the approver UI is not merely missing. It is a **named, unassigned +dependency** that currently blocks an OIDC client registration in another +repository. **This repository claims it.** + +The claim is not opportunistic. The founding exploration +(`history/20260909-initial-exploration/`) arrived at the same object from the +opposite direction — the German *Umlaufmappe* / *Zeichnungsbuch*, a portable +decision object that carries a question, the context needed to answer it, the +requested act, and a cryptographic binding between *identity*, *what was shown*, +and *what was bound*. That is exactly the artifact the estate is missing, and it +generalizes past approvals without ever becoming a decision point. + +## The Concept — the Decision Memo + +A **Decision Memo** is a portable object presented to one human who holds a +mandate. It carries: + +- a **question** that must be answered, and a **requested act**; +- the **brief and consequences** a reasonable decision-maker needs; +- a **packet** of hashed documents and **highlights** pointing into it; +- a **binding slice** — the identity and scope being committed, which is signed; +- an **awareness slice** — orientation (proposed role, other roles, other + tenants, last session), which is shown and hashed but **not** signed; +- a **route** (`Zeichnungsleiste`), a **thread**, and a record of every + **presentation** and **disposition**; +- an append-only **evidence** chain. + +Two hashes are computed over every presentation: + +| Hash | Over | Signed? | +| --- | --- | --- | +| `view_hash` | the binding document | yes, from organizational level up | +| `awareness_hash` | the awareness document | no, unless a field was promoted | + +`view_hash` is the mechanism. It is what turns *"the file was signed"* into +*"this person, in this role, was shown this view, and bound this act."* + +## Scale Invariance Is the Thesis + +One object model, one evidence model, one UX grammar, from a ten-second login +to a multi-party instrument: + +| Level | Act | Typical binding | +| --- | --- | --- | +| L0 | Informed login / session | acknowledgment / organizational | +| L1 | Privileged authorization (JIT elevation) | organizational | +| L2 | Formal ADR / decision-record accept | organizational / AES | +| L3 | Management approval | organizational / AES | +| L4 | Contract / instrument | AES / QES | +| L5 | Multi-party international instrument | QES per party (horizon, not v1) | + +If L0 and L3 share the schema, the transitions, and the two hashes, the thesis +holds. If they fork, it does not — and the estate has bought a bespoke approvals +inbox instead of a decision surface. + +**The approval approver UI is L3.** It is the first stage precisely because it +is a real consumer with a real blocking dependency, not a demo. + +## The Disposition Vocabulary + +The differentiator is not another workflow engine. It is that *approve/reject* +is the wrong vocabulary for judgment. The verbs are: + +`comment` · `discuss` (Rücksprache) · `return` for improvement (Rückgabe) · +`forward` · `escalate` · `acknowledge` (Kenntnisnahme) · `accept` +(Mitzeichnung / Schlusszeichnung) · `decline` · `withdraw` · `configure` + +`accept` on a Kenntnisnahme step is **illegal on purpose**. **Return is success, +not failure** — a memo sent back for improvement is the surface working. + +## What informed-decision Owns + +- the **Decision Memo** object, its versions, and its lifecycle; +- the **presentation record** — what was rendered, to whom, when, in which + locale and UI release; +- **canonicalization** of the binding and awareness documents, and the + computation of `view_hash` / `awareness_hash`; +- **highlight acknowledgment** — required highlights acked before a bind is + permitted; +- the **disposition vocabulary** and its legality tables; +- the **evidence bundle** as an export — the reconstructable answer to *who + decided what, having seen what*; +- the **browser-facing OIDC client** for human principals, including the + `client_id` and callback URI that `key-cape` is waiting for; +- the **pre-sign / post-sign split**: identity and bind-scope are committed; + hats, last-used role, and other-tenant orientation are awareness unless + explicitly promoted. + +## What informed-decision Does Not Own + +- **the decision** — `access-engine`, always and only (statute §6). This + repository renders a question and records an answer; it never evaluates + whether an actor may do a thing; +- **the approval object** — `approval-engine`. This surface calls that engine's + mutations with an `approval:approve`-scoped token; it never holds approval + current-state and never answers "is this approval valid"; +- **approval doctrine** — which acts need approval, how many approvers, which + separations of duty — `gate-house`; +- **identity and authentication** — `key-cape`. Identity is *imported* + (OIDC, passkeys, eID, EUDI Wallet, QTSP), never invented here; +- **the evidence archive** — `audit-core`. This repository emits and exports; + the trail is held there; +- **credentials materialized after a decision** — `secrets-engine`; +- **notification transport** — a memo may be announced by other means; the + dialogue stays on the object. + +## Design Principles + +1. **One object, many depths.** Login and instrument share a schema. +2. **Presentation is part of the signed payload.** Dynamic linking, not a + signature over a file nobody read. +3. **Competence before convenience.** Routing follows mandate. +4. **Return is success.** +5. **Dialogue stays on the object.** Side-channel email is an anti-pattern. +6. **Binding level is explicit.** Kenntnisnahme ≠ Mitzeichnung ≠ QES. +7. **Evidence is an export, not a screenshot.** +8. **Least privilege of attention.** Highlights first, full packet available, + no dark patterns — and the UI must make unmistakable that the *whole* + instrument is bound, not only the highlights. +9. **Identity is imported.** +10. **Humans bind, agents draft.** An agent may assemble a memo. It may never + complete the binding act on the principal's surface. +11. **Awareness is not consent.** Defaulting a role to last-used is required + for situational awareness and forbidden from silently entering `view_hash`. +12. **Informedness without surveillance.** Evidence is the hash of the + presented view plus explicit highlight acknowledgment — never keystroke + analytics, dwell timers, or attention theater. + +## Layer Placement — Provisional + +Under security layer model v0.7 this repository is **not** an Engine. It is +browser-facing and it causes a protected side effect on the far side of a +decision, which is the shape statute §6.4 and companion §5 call **PEP-shaped**. +`approval-engine`'s own INTENT names this expectation: *"Callers that do are +PEP-shaped and owe companion §5 / statute §6.4."* + +This is declared **provisional** because the catalog row does not exist yet and +inventing one is the drift §17 exists to prevent. `INFD-WP-0001-T02` takes the +question to `gate-house` and writes `layer.yaml` from the answer, not from this +paragraph. The framing this repository will argue: informed-decision is a +**presentation and binding surface** — a PEP for the acts it fronts, and a PIP +for exactly one fact, *what was presented*, which it supplies as a claim and +never evaluates. + +## What Would Make This Repository Wrong + +- **It starts deciding.** Any surface that answers "may this actor do X" rather + than "here is the question, and here is what you were shown" is the failure + statute §6 names. +- **It becomes an approvals inbox and nothing else.** If the object that ships + cannot also carry an L0 login banner and an L2 ADR accept, the scale-invariance + thesis is dead and the estate bought a queue. +- **It forks the schema per level.** Three levels, three object models, one + broken promise. +- **Awareness leaks into the signature.** If a last-used role or a tenant + orientation hint enters `view_hash`, the surface signs something the person + did not commit to. +- **Highlights shrink the legal object.** If acking three highlights is + presented as binding only those three sections, the UI has produced a lie. +- **An agent binds.** If any automation can complete a disposition on a + principal's behalf, the identity half is fiction. +- **It reimplements `approval-engine`.** Caching approval validity, inferring + consumption from a decision record, or holding approval current-state here + breaks `GH-DEC-2026-003` and the atomicity contract. +- **It invents identity.** A local password, a local session token that is not + derived from `key-cape`, or a self-issued assurance level. +- **Nobody uses it.** If `approval-engine` never receives an `approval:approve` + token from this component's client, the surface is decorative and the gap it + claimed to close is still open. + +## Success Criteria + +1. `key-cape` holds a registered browser client whose `client_id` and callback + URI this repository owns and published, and `KEY-WP-0013-T02` is unblocked. +2. A human approver completes an approval entry against `approval-engine` + entirely through this surface, with an access token carrying + `aud=approval-engine`, `principal_type: human`, and scope `approval:approve`. +3. The approval entry is reconstructable from a `view_hash` — the estate can + show what the approver was looking at. +4. Required highlights cannot be bypassed: a bind attempt without acks fails. +5. `return`, `discuss`, and `escalate` are exercisable on a real approval and + are not modelled as `decline`. +6. The same schema renders an L0 login banner and an L2 ADR accept, with the + binding/awareness split intact and the isolation vectors green. +7. An evidence bundle exports and verifies offline against + `audit-core`'s record of the same transitions. +8. No endpoint in this repository answers an authorization question. diff --git a/README.md b/README.md index 6f288e2..b62be53 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,37 @@ # informed-decision -Userinterface for executive decisions modeled as a sign and return book. \ No newline at end of file +User interface for executive decisions, modelled as a sign-and-return book — +the German *Umlaufmappe* / *Zeichnungsbuch*, made cryptographic. + +A **Decision Memo** carries a question, the context needed to answer it, the +requested act, and a binding between *identity*, *what was shown*, and *what was +bound*. The promise is not "the file was signed" but **"this person, in this +role, was shown this view, and bound this act."** + +One object model from a ten-second login (L0) to a multi-party instrument (L5). + +## Where to start + +| File | What it is | +| --- | --- | +| `INTENT.md` | Why this repository exists and what it must never become | +| `GOAL.md` | The current stage, its invariants, and its definition of done | +| `workplans/` | Current work | +| `history/20260909-initial-exploration/` | Founding exploration — schema, state transitions, canonicalization, vectors | + +## Stage 1 + +Own the browser-facing **approver UI** that `approval-engine` deliberately does +not contain, and answer in writing who owns it. `approval-engine` is a +bearer-token resource server with no browser client; `key-cape` +(`KEY-WP-0013-T02`) is waiting on a `client_id` and callback URI that no +component has claimed. This repository claims them. + +See `GOAL.md`. + +## Boundaries + +This repository renders questions and records answers. It does **not** decide +(`access-engine`), does not own the approval object (`approval-engine`), does +not author approval doctrine (`gate-house`), does not authenticate anyone +(`key-cape`), and does not archive the trail (`audit-core`). diff --git a/history/20260909-initial-exploration/InitialExploration.md b/history/20260909-initial-exploration/InitialExploration.md new file mode 100644 index 0000000..0b99add --- /dev/null +++ b/history/20260909-initial-exploration/InitialExploration.md @@ -0,0 +1,325 @@ +# informed-decision — Initial exploration + +Working notes from the founding conversation (9 September 2026). +Use this file plus the sibling artefacts as the project starting point. + +Companion files: + +| File | What it is | +|---|---| +| `decision-memo.schema.json` | JSON Schema draft 2020-12, object model v0.2 | +| `decision-memo-schema.md` | Human commentary on the schema and invariants | +| `decision-memo.example.json` | ADR accept example (L2) | +| `decision-memo.login.example.json` | Login banner example (L0) with binding vs awareness | +| `state-transitions.md` | Verb × status × step-kind tables | +| `canonicalize.md` | `view_hash` / `awareness_hash` algorithm | +| `canonicalize.py` | Reference canonicalizer | +| `vectors/` | Binding and awareness fixtures plus expected hashes | + +--- + +## 1. Intent + +Build **informed-decision**: a user interface for executive (and everyday) decisions modelled as a *sign-and-return book*. + +Use cases named in the brief: + +- logging into a system +- authorizing an action that needs elevated privileges +- formally accepting an ADR +- approving a management decision you are responsible for +- signing a business contract +- at greatest depth: multi-party sign-off of complex international instruments + +Required verbs on the book: + +- present necessary context +- add comments +- contact the requester +- return the memo for improvement +- forward or escalate +- accept electronically on the user’s identity +- document what happened with auditability +- establish cryptographic proof (sign the decision, stamp relevant files) + +Spectrum: + +- **Simplest:** commentary on a login mask — which system, which identity, which privileges. +- **Deepest:** complex decision memos with multiple documents, highlight pointers to critical sections, multi-party sign-off. + +The German administrative metaphor is the right one: *Umlaufmappe*, *Zeichnungsleiste*, *Mitzeichnung*, *Schlusszeichnung*, *Kenntnisnahme*, *Rücksprache*, *Bitte um Berücksichtigung*. + +--- + +## 2. Unifying insight + +A sign-and-return book is not a document folder. It is a **portable decision object** that travels with: + +- a question that must be answered +- the context a reasonable decision-maker needs +- a requested act (login, elevate, approve, accept, sign, escalate) +- a record of what happened +- a cryptographic binding between *identity*, *what was shown*, and *what was bound* + +Existing product categories split that object: + +| Category | Strength | Gap | +|---|---|---| +| PAM / JIT access | time-bound elevation, justification | almost no work-object context | +| E-sign / CLM | ceremony, sealed PDFs, routing | weak “why this is on your desk” | +| ADR tools | context / decision / consequences | “accepted” is a Git status, not an identity act | +| Board portals | packs, votes, elite audience | too heavy for daily operational acts | +| Digital *Umlaufmappe* (SAP, enaio, M-Files) | circulation + protocol | weak proof of *informedness*; not login-scale | +| Decision intelligence | recommendation and policy | not the binding act | + +White space: one object model, one evidence model, one UX grammar, from a 10-second login to a treaty. + +The differentiator is not another workflow engine. It is: + +1. **Informedness as a measurable property** (what was presented, highlighted, opened, attested). +2. **A disposition vocabulary richer than approve/reject.** +3. **Cryptographic binding of presentation to act** (dynamic linking), not only of file to identity. +4. **Scale invariance.** + +Research notes that belong in the backlog: + +- eIDAS / eIDAS 2.0 / EUDI Wallet: QES becomes a mass wallet capability around 2026–27; identity should be imported, not invented. +- Dynamic linking: the human-readable content actually presented must be bound into the signature. +- Accountability UI (identifiability, evaluation, social presence) reduces abuse of access — relevant to the login end of the spectrum. +- Agents may draft memos; they must not be able to complete the binding act on the principal’s surface. + +--- + +## 3. Vision and mission drafts + +**Working vision** + +People should never have to bind an identity to an action they could not reasonably understand. Informed-decision is the book in which that understanding is presented, contested, returned, escalated, and sealed. + +Alternates kept: + +- Every binding act is an informed act: the person who decides sees what matters, can send the question back, and leaves cryptographic proof of what they bound themselves to. +- Replace the circulating folder, the login banner, and the signature page with one governed surface. +- Make European identity useful at the moment of judgment: wallet-grade identity, qualified signatures, and an evidence pack that still answers *who decided what, having seen what*. + +**Working mission** + +Give every organization a reusable Decision Memo: assemble the minimum necessary context, route it to the person who actually holds the mandate, let them comment, discuss, return or escalate, and — when they accept — bind identity, presentation, and outcome into a verifiable evidence bundle. + +**Promise** + +Not “the file was signed.” +“This person, in this role, was shown this view, and bound this act.” + +--- + +## 4. Design principles + +1. One object, many depths. Login and treaty share a schema. +2. Presentation is part of the signed payload. +3. Competence before convenience. Routing follows mandate. +4. Return is success, not failure. +5. Dialogue stays on the object. Side-channel email is an anti-pattern. +6. Binding level is explicit. Kenntnisnahme ≠ Mitzeichnung ≠ QES. +7. Evidence is an export, not a screenshot. +8. Least privilege of attention. Highlights first; full packet available; no dark patterns. +9. Identity is imported (SSO, passkeys, eID, EUDI Wallet, QTSP). +10. Humans bind. Agents draft. + +Later addition that is now first-class: + +11. **Pre-sign and post-sign information are different objects.** Identity and bind-scope are committed. Hats, last-used role, and other-tenant orientation are awareness unless promoted. + +--- + +## 5. Depth spectrum + +| Level | Act | Typical binding | +|---|---|---| +| L0 | Informed login / session | acknowledgment / organizational | +| L1 | Privileged authorization (JIT) | organizational | +| L2 | Formal ADR / decision-record accept | organizational / AES | +| L3 | Management approval | organizational / AES | +| L4 | Contract / instrument | AES / QES | +| L5 | Multi-party international instrument | QES per party (horizon, not v1) | + +v1 should prove the thesis on A+B+C sharing one object: informed login/elevation, formal ADR, one real contract type. + +--- + +## 6. Decision Memo object (v0.2) + +``` +DecisionMemo +├── requester / subject / parties[] + mandate +├── question + requested_act + binding_level +├── brief + consequences +├── identity_context.binding PRE-SIGN identity + scope → view_hash +├── identity_context.awareness hats, last-used, tenants → awareness_hash +├── session POST-SIGN live hat +├── packet[] hashed files +├── highlights[] pointers into packet +├── route.steps[] Laufweg / Zeichnungsleiste +├── thread[] +├── presentations[] +├── dispositions[] +├── relations[] +└── evidence append-only hash chain +``` + +Lifecycle: + +``` +draft → submitted → circulating | awaiting_actor | in_discussion + ↘ returned —new version→ submitted + ↘ completed (outcome set) + ↘ withdrawn | expired +completed —new memo→ superseded + +L0/L1 extra: completed login/elevate → session.active → configure hats → session.ended +``` + +Core invariants (I1–I13) live in `decision-memo-schema.md`. The ones that must not be bargained away: + +- One question per memo. +- Binding acts reference a presentation of the same version. +- AES/QES dynamically links `view_hash`. +- Required highlights must be acked before bind. +- Agents may draft, not bind. +- Return always carries structured reasons. +- Highlights never shrink the legal object. +- `binding` is signed; `awareness` is not, unless `promote` copied named fields into `awareness_promoted`. +- Hats are not scopes. `requires_new_bind` or `hat.elevates` means a new memo (or promote + new version). + +--- + +## 7. Pre-sign vs post-sign (login / multi-tenant) + +The login case forced a split that applies everywhere. + +**Pre-sign / binding slice** (editable, signed) + +- which identity you log in as (ordinary vs break-glass) +- which scope/tenant/system you enter +- privileges granted *at the gate* +- monitoring and consent terms + +**Awareness slice** (shown on the same surface, hashed separately, not signed) + +- proposed hat after login (`last_used` | `system_default` | `policy` | `explicit`) +- other hats available inside that bound scope +- other tenants you support (orientation) +- last session summary + +**Post-sign / session** + +- `configure(hat)` if `elevates=false` +- switching to a tenant with `requires_new_bind=true` opens a child memo +- an elevating hat (`Payroll Admin`) cannot be configured; it must be promoted into binding or issued as its own elevation memo + +Banner sketch: + +``` +PRE-SIGN (bound) + Identity [ Bernd Worsch ▾ ] + Scope [ ACME Corp / Payroll-Prod ▾ ] + +AWARENESS (not signed) + After login you will wear Finance Controller + source: last used, Tue 18:12 CEST + Other hats: Auditor · Payroll Admin (needs new bind) + Other tenants: Beta GmbH (needs new bind) +``` + +Defaulting the hat to last-used is required for situational awareness and forbidden from silently entering `view_hash`. + +--- + +## 8. State transitions (summary) + +Actors: requester, current-step assignee, any party, session subject, system, agent. + +Overlay verbs always available in circulation: `comment`, `discuss`. Requester may `withdraw` before terminal. System `stamp`s evidence. + +| Verb | Weak step (`inform`/`review`) | Co-sign | Bind step | +|---|---|---|---| +| return / forward / escalate | yes | yes | yes | +| acknowledge | completes step | completes step | overlay | +| accept | **illegal** | completes step | may complete memo | +| decline | illegal | completes step/memo | completes memo `declined` | +| configure | illegal until `session.active` | same | same | + +`accept` on Kenntnisnahme is illegal on purpose. + +After login/elevate bind: + +- `configure(hat)` allowed only for non-elevating hats in `available_hats` +- other-tenant with `requires_new_bind` → `scope.rebind_required` + child memo +- binding verbs on a sealed version are illegal + +Full tables, guards (`G_PRES`, `G_ACK`, `G_MAND`, `G_HAT`, …), and illegal combinations: `state-transitions.md`. + +--- + +## 9. Canonicalization (summary) + +Two hashes on every presentation. + +| Hash | Input | Signed? | +|---|---|---| +| `view_hash` | binding document | yes, from organizational level up | +| `awareness_hash` | awareness document | no, unless promote ran | + +Profile: JCS-inspired restricted JSON — UTF-8, sorted keys, no insignificant whitespace, integers only, unknown keys stripped, nulls dropped. + +`view_hash` allow-list: `memo_id`, `memo_version`, `question`, `requested_act`, `binding_level`, `brief`, `locale`, `ui_release`, normalized `packet`, normalized `highlights`, `binding`, optional `awareness_promoted`. + +`awareness_hash` allow-list: proposed hat + source, available hats/scopes, last session, situation note. + +``` +canonical = dumps(document) +hash = SHA-256(UTF-8(canonical)) +``` + +Reference: `canonicalize.py`. Spec: `canonicalize.md`. + +Computed vectors (`vectors/expected.json`): + +``` +login view_hash 492d9d311bf44ec9de0d0abef28abac7d31df2781527e8fe276a3186ee1b06b8 +login awareness_hash 2be7742970a01e7a879ae5040660659fb8c9a5c024e6c7fb338f3944a4fe05d1 +ADR-0042 view_hash 1c89ec07c3cc9d16f85a1ba1be5169456b3c55d21161f64037787779ae91f202 +``` + +Isolation that must stay green: + +1. Shuffling object keys does not change hashes. +2. Editing awareness fields does not change `view_hash`. +3. Changing `binding.target` ACME → Beta does change `view_hash`. +4. Selecting a hat after login emits `session.hat_selected` and does not rewrite `view_hash`. + +AES/QES signed attributes carry `memo_id`, `memo_version`, `disposition.verb`, `presentation.view_hash`. They do not carry `awareness_hash` unless promote ran. + +--- + +## 10. Open questions (still open) + +1. Beachhead buyer: German public-sector *Schriftgut*, platform-eng ADR+PAM, or mid-market contract? +2. Product vs protocol? Long game is both (Decision Memo + Evidence Bundle + Disposition verbs in front of IdP, Git, and e-sign). +3. How to evidence “informed” without surveillance theater — hash of presented view + explicit highlight ack, not keystroke analytics. +4. Mandate graph: without a living map of who may bind what, escalate is just forwarding. +5. Liability of highlights: legally the whole instrument is bound; the UI must make that unmistakable. +6. Retention / qualified archive for L4+. +7. Name: keep `informed-decision`; use *Umlaufmappe* / *Zeichnungsbuch* in DACH language. + +--- + +## 11. Suggested next build slice + +Do not start at L5. Ship a thin Decision Memo runtime that already has the split hashes: + +1. Embeddable L0 login/elevation component in front of an IdP or PAM. +2. L2 ADR accept with comment / return / escalate / accept and AES-optional seal. +3. One single-jurisdiction contract path. + +If those three share this schema, the transitions, and the two hashes, the vision is real. If they fork, it is not. diff --git a/history/20260909-initial-exploration/canonicalize.md b/history/20260909-initial-exploration/canonicalize.md new file mode 100644 index 0000000..4ec2d49 --- /dev/null +++ b/history/20260909-initial-exploration/canonicalize.md @@ -0,0 +1,184 @@ +# Canonicalization algorithm v0.2 + +Two hashes exist on every presentation. + +| Hash | Input | Enters the signature? | +|---|---|---| +| `view_hash` | binding document | yes, for every binding verb at `organizational` and above | +| `awareness_hash` | awareness document | no, unless a `promote` disposition copied named fields into `awareness_promoted` | + +That split is the whole point of pre-sign vs post-sign. + +Reference implementation: `canonicalize.py`. + +## 1. Profile + +Restricted JCS (RFC 8785 subset): + +1. UTF-8. +2. No insignificant whitespace. +3. Object keys sorted by raw UTF-8 bytes (all keys in this spec are ASCII). +4. Arrays keep author order except where this spec sorts (`packet` by `item_id`, `highlights` by `id`, hats/scopes/identities by `id`, permission lists lexicographically). +5. Numbers are integers only. Timestamps stay strings (`YYYY-MM-DDTHH:MM:SSZ`). +6. Strings use RFC 8259 escaping; U+0000–U+001F as `\u00xx`. +7. Drop keys whose value is `null`. Keep empty arrays. +8. Unknown keys are stripped. Adding a field to the live memo does not change a hash until it is added to the allow-list below. + +## 2. Binding document (`view_hash`) + +Allow-list, then normalize: + +``` +memo_id +memo_version +question +requested_act +binding_level +brief +locale +ui_release +packet[] → {item_id, hash} sorted by item_id +highlights[] → {id, item_id, locator, required_ack, severity} sorted by id +binding → BindingSlice (pre-sign identity + scope) +awareness_promoted → only fields explicitly promoted into the bind +``` + +`binding` allow-list: + +``` +principal +available_identities[] sorted by id +target the scope this act enters +available_bind_scopes[] scopes choosable as *this* bind, sorted by id +granted_at_bind roles/permissions sorted +justification +blast_radius +terms +``` + +Hats, last-used role, other-tenant orientation, situation notes **do not appear here**. + +`hash` form is always `alg:hex` (`sha256:…`). + +Then: + +``` +canonical = dumps(binding_document) +view_hash = SHA-256(UTF-8(canonical)) +``` + +`dumps` is the JCS-subset serializer in `canonicalize.py`. + +## 3. Awareness document (`awareness_hash`) + +Allow-list: + +``` +memo_id +memo_version +locale +ui_release +proposed_hat +proposed_hat_source system_default | last_used | policy | explicit | inferred +available_hats[] sorted by id +available_scopes[] sorted by id (orientation only) +last_session +situation_note +``` + +Same `dumps` + SHA-256. + +## 4. Dynamic linking + +A binding disposition stores both hashes on the presentation and signs only `view_hash`. + +Signed attributes (AES/QES) MUST contain at least: + +``` +memo_id +memo_version +disposition.verb +presentation.view_hash +``` + +They MUST NOT contain `awareness_hash` unless `promote` ran. + +## 5. Promote + +`promote` copies named awareness fields into `binding.awareness_promoted` on a **new memo version**. Typical case: an elevating hat (`hat.elevates=true`) must become part of the bind, or a tenant that looked like a hat is actually a bind-scope. + +After promote: + +- new `view_hash` +- previous presentations invalid +- route restarts or current step is re-presented + +## 6. Test vectors + +Computed with `canonicalize.py`. + +### V1 — login binding (identity + tenant) + +Input: `vectors/login-binding.json` + +`view_hash.hex` = + +``` +492d9d311bf44ec9de0d0abef28abac7d31df2781527e8fe276a3186ee1b06b8 +``` + +What is inside: chosen principal, break-glass identity as an *available bind identity*, target tenant ACME, sibling tenant Beta as an *available bind-scope*, gate privileges `authenticated` + `session.create`, monitoring terms. + +What is not inside: Finance Controller hat, last session, Payroll Admin. + +### V2 — login awareness (hats + last session) + +Input: `vectors/login-awareness.json` + +`awareness_hash.hex` = + +``` +2be7742970a01e7a879ae5040660659fb8c9a5c024e6c7fb338f3944a4fe05d1 +``` + +Proposed hat `hat:finance-controller` source `last_used`. Payroll Admin is listed with `elevates=true` so the UI can show it and the state machine can refuse `configure` on it. + +### V3 — ADR accept + +Input: `vectors/adr-binding.json` + +`view_hash.hex` = + +``` +1c89ec07c3cc9d16f85a1ba1be5169456b3c55d21161f64037787779ae91f202 +``` + +### Isolation checks the implementation must keep green + +1. Shuffling object keys in the input JSON does not change either hash. +2. Adding or changing awareness fields on a combined object does not change `view_hash`. +3. Changing `binding.target.id` from `tenant:acme` to `tenant:beta` **does** change `view_hash` (that is a different login). +4. Changing only `proposed_hat` changes `awareness_hash` only. +5. Selecting `hat:finance-controller` after login emits `session.hat_selected` and does not rewrite `view_hash`. + +## 7. UI mapping (login) + +``` +┌─────────────────────────────────────────────────────────┐ +│ PRE-SIGN (editable, bound) │ +│ Identity [ Bernd Worsch ▾ ] │ +│ Scope [ ACME Corp / Payroll-Prod ▾ ] │ +│ At gate authenticated · session.create │ +│ Terms sessions recorded │ +└─────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────┐ +│ AWARENESS (shown, not signed) │ +│ After login you will wear │ +│ Finance Controller (last used, Tue 18:12) │ +│ other hats: Auditor · Payroll Admin (needs new bind) │ +│ Other tenants you support: Beta GmbH (needs new bind) │ +└─────────────────────────────────────────────────────────┘ + [ Switch identity ] [ Change tenant ] [ Proceed ] +``` + +Proceed signs V1. After session start, hat dropdown is `configure`. Tenant dropdown to Beta opens a child memo. diff --git a/history/20260909-initial-exploration/canonicalize.py b/history/20260909-initial-exploration/canonicalize.py new file mode 100644 index 0000000..c9a4c03 --- /dev/null +++ b/history/20260909-initial-exploration/canonicalize.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""Canonicalization for informed-decision view_hash and awareness_hash. + +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, + } + + +if __name__ == "__main__": + import pathlib + import sys + + path = pathlib.Path(sys.argv[1]) + data = json.loads(path.read_text()) + kind = sys.argv[2] if len(sys.argv) > 2 else "view" + result = view_hash(data) if kind == "view" else awareness_hash(data) + print(result["canonical"]) + print(result["hex"]) diff --git a/history/20260909-initial-exploration/decision-memo-schema.md b/history/20260909-initial-exploration/decision-memo-schema.md new file mode 100644 index 0000000..e0de7ab --- /dev/null +++ b/history/20260909-initial-exploration/decision-memo-schema.md @@ -0,0 +1,149 @@ +# Decision Memo object schema v0.2 + +Machine-readable schema: `decision-memo.schema.json` +Worked example: `decision-memo.example.json` + +The Decision Memo is one object at every depth (L0 login banner through L5 multi-party instrument). Depth changes which fields are populated, not which types exist. + +## 1. Object graph + +``` +DecisionMemo +├── requester / subject / parties[] identity + mandate +├── question + requested_act + binding_level what is being asked +├── brief + consequences +├── identity_context.binding PRE-SIGN identity + scope (in view_hash) +├── identity_context.awareness hats, last-used, other tenants (awareness_hash) +├── session POST-SIGN live hat inside the bound scope +├── packet[] files, hashed +├── highlights[] pointers into packet +├── route.steps[] Laufweg / Zeichnungsleiste +├── thread[] discussion stays on the object +├── presentations[] what each actor was shown +├── dispositions[] comment | return | forward | accept… +├── relations[] supersedes / relates_to +└── evidence hash-chained event log + optional seal +``` + +## 2. Lifecycle + +``` +draft + → submitted + → circulating / awaiting_actor / in_discussion + → returned ──(new version)──→ submitted + → completed (outcome set) + → withdrawn | expired +completed ─(new memo)→ superseded +``` + +Rules: + +- Edits in `draft` bump `version` without a new `id`. +- After `submitted`, content edits that change `brief`, `packet`, or `highlights` **must** bump `version` and invalidate unused presentations. +- `return` sets status `returned`, does not mutate the sealed packet in place. Requester opens version N+1, which `supersedes` the circulation of N. +- `forward` replaces the current step assignee. `escalate` adds or replaces a step of higher competence. +- Terminal statuses: `completed`, `withdrawn`, `expired`, `superseded`. +- `outcome` is only set when `status=completed` (or timed-out/cancelled mappings). + +## 3. Invariants + +I1. **One question.** `question` is a single present-tense sentence. If you need two questions, you need two memos. + +I2. **Presentation binds the act.** Every `disposition` with verb in `{acknowledge, accept, decline, abstain, recuse, sign}` MUST reference a `presentation_id` whose `memo_version` equals the memo version at act time. + +I3. **Dynamic linking.** Binding acts at `aes` or higher MUST set `signature.presentation_bound=true` and include `presentation.view_hash` in the signed attributes. + +I4. **Required highlights.** If any highlight has `required_ack=true`, a binding act is invalid unless those ids are in `presentation.acked_highlight_ids`. + +I5. **Mandate fit.** The actor on a `schlusszeichnung` / `sign` step MUST carry a `mandate` whose scope covers `requested_act` and any `legal` / value constraints. + +I6. **Packet integrity.** `packet[].hash` is content-addressed. Replacing bytes is a new `item.id` (or a new item version + `replaces`). + +I7. **Evidence is append-only.** `evidence.events[].event_hash = H(seq || type || at || payload_hash || prev_hash)`. `head_hash` is the last `event_hash`. + +I8. **Agents may draft, not bind.** `party.kind=agent` may create highlights and brief text. Agents MUST NOT emit binding verbs. + +I9. **Return is data.** `verb=return` requires `return.reasons[]`. Empty “please fix” is invalid. + +I10. **Whole instrument vs excerpt.** Highlights never narrow the legal object. `legal` and the primary packet item define what is bound; highlights are attention, not scope. + +I11. **Pre-sign vs post-sign.** `identity_context.binding` is in `view_hash`. `identity_context.awareness` is only in `awareness_hash`. Binding verbs sign the former. `configure` mutates `session` after bind and never rewrites `binding`. + +I12. **Hats are not scopes.** A hat (`elevates=false`) is a perspective inside an already bound scope. A scope with `requires_new_bind=true` (other tenant, other identity, elevating hat) is a new memo. + +I13. **Defaults are awareness.** `proposed_hat_source` of `last_used` or `system_default` must be shown and must not be silently copied into `view_hash`. + +## 4. Disposition verbs + +| Verb | Changes route? | Binding? | Required payload | +|---|---|---|---| +| comment | no | no | body | +| discuss | may set `in_discussion` | no | body + optional transfer.to for requester | +| return | yes → requester | no | return.reasons | +| forward | yes → peer assignee | no | transfer.to | +| escalate | yes → higher mandate | no | transfer.to + reason | +| acknowledge | completes inform/review step | weak | presentation_id | +| accept | may complete memo | yes | presentation_id + optional signature | +| decline | may complete memo | yes | presentation_id + body | +| abstain / recuse | completes step for actor | yes | presentation_id + reason | +| withdraw | terminal | requester only | body | +| stamp | no | system/org seal | signature on listed items | + +## 5. Canonical `view_hash` + +Serialize, UTF-8, JCS (RFC 8785), then hash: + +```json +{ + "memo_id": "...", + "memo_version": 2, + "question": "...", + "requested_act": "accept", + "binding_level": "aes", + "brief": "...", + "packet": [{"item_id":"...","hash":"sha256:..."}], + "highlights": [{"id":"...","item_id":"...","locator":{...},"required_ack":true}], + "ui_release": "informed-decision@0.3.1", + "locale": "en" +} +``` + +That document is what “informed” means in the evidence bundle. + +## 6. Evidence event minimum set + +For a completed accept: + +1. `memo.created` +2. `memo.submitted` +3. `packet.attached` (per item) +4. `highlight.added` (per required highlight) +5. `route.started` +6. `identity.authenticated` +7. `presentation.opened` +8. `presentation.highlight_acked` +9. `disposition.recorded` (accept) +10. `signature.created` (if aes/qes) +11. `timestamp.qualified` (if qes / long retention) +12. `file.stamped` (per `is_to_be_stamped`) +13. `bundle.sealed` + +## 7. Field usage by depth + +| Field | L0 login | L1 elevate | L2 ADR | L3 approval | L4 contract | L5 treaty | +|---|---|---|---|---|---|---| +| identity_context | required | required | optional | optional | rare | rare | +| packet | optional | ticket + change | ADR + links | memo + annexes | contract + annexes | instruments + translations | +| highlights | optional | blast-radius | rollback / cost | risk spans | clauses | reservations | +| binding_level | acknowledgment / organizational | organizational | organizational / aes | organizational / aes | aes / qes | qes per party | +| route | one step | requester → approver | review → accept | matrix | sequential/parallel signers | hybrid + deposit | +| legal.form_requirement | none | none | none | none / textform | textform / schriftform | varies by party | + +## 8. What is deliberately not in v0.1 + +- Full mandate-graph resolution (referenced by `mandate.policy_ref`, not modelled here) +- Payment / consideration objects +- Translation alignment and reservation semantics for L5 +- Raw PII in evidence (use `ip_hash`, identifier schemes) +- UI widget tree — only `ui_release` + canonical view diff --git a/history/20260909-initial-exploration/decision-memo.example.json b/history/20260909-initial-exploration/decision-memo.example.json new file mode 100644 index 0000000..af5a9b9 --- /dev/null +++ b/history/20260909-initial-exploration/decision-memo.example.json @@ -0,0 +1,150 @@ +{ + "id": "01K4Q8Z3R7V2N6K9M1P5T8W4XC", + "version": 2, + "schema_version": "0.2.0", + "status": "awaiting_actor", + "depth": 2, + "title": "Accept ADR-0042: event-sourced billing ledger", + "question": "Do you formally accept ADR-0042 as the billing architecture for prod?", + "requested_act": "accept", + "binding_level": "aes", + "locale": "en", + "created_at": "2026-09-08T14:02:11Z", + "updated_at": "2026-09-09T07:18:44Z", + "submitted_at": "2026-09-08T15:10:00Z", + "due_at": "2026-09-12T16:00:00Z", + "requester": { + "id": "01K4Q8A1B2C3D4E5F6G7H8J9K0", + "kind": "person", + "display_name": "Lea Hoffmann", + "role": "staff engineer", + "identifiers": [ + { "scheme": "email", "value": "lea.hoffmann@example.com" }, + { "scheme": "idp:oidc-sub", "value": "auth.example.com|a8f21c" } + ] + }, + "parties": [ + { + "id": "01K4Q8MANDATE000000000001", + "kind": "person", + "display_name": "Bernd Worsch", + "role": "principal engineer / ADR acceptor", + "mandate": { + "id": "01K4Q8MANDATE00000000000A", + "scope": "Accept architecture decision records for billing and payments", + "policy_ref": "https://policy.example.com/adr-acceptance" + } + } + ], + "brief": "Replace nightly batch invoicing with an append-only event ledger. Replay is the source of truth for invoices. Dual-run for 60 days. Rollback: freeze the ledger and revert the read model to the last nightly snapshot.", + "consequences": "On-call owns replay tooling. Finance reporting moves from SQL warehouse tables to a projection job. No in-place updates of posted invoices.", + "packet": [ + { + "id": "01K4Q8DOC0000000000000001", + "role": "primary", + "filename": "ADR-0042.md", + "media_type": "text/markdown", + "language": "en", + "bytes": 12880, + "hash": { "alg": "sha256", "hex": "6b1c0f8a9d2e4c7b8a1f0e3d5c6b7a8f9e0d1c2b3a4f5e6d7c8b9a0f1e2d3c4b" }, + "storage_uri": "s3://decisions/adr/0042.md", + "version": 2, + "is_to_be_signed": true, + "label": "ADR-0042" + } + ], + "highlights": [ + { + "id": "01K4Q8HL00000000000000001", + "item_id": "01K4Q8DOC0000000000000001", + "severity": "critical", + "locator": { + "kind": "markdown_heading", + "heading": "Consequences / rollback" + }, + "title": "Rollback is operationally expensive", + "note": "Accepting this ADR commits on-call to ledger freeze drills before go-live.", + "required_ack": true, + "author_id": "01K4Q8A1B2C3D4E5F6G7H8J9K0", + "created_at": "2026-09-08T14:20:00Z", + "source": "requester" + } + ], + "route": { + "mode": "sequential", + "steps": [ + { + "id": "01K4Q8STEP000000000000001", + "ordinal": 1, + "kind": "review", + "assignee": { + "id": "01K4Q8MANDATE000000000002", + "kind": "role", + "display_name": "Billing domain reviewer" + }, + "required_verbs": ["acknowledge"], + "status": "completed", + "completed_at": "2026-09-08T17:40:00Z" + }, + { + "id": "01K4Q8STEP000000000000002", + "ordinal": 2, + "kind": "schlusszeichnung", + "assignee": { + "id": "01K4Q8MANDATE000000000001", + "kind": "person", + "display_name": "Bernd Worsch" + }, + "required_verbs": ["accept", "return", "escalate"], + "min_binding_level": "aes", + "status": "active", + "due_at": "2026-09-12T16:00:00Z" + } + ], + "current_step_ids": ["01K4Q8STEP000000000000002"] + }, + "thread": [ + { + "id": "01K4Q8MSG0000000000000001", + "author_id": "01K4Q8MANDATE000000000002", + "body": "Rollback drill date is missing. Acceptable if added before go-live.", + "created_at": "2026-09-08T17:39:12Z" + } + ], + "presentations": [ + { + "id": "01K4Q8PRES000000000000001", + "actor_id": "01K4Q8MANDATE000000000001", + "memo_version": 2, + "opened_at": "2026-09-09T07:10:00Z", + "ui_release": "informed-decision@0.3.1", + "locale": "en", + "viewport": "brief", + "packet_hashes": [ + { + "item_id": "01K4Q8DOC0000000000000001", + "hash": { "alg": "sha256", "hex": "6b1c0f8a9d2e4c7b8a1f0e3d5c6b7a8f9e0d1c2b3a4f5e6d7c8b9a0f1e2d3c4b" } + } + ], + "highlight_ids": ["01K4Q8HL00000000000000001"], + "highlight_set_hash": { "alg": "sha256", "hex": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + "brief_hash": { "alg": "sha256", "hex": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, + "view_hash": { "alg": "sha256", "hex": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" }, + "acked_highlight_ids": ["01K4Q8HL00000000000000001"], + "auth_context": { + "method": "passkey", + "assurance": "substantial", + "idp": "https://auth.example.com" + } + } + ], + "dispositions": [], + "relations": [ + { "kind": "supersedes", "to": "01K4Q8OLDADR0000000000001", "note": "Supersedes ADR-0011 batch invoicing" } + ], + "legal": { + "form_requirement": "none", + "retention_years": 10, + "classification": "internal" + } +} diff --git a/history/20260909-initial-exploration/decision-memo.login.example.json b/history/20260909-initial-exploration/decision-memo.login.example.json new file mode 100644 index 0000000..94ea92c --- /dev/null +++ b/history/20260909-initial-exploration/decision-memo.login.example.json @@ -0,0 +1,153 @@ +{ + "id": "01K4LOGIN00000000000000001", + "version": 1, + "schema_version": "0.2.0", + "status": "awaiting_actor", + "depth": 0, + "title": "Login to Payroll-Prod / ACME", + "question": "Log into Payroll-Prod as Bernd Worsch in tenant ACME?", + "requested_act": "login", + "binding_level": "organizational", + "locale": "en", + "created_at": "2026-09-09T08:01:00Z", + "updated_at": "2026-09-09T08:01:00Z", + "requester": { + "id": "01K4SYSTEM00000000000000001", + "kind": "system", + "display_name": "Payroll-Prod IdP" + }, + "subject": { + "id": "01K4PERSONBERND00000000001", + "kind": "person", + "display_name": "Bernd Worsch" + }, + "brief": "You are entering Payroll-Prod. Sessions are recorded. Privileges at the gate are identity-scoped, not hat-scoped.", + "identity_context": { + "system": "Payroll-Prod", + "environment": "prod", + "binding": { + "principal": { + "id": "01K4PERSONBERND00000000001", + "kind": "person", + "display_name": "Bernd Worsch", + "role": "employee", + "identifiers": [ + { "scheme": "email", "value": "bernd.worsch@example.com" }, + { "scheme": "idp:oidc-sub", "value": "auth.example.com|bernd" } + ] + }, + "available_identities": [ + { + "id": "01K4PERSONBERND00000000001", + "kind": "person", + "display_name": "Bernd Worsch" + }, + { + "id": "01K4PERSONBERNDADMIN000001", + "kind": "person", + "display_name": "Bernd Worsch (break-glass)" + } + ], + "target": { + "kind": "tenant", + "id": "tenant:acme", + "label": "ACME Corp", + "environment": "prod", + "requires_new_bind": true + }, + "available_bind_scopes": [ + { + "kind": "tenant", + "id": "tenant:acme", + "label": "ACME Corp", + "environment": "prod", + "requires_new_bind": true + }, + { + "kind": "tenant", + "id": "tenant:beta", + "label": "Beta GmbH", + "environment": "prod", + "requires_new_bind": true + } + ], + "granted_at_bind": { + "roles": ["authenticated"], + "permissions": ["session.create"] + }, + "terms": { + "monitoring": true, + "consent_code": "LOGIN-PROD-2026" + } + }, + "awareness": { + "proposed_hat": { + "id": "hat:finance-controller", + "label": "Finance Controller", + "kind": "access_profile", + "elevates": false, + "scope_id": "tenant:acme" + }, + "proposed_hat_source": "last_used", + "available_hats": [ + { + "id": "hat:finance-controller", + "label": "Finance Controller", + "kind": "access_profile", + "elevates": false + }, + { + "id": "hat:auditor-readonly", + "label": "Auditor (read-only)", + "kind": "perspective", + "elevates": false + }, + { + "id": "hat:payroll-admin", + "label": "Payroll Admin", + "kind": "role", + "elevates": true + } + ], + "available_scopes": [ + { + "kind": "tenant", + "id": "tenant:beta", + "label": "Beta GmbH", + "environment": "prod", + "requires_new_bind": true + } + ], + "last_session": { + "ended_at": "2026-09-08T16:12:00Z", + "hat_id": "hat:finance-controller", + "scope_id": "tenant:acme" + }, + "situation_note": "Last session Tuesday 18:12 CEST as Finance Controller in ACME." + } + }, + "packet": [], + "highlights": [], + "route": { + "mode": "sequential", + "steps": [ + { + "id": "01K4LOGINSTEP0000000000001", + "ordinal": 1, + "kind": "acknowledge", + "assignee": { + "id": "01K4PERSONBERND00000000001", + "kind": "person", + "display_name": "Bernd Worsch" + }, + "required_verbs": ["accept", "decline"], + "min_binding_level": "organizational", + "status": "active" + } + ], + "current_step_ids": ["01K4LOGINSTEP0000000000001"] + }, + "session": { + "status": "pending" + } +} diff --git a/history/20260909-initial-exploration/decision-memo.schema.json b/history/20260909-initial-exploration/decision-memo.schema.json new file mode 100644 index 0000000..f4bd0c3 --- /dev/null +++ b/history/20260909-initial-exploration/decision-memo.schema.json @@ -0,0 +1,910 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://informed-decision.dev/schema/decision-memo/v0.2", + "title": "Informed-Decision Decision Memo", + "description": "Portable decision object: context, circulation, disposition, and cryptographic evidence of an informed identity-bound act.", + "$defs": { + "uri": { "type": "string", "format": "uri" }, + "datetime": { "type": "string", "format": "date-time" }, + "ulid": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "description": "ULID. UUID v7 is also acceptable at the API boundary." + }, + "sha256": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "contentHash": { + "type": "object", + "additionalProperties": false, + "required": ["alg", "hex"], + "properties": { + "alg": { "type": "string", "enum": ["sha256", "sha384", "sha3-256"] }, + "hex": { "type": "string", "pattern": "^[a-f0-9]{64,96}$" } + } + }, + "locale": { "type": "string", "pattern": "^[a-z]{2}(-[A-Z]{2})?$" }, + "markdown": { "type": "string", "maxLength": 100000 }, + "shortText": { "type": "string", "minLength": 1, "maxLength": 280 }, + "mediumText": { "type": "string", "maxLength": 4000 }, + + "bindingLevel": { + "type": "string", + "description": "How strongly the resulting act is meant to bind.", + "enum": [ + "none", + "acknowledgment", + "organizational", + "ses", + "aes", + "qes", + "qeseal" + ] + }, + "assuranceLevel": { + "type": "string", + "enum": ["low", "substantial", "high"] + }, + "depthLevel": { + "type": "integer", + "minimum": 0, + "maximum": 5, + "description": "L0 login … L5 multi-party instrument. Advisory; schema is shared." + }, + "requestedAct": { + "type": "string", + "enum": [ + "login", + "elevate", + "acknowledge", + "approve", + "accept", + "decline", + "sign", + "seal", + "countersign", + "ratify" + ] + }, + "memoStatus": { + "type": "string", + "enum": [ + "draft", + "submitted", + "circulating", + "awaiting_actor", + "in_discussion", + "returned", + "completed", + "withdrawn", + "expired", + "superseded" + ] + }, + "outcome": { + "type": "string", + "enum": [ + "accepted", + "declined", + "acknowledged", + "abstained", + "recused", + "cancelled", + "timed_out" + ] + }, + "dispositionVerb": { + "type": "string", + "enum": [ + "comment", + "discuss", + "return", + "forward", + "escalate", + "acknowledge", + "accept", + "decline", + "abstain", + "recuse", + "withdraw", + "stamp", + "configure", + "promote" + ] + }, + "sessionStatus": { + "type": "string", + "enum": ["none", "pending", "active", "ended"] + }, + "awarenessSource": { + "type": "string", + "enum": ["system_default", "last_used", "policy", "explicit", "inferred"] + }, + "scopeKind": { + "type": "string", + "enum": ["system", "tenant", "workspace", "resource", "privilege_set", "environment"] + }, + "partyKind": { + "type": "string", + "enum": ["person", "org", "role", "group", "system", "agent"] + }, + "routeMode": { + "type": "string", + "enum": ["sequential", "parallel", "hybrid"] + }, + "stepKind": { + "type": "string", + "enum": [ + "inform", + "comment", + "review", + "mitzeichnung", + "schlusszeichnung", + "acknowledge", + "approve", + "sign", + "seal", + "deposit" + ] + }, + "documentRole": { + "type": "string", + "enum": [ + "brief", + "primary", + "annex", + "supporting", + "prior_decision", + "diff", + "translation", + "evidence_export" + ] + }, + "highlightSeverity": { + "type": "string", + "enum": ["critical", "material", "informational"] + }, + "evidenceEventType": { + "type": "string", + "enum": [ + "memo.created", + "memo.updated", + "memo.submitted", + "memo.withdrawn", + "memo.superseded", + "memo.expired", + "packet.attached", + "packet.detached", + "highlight.added", + "highlight.updated", + "route.started", + "route.step_assigned", + "route.step_completed", + "route.returned", + "route.forwarded", + "route.escalated", + "presentation.opened", + "presentation.viewed", + "presentation.highlight_acked", + "identity.authenticated", + "identity.step_up", + "disposition.recorded", + "signature.created", + "seal.created", + "timestamp.qualified", + "file.stamped", + "bundle.sealed", + "awareness.shown", + "session.started", + "session.configured", + "session.hat_selected", + "session.ended", + "scope.rebind_required" + ] + }, + + "identifier": { + "type": "object", + "additionalProperties": false, + "required": ["scheme", "value"], + "properties": { + "scheme": { + "type": "string", + "description": "idp:oidc-sub | eudi:pid | eidas:person | email | employee_id | lei | orga_id | did" + }, + "value": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "issuing_authority": { "type": "string" } + } + }, + "mandate": { + "type": "object", + "additionalProperties": false, + "required": ["id", "scope"], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "scope": { + "type": "string", + "description": "Human-readable competence: 'CFO contracts ≤ 250k EUR', 'prod-admin JIT', 'ADR acceptor'." + }, + "policy_ref": { "$ref": "#/$defs/uri" }, + "max_value": { + "type": "object", + "additionalProperties": false, + "properties": { + "amount": { "type": "number" }, + "currency": { "type": "string", "minLength": 3, "maxLength": 3 } + } + }, + "valid_from": { "$ref": "#/$defs/datetime" }, + "valid_until": { "$ref": "#/$defs/datetime" }, + "delegation_of": { "$ref": "#/$defs/ulid" } + } + }, + "partyRef": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "display_name"], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "kind": { "$ref": "#/$defs/partyKind" }, + "display_name": { "type": "string", "minLength": 1, "maxLength": 200 }, + "identifiers": { + "type": "array", + "items": { "$ref": "#/$defs/identifier" } + }, + "org_id": { "$ref": "#/$defs/ulid" }, + "role": { "type": "string" }, + "mandate": { "$ref": "#/$defs/mandate" }, + "contact": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": { "type": "string", "format": "email" }, + "channel": { "type": "string", "enum": ["in_app", "email", "matrix", "teams"] } + } + } + } + }, + "locator": { + "type": "object", + "additionalProperties": false, + "description": "Stable pointer into a packet item. Prefer quote+context over pixel boxes.", + "required": ["kind"], + "properties": { + "kind": { + "type": "string", + "enum": [ + "text_quote", + "pdf_page_region", + "markdown_heading", + "json_pointer", + "xpath", + "sheet_range", + "whole_document" + ] + }, + "quote": { "type": "string", "maxLength": 2000 }, + "prefix": { "type": "string", "maxLength": 200 }, + "suffix": { "type": "string", "maxLength": 200 }, + "page": { "type": "integer", "minimum": 1 }, + "bbox": { + "type": "array", + "items": { "type": "number" }, + "minItems": 4, + "maxItems": 4 + }, + "heading": { "type": "string" }, + "pointer": { "type": "string" }, + "sheet": { "type": "string" }, + "range": { "type": "string" } + } + }, + "packetItem": { + "type": "object", + "additionalProperties": false, + "required": ["id", "role", "filename", "media_type", "hash", "bytes"], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "role": { "$ref": "#/$defs/documentRole" }, + "filename": { "type": "string" }, + "media_type": { "type": "string" }, + "language": { "$ref": "#/$defs/locale" }, + "bytes": { "type": "integer", "minimum": 0 }, + "hash": { "$ref": "#/$defs/contentHash" }, + "storage_uri": { "$ref": "#/$defs/uri" }, + "pages": { "type": "integer", "minimum": 1 }, + "version": { "type": "integer", "minimum": 1 }, + "replaces": { "$ref": "#/$defs/ulid" }, + "is_to_be_signed": { "type": "boolean", "default": false }, + "is_to_be_stamped": { "type": "boolean", "default": false }, + "label": { "type": "string" } + } + }, + "highlight": { + "type": "object", + "additionalProperties": false, + "required": ["id", "item_id", "severity", "locator", "author_id", "created_at"], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "item_id": { "$ref": "#/$defs/ulid" }, + "severity": { "$ref": "#/$defs/highlightSeverity" }, + "locator": { "$ref": "#/$defs/locator" }, + "title": { "$ref": "#/$defs/shortText" }, + "note": { "$ref": "#/$defs/mediumText" }, + "required_ack": { "type": "boolean", "default": false }, + "author_id": { "$ref": "#/$defs/ulid" }, + "created_at": { "$ref": "#/$defs/datetime" }, + "source": { "type": "string", "enum": ["requester", "reviewer", "system", "agent"] } + } + }, + "routeStep": { + "type": "object", + "additionalProperties": false, + "required": ["id", "ordinal", "kind", "assignee", "required_verbs"], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "ordinal": { "type": "integer", "minimum": 1 }, + "parallel_group": { + "type": "string", + "description": "Steps sharing a group may complete in any order. Next ordinal waits for the group." + }, + "kind": { "$ref": "#/$defs/stepKind" }, + "assignee": { "$ref": "#/$defs/partyRef" }, + "required_verbs": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/dispositionVerb" } + }, + "min_binding_level": { "$ref": "#/$defs/bindingLevel" }, + "due_at": { "$ref": "#/$defs/datetime" }, + "started_at": { "$ref": "#/$defs/datetime" }, + "completed_at": { "$ref": "#/$defs/datetime" }, + "status": { + "type": "string", + "enum": ["pending", "ready", "active", "blocked", "completed", "skipped", "returned"] + }, + "completion_disposition_id": { "$ref": "#/$defs/ulid" }, + "instructions": { "$ref": "#/$defs/mediumText" } + } + }, + "route": { + "type": "object", + "additionalProperties": false, + "required": ["mode", "steps"], + "properties": { + "mode": { "$ref": "#/$defs/routeMode" }, + "policy_ref": { "$ref": "#/$defs/uri" }, + "steps": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/routeStep" } + }, + "current_step_ids": { + "type": "array", + "items": { "$ref": "#/$defs/ulid" } + } + } + }, + "commentAnchor": { + "type": "object", + "additionalProperties": false, + "properties": { + "item_id": { "$ref": "#/$defs/ulid" }, + "highlight_id": { "$ref": "#/$defs/ulid" }, + "locator": { "$ref": "#/$defs/locator" } + } + }, + "threadMessage": { + "type": "object", + "additionalProperties": false, + "required": ["id", "author_id", "body", "created_at"], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "author_id": { "$ref": "#/$defs/ulid" }, + "body": { "$ref": "#/$defs/markdown" }, + "anchor": { "$ref": "#/$defs/commentAnchor" }, + "created_at": { "$ref": "#/$defs/datetime" }, + "visibility": { + "type": "string", + "enum": ["all_parties", "internal", "step_only"], + "default": "all_parties" + } + } + }, + "presentationSnapshot": { + "type": "object", + "additionalProperties": false, + "description": "Canonical record of what an actor was shown. The disposition MUST reference a snapshot.", + "required": [ + "id", + "actor_id", + "memo_version", + "opened_at", + "ui_release", + "locale", + "packet_hashes", + "highlight_set_hash", + "view_hash" + ], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "actor_id": { "$ref": "#/$defs/ulid" }, + "memo_version": { "type": "integer", "minimum": 1 }, + "opened_at": { "$ref": "#/$defs/datetime" }, + "closed_at": { "$ref": "#/$defs/datetime" }, + "ui_release": { "type": "string" }, + "locale": { "$ref": "#/$defs/locale" }, + "viewport": { "type": "string", "enum": ["banner", "brief", "packet", "sign", "awareness", "configure"] }, + "phase": { "type": "string", "enum": ["pre_bind", "bind", "post_bind"] }, + "awareness_hash": { + "$ref": "#/$defs/contentHash", + "description": "Hash of the awareness slice as shown. Not part of view_hash unless a promote disposition copied fields into binding." + }, + "packet_hashes": { + "type": "array", + "items": { + "type": "object", + "required": ["item_id", "hash"], + "additionalProperties": false, + "properties": { + "item_id": { "$ref": "#/$defs/ulid" }, + "hash": { "$ref": "#/$defs/contentHash" } + } + } + }, + "highlight_ids": { + "type": "array", + "items": { "$ref": "#/$defs/ulid" } + }, + "highlight_set_hash": { "$ref": "#/$defs/contentHash" }, + "brief_hash": { "$ref": "#/$defs/contentHash" }, + "view_hash": { + "$ref": "#/$defs/contentHash", + "description": "Hash of the canonical presentation document (brief + ordered highlights + visible packet manifests)." + }, + "acked_highlight_ids": { + "type": "array", + "items": { "$ref": "#/$defs/ulid" } + }, + "auth_context": { + "type": "object", + "additionalProperties": false, + "properties": { + "method": { + "type": "string", + "enum": [ + "password", + "mfa", + "passkey", + "oidc", + "saml", + "eudi_wallet", + "national_eid", + "qscd" + ] + }, + "assurance": { "$ref": "#/$defs/assuranceLevel" }, + "idp": { "type": "string" }, + "session_id": { "type": "string" } + } + } + } + }, + "returnPayload": { + "type": "object", + "additionalProperties": false, + "required": ["reasons"], + "properties": { + "reasons": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["code", "text"], + "additionalProperties": false, + "properties": { + "code": { + "type": "string", + "enum": [ + "insufficient_context", + "wrong_scope", + "wrong_assignee", + "highlights_incomplete", + "document_stale", + "legal_issue", + "commercial_issue", + "risk_unaddressed", + "other" + ] + }, + "text": { "$ref": "#/$defs/mediumText" }, + "anchor": { "$ref": "#/$defs/commentAnchor" } + } + } + }, + "required_changes": { "$ref": "#/$defs/markdown" } + } + }, + "transferPayload": { + "type": "object", + "additionalProperties": false, + "required": ["to"], + "properties": { + "to": { "$ref": "#/$defs/partyRef" }, + "reason": { "$ref": "#/$defs/mediumText" }, + "keep_self_informed": { "type": "boolean", "default": true } + } + }, + "signaturePayload": { + "type": "object", + "additionalProperties": false, + "required": ["level", "signed_hash"], + "properties": { + "level": { "$ref": "#/$defs/bindingLevel" }, + "profile": { + "type": "string", + "enum": ["PAdES-B-LT", "PAdES-B-LTA", "XAdES-B-LT", "CAdES-B-LT", "JAdES", "raw"] + }, + "signed_hash": { "$ref": "#/$defs/contentHash" }, + "signed_item_ids": { + "type": "array", + "items": { "$ref": "#/$defs/ulid" } + }, + "presentation_bound": { + "type": "boolean", + "description": "True when view_hash is included in the signed attributes (dynamic linking)." + }, + "certificate_ref": { "type": "string" }, + "tsp": { "type": "string" }, + "qts_token_ref": { "type": "string" }, + "cms_or_jose": { "type": "string", "description": "Detached signature reference or storage URI." } + } + }, + "disposition": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "verb", + "actor", + "step_id", + "presentation_id", + "created_at", + "payload_hash" + ], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "verb": { "$ref": "#/$defs/dispositionVerb" }, + "actor": { "$ref": "#/$defs/partyRef" }, + "step_id": { "$ref": "#/$defs/ulid" }, + "presentation_id": { + "$ref": "#/$defs/ulid", + "description": "Required binding to a presentation snapshot. No snapshot, no binding act." + }, + "body": { "$ref": "#/$defs/markdown" }, + "return": { "$ref": "#/$defs/returnPayload" }, + "transfer": { "$ref": "#/$defs/transferPayload" }, + "signature": { "$ref": "#/$defs/signaturePayload" }, + "configure": { "$ref": "#/$defs/configurePayload" }, + "created_at": { "$ref": "#/$defs/datetime" }, + "payload_hash": { "$ref": "#/$defs/contentHash" }, + "actor_signature": { + "description": "Optional signature over payload_hash + presentation.view_hash + memo.id@version.", + "$ref": "#/$defs/signaturePayload" + } + } + }, + "evidenceEvent": { + "type": "object", + "additionalProperties": false, + "required": ["id", "seq", "type", "at", "payload_hash", "prev_hash", "event_hash"], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "seq": { "type": "integer", "minimum": 0 }, + "type": { "$ref": "#/$defs/evidenceEventType" }, + "at": { "$ref": "#/$defs/datetime" }, + "actor_id": { "$ref": "#/$defs/ulid" }, + "payload": { "type": "object" }, + "payload_hash": { "$ref": "#/$defs/contentHash" }, + "prev_hash": { "$ref": "#/$defs/contentHash" }, + "event_hash": { "$ref": "#/$defs/contentHash" }, + "ip_hash": { + "type": "string", + "description": "Optional HMAC of network address; store raw IP only where lawful." + }, + "device_attestation": { "type": "string" } + } + }, + "evidenceBundle": { + "type": "object", + "additionalProperties": false, + "required": ["id", "memo_id", "memo_version", "events", "head_hash"], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "memo_id": { "$ref": "#/$defs/ulid" }, + "memo_version": { "type": "integer" }, + "events": { + "type": "array", + "items": { "$ref": "#/$defs/evidenceEvent" } + }, + "head_hash": { "$ref": "#/$defs/contentHash" }, + "sealed_at": { "$ref": "#/$defs/datetime" }, + "seal": { "$ref": "#/$defs/signaturePayload" }, + "qualified_timestamp_ref": { "type": "string" }, + "export_uri": { "$ref": "#/$defs/uri" } + } + }, + "relation": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "to"], + "properties": { + "kind": { + "type": "string", + "enum": [ + "supersedes", + "superseded_by", + "relates_to", + "implements", + "triggered_by", + "attachment_of", + "translation_of" + ] + }, + "to": { "$ref": "#/$defs/ulid" }, + "note": { "type": "string" } + } + }, + "scopeRef": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "id", "label"], + "properties": { + "kind": { "$ref": "#/$defs/scopeKind" }, + "id": { "type": "string", "minLength": 1, "maxLength": 200 }, + "label": { "type": "string" }, + "environment": { "type": "string", "enum": ["dev", "test", "stage", "prod", "other"] }, + "requires_new_bind": { + "type": "boolean", + "default": false, + "description": "If true, entering this scope is a new binding act, not a post-sign configure." + } + } + }, + "hat": { + "type": "object", + "additionalProperties": false, + "description": "Post-bind perspective: role, access profile, or UI/work mode inside an already bound scope.", + "required": ["id", "label"], + "properties": { + "id": { "type": "string", "minLength": 1, "maxLength": 200 }, + "label": { "type": "string" }, + "kind": { + "type": "string", + "enum": ["role", "access_profile", "perspective", "duty"] + }, + "permissions_preview": { "type": "array", "items": { "type": "string" } }, + "scope_id": { "type": "string" }, + "elevates": { + "type": "boolean", + "default": false, + "description": "If true, selecting this hat is a privilege act and MUST be promoted into binding or a child memo." + } + } + }, + "bindingSlice": { + "type": "object", + "additionalProperties": false, + "description": "PRE-SIGN. Identity and scope being bound. Included in view_hash. Choosing among identities or bind-scopes happens here.", + "required": ["principal", "target"], + "properties": { + "principal": { "$ref": "#/$defs/partyRef" }, + "available_identities": { + "type": "array", + "items": { "$ref": "#/$defs/partyRef" } + }, + "target": { "$ref": "#/$defs/scopeRef" }, + "available_bind_scopes": { + "type": "array", + "description": "Scopes the actor may enter as part of THIS bind (e.g. pick tenant at login).", + "items": { "$ref": "#/$defs/scopeRef" } + }, + "granted_at_bind": { + "type": "object", + "additionalProperties": false, + "properties": { + "roles": { "type": "array", "items": { "type": "string" } }, + "permissions": { "type": "array", "items": { "type": "string" } }, + "duration_seconds": { "type": "integer", "minimum": 1 } + } + }, + "justification": { "$ref": "#/$defs/mediumText" }, + "blast_radius": { "$ref": "#/$defs/mediumText" }, + "terms": { + "type": "object", + "additionalProperties": false, + "properties": { + "monitoring": { "type": "boolean" }, + "consent_code": { "type": "string" }, + "consent_text_hash": { "$ref": "#/$defs/contentHash" } + } + } + } + }, + "awarenessSlice": { + "type": "object", + "additionalProperties": false, + "description": "Shown before and after bind for situational awareness. Hashed separately as awareness_hash. NOT in view_hash unless promoted.", + "properties": { + "proposed_hat": { "$ref": "#/$defs/hat" }, + "proposed_hat_source": { "$ref": "#/$defs/awarenessSource" }, + "available_hats": { "type": "array", "items": { "$ref": "#/$defs/hat" } }, + "available_scopes": { + "type": "array", + "description": "Other scopes visible for orientation. Those with requires_new_bind=true cannot be configured post-sign.", + "items": { "$ref": "#/$defs/scopeRef" } + }, + "last_session": { + "type": "object", + "additionalProperties": false, + "properties": { + "ended_at": { "$ref": "#/$defs/datetime" }, + "hat_id": { "type": "string" }, + "scope_id": { "type": "string" }, + "principal_id": { "$ref": "#/$defs/ulid" } + } + }, + "situation_note": { "$ref": "#/$defs/mediumText" } + } + }, + "sessionState": { + "type": "object", + "additionalProperties": false, + "description": "POST-SIGN live configuration inside a completed bind (login, elevation). Configure acts mutate this, not the sealed binding slice.", + "required": ["status"], + "properties": { + "status": { "$ref": "#/$defs/sessionStatus" }, + "started_at": { "$ref": "#/$defs/datetime" }, + "ended_at": { "$ref": "#/$defs/datetime" }, + "bound_memo_version": { "type": "integer", "minimum": 1 }, + "bound_presentation_id": { "$ref": "#/$defs/ulid" }, + "active_hat": { "$ref": "#/$defs/hat" }, + "active_hat_source": { "$ref": "#/$defs/awarenessSource" } + } + }, + "configurePayload": { + "type": "object", + "additionalProperties": false, + "properties": { + "hat": { "$ref": "#/$defs/hat" }, + "source": { "$ref": "#/$defs/awarenessSource" }, + "note": { "$ref": "#/$defs/mediumText" } + } + }, + "identityContext": { + "type": "object", + "additionalProperties": false, + "description": "L0/L1 split: binding_slice is pre-sign and signed; awareness_slice is orientation and optional post-sign configure.", + "properties": { + "system": { "type": "string" }, + "environment": { "type": "string", "enum": ["dev", "test", "stage", "prod", "other"] }, + "principal": { "$ref": "#/$defs/partyRef" }, + "requested_roles": { "type": "array", "items": { "type": "string" } }, + "requested_permissions": { "type": "array", "items": { "type": "string" } }, + "duration_seconds": { "type": "integer", "minimum": 1 }, + "justification": { "$ref": "#/$defs/mediumText" }, + "blast_radius": { "$ref": "#/$defs/mediumText" }, + "binding": { "$ref": "#/$defs/bindingSlice" }, + "awareness": { "$ref": "#/$defs/awarenessSlice" } + } + } + }, + + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "version", + "schema_version", + "status", + "created_at", + "updated_at", + "requester", + "question", + "requested_act", + "binding_level", + "brief", + "packet", + "route" + ], + "properties": { + "id": { "$ref": "#/$defs/ulid" }, + "version": { "type": "integer", "minimum": 1 }, + "schema_version": { "type": "string", "const": "0.2.0" }, + "status": { "$ref": "#/$defs/memoStatus" }, + "outcome": { "$ref": "#/$defs/outcome" }, + "depth": { "$ref": "#/$defs/depthLevel" }, + "title": { "$ref": "#/$defs/shortText" }, + "question": { + "$ref": "#/$defs/shortText", + "description": "Single present-tense decision question." + }, + "requested_act": { "$ref": "#/$defs/requestedAct" }, + "binding_level": { "$ref": "#/$defs/bindingLevel" }, + "locale": { "$ref": "#/$defs/locale" }, + "created_at": { "$ref": "#/$defs/datetime" }, + "updated_at": { "$ref": "#/$defs/datetime" }, + "submitted_at": { "$ref": "#/$defs/datetime" }, + "due_at": { "$ref": "#/$defs/datetime" }, + "completed_at": { "$ref": "#/$defs/datetime" }, + "expires_at": { "$ref": "#/$defs/datetime" }, + + "requester": { "$ref": "#/$defs/partyRef" }, + "subject": { + "$ref": "#/$defs/partyRef", + "description": "Who the decision is about, if different from requester (e.g. the person requesting elevation)." + }, + "parties": { + "type": "array", + "items": { "$ref": "#/$defs/partyRef" } + }, + + "brief": { + "$ref": "#/$defs/markdown", + "description": "Minimum necessary context. Hash of brief is part of presentation.view_hash." + }, + "consequences": { "$ref": "#/$defs/markdown" }, + "identity_context": { "$ref": "#/$defs/identityContext" }, + "session": { "$ref": "#/$defs/sessionState" }, + + "packet": { + "type": "array", + "items": { "$ref": "#/$defs/packetItem" } + }, + "highlights": { + "type": "array", + "items": { "$ref": "#/$defs/highlight" } + }, + "route": { "$ref": "#/$defs/route" }, + "thread": { + "type": "array", + "items": { "$ref": "#/$defs/threadMessage" } + }, + "presentations": { + "type": "array", + "items": { "$ref": "#/$defs/presentationSnapshot" } + }, + "dispositions": { + "type": "array", + "items": { "$ref": "#/$defs/disposition" } + }, + "relations": { + "type": "array", + "items": { "$ref": "#/$defs/relation" } + }, + "evidence": { "$ref": "#/$defs/evidenceBundle" }, + + "legal": { + "type": "object", + "additionalProperties": false, + "properties": { + "governing_law": { "type": "string" }, + "form_requirement": { + "type": "string", + "enum": ["none", "textform", "schriftform", "notarial", "other"] + }, + "retention_years": { "type": "integer", "minimum": 1 }, + "classification": { + "type": "string", + "enum": ["public", "internal", "confidential", "restricted"] + } + } + }, + "extensions": { + "type": "object", + "description": "Namespaced vendor or domain extensions. Must not alter binding semantics.", + "additionalProperties": true + } + } +} diff --git a/history/20260909-initial-exploration/state-transitions.md b/history/20260909-initial-exploration/state-transitions.md new file mode 100644 index 0000000..55bce5d --- /dev/null +++ b/history/20260909-initial-exploration/state-transitions.md @@ -0,0 +1,184 @@ +# Decision Memo state transitions v0.2 + +Actors: `requester`, `assignee` (current ready step), `party` (any listed party), `subject` (session principal after bind), `system`, `agent`. + +Guards: + +| Id | Meaning | +|---|---| +| G_STEP | Verb is in `route.steps[current].required_verbs` **or** is a always-on overlay verb for that step kind (table B) | +| G_PRES | `presentation_id` exists, `memo_version` matches, `phase` is `pre_bind` or `bind` | +| G_ACK | All `highlights[required_ack=true]` are in `presentation.acked_highlight_ids` | +| G_MAND | Actor mandate covers `requested_act` + legal/value constraints | +| G_LEVEL | Actor signature meets `step.min_binding_level` and memo `binding_level` | +| G_REASONS | `return.reasons` has ≥1 coded reason | +| G_TARGET | `transfer.to` is a resolvable party with compatible mandate | +| G_ESC | Target mandate is *strictly higher* than current assignee for this act | +| G_NOAGENT | `actor.kind ≠ agent` | +| G_SESSION | `session.status = active` and actor is `session` subject | +| G_HAT | `configure.hat.id` ∈ `awareness.available_hats` and `hat.elevates = false` | +| G_PROMOTE | Field moved from awareness → binding; new memo version; presentations invalidated | + +Overlay verbs (always available when status allows, even if not in `required_verbs`): `comment`, `discuss`. +Requester-only overlay: `withdraw` while not terminal. +System-only: `stamp`, expiry. + +## A. Memo-level status machine + +``` + submit + draft ──────────────► submitted + ▲ │ + │ │ route.started + │ edit (ver++) ▼ + │ circulating + │ │ + │ ├── first ready step → awaiting_actor + │ ├── discuss → in_discussion + │ │ │ + │ │ └── assignee resumes → awaiting_actor + │ │ + │ ├── return → returned ──requester revises (ver++)──► submitted + │ ├── last binding verb → completed (+ outcome) + │ ├── withdraw → withdrawn + │ └── due passed → expired (outcome timed_out) + │ + └── completed ── new memo relation.supersedes ──► superseded + +L0/L1 extra: + completed + requested_act ∈ {login, elevate} + → session.status = active (bound_configuring window) + → configure / session.ended + → session.status = ended +``` + +`in_discussion` is a substate of circulation. It does not reset the step. + +## B. Verb × status × step kind + +Legend: `✓` allowed · `—` forbidden · `C` allowed and may complete the step · `M` allowed and may complete the *memo* · `S` session-only after bind. + +Step kinds are grouped: + +- **Weak:** `inform`, `comment`, `review`, `acknowledge` +- **Co-sign:** `mitzeichnung`, `approve` +- **Bind:** `schlusszeichnung`, `sign`, `seal`, `deposit` + +### B1. Status `draft` + +| Verb | requester | assignee | agent | Effect | +|---|---|---|---|---| +| comment | ✓ | — | ✓ | thread only | +| discuss | ✓ | — | — | stays draft | +| withdraw | ✓ | — | — | → withdrawn | +| accept / decline / acknowledge / return / forward / escalate / configure | — | — | — | not circulating | +| submit (system/requester) | ✓ | — | — | → submitted | + +### B2. Status `submitted` / `circulating` / `awaiting_actor` / `in_discussion` + +| Verb | Weak step | Co-sign | Bind step | Guards | Status after | +|---|---|---|---|---|---| +| comment | ✓ any party | ✓ | ✓ | — | unchanged (`in_discussion` if was circulating) | +| discuss | ✓ any party | ✓ | ✓ | — | `in_discussion` | +| return | ✓ assignee | ✓ | ✓ | G_NOAGENT G_REASONS | `returned` | +| forward | ✓ assignee | ✓ | ✓ | G_NOAGENT G_TARGET | stays circulating; step assignee replaced | +| escalate | ✓ assignee | ✓ | ✓ | G_NOAGENT G_TARGET G_ESC | circulating; higher step inserted/activated | +| acknowledge | C assignee | C | ✓ overlay | G_NOAGENT G_PRES G_ACK | step complete; memo completes only if this was the last step *and* `requested_act=acknowledge` | +| accept | — | C assignee | **M** assignee | G_NOAGENT G_PRES G_ACK G_MAND G_LEVEL G_STEP | last bind/co-sign step → `completed` outcome=`accepted` | +| decline | — | C assignee | **M** assignee | G_NOAGENT G_PRES G_STEP | → `completed` outcome=`declined` (memo ends; later steps skipped) | +| abstain | — | C assignee | C assignee | G_NOAGENT G_PRES | step complete for this actor; parallel group may continue; if actor was sole bind step → `completed` outcome=`abstained` | +| recuse | ✓ | C | C | G_NOAGENT G_PRES | step unassigned; must forward/escalate or return | +| withdraw | requester only | same | same | not terminal | → `withdrawn` | +| stamp | system | system | system | after a bind disposition or on completion | unchanged | +| configure | — | — | — | not yet bound | — | +| promote | requester | requester | requester | G_PROMOTE | version++; back toward submitted | + +`accept` on a **weak** step is illegal. Weak steps take `acknowledge`. That is how Kenntnisnahme stays distinct from Schlusszeichnung. + +### B3. Status `returned` + +| Verb | Who | Effect | +|---|---|---| +| comment / discuss | any party | thread | +| revise + submit | requester | version++; → submitted; new route from policy | +| withdraw | requester | → withdrawn | +| binding verbs | — | illegal on a returned version | + +### B4. Status `completed` + +| Verb | Who | Effect | +|---|---|---| +| comment | parties | thread; does not unseal | +| stamp | system | file.stamped / bundle.sealed | +| configure | subject | **S** session hat; see §C | +| promote | requester | not on this version; open child memo | +| accept / return / … | — | sealed | + +If `requested_act ∈ {login, elevate}` then `session.status` becomes `active` at the completing accept/acknowledge. + +### B5. Terminal `withdrawn` | `expired` | `superseded` + +No actor verbs except system `stamp` of the evidence bundle already accumulated. + +## C. Post-bind session transitions + +Applies when `session.status = active`. + +``` +pre_bind presentation + │ actor chooses identity + bind-scope + │ sees proposed hat / other tenants as AWARENESS only + ▼ +bind disposition (accept | acknowledge) + │ view_hash covers binding_slice only + │ awareness_hash recorded alongside, not signed + ▼ +session.active + ├── configure(hat) if hat.elevates=false ∧ hat in available_hats + ├── attempt other scope + │ requires_new_bind=false → still configure (same bind) + │ requires_new_bind=true → emit scope.rebind_required, open child memo + └── session.ended logout / expiry / revoke +``` + +| Event | In `view_hash`? | In `awareness_hash`? | Evidence event | +|---|---|---|---| +| Chosen identity | yes | copy ok | identity.authenticated | +| Chosen bind-scope / tenant of *this* login | yes | copy ok | disposition.recorded | +| Privileges granted *at the gate* | yes | preview ok | disposition.recorded | +| Proposed / last-used hat shown | no | yes | awareness.shown | +| Hat selected after login | no | yes | session.hat_selected | +| Hat with `elevates=true` | must promote | — | promote + new bind | +| Other tenant with `requires_new_bind` | no | listed | scope.rebind_required | + +Defaulting the hat to `last_used` or `system_default` is an awareness act. Showing it on the login banner is required for situational awareness and forbidden from silently entering `view_hash`. + +## D. Completing a step vs completing the memo + +A step completes when the assignee records a verb in that step’s completion set: + +| Step kind | Completion set | +|---|---| +| inform, review, acknowledge | acknowledge, return, recuse | +| comment | comment (if so configured) or acknowledge | +| mitzeichnung, approve | accept, decline, abstain, recuse, return | +| schlusszeichnung, sign, seal, deposit | accept, decline, abstain, recuse, return | + +The memo completes when: + +1. A bind-step `decline` is recorded, or +2. Every required step is `completed` or `skipped`, and at least one completing verb in the memo’s `requested_act` family was recorded, or +3. Requester `withdraw`, or system expiry. + +Parallel groups: all members must reach a terminal step status before the next ordinal becomes `ready`. + +## E. Illegal combinations (explicit) + +- Agent + any binding verb +- `accept` on `inform`/`review` +- `configure` before `session.active` +- `configure` of a hat with `elevates=true` +- `configure` of a scope with `requires_new_bind=true` +- Binding verb whose presentation `phase=post_bind` +- Binding verb whose presentation `awareness_hash` was substituted for `view_hash` +- Mutating `identity_context.binding` after the first bind disposition of that version diff --git a/history/20260909-initial-exploration/vectors/adr-binding.json b/history/20260909-initial-exploration/vectors/adr-binding.json new file mode 100644 index 0000000..8303883 --- /dev/null +++ b/history/20260909-initial-exploration/vectors/adr-binding.json @@ -0,0 +1,31 @@ +{ + "memo_id": "01K4Q8Z3R7V2N6K9M1P5T8W4XC", + "memo_version": 2, + "question": "Do you formally accept ADR-0042 as the billing architecture for prod?", + "requested_act": "accept", + "binding_level": "aes", + "brief": "Replace nightly batch invoicing with an append-only event ledger.", + "locale": "en", + "ui_release": "informed-decision@0.3.1", + "packet": [ + { + "item_id": "01K4Q8DOC0000000000000001", + "hash": { + "alg": "sha256", + "hex": "6b1c0f8a9d2e4c7b8a1f0e3d5c6b7a8f9e0d1c2b3a4f5e6d7c8b9a0f1e2d3c4b" + } + } + ], + "highlights": [ + { + "id": "01K4Q8HL00000000000000001", + "item_id": "01K4Q8DOC0000000000000001", + "severity": "critical", + "required_ack": true, + "locator": { + "kind": "markdown_heading", + "heading": "Consequences / rollback" + } + } + ] +} diff --git a/history/20260909-initial-exploration/vectors/expected.json b/history/20260909-initial-exploration/vectors/expected.json new file mode 100644 index 0000000..b7cea93 --- /dev/null +++ b/history/20260909-initial-exploration/vectors/expected.json @@ -0,0 +1,5 @@ +{ + "login_view_hash": "492d9d311bf44ec9de0d0abef28abac7d31df2781527e8fe276a3186ee1b06b8", + "login_awareness_hash": "2be7742970a01e7a879ae5040660659fb8c9a5c024e6c7fb338f3944a4fe05d1", + "adr_view_hash": "1c89ec07c3cc9d16f85a1ba1be5169456b3c55d21161f64037787779ae91f202" +} diff --git a/history/20260909-initial-exploration/vectors/login-awareness.json b/history/20260909-initial-exploration/vectors/login-awareness.json new file mode 100644 index 0000000..389e45a --- /dev/null +++ b/history/20260909-initial-exploration/vectors/login-awareness.json @@ -0,0 +1,63 @@ +{ + "memo_id": "01K4LOGIN00000000000000001", + "memo_version": 1, + "locale": "en", + "ui_release": "informed-decision@0.4.0", + "proposed_hat": { + "id": "hat:finance-controller", + "label": "Finance Controller", + "kind": "access_profile", + "elevates": false, + "permissions_preview": ["invoice.read", "invoice.export"], + "scope_id": "tenant:acme" + }, + "proposed_hat_source": "last_used", + "available_hats": [ + { + "id": "hat:finance-controller", + "label": "Finance Controller", + "kind": "access_profile", + "elevates": false, + "permissions_preview": ["invoice.read", "invoice.export"], + "scope_id": "tenant:acme" + }, + { + "id": "hat:auditor-readonly", + "label": "Auditor (read-only)", + "kind": "perspective", + "elevates": false, + "permissions_preview": ["invoice.read"], + "scope_id": "tenant:acme" + }, + { + "id": "hat:payroll-admin", + "label": "Payroll Admin", + "kind": "role", + "elevates": true, + "permissions_preview": ["payroll.run", "employee.export-all"], + "scope_id": "tenant:acme" + } + ], + "available_scopes": [ + { + "kind": "tenant", + "id": "tenant:acme", + "label": "ACME Corp", + "environment": "prod", + "requires_new_bind": true + }, + { + "kind": "tenant", + "id": "tenant:beta", + "label": "Beta GmbH", + "environment": "prod", + "requires_new_bind": true + } + ], + "last_session": { + "ended_at": "2026-09-08T16:12:00Z", + "hat_id": "hat:finance-controller", + "scope_id": "tenant:acme" + }, + "situation_note": "Last session Tuesday 18:12 CEST as Finance Controller in ACME. Payroll Admin is an elevating hat and needs its own bind." +} diff --git a/history/20260909-initial-exploration/vectors/login-binding.json b/history/20260909-initial-exploration/vectors/login-binding.json new file mode 100644 index 0000000..70bbb8b --- /dev/null +++ b/history/20260909-initial-exploration/vectors/login-binding.json @@ -0,0 +1,73 @@ +{ + "memo_id": "01K4LOGIN00000000000000001", + "memo_version": 1, + "question": "Log into Payroll-Prod as Bernd Worsch in tenant ACME?", + "requested_act": "login", + "binding_level": "organizational", + "brief": "You are entering Payroll-Prod. Sessions are recorded. Privileges at the gate are identity-scoped, not hat-scoped.", + "locale": "en", + "ui_release": "informed-decision@0.4.0", + "packet": [], + "highlights": [], + "binding": { + "principal": { + "id": "01K4PERSONBERND00000000001", + "kind": "person", + "display_name": "Bernd Worsch", + "role": "employee", + "identifiers": [ + { "scheme": "email", "value": "bernd.worsch@example.com" }, + { "scheme": "idp:oidc-sub", "value": "auth.example.com|bernd" } + ] + }, + "available_identities": [ + { + "id": "01K4PERSONBERND00000000001", + "kind": "person", + "display_name": "Bernd Worsch", + "identifiers": [ + { "scheme": "idp:oidc-sub", "value": "auth.example.com|bernd" } + ] + }, + { + "id": "01K4PERSONBERNDADMIN000001", + "kind": "person", + "display_name": "Bernd Worsch (break-glass)", + "identifiers": [ + { "scheme": "idp:oidc-sub", "value": "auth.example.com|bernd-bg" } + ] + } + ], + "target": { + "kind": "tenant", + "id": "tenant:acme", + "label": "ACME Corp", + "environment": "prod", + "requires_new_bind": true + }, + "available_bind_scopes": [ + { + "kind": "tenant", + "id": "tenant:acme", + "label": "ACME Corp", + "environment": "prod", + "requires_new_bind": true + }, + { + "kind": "tenant", + "id": "tenant:beta", + "label": "Beta GmbH", + "environment": "prod", + "requires_new_bind": true + } + ], + "granted_at_bind": { + "roles": ["authenticated"], + "permissions": ["session.create"] + }, + "terms": { + "monitoring": true, + "consent_code": "LOGIN-PROD-2026" + } + } +} diff --git a/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md b/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md new file mode 100644 index 0000000..b5a3f6a --- /dev/null +++ b/workplans/INFD-WP-0001-founding-specs-and-approver-ui-ownership.md @@ -0,0 +1,289 @@ +--- +id: INFD-WP-0001 +type: workplan +title: "Founding specs and approver-UI ownership" +domain: infotech +repo: informed-decision +status: proposed +owner: claude +topic_slug: netkingdom +created: "2026-09-09" +updated: "2026-09-09" +origin: founding +origin_ref: history/20260909-initial-exploration/InitialExploration.md +--- + +# INFD-WP-0001 — Founding specs and approver-UI ownership + +Establish `informed-decision` as a governed repository in the NetKingdom estate, +settle who owns the browser-facing approver UI that `approval-engine` +deliberately does not contain, and produce the specification set that Stage 1 +implementation will be built against. + +The trigger is concrete and dated. On 2026-09-08 `key-cape` (`KEY-WP-0013-T02`) +asked `approval-engine` for the human approver client's `client_id` and callback +URI. `approval-engine` correctly declined to invent them and recorded in +`docs/keycape-service-registrations.md` that the human approver flow *"belongs to +whichever browser-facing approver UI presents `approval:approve` tokens to this +engine. That component is not in this repo."* `APPROVAL-WP-0002-T01` remains +`progress` partly because of it. This workplan closes that gap by naming the +owner and shipping the contract. + +**Status is `proposed`, pending review** against the current `approval-engine`, +`access-engine`, `key-cape`, `gate-house` and `audit-core` contracts, and +against the deployment estate. T02 is the gate: if `gate-house` places this +component differently, T03–T05 are rewritten before they are written. + +Scope boundary for this workplan: **specification and contract, plus one +walking skeleton**. Full L3 product build is residual and belongs to +`INFD-WP-0002`. + +## Establish the founding documents + +```task +id: INFD-WP-0001-T01 +status: done +priority: high +``` + +Write the repository's stable statements of purpose and current stage, derived +from the founding exploration rather than reinvented. + +Acceptance: `INTENT.md` states purpose, the Decision Memo concept, ownership and +non-ownership against the named estate repositories, design principles, wrongness +conditions and success criteria; `GOAL.md` states the Stage 1 outcome, exclusions, +invariants and definition of done; `README.md` orients a new reader in under a +minute; `history/20260909-initial-exploration/` is preserved unmodified as the +provenance record. + +Completed 2026-09-09: `INTENT.md`, `GOAL.md` and `README.md` written. `SCOPE.md` +is deliberately deferred to T06 — a scope file written before the layer ruling +and the specs would describe an imagined boundary, which is the drift `SCOPE.md` +exists to prevent. + +## Settle layer placement and approver-UI ownership with gate-house + +```task +id: INFD-WP-0001-T02 +status: todo +priority: high +``` + +Take the ownership question to `gate-house` as doctrine rather than asserting a +catalog row. The proposal to argue: `informed-decision` owns the browser-facing +approver surface, the presentation record and the evidence of informedness; it +is **PEP-shaped** under statute §6.4 and companion §5 because it is +browser-facing and causes a protected side effect on the far side of a decision; +it supplies exactly one PIP-like fact — *what was presented* — as a claim, and +never evaluates it. + +Raise explicitly, and do not paper over: + +- §17 has no request-claim schema owner assigned. A `view_hash`-bearing + presentation claim must yield to that schema when it exists rather than + inventing a permanent local shape. +- `approval-engine`'s claim already carries *"a digest over the same canonical + binding the decision point already computes"*. Whether `view_hash` is that + digest, a sibling of it, or a distinct presentation attestation is a real + question and the wrong answer creates two competing canonicalizations of the + same act. This is the single highest-risk unknown in the workplan. +- A surface that renders both the question and the answer is adjacent to the + self-dealing objection that kept the object out of `access-engine`. State why + it is not the same failure: this repository holds no state a decision reads + as authority. + +Acceptance: an intake is filed with `gate-house`; a ruling or a recorded decision +exists; `layer.yaml` is written from the ruling and matches the catalog row, not +this workplan's prose; `INTENT.md` and `GOAL.md` are amended if the ruling +differs from the proposal; the `view_hash`-versus-binding-digest relationship is +recorded as a decision, not left implicit. Blocking for T05 and T07. + +## Product Requirements Document + +```task +id: INFD-WP-0001-T03 +status: todo +priority: high +``` + +Write `docs/specs/ProductRequirementsDocument.md` for Stage 1: the L3 approval +approver surface, scoped to one real consumer. + +Must cover: the personas (approver holding a mandate, requester, observer/auditor); +the disposition vocabulary and which verbs are legal on which step kinds; the +required-highlight acknowledgment gate; the pre-sign/awareness split as it +appears in the UI; the evidence bundle as an export; accessibility and locale +(DE/EN, given the *Umlaufmappe* framing); and the explicit non-requirements from +`GOAL.md` — no mandate graph, no QES, no notification transport. + +State the anti-requirements as first-class: no dark patterns, no dwell timers, +no keystroke analytics, and a UI that makes unmistakable that the whole +instrument is bound rather than only the acknowledged highlights. + +Acceptance: every requirement traces to either a `GOAL.md` definition-of-done +item or a named external contract; each requirement is testable; the document +names what it is deliberately not requiring and why. + +## Use Case Catalog + +```task +id: INFD-WP-0001-T04 +status: todo +priority: medium +``` + +Write `docs/specs/UseCaseCatalog.md` covering the full depth spectrum L0–L5, with +Stage 1 scope marked, so that scale invariance is testable as a design property +rather than a claim in a vision statement. + +For each level: actor, trigger, requested act, binding level, the verbs that must +be available, the evidence produced, and the estate repository that is the +counterparty. Include the L0 login banner and the L2 ADR accept in full even +though they are out of Stage 1 build scope — their purpose here is to constrain +the schema so the object cannot fork later. + +Include the negative cases: `accept` on a Kenntnisnahme step (illegal by design), +a bind attempt with unacknowledged required highlights, an agent attempting a +disposition, and a tenant switch requiring a new bind. + +Acceptance: every use case maps onto the single Decision Memo schema with no +level-specific object; each negative case names the invariant it protects; the +catalog states for each level which estate repository would consume it. + +## Architecture Blueprint + +```task +id: INFD-WP-0001-T05 +status: todo +priority: high +``` + +Write `docs/specs/ArchitectureBlueprint.md`. Depends on T02 — the layer ruling +determines what this component is permitted to be. + +Must cover: the component boundary and its position in the security layer model; +the call graph to `key-cape` (OIDC authorization-code + PKCE for humans), +`approval-engine` (bearer `approval:approve`, never `approval:consume`), +`access-engine` (decision, never rendered here) and `audit-core` (evidence +emission); the storage posture for memos, presentations and dispositions; +the deployment shape including the Ingress and external origin that +`approval-engine` explicitly does not have; and the failure modes — what the +surface does when `approval-engine`, `key-cape` or `audit-core` is unavailable. + +Fail-closed is the default and must be stated per dependency. A surface that +degrades into showing a memo it cannot bind is acceptable; a surface that +degrades into binding without evidence is not. + +Acceptance: no component in the diagram renders an authorization decision; the +token audiences, scopes and principal types match `approval-engine`'s +`docs/keycape-service-registrations.md` exactly; every external dependency has a +stated unavailable-stance; the blueprint names which parts are Stage 1 and which +are placeholders. + +## Evidence model, schema promotion and canonicalization under test + +```task +id: INFD-WP-0001-T06 +status: todo +priority: high +``` + +Promote the exploration artifacts from `history/` into governed, tested +repository assets, and write `docs/specs/EvidenceModel.md`. + +Move `decision-memo.schema.json` to `schemas/`, `canonicalize.py` into the +package, and the fixtures in `vectors/` into the test suite. `history/` stays +untouched as provenance; the governed copies are the ones that change. + +The four isolation properties from the exploration become tests that must stay +green: + +1. shuffling object keys does not change either hash; +2. editing an awareness field does not change `view_hash`; +3. changing `binding.target` does change `view_hash`; +4. selecting a role after login emits `session.hat_selected` and does not + rewrite `view_hash`. + +`EvidenceModel.md` covers what an evidence bundle contains, how it verifies +offline, its relationship to `audit-core`'s archive, and — carried over from +`approval-engine`'s reasoning rather than rediscovered — the honest statement +that a hash chain proves records were not altered after arrival and cannot prove +a record was never sent. + +Write `SCOPE.md` as the last step of this task, once the layer ruling and the +specs have fixed the real boundary. + +Acceptance: schema, canonicalizer and vectors live outside `history/` and are +exercised in CI; the three published expected hashes reproduce byte-for-byte; +`EvidenceModel.md` states the residual it does not close; `SCOPE.md` exists and +describes the implemented-and-first-cut boundary, not the aspiration. + +## Publish the OIDC browser-client contract to key-cape + +```task +id: INFD-WP-0001-T07 +status: todo +priority: high +``` + +Own and publish the two strings `approval-engine` could not supply: the human +approver client's `client_id` and its full callback URI. Depends on T02 for the +ownership ruling and on T05 for the deployment origin. + +The registration is an authorization-code + PKCE public or confidential browser +client — not `client_credentials` — and the resulting access token must carry +`aud=approval-engine`, `principal_type: human`, `tenant: tenant:platform` and +scope `approval:approve`. Redirect URIs match exactly at `/authorize`, so the +origin must be a real deployed origin, decided in T05, not a placeholder. + +Do not request `approval:consume`: `approval-engine` refuses it for human +principals, and consumption belongs to the PEP that causes the side effect. + +Acceptance: `docs/keycape-client-registration.md` publishes both strings and the +expected token shape; the contract is sent to `key-cape` referencing +`KEY-WP-0013-T02`, and to `approval-engine` referencing its +`docs/keycape-service-registrations.md` follow-up; a token issued against the +registration is accepted by `approval-engine`'s verifier; `KEY-WP-0013-T02` is +unblocked. **This is the task that discharges the gap that created this +repository.** + +## Walking skeleton — one approval, end to end + +```task +id: INFD-WP-0001-T08 +status: todo +priority: medium +``` + +Prove the specs against reality with the thinnest possible L3 path: sign in via +`key-cape`, list approvals awaiting this principal from `approval-engine`, +render one as a Decision Memo with brief, packet and highlights, acknowledge the +required highlights, and submit an approval entry with a stored presentation +record carrying `view_hash`. + +`return` and `discuss` are in this skeleton, not deferred. They are the +differentiator; a skeleton with only approve/reject proves the wrong product. + +Acceptance: one approval is approved by a real human through this surface +against a deployed `approval-engine`; the approval entry is reconstructable from +a stored presentation; a bind attempt with unacknowledged required highlights +fails closed; `return` produces a structured reason and is distinguishable from +`decline` in the record; no code path in this repository evaluates whether the +act is permitted. + +Gated externally on `approval-engine` `APPROVAL-WP-0002-T01` reaching `done` and +on the service being deployed with an origin this surface can reach. + +## Known risks + +- **T02 is a hard gate.** Writing the blueprint before the layer ruling risks + building a component the statute does not permit in that shape. +- **Two canonicalizations.** If `view_hash` and `approval-engine`'s binding + digest are not reconciled in T02, the estate ends up with two hashes over the + same act and no rule for which one is authoritative. +- **Deployment origin is on someone else's critical path.** T07 cannot complete + without a real external origin, and this repository does not yet own an + Ingress. This is the most likely cause of slip. +- **Scope pressure toward an approvals inbox.** The fastest way to close + `KEY-WP-0013-T02` is to build a queue with two buttons. That would satisfy the + dependency and abandon the thesis. T04 exists to make the cost of that visible.