informed-decision/docs/specs/FrictionlessDecisionIntake.md
tegwick c1cf6cf54c Implement offline workload intake and record blocked owner dependencies
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a1029c-c003-7283-acc9-930b434e6cae
2026-10-03 18:45:10 +02:00

375 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Frictionless decision intake
**Status:** proposed design, 2026-10-03. **Workplan:** `INFD-WP-0005`.
**Driver:** CUST-WP-0075, security close-down checklist row 2.
**Founder direction (2026-10-03):** "The idea about having decisions.coulomb.social
is to make it easy and frictionless to pose and answer decisions. It makes no
sense to have manual steps to provide the decisions in the first place."
**Target: posing a decision takes no human step. Only answering is human.**
This is an addendum to the Stage 1 specs, not a new product. It keeps the
Decision Memo, the review UI, `CompactSignoffBatches.md` and its
anti-requirements. It removes the steps that put a drafted memo in front of the
reviewer, and does not touch the step where the reviewer answers.
## 1. What posing costs today
Example: `docs/batches/2026-10-03-provisioning-window/OPERATOR.md`.
| Step | What happens | Why it is manual |
|---|---|---|
| A3 | The founder runs `warden access informed-decision-sitting-requester-login --exec -- tools/create_sitting_approvals.sh` | The create-only client secret (`informed-decision-sitting-requester`, `approval:create` only, `sub=informed-decision`) is held in **attended reader** custody, so a human has to log in only to read it |
| A4 | Someone hand-edits `access-engine/examples/informed-decision-sitting/policy.md` (new exact record, version bump), CI builds it, the pin is redeployed, `deploy/sitting-admission.json` is bumped and the review service rolls out | Admission is an **exact-record list**: each memo id, approval id and binding digest is a policy value |
| A5 | An operator runs `attach_compact_bindings.py` and `load_sitting_memos.py` against the live store | There is no intake: memos are files that reach the store through an operator CLI |
| A6 | The founder signs in with fresh MFA and answers | **This is the only step that has to be human** |
So the attended human appears three times before the one act that needs him.
None of A3 to A5 adds judgment about the question. A3 is custody friction, A4
duplicates facts approval-engine already holds, and A5 is plumbing.
## 2. Target flow
```text
agent / service informed-decision (intake) approval-engine access-engine founder
| POST /v1/memos ------------> authenticate workload
| (memo + packet) validate (schema, 1 question,
| secret scan, packet digests)
| idempotency check
| POST /v1/approvals ------------> draft, digest
| (own create-only client) (human_control)
| pin approval_id + digest on memo
| save memo + packet in live store
| audit-core: memo.posed
| <-- 201 {memo_id, url} ----- notify founder (hub message)
| opens link
| GET memo -> approval-engine get-by-id (human token)
| check digest/actor/human_control
| POST /v1/check (attribute rule) ---------------> allow/deny
| render; view_hash
| ack + accept
| fresh MFA check, POST /entries (human token)
| audit-core: disposition
| write-back: resolve hub decision (memo id in rationale)
```
## 3. Intake API
`POST /v1/memos` on a **separate in-cluster listener** of the review service
(not on the public `decisions.coulomb.social` ingress). The browser surface
stays read-and-answer only.
### 3.1 Submitter identity (workload, not human)
- The caller presents a **projected Kubernetes service-account token** with
audience `informed-decision-intake`. The service verifies it with
TokenReview, the same pattern access-engine uses for its own callers.
- The verified `system:serviceaccount:<ns>:<sa>` is looked up in
`deploy/intake-submitters.json`, a deployment-owned allow list mapping each
submitter to a `submitter_id` and to the memo classes and review groups it
may pose. An unknown or unmapped caller is refused (`submitter_unknown`).
- The submitter id is **never read from the body.** The body may carry
`drafted_by` (for example `claude@the-custodian`), which is shown to the
reviewer as *asserted* provenance, not as authenticated identity.
- **First submitter: State Hub.** Most decisions are drafted by coding agents on
the workstation, which have no workload identity. They already talk to the
hub. The hub (`system:serviceaccount:state-hub:state-hub`) forwards a memo
attached to a hub decision, so `serves.hub_decision_id` is known and
authenticated by construction. In-cluster services (activity-core,
secrets-engine, ...) may call intake directly once they are listed.
- No human token is ever accepted on this route. A human posing a decision goes
through an agent or the hub like everyone else; there is no "human submitter"
shortcut that could become a bind.
### 3.2 Envelope
Schema: `schemas/memo-intake.schema.json` (kind `informed-decision.memo-intake`,
version 1). Closed object:
| Field | Meaning |
|---|---|
| `idempotency_key` | Submitter-chosen, 8 to 128 chars `[A-Za-z0-9._:-]` |
| `memo_class` | One of the classes in the submitter's allow list (for example `credential-lane`, `work-record-accept`, `spend-envelope`, `adr-accept`) |
| `review_group` | For example `net-kingdom-admins`. Must be allowed for the submitter |
| `drafted_by` | Optional, asserted provenance string |
| `serves` | Optional `{hub_decision_id, resolves: "on-answer" \| "never"}` |
| `approval_intent` | `{action, principal, purpose, target{id,type,system}}`; `actor` is **not** accepted, intake sets it to `informed-decision` |
| `memo` | The memo record in the operational shape `records.memo_from` reads (as in `docs/batches/*/…memo.json`) |
| `packet` | `[{item_id, media_type, content}]`, UTF-8 text, at most 256 KiB each |
### 3.3 Validation (all fail closed, first failure wins, stable reason codes)
1. **Shape.** Closed envelope, closed memo keys, known enums.
2. **Unsigned draft.** `version == 1`, `sealed == false`, `approval_id` and
`approval_binding_digest` are `null` (intake fills them),
`binding.principal.id == "pending-human-session"`. A submitter cannot
pre-bind or pin an approval.
3. **One question per memo.** `question` is one line, at most 400 chars, ends
with `?` and contains exactly one `?`. `requested_act` is one value. A memo
whose target or highlights reference more than one work record of different
acts is refused (`bundled_acts`). This is a mechanical floor; the
anti-requirement still binds drafters.
4. **Highlights.** Unique ids, each `item_id` names a packet item. At least
one highlight with `required_ack` for `critical` severity is preserved as
given; intake never drops or reorders highlights.
5. **Packet integrity.** Every `memo.packet[*]` has exactly one packet entry
with the same `item_id`, and `sha256(content)` equals the memo's `hash`.
No orphan packet content.
6. **Secret-marker scan** over every string in the memo, the intent and the
packet: PEM private keys, `Bearer` tokens, JWT-shaped strings, OpenBao/Vault
tokens (`hvs.`, `s.`), provider key prefixes (`sk-`, `sk-or-`, `ghp_`,
`github_pat_`, `glpat-`, `AKIA`), `password=`/`secret=`/`token=`
assignments, credentials in URLs. A hit refuses the whole submission with
`secret_marker` and the field path only; the matched text is never echoed,
logged or stored.
7. **Authorization of the pose.** `memo_class` and `review_group` are in the
submitter's allow list.
### 3.4 Idempotency
`submission_digest = sha256(canonical JSON of {submitter_id, envelope without
idempotency_key})`. The ledger key is `(submitter_id, idempotency_key)`:
- new key → accept;
- same key, same digest → **replay**: return the stored `{memo_id, approval_id}`,
create nothing;
- same key, different digest → `409 idempotency_conflict`;
- a memo id already in the store under another key → `409 memo_id_taken`.
The ledger row is written **before** the approval-engine POST with state
`creating`, and finished after. A crash before receipt retention leaves a `creating`
row that refuses automatic retry; see §4 for recovery requirements.
A new version of a memo is a new submission with a new memo id or an explicit
`supersedes` relation in a later version of this contract; intake never
mutates a stored memo.
## 4. Approval creation without a human login
**approval-engine already supports this.** `docs/caller-authentication.md`:
"A service or agent with `approval:create` may draft that request." Create
needs `binding.actor == sub`, and the requester client
`informed-decision-sitting-requester` exists (CCR-2026-0026/0027, create-only,
`sub=informed-decision`, `principal_type=service`, 15 min tokens).
The only reason A3 is attended is **custody**: the client secret is readable
only through the attended reader lane. The change:
- railiance-platform delivers `CLIENT_SECRET` from
`platform/workloads/informed-decision/sitting-requester` to the review
workload by ESO (a workload read grant, the shape `secrets-engine-requester`
already has). No new KeyCape client, no new scope.
- The intake does `client_credentials` → `POST /v1/approvals` with
`human_control: true`, `required_count: 1`, `binding.actor =
"informed-decision"` and the submitter's `approval_intent`.
- **The submitting workload is the requester; the human is only the
approver.** Approval-engine records `informed-decision` as requester. The
originating submitter and `drafted_by` are recorded on the memo and in
audit-core, not invented into the approval binding.
- GH-DEC-2026-016 still holds: `POST /entries` on a `human_control` approval
refuses any non-human principal at bind time, independently of us.
What approval-engine would ideally add (requested, not blocking):
1. An `Idempotency-Key` on `POST /v1/approvals`, so a retried create after a
crash returns the same draft instead of leaving an orphan. Until then a `creating` row without a receipt requires explicit
owner-assisted reconciliation and never automatically repeats creation. A
`created` row with a durable receipt retries local loading only. An orphan
draft is never bound and expires.
2. An optional, non-binding `requested_via` evidence field so the engine's
own record names the original submitter.
## 5. Admission without a per-memo policy deploy
### 5.1 What the exact-record list protects, and what replaces it
The exact-record list (`informed-decision.compact-sitting` v1–v6) does two
jobs:
- **(a) the human sees exactly what is bound:** the memo version, approval id
and binding digest the reviewer acts on are the ones an operator pinned;
- **(b) posing is operator-gated:** nothing reaches a reviewer unless someone
edited policy.
(b) is the friction the founder wants removed. (a) must stay. (a) does not need
a policy value per memo: approval-engine already holds the binding digest, and
the review service already re-reads the approval live and compares it with the
memo pin before rendering and before every act (`approval_client`). The
`view_hash` covers memo content; the binding digest covers the act. Both stay
distinct and non-substitutable (open hub decision `8c3c0d14`).
### 5.2 The attribute rule (access-engine, new branch or package)
Allow `read`, `acknowledge`, `accept`, `return`, `discuss`, `decline` when:
- the identity bar is unchanged: human, `authentication-derived` principal
type, `net-kingdom-admins` (or the memo's `review_group`) in verified groups,
KeyCape AAL2 `["pwd","otp"]`, MFA age at most 900 s (12 h for `list`);
- `resource.system == "informed-decision"`, `type == "decision-memo"`,
`resource.id` matches `memo:[a-z0-9][a-z0-9-]{2,62}`;
- `context.review_group` is in the subject's groups (eligibility, not a bind);
- `context.memo_class` is in `admitted_classes` (generated set);
- `context.intake_submitter` is in `admitted_submitters` (generated from
`deploy/intake-submitters.json`, same pattern as
`tools/gen_ops_warden_fetch.py`: separate data, `--check` drift test);
- `context.approval_requester == "informed-decision"`,
`context.approval_human_control == true`;
- `context.approval_id` is a UUID and `context.approval_binding_digest`
matches `sha256:[0-9a-f]{64}`;
- `context.memo_version >= 1`.
Deny by default, first failing reason named. The **existing exact records stay
as a second branch** until their memos are terminal, so nothing admitted today
loses admission. A new memo needs no policy edit, no image build and no pin
redeploy. A new memo class or submitter changes generated data and, until
§5.3 lands, an image.
### 5.3 Who asserts the context attributes
Build mode: informed-decision is the PEP and carries `approval_requester`,
`approval_human_control` and the digest **as read live from approval-engine**
(the get-by-id it already does with the human's own token), plus
`intake_submitter` and `memo_class` from its own intake ledger. This is the
consumer asserting facts that the PDP decides on. The exact-record list avoided
that by having an operator repeat the facts in policy. It is recorded as
relaxation R3.
Close-down: the PDP verifies the approval fact itself from the
approval-engine claim (`GET /v1/approvals/{id}/claim`, `approval-claim.md`),
signed by approval-engine or fetched by access-engine as PIP, and verifies the
intake fact from a signed intake receipt. Then the PEP carries only ids.
### 5.4 What stays fail-closed
- No allow without a live approval-engine read that matches the memo pin.
- Unknown class, unknown submitter, wrong requester, `human_control` false,
malformed digest, stale MFA: deny.
- PDP unreachable: refuse (no local allow fallback, as today).
- `list` keeps its own rule; a `list` allow satisfies nothing else.
## 6. Load and notification
- Intake runs inside the review service, so a validated, approval-pinned memo
is written with `Store.put_document` + `Store.save_memo` in the same
transaction as the ledger row. No CLI, no `bound/` directory.
- The binding principal is resolved from deployment config
(`deploy/review-groups.json`: review group → named recipient KeyCape `sub`),
never from memo content. Draft principal `pending-human-session` stays in the
unsigned record; the live bind still substitutes the authenticated subject.
- Notification: a State Hub message to the founder's inbox (`to_agent`
configured, default `bernd`) with memo id, question, class, submitter and the
review link. The signed-in overview (INFD-WP-0003) lists it as pending.
No packet content, no highlights, no secrets in the notification. Email/push
is a later channel owned elsewhere.
- audit-core receives `informed-decision.memo.posed` (submitter, class,
memo id, approval id, binding digest, submission digest; no content).
## 7. Invariants kept
| Invariant | Where it is enforced after the change |
|---|---|
| Humans bind; agents never accept/decline/seal | Intake has no bind route; workload tokens are refused on every browser route; approval-engine GH-DEC-2026-016 refuses non-human entries on `human_control` approvals; the requester client has no `approval:approve` |
| MFA freshness on answer | Unchanged PDP identity bar (900 s) plus the review service's own act-time check |
| One question per memo | Intake validation §3.3(3); anti-requirement unchanged |
| No approve-all | No batch act exists; intake creates one approval per memo; acts are per memo |
| Human sees exactly what is bound | `view_hash` per presentation; live approval read and digest compare before render and act; digest carried to PDP |
| audit-core evidence | `memo.posed` added; presentation, disposition and evidence path unchanged |
| No secret values | Secret-marker scan at intake; notifications carry no content; client secret only via ESO into the pod |
| Membership is not a bind | `review_group` is eligibility only; no auto-approval |
## 8. Write-back to State Hub
When a memo with `serves.hub_decision_id` and `resolves: "on-answer"` gets a
terminal human disposition (`accept` or `decline`; `return` and `discuss` do
not resolve), the review service calls the sanctioned hub write:
```text
POST /decisions/{hub_decision_id}/resolve
{"decided_by": "<reviewer display name> via informed-decision",
"rationale": "<verb> in informed-decision memo <memo_id> v<version>; approval <approval_id>;
binding <digest>; view_hash <view_hash>; disposition <id> at <time>",
"write_log": true}
```
- Behind `INFD_HUB_WRITEBACK=1` (default off). Off means the payload is built
and recorded, not sent.
- Idempotent: `409 already resolved` with our memo id in the hub rationale is
success; a 409 without it is a recorded `conflict` for a human to look at.
Transport errors retry from a local outbox; the human's act is never undone
by a write-back failure.
- One decision, one resolving memo. A packet that spans several lanes (like D1)
uses `resolves: "never"` on its per-lane memos and gets a progress note
instead; the hub decision is resolved by its own resolving memo.
- The hub is a read model: the write-back reflects an act that already
happened in informed-decision. It never decides.
## 9. Build mode now, close-down later
| # | Relaxation accepted now | Re-arm at close-down |
|---|---|---|
| R1 | Posing is no longer operator-gated: any listed submitter can put a memo in front of the founder | Keep, but the submitter list and class list are reviewed data with a signed regeneration path |
| R2 | Workstation agents pose through State Hub, whose API is unauthenticated on the loopback tunnel; `drafted_by` is asserted | Hub authenticates its writers (agent workload/OIDC identity) before forwarding; the forwarded submitter carries that identity |
| R3 | PEP-carried approval and intake attributes in the PDP request (§5.3) | access-engine verifies a signed approval-engine claim and a signed intake receipt itself |
| R4 | The requester client secret moves from attended to workload custody (ESO into the review pod) | Rotation schedule, ESO read audit, and proof the pod cannot read sibling paths |
| R5 | Admitted classes/submitters baked into the access-engine image | Runtime-loaded policy data with integrity checks, or a reviewed regeneration pipeline (same as checklist row 6) |
| R6 | Hub write-back uses the unauthenticated hub API | Hub requires the informed-decision workload identity for `resolve` and records it |
| R7 | No `Idempotency-Key` on approval create; orphan drafts possible after a crash | approval-engine idempotent create |
| R8 | Notification only via hub inbox | Authenticated notification channel; no content beyond id and question |
Never relaxed, build mode or not: humans bind, fresh MFA on answer, one
question per memo, no approve-all, no secret values, audit-core evidence.
## 10. Migration and ownership
| Phase | What | Owner | Needs |
|---|---|---|---|
| 0 (now, offline) | Intake validation module + envelope schema + tests; hub write-back adapter behind a flag | informed-decision | nothing |
| 1 | Intake listener, TokenReview, submitter config, ledger, store write, notification, `memo.posed` | informed-decision | railiance-platform (SA/NetworkPolicy), audit-core (event type) |
| 2 | Requester secret to the pod by ESO; intake creates approvals | railiance-platform, informed-decision | founder go for the custody change |
| 3 | Attribute admission branch + generator + fixtures; deploy | access-engine | founder go for deploy |
| 4 | State Hub forwards decision memos as a submitter | state-hub | — |
| 5 | Write-back flag on | informed-decision | phases 1–3 live |
| Close-down | R1–R8 | each owner | founder |
Unchanged: key-cape (the create-only client already exists; no new scope).
gate-house is asked to confirm that attribute admission plus live digest checks
satisfy the doctrine the exact-record list was serving.
The operator scripts (`create_sitting_approvals.*`, `attach_compact_bindings.py`,
`load_sitting_memos.py`) stay as the fallback until phase 3 is live, then
retire.
## 11. Doctrine response and local implementation — 2026-10-03
Gate House reply `4a290496-5aa7-4488-a01d-f8fde925a54b` confirms no human act
is required at posing time. Draft GH-DEC-2026-023 remains proposed and awaits
the founder's ruling. The response imposes these requirements now:
- C1: PDP allow is eligibility only. MUST read the approval live and compare
approval id and native digest with the memo pin; never obtain approval fact
from an allow.
- C2: view_hash MUST NEVER enter PDP context. The binding digest MUST come from
the engine's live response and MUST NOT be recomputed here.
- C3: every intake-created approval MUST have human_control true; no class may
override it. PDP MUST deny false. Principal identity stays authentication-derived.
- C4: removing a submitter from runtime data MUST refuse the next pose without
a policy image rebuild, including attempts to replay previously accepted data.
- C5: submitter/class additions MUST be reviewed commits by someone other than
the submitter being admitted. Local allow-list refusal grants no PDP permission.
- C6: state-hub submitter proves authenticated forwarding only. drafted_by MUST
remain visibly asserted; loopback hub access does not authenticate the drafter.
Findings F1–F3 remain open: PEP-asserted admission facts require independently
verified signed claims/receipts at close-down; ungated posing can flood human
attention; hub notifications can impersonate a review request. Intake now
requires a configured positive per-submitter daily budget. At close-down the
budget is mandatory, the authenticated overview is authoritative, notifications
carry ids, and terminal exact-record memos lose the compatibility branch.
R2/R5 re-arming includes authenticated drafting identity and independent grant
review. The unsigned PDP attribution gap remains explicitly declared.
The offline service and its durable integration worker are implemented; shipped
registries are empty and transports are injected. Runtime composition and live
acceptance remain blocked on the owner requirements in
`docs/intake-dependency-requirements.md`. These requirements do not arm a live
control or grant custody/deployment approval.