Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1029c-c003-7283-acc9-930b434e6cae
375 lines
22 KiB
Markdown
375 lines
22 KiB
Markdown
# 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.
|