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:
parent
a7ec5a5f87
commit
a973d3656a
7 changed files with 1314 additions and 0 deletions
339
docs/specs/FrictionlessDecisionIntake.md
Normal file
339
docs/specs/FrictionlessDecisionIntake.md
Normal 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.
|
||||
99
informed_decision/hub_writeback.py
Normal file
99
informed_decision/hub_writeback.py
Normal 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
292
informed_decision/intake.py
Normal 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,
|
||||
)
|
||||
138
schemas/memo-intake.schema.json
Normal file
138
schemas/memo-intake.schema.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
89
tests/test_hub_writeback.py
Normal file
89
tests/test_hub_writeback.py
Normal 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
191
tests/test_intake.py
Normal 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)
|
||||
166
workplans/INFD-WP-0005-frictionless-decision-intake.md
Normal file
166
workplans/INFD-WP-0005-frictionless-decision-intake.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue