informed-decision/docs/specs/FrictionlessDecisionIntake.md
tegwick ff59199c3b Complete intake delivery wiring and narrow remaining owner blockers
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a1029c-c003-7283-acc9-930b434e6cae
2026-10-03 21:34:50 +02:00

22 KiB
Raw Permalink Blame History

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

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: 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:

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. Optional review-runtime delivery configuration now binds State Hub transports and schedules posed audit evidence, notification and disabled-by-default resolution. Private intake listener 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.