# 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 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: the retained State Hub notification endpoint creates a message to the founder's inbox (`bernd`) with memo id, question, class and submitter. The owner contract prohibits URLs and names the signed-in overview as the authoritative review entry point, replacing the initially proposed review link. The overview (INFD-WP-0003) lists admissible memos 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