INFD: frictionless decision intake design and workplan (CUST-WP-0075)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 3775@bnt-lap001
Assistant-Session: cfacb6a5-ad5b-4f79-ac67-053e3b0d34a3
This commit is contained in:
tegwick 2026-10-03 09:21:05 +02:00
parent a7ec5a5f87
commit a973d3656a
7 changed files with 1314 additions and 0 deletions

View file

@ -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:<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 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": "<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.

View file

@ -0,0 +1,99 @@
"""State Hub decision write-back (INFD-WP-0005, FrictionlessDecisionIntake §8).
When a human's terminal disposition (accept or decline) answers a memo that
serves a hub decision with ``resolves: on-answer``, resolve that decision with
the memo id in the rationale. ``POST /decisions/{id}/resolve`` is the hub's
sanctioned write; it reflects an act already taken here and decides nothing.
Off unless ``INFD_HUB_WRITEBACK=1``: when off, the payload is built and
returned as ``skipped`` so the evidence exists, but nothing is sent. The
transport is injected; this module opens no socket by itself.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass
from typing import Callable, Mapping
FLAG = "INFD_HUB_WRITEBACK"
RESOLVING_VERBS = frozenset({"accept", "decline"})
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
#: (method, url, json body) -> (status, parsed json or {})
Transport = Callable[[str, str, dict], tuple[int, dict]]
@dataclass(frozen=True)
class Answer:
"""A human's recorded terminal disposition on one memo."""
memo_id: str
memo_version: int
verb: str
disposition_id: str
decided_at: str
reviewer_display: str
approval_id: str | None
approval_binding_digest: str | None
view_hash: str
actor_kind: str = "person"
@dataclass(frozen=True)
class WritebackResult:
status: str # not_applicable | skipped | resolved | already_resolved | conflict | failed
decision_id: str | None
payload: dict | None = None
detail: str = ""
def enabled(env: Mapping[str, str] | None = None) -> bool:
return (os.environ if env is None else env).get(FLAG) == "1"
def build_resolution(answer: Answer) -> dict:
if answer.actor_kind != "person":
raise ValueError("only a human disposition can resolve a hub decision")
if answer.verb not in RESOLVING_VERBS:
raise ValueError(f"verb {answer.verb!r} does not resolve a decision")
rationale = (
f"{answer.verb} in informed-decision memo {answer.memo_id} v{answer.memo_version}; "
f"approval {answer.approval_id or 'none'}; binding {answer.approval_binding_digest or 'none'}; "
f"view_hash {answer.view_hash}; disposition {answer.disposition_id} at {answer.decided_at}"
)
return {
"decided_by": f"{answer.reviewer_display} via informed-decision",
"rationale": rationale,
"write_log": True,
}
def write_back(answer: Answer, serves: Mapping | None, *, base_url: str,
transport: Transport, env: Mapping[str, str] | None = None) -> WritebackResult:
if not serves or serves.get("resolves") != "on-answer" or answer.verb not in RESOLVING_VERBS:
return WritebackResult("not_applicable", (serves or {}).get("hub_decision_id"))
decision_id = serves.get("hub_decision_id", "")
if not _UUID_RE.match(decision_id or ""):
raise ValueError("hub_decision_id must be a lowercase UUID")
payload = build_resolution(answer)
if not enabled(env):
return WritebackResult("skipped", decision_id, payload, f"{FLAG} is not 1")
url = f"{base_url.rstrip('/')}/decisions/{decision_id}/resolve"
try:
status, body = transport("POST", url, payload)
except OSError as exc:
return WritebackResult("failed", decision_id, payload, type(exc).__name__)
if 200 <= status < 300:
return WritebackResult("resolved", decision_id, payload)
if status == 409:
# Already resolved: ours if the hub rationale names this memo.
try:
_, current = transport("GET", f"{base_url.rstrip('/')}/decisions/{decision_id}", {})
except OSError as exc:
return WritebackResult("failed", decision_id, payload, type(exc).__name__)
if f"memo {answer.memo_id} " in (current.get("rationale") or ""):
return WritebackResult("already_resolved", decision_id, payload)
return WritebackResult("conflict", decision_id, payload, json.dumps(body)[:200])
return WritebackResult("failed", decision_id, payload, f"http_{status}")

292
informed_decision/intake.py Normal file
View file

