diff --git a/docs/specs/FrictionlessDecisionIntake.md b/docs/specs/FrictionlessDecisionIntake.md new file mode 100644 index 0000000..a8ad8c7 --- /dev/null +++ b/docs/specs/FrictionlessDecisionIntake.md @@ -0,0 +1,339 @@ +# 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::` 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 between the two leaves a `creating` +row that a retry resumes; see §4 for the orphan-draft gap. + +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 intake + resumes `creating` rows by looking up its own receipt, and an orphan draft + is harmless (never bound, 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": " via informed-decision", + "rationale": " in informed-decision memo v; approval ; + binding ; view_hash ; disposition at