@ -0,0 +1,292 @@
"""Memo intake validation (INFD-WP-0005, docs/specs/FrictionlessDecisionIntake.md §3).
Pure and offline: validates a submission envelope from an authenticated
workload and returns the parsed draft plus its submission digest. It creates no
approval, writes no store row and binds nothing. The submitter id comes from
the caller's verified workload identity, never from the body.
Every refusal is an ``IntakeRefused`` with a stable reason code and a field
path. A secret-marker hit never echoes the matched text.
"""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from typing import Any, Mapping
from .memo import BindingLevel, Memo, StepKind
from .records import memo_from
KIND = "informed-decision.memo-intake"
VERSION = 1
DRAFT_PRINCIPAL = "pending-human-session"
REQUESTER_ACTOR = "informed-decision"
MAX_QUESTION = 400
MAX_PACKET_BYTES = 256 * 1024
ENVELOPE_KEYS = {"kind", "version", "idempotency_key", "memo_class", "review_group",
"drafted_by", "serves", "approval_intent", "memo", "packet"}
ENVELOPE_REQUIRED = ENVELOPE_KEYS - {"drafted_by", "serves"}
MEMO_KEYS = {"id", "version", "question", "requested_act", "binding_level", "brief",
"binding", "step_kind", "packet", "highlights", "locale", "ui_release",
"approval_id", "approval_binding_digest", "sealed"}
INTENT_KEYS = {"action", "principal", "purpose", "target"}
TARGET_KEYS = {"id", "type", "system"}
HIGHLIGHT_SEVERITIES = {"informational", "warning", "critical"}
_KEY_RE = re.compile(r"^[A-Za-z0-9._:-]{8,128}$")
_MEMO_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{2,62}$")
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,62}$")
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
_SHA_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
#: Secret markers (§3.3.6). Shapes, not values; deliberately broad.
SECRET_MARKERS: tuple[tuple[str, re.Pattern[str]], ...] = (
("pem_private_key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
("bearer_token", re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]{16,}")),
("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")),
("vault_token", re.compile(r"\b(hvs|hvb|hvr)\.[A-Za-z0-9_-]{20,}")),
("provider_key", re.compile(r"\b(sk-(or-)?[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{30,}|"
r"github_pat_[A-Za-z0-9_]{30,}|glpat-[A-Za-z0-9_-]{20,}|"
r"AKIA[0-9A-Z]{16}|xox[abpr]-[A-Za-z0-9-]{10,})")),
("credential_assignment", re.compile(
r"(?i)\b(password|passwd|client_secret|secret|api[_-]?key|access[_-]?token|token)"
r"\s*[:=]\s*['\"]?(?=[A-Za-z0-9_+.=-]*\d)[A-Za-z0-9_+.=-]{12,}(?=['\"\s,;)]|$)")),
("url_credentials", re.compile(r"://[^/@\s:]+:[^/@\s]+@")),
)
class IntakeRefused(ValueError):
def __init__(self, reason: str, path: str = "") -> None:
super().__init__(f"{reason} at {path or '$'}")
self.reason = reason
self.path = path
@dataclass(frozen=True)
class SubmitterGrant:
"""One row of deploy/intake-submitters.json, keyed by verified workload."""
submitter_id: str
memo_classes: frozenset[str]
review_groups: frozenset[str]
@dataclass(frozen=True)
class ValidatedSubmission:
submitter_id: str
idempotency_key: str
submission_digest: str
memo_class: str
review_group: str
drafted_by: str | None
serves: dict | None
approval_binding: dict
memo: Memo
packet: dict[str, bytes]
def _closed(value: Any, keys: set[str], required: set[str], path: str) -> dict:
if not isinstance(value, dict):
raise IntakeRefused("not_an_object", path)
extra = set(value) - keys
if extra:
raise IntakeRefused("unknown_field", f"{path}.{sorted(extra)[0]}")
missing = required - set(value)
if missing:
raise IntakeRefused("missing_field", f"{path}.{sorted(missing)[0]}")
return value
def _text(value: Any, path: str, *, max_len: int = 4000, pattern: re.Pattern[str] | None = None) -> str:
if not isinstance(value, str) or not value.strip() or len(value) > max_len:
raise IntakeRefused("invalid_text", path)
if pattern is not None and not pattern.match(value):
raise IntakeRefused("invalid_format", path)
return value
def _strings(value: Any, path: str):
if isinstance(value, str):
yield path, value
elif isinstance(value, dict):
for key, item in value.items():
yield from _strings(item, f"{path}.{key}")
elif isinstance(value, list):
for i, item in enumerate(value):
yield from _strings(item, f"{path}[{i}]")
def scan_secret_markers(value: Any, path: str = "$") -> None:
"""Refuse on the first secret-shaped string. Reports the path, never the text."""
for where, text in _strings(value, path):
for name, pattern in SECRET_MARKERS:
if pattern.search(text):
raise IntakeRefused(f"secret_marker:{name}", where)
def check_one_question(question: str, path: str = "$.memo.question") -> None:
if "\n" in question or "\r" in question:
raise IntakeRefused("question_multiline", path)
if len(question) > MAX_QUESTION:
raise IntakeRefused("question_too_long", path)
if question.count("?") != 1 or not question.rstrip().endswith("?"):
raise IntakeRefused("not_one_question", path)
def canonical(value: Any) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
allow_nan=False).encode("utf-8")
def submission_digest(submitter_id: str, envelope: Mapping[str, Any]) -> str:
body = {k: v for k, v in envelope.items() if k != "idempotency_key"}
return "sha256:" + hashlib.sha256(canonical({"submitter": submitter_id, "envelope": body})).hexdigest()
def idempotency_outcome(stored_digest: str | None, digest: str) -> str:
"""``new`` | ``replay`` for the ledger key (submitter, idempotency_key)."""
if stored_digest is None:
return "new"
if stored_digest == digest:
return "replay"
raise IntakeRefused("idempotency_conflict", "$.idempotency_key")
def _check_memo(memo: Any, packet_hashes: dict[str, str]) -> Memo:
memo = _closed(memo, MEMO_KEYS, MEMO_KEYS - {"locale", "ui_release"}, "$.memo")
_text(memo["id"], "$.memo.id", max_len=63, pattern=_MEMO_ID_RE)
if memo["version"] != 1 or isinstance(memo["version"], bool):
raise IntakeRefused("draft_version_must_be_1", "$.memo.version")
if memo["sealed"] is not False:
raise IntakeRefused("draft_must_be_unsealed", "$.memo.sealed")
for field in ("approval_id", "approval_binding_digest"):
if memo[field] is not None:
raise IntakeRefused("approval_pinned_by_submitter", f"$.memo.{field}")
check_one_question(_text(memo["question"], "$.memo.question"))
_text(memo["requested_act"], "$.memo.requested_act", max_len=40, pattern=_SLUG_RE)
_text(memo["brief"], "$.memo.brief", max_len=20000)
try:
BindingLevel(memo["binding_level"])
StepKind(memo["step_kind"])
except ValueError:
raise IntakeRefused("unknown_enum", "$.memo.step_kind") from None
binding = memo["binding"]
if not isinstance(binding, dict) or not isinstance(binding.get("principal"), dict):
raise IntakeRefused("invalid_binding", "$.memo.binding")
if binding["principal"].get("id") != DRAFT_PRINCIPAL:
raise IntakeRefused("principal_prebound", "$.memo.binding.principal.id")
items = memo["packet"]
if not isinstance(items, list) or not items:
raise IntakeRefused("packet_required", "$.memo.packet")
seen: set[str] = set()
for i, item in enumerate(items):
item = _closed(item, {"item_id", "label", "hash"}, {"item_id", "label", "hash"}, f"$.memo.packet[{i}]")
if item["item_id"] in seen:
raise IntakeRefused("duplicate_packet_item", f"$.memo.packet[{i}]")
seen.add(item["item_id"])
if item["item_id"] not in packet_hashes:
raise IntakeRefused("packet_content_missing", f"$.memo.packet[{i}]")
if item["hash"] != packet_hashes[item["item_id"]]:
raise IntakeRefused("packet_digest_mismatch", f"$.memo.packet[{i}].hash")
orphans = set(packet_hashes) - seen
if orphans:
raise IntakeRefused("orphan_packet_content", "$.packet")
highlights = memo["highlights"]
if not isinstance(highlights, list):
raise IntakeRefused("invalid_highlights", "$.memo.highlights")
ids: set[str] = set()
work_records: set[tuple] = set()
for i, h in enumerate(highlights):
if not isinstance(h, dict) or h.get("id") in ids or not h.get("id"):
raise IntakeRefused("duplicate_highlight", f"$.memo.highlights[{i}]")
ids.add(h["id"])
if h.get("item_id") not in seen:
raise IntakeRefused("highlight_item_unknown", f"$.memo.highlights[{i}].item_id")
if h.get("severity", "informational") not in HIGHLIGHT_SEVERITIES:
raise IntakeRefused("unknown_enum", f"$.memo.highlights[{i}].severity")
loc = h.get("locator") or {}
if loc.get("kind") == "work_record":
work_records.add((loc.get("workplan"), loc.get("task")))
if len(work_records) > 1:
raise IntakeRefused("bundled_acts", "$.memo.highlights")
try:
return memo_from(memo)
except (TypeError, ValueError, KeyError):
raise IntakeRefused("memo_unparseable", "$.memo") from None
def validate_submission(envelope: Any, *, submitter_id: str, grant: SubmitterGrant | None) -> ValidatedSubmission:
"""Validate one intake envelope for an already-authenticated submitter."""
if grant is None or grant.submitter_id != submitter_id:
raise IntakeRefused("submitter_unknown", "$")
env = _closed(envelope, ENVELOPE_KEYS, ENVELOPE_REQUIRED, "$")
if env["kind"] != KIND or env["version"] != VERSION:
raise IntakeRefused("unsupported_kind", "$.kind")
key = _text(env["idempotency_key"], "$.idempotency_key", max_len=128, pattern=_KEY_RE)
memo_class = _text(env["memo_class"], "$.memo_class", max_len=63, pattern=_SLUG_RE)
review_group = _text(env["review_group"], "$.review_group", max_len=63, pattern=_SLUG_RE)
drafted_by = env.get("drafted_by")
if drafted_by is not None:
_text(drafted_by, "$.drafted_by", max_len=200)
# Secret scan before anything is parsed further or echoed anywhere.
scan_secret_markers(env)
serves = env.get("serves")
if serves is not None:
serves = _closed(serves, {"hub_decision_id", "resolves"}, {"hub_decision_id", "resolves"}, "$.serves")
_text(serves["hub_decision_id"], "$.serves.hub_decision_id", max_len=36, pattern=_UUID_RE)
if serves["resolves"] not in ("on-answer", "never"):
raise IntakeRefused("unknown_enum", "$.serves.resolves")
intent = _closed(env["approval_intent"], INTENT_KEYS, INTENT_KEYS, "$.approval_intent")
for field in ("action", "principal", "purpose"):
_text(intent[field], f"$.approval_intent.{field}", max_len=400)
target = _closed(intent["target"], TARGET_KEYS, TARGET_KEYS, "$.approval_intent.target")
for field in TARGET_KEYS:
_text(target[field], f"$.approval_intent.target.{field}", max_len=200)
packet = env["packet"]
if not isinstance(packet, list) or not packet:
raise IntakeRefused("packet_required", "$.packet")
contents: dict[str, bytes] = {}
hashes: dict[str, str] = {}
for i, item in enumerate(packet):
item = _closed(item, {"item_id", "media_type", "content"}, {"item_id", "media_type", "content"}, f"$.packet[{i}]")
if item["media_type"] not in ("text/markdown", "text/plain", "application/json"):
raise IntakeRefused("unsupported_media_type", f"$.packet[{i}].media_type")
if not isinstance(item["content"], str):
raise IntakeRefused("invalid_text", f"$.packet[{i}].content")
data = item["content"].encode("utf-8")
if len(data) > MAX_PACKET_BYTES:
raise IntakeRefused("packet_too_large", f"$.packet[{i}]")
if item["item_id"] in contents:
raise IntakeRefused("duplicate_packet_item", f"$.packet[{i}]")
contents[item["item_id"]] = data
hashes[item["item_id"]] = "sha256:" + hashlib.sha256(data).hexdigest()
memo = _check_memo(env["memo"], hashes)
if memo_class not in grant.memo_classes:
raise IntakeRefused("memo_class_not_admitted", "$.memo_class")
if review_group not in grant.review_groups:
raise IntakeRefused("review_group_not_admitted", "$.review_group")
return ValidatedSubmission(
submitter_id=submitter_id,
idempotency_key=key,
submission_digest=submission_digest(submitter_id, env),
memo_class=memo_class,
review_group=review_group,
drafted_by=drafted_by,
serves=dict(serves) if serves else None,
approval_binding={**{k: intent[k] for k in ("action", "principal", "purpose")},
"actor": REQUESTER_ACTOR, "target": dict(target)},
memo=memo,
packet=contents,
)

View file

@ -0,0 +1,138 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://decisions.coulomb.social/schemas/memo-intake.schema.json",
"title": "informed-decision memo intake envelope v1",
"description": "POST /v1/memos body (docs/specs/FrictionlessDecisionIntake.md \u00a73.2). The submitter comes from the verified workload token, never from this body. informed_decision/intake.py is the enforcing validator; this schema documents the shape.",
"type": "object",
"additionalProperties": false,
"required": [
"kind",
"version",
"idempotency_key",
"memo_class",
"review_group",
"approval_intent",
"memo",
"packet"
],
"properties": {
"kind": {
"const": "informed-decision.memo-intake"
},
"version": {
"const": 1
},
"idempotency_key": {
"type": "string",
"pattern": "^[A-Za-z0-9._:-]{8,128}$"
},
"memo_class": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9-]{1,62}$"
},
"review_group": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9-]{1,62}$"
},
"drafted_by": {
"type": "string",
"maxLength": 200,
"description": "Asserted provenance, shown as such; not an identity."
},
"serves": {
"type": "object",
"additionalProperties": false,
"required": [
"hub_decision_id",
"resolves"
],
"properties": {
"hub_decision_id": {
"type": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"resolves": {
"enum": [
"on-answer",
"never"
]
}
}
},
"approval_intent": {
"type": "object",
"additionalProperties": false,
"required": [
"action",
"principal",
"purpose",
"target"
],
"description": "binding.actor is not accepted; intake sets it to informed-decision.",
"properties": {
"action": {
"type": "string"
},
"principal": {
"type": "string"
},
"purpose": {
"type": "string"
},
"target": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"type",
"system"
],
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string"
},
"system": {
"type": "string"
}
}
}
}
},
"memo": {
"type": "object",
"description": "Unsigned draft in the operational shape read by informed_decision.records.memo_from: version 1, sealed false, approval_id and approval_binding_digest null, binding.principal.id pending-human-session."
},
"packet": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"item_id",
"media_type",
"content"
],
"properties": {
"item_id": {
"type": "string"
},
"media_type": {
"enum": [
"text/markdown",
"text/plain",
"application/json"
]
},
"content": {
"type": "string",
"maxLength": 262144
}
}
}
}
}
}

View file

@ -0,0 +1,89 @@
"""Hub decision write-back behind INFD_HUB_WRITEBACK (INFD-WP-0005-T03). Offline."""
import pytest
from informed_decision.hub_writeback import Answer, build_resolution, write_back
D1 = "62aa95e4-3ccc-4867-8806-5e3db005b483"
BASE = "http://hub.test"
ON = {"INFD_HUB_WRITEBACK": "1"}
def answer(**kw):
data = dict(memo_id="infd-20261003-p01", memo_version=1, verb="accept", disposition_id="disp-1",
decided_at="2026-10-03T12:00:00Z", reviewer_display="Bernd Worsch",
approval_id="ba5ce2d8-8b6d-40be-af89-2e8c147029a3",
approval_binding_digest="sha256:" + "a" * 64, view_hash="sha256:" + "b" * 64)
data.update(kw)
return Answer(**data)
class Hub:
def __init__(self, *responses):
self.responses = list(responses)
self.calls = []
def __call__(self, method, url, body):
self.calls.append((method, url, body))
return self.responses.pop(0)
def test_rationale_carries_memo_id_and_pins():
payload = build_resolution(answer())
assert "memo infd-20261003-p01 v1" in payload["rationale"]
assert "approval ba5ce2d8" in payload["rationale"]
assert payload["decided_by"] == "Bernd Worsch via informed-decision"
def test_agents_cannot_resolve():
with pytest.raises(ValueError):
build_resolution(answer(actor_kind="agent"))
def test_flag_off_builds_but_does_not_send():
hub = Hub()
out = write_back(answer(), {"hub_decision_id": D1, "resolves": "on-answer"}, base_url=BASE,
transport=hub, env={})
assert out.status == "skipped" and out.payload and hub.calls == []
@pytest.mark.parametrize("verb,serves", [
("return", {"hub_decision_id": D1, "resolves": "on-answer"}),
("discuss", {"hub_decision_id": D1, "resolves": "on-answer"}),
("accept", {"hub_decision_id": D1, "resolves": "never"}),
("accept", None),
])
def test_not_applicable(verb, serves):
hub = Hub()
out = write_back(answer(verb=verb), serves, base_url=BASE, transport=hub, env=ON)
assert out.status == "not_applicable" and hub.calls == []
def test_resolves_when_on():
hub = Hub((200, {"id": D1, "status": "resolved"}))
out = write_back(answer(verb="decline"), {"hub_decision_id": D1, "resolves": "on-answer"},
base_url=BASE, transport=hub, env=ON)
assert out.status == "resolved"
assert hub.calls[0][:2] == ("POST", f"{BASE}/decisions/{D1}/resolve")
def test_409_is_idempotent_only_for_our_memo():
ours = Hub((409, {}), (200, {"rationale": "accept in informed-decision memo infd-20261003-p01 v1; ..."}))
assert write_back(answer(), {"hub_decision_id": D1, "resolves": "on-answer"}, base_url=BASE,
transport=ours, env=ON).status == "already_resolved"
theirs = Hub((409, {}), (200, {"rationale": "resolved by hand"}))
assert write_back(answer(), {"hub_decision_id": D1, "resolves": "on-answer"}, base_url=BASE,
transport=theirs, env=ON).status == "conflict"
def test_transport_error_is_failed_not_raised():
def broken(*_):
raise ConnectionRefusedError()
out = write_back(answer(), {"hub_decision_id": D1, "resolves": "on-answer"}, base_url=BASE,
transport=broken, env=ON)
assert out.status == "failed"
def test_bad_decision_id_refused():
with pytest.raises(ValueError):
write_back(answer(), {"hub_decision_id": "D1", "resolves": "on-answer"}, base_url=BASE,
transport=Hub(), env=ON)

191
tests/test_intake.py Normal file
View file

@ -0,0 +1,191 @@
"""Intake validation (INFD-WP-0005-T02). Offline; uses the real p01 draft."""
import copy
import json
from pathlib import Path
import pytest
from informed_decision.intake import (IntakeRefused, SubmitterGrant, idempotency_outcome,
scan_secret_markers, submission_digest,
validate_submission)
ROOT = Path(__file__).resolve().parents[1]
SITTING = ROOT / "docs" / "batches" / "2026-10-03-provisioning-window" / "provisioning"
GRANT = SubmitterGrant("state-hub", frozenset({"credential-lane"}), frozenset({"net-kingdom-admins"}))
def envelope():
memo = json.loads((SITTING / "infd-20261003-p01.memo.json").read_text())
content = (SITTING / "infd-20261003-p01.packet.md").read_text()
return {
"kind": "informed-decision.memo-intake",
"version": 1,
"idempotency_key": "d1-p01-2026-10-03",
"memo_class": "credential-lane",
"review_group": "net-kingdom-admins",
"drafted_by": "claude@the-custodian",
"serves": {"hub_decision_id": "62aa95e4-3ccc-4867-8806-5e3db005b483", "resolves": "never"},
"approval_intent": {
"action": "accept",
"principal": "railiance-platform",
"purpose": "Accept the 2026-09-13 scoped delivery proof",
"target": {"id": "ccr:CCR-2026-0019-delivery-proof", "type": "credential-lane",
"system": "railiance-platform"},
},
"memo": memo,
"packet": [{"item_id": "infd-20261003-p01-packet", "media_type": "text/markdown",
"content": content}],
}
def refused(env, reason, grant=GRANT, submitter="state-hub"):
with pytest.raises(IntakeRefused) as exc:
validate_submission(env, submitter_id=submitter, grant=grant)
assert exc.value.reason == reason, exc.value
return exc.value
def test_real_draft_is_accepted_and_actor_is_forced():
out = validate_submission(envelope(), submitter_id="state-hub", grant=GRANT)
assert out.memo.id == "infd-20261003-p01"
assert out.approval_binding["actor"] == "informed-decision"
assert out.submission_digest.startswith("sha256:")
assert out.packet["infd-20261003-p01-packet"]
def test_submitter_comes_from_grant_not_body():
refused(envelope(), "submitter_unknown", grant=None)
refused(envelope(), "submitter_unknown", submitter="someone-else")
env = envelope()
env["submitter_id"] = "state-hub"
refused(env, "unknown_field")
def test_actor_cannot_be_supplied():
env = envelope()
env["approval_intent"]["actor"] = "agent"
refused(env, "unknown_field")
@pytest.mark.parametrize("field,value,reason", [
("approval_id", "ba5ce2d8-8b6d-40be-af89-2e8c147029a3", "approval_pinned_by_submitter"),
("sealed", True, "draft_must_be_unsealed"),
("version", 2, "draft_version_must_be_1"),
])
def test_draft_cannot_be_prebound(field, value, reason):
env = envelope()
env["memo"][field] = value
refused(env, reason)
def test_principal_must_be_pending():
env = envelope()
env["memo"]["binding"]["principal"]["id"] = "some-keycape-sub"
refused(env, "principal_prebound")
@pytest.mark.parametrize("question,reason", [
("Accept the proof? And deploy the lane?", "not_one_question"),
("Accept the proof.", "not_one_question"),
("Accept the\nproof?", "question_multiline"),
("A" * 400 + "?", "question_too_long"),
])
def test_one_question_per_memo(question, reason):
env = envelope()
env["memo"]["question"] = question
refused(env, reason)
def test_bundled_work_records_refused():
env = envelope()
extra = copy.deepcopy(env["memo"]["highlights"][0])
extra["id"] = "h2"
extra["locator"] = {"kind": "work_record", "workplan": "MASON-WP-0007", "task": "MASON-WP-0007-T05"}
env["memo"]["highlights"].append(extra)
refused(env, "bundled_acts")
def test_packet_digest_must_match():
env = envelope()
env["packet"][0]["content"] += "\nedited after hashing"
refused(env, "packet_digest_mismatch")
def test_orphan_and_missing_packet_content():
env = envelope()
env["packet"].append({"item_id": "extra", "media_type": "text/plain", "content": "x"})
refused(env, "orphan_packet_content")
env = envelope()
env["packet"][0]["item_id"] = "other"
refused(env, "packet_content_missing")
@pytest.mark.parametrize("leak", [
"-----BEGIN OPENSSH PRIVATE KEY-----",
"Authorization: Bearer abcdefghijklmnop1234",
"eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ4eHh4In0.c2lnbmF0dXJlMTIz",
"hvs.CAESIAbcdefghijklmnopqrstuv",
"key sk-or-v1-0123456789abcdef0123456789",
"AKIAABCDEFGHIJKLMNOP",
"client_secret=Zx8f9a7b6c5d4e3f",
"https://user:pw@forgejo.example/x",
])
def test_secret_markers_refuse_without_echo(leak):
env = envelope()
env["packet"][0]["content"] += "\n" + leak
# Recompute the hash so only the secret scan can refuse.
import hashlib
env["memo"]["packet"][0]["hash"] = "sha256:" + hashlib.sha256(env["packet"][0]["content"].encode()).hexdigest()
err = refused(env, err_reason(env))
assert leak not in str(err)
assert err.path == "$.packet[0].content"
def err_reason(env):
try:
validate_submission(env, submitter_id="state-hub", grant=GRANT)
except IntakeRefused as exc:
assert exc.reason.startswith("secret_marker:")
return exc.reason
raise AssertionError("not refused")
def test_custody_paths_and_prose_are_not_secrets():
scan_secret_markers({"brief": "Client secret: platform/workloads/informed-decision/sitting-requester; "
"token lifetime 15m; access_token: provided by key-cape"})
def test_class_and_group_must_be_admitted():
env = envelope()
env["memo_class"] = "adr-accept"
refused(env, "memo_class_not_admitted")
env = envelope()
env["review_group"] = "everyone"
refused(env, "review_group_not_admitted")
def test_serves_is_closed():
env = envelope()
env["serves"]["resolves"] = "always"
refused(env, "unknown_enum")
env = envelope()
env["serves"]["hub_decision_id"] = "D1"
refused(env, "invalid_format")
def test_idempotency():
env = envelope()
digest = submission_digest("state-hub", env)
assert digest == submission_digest("state-hub", {**env, "idempotency_key": "another-key-1"})
assert digest != submission_digest("activity-core", env)
assert idempotency_outcome(None, digest) == "new"
assert idempotency_outcome(digest, digest) == "replay"
with pytest.raises(IntakeRefused) as exc:
idempotency_outcome("sha256:" + "0" * 64, digest)
assert exc.value.reason == "idempotency_conflict"
def test_schema_documents_the_example():
jsonschema = pytest.importorskip("jsonschema")
schema = json.loads((ROOT / "schemas" / "memo-intake.schema.json").read_text())
jsonschema.validate(envelope(), schema)

View file

@ -0,0 +1,166 @@
---
id: INFD-WP-0005
type: workplan
title: "Frictionless decision intake — agents pose memos, only answering is human"
domain: infotech
repo: informed-decision
status: proposed
flavor: implementation
owner: claude
topic_slug: netkingdom
created: "2026-10-03"
updated: "2026-10-03"
related:
- INFD-WP-0002
- INFD-WP-0003
- CUST-WP-0075
origin: demand
origin_ref: "founder 2026-10-03: 'It makes no sense to have manual steps to provide the decisions in the first place. We need to improve that.' Security close-down checklist row 2."
---
# Frictionless decision intake
Design: `docs/specs/FrictionlessDecisionIntake.md`.
Today a drafted memo reaches the review page only after an attended requester
login (A3), a hand-edited exact-record admission package with CI build and pin
redeploy (A4), and an operator CLI load (A5). Target: a workload submits a memo
with one authenticated call; informed-decision creates the approval with its
own create-only client, admits by attribute rule, loads and notifies. Only the
founder's answer is human. Build-mode relaxations R1 to R8 (spec §9) were sent
to the-custodian for the close-down checklist (message `35a87432`).
Owner messages sent 2026-10-03 (hub message ids):
| Owner | Message | Ask |
|---|---|---|
| access-engine | `306bcdd2-4fe4-4ccf-8aac-590c2abe51c4` | attribute admission branch + generated submitter/class sets |
| approval-engine | `59c5ee82-c706-4432-bab4-24af64031d39` | confirm service-requested approvals; Idempotency-Key; `requested_via` |
| railiance-platform | `cd811073-d6db-41cf-8a57-10df0b360eab` | ESO workload custody for the requester secret; intake listener, NetworkPolicy, SA audience |
| audit-core | `23b670ac-809e-49bf-9233-e0b62cf0a039` | `informed-decision.memo.posed` event type |
| state-hub | `e30dea31-90e3-4fc0-954a-ed8412b52aca` | hub forwards decision memos as submitter; write-back acknowledgement |
| gate-house | `6ab42744-b651-4136-9794-c0dc092509b7` | doctrine check: no human act required at posing time |
| the-custodian | `35a87432-b0d5-4301-9fb9-d6a99bac4e0c` | close-down checklist rows R1–R8 |
## Tasks
```task
id: INFD-WP-0005-T01
status: done
priority: high
```
Design spec `docs/specs/FrictionlessDecisionIntake.md`: intake API, workload
identity, validation, idempotency, approval creation without a human login,
attribute admission, load/notify, invariants, write-back, build-mode
relaxations, migration.
```task
id: INFD-WP-0005-T02
status: done
priority: high
```
Offline intake validation: `informed_decision/intake.py` and
`schemas/memo-intake.schema.json`, with tests in `tests/test_intake.py`
(closed envelope, unsigned draft, one question, bundled acts, packet digests,
secret-marker scan without echo, submitter/class/group grants, idempotency).
```task
id: INFD-WP-0005-T03
status: done
priority: medium
```
Hub write-back adapter `informed_decision/hub_writeback.py` behind
`INFD_HUB_WRITEBACK=1` (default off), with tests in `tests/test_hub_writeback.py`.
Not wired into the disposition path yet (T07).
```task
id: INFD-WP-0005-T04
status: todo
priority: high
```
Intake service: separate in-cluster listener `POST /v1/memos`, TokenReview of
the projected SA token (audience `informed-decision-intake`),
`deploy/intake-submitters.json`, idempotency ledger in the store, memo + packet
written in one transaction, principal resolved from `deploy/review-groups.json`.
Offline-testable with a fake TokenReview and fake approval client.
```task
id: INFD-WP-0005-T05
status: wait
priority: high
depends_on: [INFD-WP-0005-T04]
blocked_on: message-from:railiance-platform
needs_human: true
blocking_reason: "Requester CLIENT_SECRET must reach the review pod by ESO (railiance-platform, msg cd811073) and the custody change needs the founder's go"
```
Intake creates the approval itself with `informed-decision-sitting-requester`
(`client_credentials`, `human_control: true`, `required_count: 1`,
`binding.actor = informed-decision`), pins approval id and engine digest on the
memo, and records the receipt. Replaces OPERATOR step A3.
```task
id: INFD-WP-0005-T06
status: wait
priority: high
blocked_on: message-from:access-engine
blocking_reason: "Attribute admission branch and generated submitter/class sets are access-engine's change (msg 306bcdd2); deploy needs founder go"
```
Consumer side of attribute admission: carry `review_group`, `memo_class`,
`intake_submitter`, `approval_requester`, `approval_human_control` in the
`/v1/check` context, read live from approval-engine and the intake ledger;
update `docs/flex-auth-review-contract.md`; keep exact-record memos working.
Replaces OPERATOR step A4.
```task
id: INFD-WP-0005-T07
status: todo
priority: medium
depends_on: [INFD-WP-0005-T04]
```
Notification and evidence: hub message to the founder on each posed memo
(id, question, class, submitter, link; no content); emit
`informed-decision.memo.posed` to audit-core once registered (msg `23b670ac`);
wire `hub_writeback.write_back` into the terminal-disposition path with a local
outbox. Replaces OPERATOR step A5.
```task
id: INFD-WP-0005-T08
status: wait
priority: medium
blocked_on: message-from:state-hub
blocking_reason: "State Hub as first registered submitter (forwarding decision memos with its SA token) is state-hub's change (msg e30dea31)"
```
Register state-hub as a submitter and run an end-to-end pose from a hub
decision through to the founder's review page in a dev stack.
```task
id: INFD-WP-0005-T09
status: wait
priority: medium
blocked_on: message-from:gate-house
blocking_reason: "Doctrine check that no human act is required at posing time (msg 6ab42744)"
```
Record gate-house's answer in the spec; adjust §5 if doctrine requires it.
```task
id: INFD-WP-0005-T10
status: wait
priority: low
depends_on: [INFD-WP-0005-T05, INFD-WP-0005-T06, INFD-WP-0005-T07]
needs_human: true
blocking_reason: "Live rollout of intake, admission branch and write-back flag needs the founder's go"
```
Roll out, pose one real memo end to end with no human step before the answer,
record positive and negative evidence, then retire
`create_sitting_approvals.*`, `attach_compact_bindings.py` and
`load_sitting_memos.py` to fallback status.