diff --git a/docs/decision-services-alignment-answer.md b/docs/decision-services-alignment-answer.md new file mode 100644 index 0000000..12ad87e --- /dev/null +++ b/docs/decision-services-alignment-answer.md @@ -0,0 +1,172 @@ +# Owner answer: decision services alignment (S1, S3, S4) + +**Date:** 2026-10-31 +**Answering:** net-kingdom `docs/proposals/2026-10-31-decision-services-alignment-review.md` (commit `b764561`), +trigger `docs/proposals/2026-10-31-nk56-t17-founder-approval-path.md`, hub message `6811164f`. +**Workplan:** `workplans/INFD-WP-0010-present-existing-and-proportionate-decisions.md`. + +## Summary + +| Item | Answer | Status | +| --- | --- | --- | +| S1 `present_existing` | **Accepted, adapted** as a submitter *profile* on the existing `/v1/memos` route | Implemented locally and tested; not deployed. Two owner dependencies outside this repository before live use (below) | +| S4 C1 single-screen confirm | **Accepted in principle** as a lighter memo profile on the same memo model | Not built. Needs gate-house class doctrine (S5) and an access-engine policy action first | +| S3 hub write-back | **Accepted.** The write-back already exists (`hub_writeback.py`, INFD-WP-0005) behind `INFD_HUB_WRITEBACK=1` | Enablement is a founder decision; structured links need state-hub's decision-link fields | + +The founder's direction (one surface, weight proportional to risk, a clear split with approval-engine) fits +`INTENT.md` directly: one memo model at every level is the thesis, and "never own approval state" makes the +proposal's role split the correct one. Intake creating approvals as `informed-decision` was a Stage 1 convenience, +not a principle. It stays for existing submitters (state-hub), because those approvals are used by nobody but the +human review itself. + +## S1: present an existing approval + +### Fit + +- `INTENT.md` and `AGENTS.md`: informed-decision never owns approval state, never consumes, never decides. Presenting + an object someone else created keeps all three. It removes informed-decision from the actor role, which it should + never have had for approvals that a service will execute against. +- The review side already re-reads the approval with the human's token on every open/accept, and compares the binding + digest and actor to the intake record (`review.py` `_authorize`). Only intake and the recorded requester had to + change. + +### Profile, not route (answer to review §6 Q2a) + +`present_existing` is a property of the **registered submitter**, not of the request body. Same route +(`POST /v1/memos` on the private workload listener), same envelope (`informed-decision.memo-intake` v1). Reasons: +one admission path (TokenReview, daily budget, idempotency, secret scan, packet hashing all unchanged), and a +submitter cannot choose its own weight. A row is either a create-path submitter or a present-existing submitter, +never both. + +### Contract (as implemented) + +**Registry row** (`intake-submitters.json`, reviewed deployment data, reloaded on every request): + +```json +"system:serviceaccount:sso:nk-bind-executor": { + "submitter_id": "nk-bind-executor", + "memo_classes": ["bounded-execution"], + "review_groups": ["net-kingdom-admins"], + "daily_limit": 10, + "present_existing": { + "approval_actor": "service:nk-bind-executor", + "actions": ["bounded-execution"] + } +} +``` + +The key is the Kubernetes service account (TokenReview, audience `informed-decision-intake`). The +`approval_actor` is the KeyCape subject the same workload uses towards approval-engine. The registry is the only +place that joins those two identities; the body never names the actor. `approval_actor` must match +`service:` and must not be `service:informed-decision`. `actions` is a non-empty list of slugs. + +**Envelope differences for a present-existing submitter:** + +- `memo.approval_id` (UUID) and `memo.approval_binding_digest` (`sha256:<64 hex>`) are **required**. They remain + forbidden (`approval_pinned_by_submitter`) for every other submitter. +- `approval_intent.action` must be in the profile's `actions` (`action_not_admitted`). +- `approval_intent.target` may be any flat object of 1–8 string fields (BEA uses + `{admission_id, admission_digest}`). For other submitters it stays `{id, type, system}`. +- `approval_intent.{action, principal, purpose, target}` restate the approval's binding; the actor is taken from + the profile. + +**Verification** (`intake_service.verify_existing`), by a read-only `GET /v1/approvals/{id}`; every condition must +hold, the first failure is the refusal (HTTP 409 unless noted): + +| Check | Refusal | +| --- | --- | +| returned `id` equals `memo.approval_id` | `approval_mismatch` | +| `status == "requested"` | `approval_not_requested` | +| `entries == []` | `approval_has_entries` | +| `binding.human_control is true` | `approval_not_human_controlled` | +| `binding.actor ==` the profile's `approval_actor` | `approval_actor_mismatch` | +| `binding.digest == memo.approval_binding_digest` | `approval_digest_mismatch` | +| `binding.{action, principal, purpose, target}` equal the intent | `approval_binding_mismatch` | +| `required_count == 1` (Stage 1 presents one approver) | `approval_required_count_unsupported` | +| `validity.expires_at` in the future | `approval_expired` | +| GET failed or no reader configured | `approval_unreadable` / `present_existing_unavailable` (503) | +| another memo already presents this approval | `approval_already_presented` | + +The action-class check happens at validation, before any read. On refusal the reservation is released (no engine +side effect happened), so a corrected submission can be retried. In this path informed-decision never calls create, +entries or consume. The receipt has the same shape as today (`memo_id`, `approval_id`, `approval_binding_digest`). + +**After intake:** the ledger records `profile: present_existing` and `approval_requester: `. The review then +checks on every open and accept that the live object's actor equals that requester, and it passes +`approval_requester` to access-engine as the attribute context. Accept posts the founder's entry with their own +token, as today. The executor then GETs and consumes the approval itself. + +**Code:** `informed_decision/intake.py` (`PresentExisting`, pinned validation), `intake_service.py` +(registry profile, `verify_existing`, one approval per memo), `intake_approval.py` (`IntakeApprovalReader`, +GET-only), `requester.py` (read-scoped exchange, `compose_intake` wiring), `store.py` (recorded requester). +**Tests:** `tests/test_intake_present_existing.py` (25 cases, one of them opt-in against approval-engine's real +store with `INFD_APPROVAL_ENGINE_SOURCE`). Full suite: 676 passed, 83 skipped; with the real engine 735 passed, +24 skipped. Existing submitters behave exactly as before (asserted by test). + +### What still blocks live use (outside this repository) + +1. **access-engine policy.** `informed-decision.compact-sitting` v13 hard-codes + `requester_ok if input.context.approval_requester == "informed-decision"`. A present-existing memo is therefore + refused at review time with `requester_mismatch`, which fails closed, as it should. A new policy version must admit + the `(intake_submitter, approval_requester, memo_class)` triple, here `(nk-bind-executor, service:nk-bind-executor, + bounded-execution)`, and should require `aal2` for `bounded-execution`. This needs an access-engine/flex-auth owner + change, followed by a pin update here. +2. **KeyCape requester client scope.** The intake-time GET needs `approval:read` on + `informed-decision-sitting-requester`, which today holds only `approval:create`. Until key-cape grants it, the + read exchange is refused and only present-existing submitters fail (503 `approval_unreadable`). +3. **Deployment data and network.** The registry row above, `memo_class` / submitter in the policy's admitted sets, + a NetworkPolicy from `sso/nk-bind-executor` to the private workload listener (8081), and a projected token with + audience `informed-decision-intake` on the executor Job. The founder runs these under the usual release. + +### Packet content (answer to review §6 Q2b) + +**The packet carries the admission YAML itself**, not a digest plus link. A required highlight must point at the scope +ceiling, the window, the budget and the revert. The view_hash covers what the founder was shown. A link would leave +the shown content outside the evidence and outside informed-decision's custody, which the evidence model forbids for +a binding memo. A later, optional check (WP-0010-T02) can require for `bounded-execution` that one packet item's +digest equals `target.admission_digest`. This is open only because the admission digest's canonical form needs +confirming with net-kingdom. + +## S4: C1 single-screen confirm + +**Accepted in principle, adapted.** A C1 confirm is the memo model with less ceremony, not a second model: + +- one question, one packet item, one required highlight, two to four options; +- fresh session (aal1, existing 900-second freshness); +- `view_hash` and disposition as today, and no approval-engine object; +- it resolves the hub decision it serves (S3). + +This is what `INTENT.md` calls the lower depth levels (L1/L2). It is not built because three things must exist first: + +1. gate-house doctrine for the C0–C3 classes and the rule that anything executed on the strength of a decision is + C2 (review S5). informed-decision must not decide which class a decision has; +2. an access-engine policy action for a confirm without an approval object (today review refuses + `missing_act_binding`); +3. a founder answer on whether C1 acceptances count as "binding" for N2 (workplan approvals, owner proposals). + +## S3: hub write-back + +**Accepted.** It exists already: an intake envelope's `serves: {hub_decision_id, resolves: on-answer}` queues a +resolution atomically with a terminal human disposition (accept or decline). The worker posts +`/decisions/{id}/resolve` with `decided_by: " via informed-decision"` and a rationale naming the memo, +the version, the approval, the binding digest, the `view_hash` and the disposition. Remaining work: + +- the founder's decision to set `INFD_HUB_WRITEBACK=1` in production (a release); +- with state-hub: structured fields (`memo_id`, `approval_id`, `view_hash`, `evidence_ref`) once state-hub has + decision links (review §6 Q4), replacing the parse-the-rationale idempotency check; +- state-hub enforcing that a C1/C2 decision is resolved only by this write-back, not by text (state-hub owns that + rule). + +## Open questions + +1. **Founder:** are C0–C3 and "executed means C2" the right cut? This gates S4. +2. **Founder:** enable hub write-back in production now (S3)? +3. **access-engine owner:** policy version admitting present-existing requesters (blocker 1). Should aal2 be required + per memo class in policy, or by approval-engine's `required_assurance` once APPROVAL-WP-0007-T01 enforces it? +4. **key-cape owner:** add `approval:read` to the sitting requester client (blocker 2), or register a separate + read-only client. +5. **approval-engine (review §6 Q3):** a `presentation_ref` set at create would let the object point at its memo. + informed-decision would welcome it, but it cannot be checked at intake because the memo does not exist yet. A + post-accept back-link is a better shape, and it is approval-engine's call. +6. **Multi-approver approvals** (`required_count > 1`) are refused for now. Supporting them means one memo per + approver against the same approval, which needs a deliberate change to "one approval, one memo". diff --git a/informed_decision/intake.py b/informed_decision/intake.py index 6713d57..ea23311 100644 --- a/informed_decision/intake.py +++ b/informed_decision/intake.py @@ -41,6 +41,7 @@ _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}$") +_SLUG_KEY_RE = re.compile(r"^[a-z][a-z0-9_]{0,62}$") #: Secret markers (§3.3.6). Shapes, not values; deliberately broad. SECRET_MARKERS: tuple[tuple[str, re.Pattern[str]], ...] = ( @@ -74,6 +75,18 @@ class SubmitterGrant: review_groups: frozenset[str] +@dataclass(frozen=True) +class PresentExisting: + """Submitter profile ``present_existing`` (INFD-WP-0010-T01). + + The submitter's approval already exists in approval-engine, created by its + own actor. Intake only verifies and presents it; it never creates one. + """ + + approval_actor: str + actions: frozenset[str] + + @dataclass(frozen=True) class ValidatedSubmission: submitter_id: str @@ -86,6 +99,7 @@ class ValidatedSubmission: approval_binding: dict memo: Memo packet: dict[str, bytes] + profile: str = "create" def _closed(value: Any, keys: set[str], required: set[str], path: str) -> dict: @@ -155,16 +169,21 @@ def idempotency_outcome(stored_digest: str | None, digest: str) -> str: raise IntakeRefused("idempotency_conflict", "$.idempotency_key") -def _check_memo(memo: Any, packet_hashes: dict[str, str]) -> Memo: +def _check_memo(memo: Any, packet_hashes: dict[str, str], *, pinned: bool = False) -> 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}") + if pinned: + # present_existing: the memo must name the existing approval exactly. + _text(memo["approval_id"], "$.memo.approval_id", max_len=36, pattern=_UUID_RE) + _text(memo["approval_binding_digest"], "$.memo.approval_binding_digest", max_len=71, pattern=_SHA_RE) + else: + 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) @@ -220,8 +239,26 @@ def _check_memo(memo: Any, packet_hashes: dict[str, str]) -> Memo: 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.""" +def _existing_target(value: Any, path: str) -> dict: + """An existing approval's target may have any flat shape (BEA uses admission id/digest).""" + if not isinstance(value, dict) or not 1 <= len(value) <= 8: + raise IntakeRefused("invalid_target", path) + for key, item in value.items(): + if not isinstance(key, str) or not _SLUG_KEY_RE.match(key): + raise IntakeRefused("invalid_target", path) + _text(item, f"{path}.{key}", max_len=200) + return dict(value) + + +def validate_submission(envelope: Any, *, submitter_id: str, grant: SubmitterGrant | None, + present_existing: PresentExisting | None = None) -> ValidatedSubmission: + """Validate one intake envelope for an already-authenticated submitter. + + With a ``present_existing`` profile the memo must pin an existing approval + and the intent names that approval's binding; the actor is the profile's, + never the body's. Without it, behaviour is unchanged: the memo must not pin + an approval and intake will create one as ``informed-decision``. + """ if grant is None or grant.submitter_id != submitter_id: raise IntakeRefused("submitter_unknown", "$") env = _closed(envelope, ENVELOPE_KEYS, ENVELOPE_REQUIRED, "$") @@ -247,9 +284,14 @@ def validate_submission(envelope: Any, *, submitter_id: str, grant: SubmitterGra 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) + if present_existing is not None: + target = _existing_target(intent["target"], "$.approval_intent.target") + if intent["action"] not in present_existing.actions: + raise IntakeRefused("action_not_admitted", "$.approval_intent.action") + else: + 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: @@ -270,7 +312,7 @@ def validate_submission(envelope: Any, *, submitter_id: str, grant: SubmitterGra contents[item["item_id"]] = data hashes[item["item_id"]] = "sha256:" + hashlib.sha256(data).hexdigest() - memo = _check_memo(env["memo"], hashes) + memo = _check_memo(env["memo"], hashes, pinned=present_existing is not None) if memo_class not in grant.memo_classes: raise IntakeRefused("memo_class_not_admitted", "$.memo_class") @@ -286,7 +328,9 @@ def validate_submission(envelope: Any, *, submitter_id: str, grant: SubmitterGra 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)}, + "actor": present_existing.approval_actor if present_existing else REQUESTER_ACTOR, + "target": dict(target)}, memo=memo, packet=contents, + profile="present_existing" if present_existing else "create", ) diff --git a/informed_decision/intake_approval.py b/informed_decision/intake_approval.py index cfe9bfb..4bc6db9 100644 --- a/informed_decision/intake_approval.py +++ b/informed_decision/intake_approval.py @@ -1,5 +1,9 @@ -"""Create-only approval adapter; token custody/exchange belongs to workload setup.""" +"""Create-only approval adapter and the present_existing read-only adapter. + +Token custody/exchange belongs to workload setup. +""" from datetime import datetime, timedelta, timezone +import re from .http_transport import JSONTransport, fixed_origin from .intake import canonical @@ -32,3 +36,34 @@ class IntakeApprovalClient: return result except Exception: raise IntakeError(503, 'approval_create_unresolved') from None + + + +class IntakeApprovalReader: + """GET-only adapter for present_existing (INFD-WP-0010-T01). + + It has no create, entry or consume route. Any failure is ``None``-equivalent + for the caller: intake refuses to present what it could not read. + """ + + def __init__(self, origin, token_provider, *, transport=None, allow_internal_http=False): + self.origin = fixed_origin(origin, allow_internal_http=allow_internal_http) + self.token_provider = token_provider + self.transport = transport or JSONTransport(allow_internal_http=allow_internal_http) + + def __call__(self, approval_id): + if not isinstance(approval_id, str) or not re.fullmatch( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", approval_id): + raise IntakeError(400, "invalid_approval_id") + try: + token = self.token_provider() + if (not isinstance(token, str) or not token or len(token) > 16384 + or not token.isascii() or any(c.isspace() for c in token)): + raise ValueError() + status, result = self.transport.request('GET', self.origin + '/v1/approvals/' + approval_id, + headers={'Authorization': 'Bearer ' + token}) + if status != 200 or not isinstance(result, dict): + raise ValueError() + return result + except Exception: + raise IntakeError(503, 'approval_unreadable') from None diff --git a/informed_decision/intake_service.py b/informed_decision/intake_service.py index 45172f0..f137d04 100644 --- a/informed_decision/intake_service.py +++ b/informed_decision/intake_service.py @@ -12,13 +12,18 @@ from pathlib import Path import re import uuid -from .intake import IntakeRefused, SubmitterGrant, canonical, idempotency_outcome, validate_submission +from .intake import (IntakeRefused, PresentExisting, SubmitterGrant, canonical, idempotency_outcome, + validate_submission) from .memo import Principal from .records import dumps from .store import Conflict AUDIENCE = "informed-decision-intake" MAX_BODY = 1024 * 1024 +ROW_KEYS = {"submitter_id", "memo_classes", "review_groups", "daily_limit"} +PRESENT_EXISTING_KEYS = {"approval_actor", "actions"} +_ACTOR_RE = re.compile(r"service:[a-z0-9][a-z0-9-]{1,62}") +_ACTION_RE = re.compile(r"[a-z0-9][a-z0-9-]{1,62}") class IntakeError(Exception): @@ -66,7 +71,8 @@ def configuration(submitters_path, groups_path): raise ValueError() for username, row in submitters.items(): if (not re.fullmatch(r"system:serviceaccount:[a-z0-9-]+:[a-z0-9-]+", username) - or set(row) != {"submitter_id", "memo_classes", "review_groups", "daily_limit"} + or not isinstance(row, dict) + or set(row) not in (ROW_KEYS, ROW_KEYS | {"present_existing"}) or not isinstance(row["submitter_id"], str) or not row["submitter_id"] or type(row["daily_limit"]) is not int or row["daily_limit"] < 1): @@ -74,6 +80,15 @@ def configuration(submitters_path, groups_path): for field in ("memo_classes", "review_groups"): if not isinstance(row[field], list) or any(not isinstance(x, str) for x in row[field]): raise ValueError() + if "present_existing" in row: + profile = row["present_existing"] + if (not isinstance(profile, dict) or set(profile) != PRESENT_EXISTING_KEYS + or not isinstance(profile["approval_actor"], str) + or not _ACTOR_RE.fullmatch(profile["approval_actor"]) + or profile["approval_actor"] == "service:informed-decision" + or not isinstance(profile["actions"], list) or not profile["actions"] + or any(not isinstance(a, str) or not _ACTION_RE.fullmatch(a) for a in profile["actions"])): + raise ValueError() if len({r["submitter_id"] for r in submitters.values()}) != len(submitters): raise ValueError() for row in groups.values(): @@ -84,10 +99,51 @@ def configuration(submitters_path, groups_path): raise IntakeError(503, "intake_configuration_unavailable") from None +def verify_existing(approval, submission, *, now=None): + """present_existing (INFD-WP-0010-T01): may this existing approval be presented? + + Every check must hold; the first failure is the refusal code. Nothing here + decides whether the act is permitted: it only refuses to present an object + whose binding is not exactly the one the memo names, or that a human entry + could no longer complete. + """ + memo = submission.memo + expected = submission.approval_binding + now = now or datetime.now(timezone.utc) + if not isinstance(approval, dict) or approval.get("id") != memo.approval_id: + return "approval_mismatch" + binding = approval.get("binding") + if not isinstance(binding, dict): + return "approval_mismatch" + if approval.get("status") != "requested": + return "approval_not_requested" + if approval.get("entries") != []: + return "approval_has_entries" + if binding.get("human_control") is not True: + return "approval_not_human_controlled" + if binding.get("actor") != expected["actor"]: + return "approval_actor_mismatch" + if binding.get("digest") != memo.approval_binding_digest: + return "approval_digest_mismatch" + if any(binding.get(k) != expected[k] for k in ("action", "principal", "purpose", "target")): + return "approval_binding_mismatch" + if approval.get("required_count") != 1: + return "approval_required_count_unsupported" + try: + expires = datetime.fromisoformat(approval["validity"]["expires_at"].replace("Z", "+00:00")) + if expires.tzinfo is None or expires <= now: + return "approval_expired" + except (KeyError, TypeError, AttributeError, ValueError): + return "approval_expired" + return None + + class IntakeService: - def __init__(self, store, authenticator, creator, *, submitters_path, groups_path): + def __init__(self, store, authenticator, creator, *, submitters_path, groups_path, reader=None): + """``reader(approval_id) -> dict`` is a read-only GET used only by present_existing.""" self.store, self.authenticator, self.creator = store, authenticator, creator self.submitters_path, self.groups_path = submitters_path, groups_path + self.reader = reader def pose(self, token, envelope): username = self.authenticator.authenticate(token) @@ -96,12 +152,21 @@ class IntakeService: if row is None: raise IntakeError(403, "submitter_unknown") grant = SubmitterGrant(row["submitter_id"], frozenset(row["memo_classes"]), frozenset(row["review_groups"])) - submission = validate_submission(envelope, submitter_id=grant.submitter_id, grant=grant) + profile = None + if "present_existing" in row: + profile = PresentExisting(row["present_existing"]["approval_actor"], + frozenset(row["present_existing"]["actions"])) + submission = validate_submission(envelope, submitter_id=grant.submitter_id, grant=grant, + present_existing=profile) + if profile is not None and self.reader is None: + raise IntakeError(503, "present_existing_unavailable") recipient = groups.get(submission.review_group) if recipient is None: raise IntakeError(503, "review_group_unconfigured") metadata = {"memo_class": submission.memo_class, "review_group": submission.review_group, "drafted_by": submission.drafted_by, "serves": submission.serves} + if profile is not None: + metadata.update(profile="present_existing", approval_requester=profile.approval_actor) recovered = None # Reserve before any network side effect. SQLite serializes concurrent poses. with self.store._transaction() as db: @@ -115,8 +180,9 @@ class IntakeService: return 200, json.loads(previous["receipt"]) if previous["state"] == "created": recovered = json.loads(previous["receipt"]) - else: + elif profile is None: raise IntakeError(409, "approval_create_unresolved") + # present_existing has no uncertain side effect: an interrupted read is re-verified. if not previous and (db.execute("SELECT 1 FROM memos WHERE id=?", (submission.memo.id,)).fetchone() or db.execute("SELECT 1 FROM intake_ledger WHERE memo_id=?", (submission.memo.id,)).fetchone()): raise IntakeError(409, "memo_id_taken") @@ -126,12 +192,32 @@ class IntakeService: (submission.submitter_id, day)).fetchone()[0] if not previous and count >= row["daily_limit"]: raise IntakeError(429, "submitter_budget_exhausted") + if profile is not None and not previous: + self._refuse_presented(db, submission.memo.approval_id) metadata["day"] = day if not previous: db.execute("INSERT INTO intake_ledger VALUES (?,?,?,?,?,?,NULL)", (submission.submitter_id, submission.idempotency_key, submission.submission_digest, submission.memo.id, "creating", dumps(metadata))) - if recovered is None: + if recovered is None and profile is not None: + # Read-only. No create, entry or consume is ever sent in this path. + try: + approval = self.reader(submission.memo.approval_id) + except Exception: + approval = None + refusal = "approval_unreadable" if approval is None else verify_existing(approval, submission) + if refusal is not None: + # No engine side effect happened: release the reservation. + with self.store._transaction() as db: + db.execute("DELETE FROM intake_ledger WHERE submitter=? AND key=? AND state='creating'", + (submission.submitter_id, submission.idempotency_key)) + raise IntakeError(503 if refusal == "approval_unreadable" else 409, refusal) + approval_id, digest = submission.memo.approval_id, submission.memo.approval_binding_digest + receipt = {"memo_id": submission.memo.id, "approval_id": approval_id, "approval_binding_digest": digest} + with self.store._transaction() as db: + db.execute("UPDATE intake_ledger SET state='created',receipt=? WHERE submitter=? AND key=?", + (dumps(receipt), submission.submitter_id, submission.idempotency_key)) + elif recovered is None: try: approval = self.creator({"binding": submission.approval_binding, "human_control": True, "required_count": 1}) @@ -162,6 +248,8 @@ class IntakeService: (submission.submitter_id, submission.idempotency_key)).fetchone() if current["state"] == "ready": return 200, json.loads(current["receipt"]) + if profile is not None: + self._refuse_presented(db, approval_id, except_key=(submission.submitter_id, submission.idempotency_key)) for item in envelope["packet"]: data = submission.packet[item["item_id"]] document_digest = "sha256:" + hashlib.sha256(data).hexdigest() @@ -187,6 +275,16 @@ class IntakeService: (submission.submitter_id, submission.idempotency_key)) return 201, receipt + @staticmethod + def _refuse_presented(db, approval_id, except_key=None): + """One existing approval is presented by at most one memo.""" + rows = db.execute("SELECT submitter,key FROM intake_ledger WHERE json_extract(receipt,'$.approval_id')=?", + (approval_id,)).fetchall() + if any((r["submitter"], r["key"]) != except_key for r in rows): + raise IntakeError(409, "approval_already_presented") + if db.execute("SELECT 1 FROM memos WHERE json_extract(body,'$.approval_id')=?", (approval_id,)).fetchone(): + raise IntakeError(409, "approval_already_presented") + def __call__(self, environ, start_response): try: if environ.get("PATH_INFO") != "/v1/memos": diff --git a/informed_decision/requester.py b/informed_decision/requester.py index f62dd9f..418ba0a 100644 --- a/informed_decision/requester.py +++ b/informed_decision/requester.py @@ -20,7 +20,10 @@ class RequesterError(Exception): class RequesterTokenProvider: - def __init__(self, issuer, secret_file, *, transport=None, clock=time.time): + def __init__(self, issuer, secret_file, *, transport=None, clock=time.time, scope="approval:create"): + if scope not in ("approval:create", "approval:read"): + raise ValueError("requester scope is create or read only") + self.scope = scope self.issuer = https_origin(issuer) self.secret_file = Path(secret_file) if not self.secret_file.is_absolute(): @@ -44,7 +47,7 @@ class RequesterTokenProvider: headers={"Content-Type": "application/x-www-form-urlencoded", "Authorization": "Basic " + credentials}, body=urlencode({"grant_type": "client_credentials", - "scope": "approval:create"}).encode()) + "scope": self.scope}).encode()) if (status != 200 or not isinstance(result.get("token_type"), str) or result["token_type"].lower() != "bearer" or "refresh_token" in result): @@ -71,7 +74,7 @@ class RequesterTokenProvider: or claims.get("tenant") != "tenant:platform" or claims.get("principal_type") != "service" or claims.get("roles") != [CLIENT_ID] - or claims.get("scope") != "approval:create" + or claims.get("scope") != self.scope or type(claims["iat"]) is not int or type(claims["exp"]) is not int or claims["iat"] > now or claims["exp"] <= now or not 0 < claims["exp"] - claims["iat"] <= 900): @@ -88,9 +91,13 @@ def compose_intake(store, token_review_call, *, issuer, secret_file, This factory grants no submitter and starts no socket. The separate private listener and deployment remain reviewed owner configuration. """ - from .intake_approval import IntakeApprovalClient + from .intake_approval import IntakeApprovalClient, IntakeApprovalReader from .intake_service import IntakeService, TokenReview provider = RequesterTokenProvider(issuer, secret_file, transport=transport) creator = IntakeApprovalClient(approval_origin, provider, allow_internal_http=True) + # present_existing only; needs approval:read on the requester client (KeyCape registration). + # Until granted, that exchange is refused and only present_existing submitters fail closed. + read_provider = RequesterTokenProvider(issuer, secret_file, transport=transport, scope="approval:read") + reader = IntakeApprovalReader(approval_origin, read_provider, allow_internal_http=True) return IntakeService(store, TokenReview(token_review_call), creator, - submitters_path=submitters_path, groups_path=groups_path) + submitters_path=submitters_path, groups_path=groups_path, reader=reader) diff --git a/informed_decision/store.py b/informed_decision/store.py index 3df961c..ba253b5 100644 --- a/informed_decision/store.py +++ b/informed_decision/store.py @@ -282,7 +282,9 @@ class Store: or receipt['approval_binding_digest'] != memo.approval_binding_digest): raise Conflict('intake receipt does not match memo') return {'review_group': metadata['review_group'], 'memo_class': metadata['memo_class'], - 'intake_submitter': row['submitter'], 'approval_requester': 'informed-decision', + 'intake_submitter': row['submitter'], + # present_existing names the approval's own actor; the create path is informed-decision. + 'approval_requester': metadata.get('approval_requester', 'informed-decision'), 'approval_human_control': True} def intake_context(self, memo): diff --git a/tests/test_intake_present_existing.py b/tests/test_intake_present_existing.py new file mode 100644 index 0000000..c913069 --- /dev/null +++ b/tests/test_intake_present_existing.py @@ -0,0 +1,250 @@ +"""present_existing intake profile (INFD-WP-0010-T01). + +A registered submitter presents an approval it created itself in approval-engine. +Intake verifies the object read-only and never creates, enters or consumes. +Synthetic fixtures; the opt-in test at the end uses approval-engine's real store +(INFD_APPROVAL_ENGINE_SOURCE) to produce the object shape and binding digest. +""" +from datetime import datetime, timedelta, timezone +import json +import os +from pathlib import Path +import sys + +import pytest + +from informed_decision.intake import IntakeRefused, PresentExisting, SubmitterGrant, validate_submission +from informed_decision.intake_service import IntakeService, TokenReview, configuration, IntakeError +from informed_decision.store import Store +from test_intake import envelope as base_envelope +from test_intake_service import request +from informed_decision.intake_service import AUDIENCE + +EXECUTOR = 'system:serviceaccount:sso:nk-bind-executor' +STATE_HUB = 'system:serviceaccount:state-hub:state-hub' +ACTOR = 'service:nk-bind-executor' +APPROVAL = '0f1e2d3c-4b5a-4968-8776-655443322110' +DIGEST = 'sha256:' + 'b' * 64 +TARGET = {'admission_id': 'BEA-2026-0001', 'admission_digest': 'sha256:' + 'c' * 64} +PROFILE = PresentExisting(ACTOR, frozenset({'bounded-execution'})) +GRANT = SubmitterGrant('nk-bind-executor', frozenset({'bounded-execution'}), frozenset({'net-kingdom-admins'})) + + +def envelope(approval_id=APPROVAL, digest=DIGEST): + env = base_envelope() + env['idempotency_key'] = 'bea-2026-0001-r1' + env['memo_class'] = 'bounded-execution' + env['approval_intent'] = {'action': 'bounded-execution', 'principal': 'admin', + 'purpose': 'Rotate one consumer bind password', 'target': dict(TARGET)} + env['memo']['id'] = 'bea-2026-0001-r1' + env['memo']['approval_id'] = approval_id + env['memo']['approval_binding_digest'] = digest + del env['serves'] + return env + + +def approval(**changes): + now = datetime.now(timezone.utc) + body = {'id': APPROVAL, 'status': 'requested', 'entries': [], 'required_count': 1, + 'binding': {'action': 'bounded-execution', 'actor': ACTOR, 'principal': 'admin', + 'purpose': 'Rotate one consumer bind password', 'target': dict(TARGET), + 'digest': DIGEST, 'human_control': True, 'pdp_path': False, 'pdp_digest': None}, + 'validity': {'not_before': (now - timedelta(minutes=1)).isoformat(), + 'expires_at': (now + timedelta(hours=1)).isoformat()}} + for key, value in changes.items(): + if key.startswith('binding.'): + body['binding'][key[8:]] = value + else: + body[key] = value + return body + + +def review(body): + username = {'executor-token': EXECUTOR, 'hub-token': STATE_HUB}[body['spec']['token']] + return {'status': {'authenticated': True, 'audiences': [AUDIENCE], + 'user': {'username': username, 'uid': 'workload-uid'}}} + + +@pytest.fixture +def setup(tmp_path): + private = tmp_path / 'private' + private.mkdir(mode=0o700) + store = Store(private / 'review.sqlite') + submitters, groups = tmp_path / 'submitters.json', tmp_path / 'groups.json' + submitters.write_text(json.dumps({ + EXECUTOR: {'submitter_id': 'nk-bind-executor', 'memo_classes': ['bounded-execution'], + 'review_groups': ['net-kingdom-admins'], 'daily_limit': 5, + 'present_existing': {'approval_actor': ACTOR, 'actions': ['bounded-execution']}}, + STATE_HUB: {'submitter_id': 'state-hub', 'memo_classes': ['credential-lane'], + 'review_groups': ['net-kingdom-admins'], 'daily_limit': 5}})) + groups.write_text(json.dumps({'net-kingdom-admins': {'subject': 'keycape-human', 'display_name': 'Reviewer'}})) + created, reads = [], [] + current = {'body': approval()} + + def create(body): + created.append(body) + return {'id': '12345678-1234-1234-1234-123456789abc', 'status': 'requested', 'binding': { + **body['binding'], 'human_control': True, 'digest': 'sha256:' + 'a' * 64}} + + def read(approval_id): + reads.append(approval_id) + return json.loads(json.dumps(current['body'])) + + service = IntakeService(store, TokenReview(review), create, submitters_path=submitters, + groups_path=groups, reader=read) + return service, store, created, reads, current + + +def test_presents_existing_without_creating(setup): + service, store, created, reads, _ = setup + status, receipt = request(service, envelope(), token='executor-token') + assert status == 201 + assert receipt == {'memo_id': 'bea-2026-0001-r1', 'approval_id': APPROVAL, 'approval_binding_digest': DIGEST} + assert created == [] and reads == [APPROVAL] + memo = store.memo('bea-2026-0001-r1') + assert memo.approval_id == APPROVAL and memo.binding.principal.id == 'keycape-human' + context = store.intake_context(memo) + assert context['approval_requester'] == ACTOR + assert context['intake_submitter'] == 'nk-bind-executor' + # Replay is served from the ledger without another read. + assert request(service, envelope(), token='executor-token') == (200, receipt) + assert reads == [APPROVAL] + + +@pytest.mark.parametrize('changes, code', [ + ({'status': 'approved'}, 'approval_not_requested'), + ({'status': 'consumed'}, 'approval_not_requested'), + ({'entries': [{'subject': 'someone'}]}, 'approval_has_entries'), + ({'binding.human_control': False}, 'approval_not_human_controlled'), + ({'binding.actor': 'informed-decision'}, 'approval_actor_mismatch'), + ({'binding.actor': 'service:other'}, 'approval_actor_mismatch'), + ({'binding.digest': 'sha256:' + 'd' * 64}, 'approval_digest_mismatch'), + ({'binding.purpose': 'Something else'}, 'approval_binding_mismatch'), + ({'binding.target': {'admission_id': 'BEA-2026-0002', 'admission_digest': TARGET['admission_digest']}}, + 'approval_binding_mismatch'), + ({'required_count': 2}, 'approval_required_count_unsupported'), + ({'validity': {'not_before': '2026-01-01T00:00:00+00:00', 'expires_at': '2026-01-02T00:00:00+00:00'}}, + 'approval_expired'), + ({'id': '99999999-1234-1234-1234-123456789abc'}, 'approval_mismatch'), +]) +def test_refuses_unpresentable_approval_and_releases_reservation(setup, changes, code): + service, store, created, _, current = setup + current['body'] = approval(**changes) + assert request(service, envelope(), token='executor-token') == (409, {'error': code}) + assert created == [] + with store._connection() as db: + assert db.execute('SELECT COUNT(*) FROM memos').fetchone()[0] == 0 + assert db.execute('SELECT COUNT(*) FROM intake_ledger').fetchone()[0] == 0 + # Once the object is presentable the same submission succeeds. + current['body'] = approval() + assert request(service, envelope(), token='executor-token')[0] == 201 + + +def test_unreadable_approval_fails_closed(setup): + service, store, created, _, _ = setup + def broken(approval_id): + raise OSError('upstream detail never echoed') + service.reader = broken + assert request(service, envelope(), token='executor-token') == (503, {'error': 'approval_unreadable'}) + service.reader = None + assert request(service, envelope(), token='executor-token') == (503, {'error': 'present_existing_unavailable'}) + assert created == [] + + +def test_one_approval_one_memo(setup): + service, _, _, _, _ = setup + assert request(service, envelope(), token='executor-token')[0] == 201 + second = envelope() + second['idempotency_key'] = 'bea-2026-0001-r2' + second['memo']['id'] = 'bea-2026-0001-r2' + assert request(service, second, token='executor-token') == (409, {'error': 'approval_already_presented'}) + + +def test_profile_rejects_unpinned_and_unadmitted_actions(setup): + service, _, created, reads, _ = setup + unpinned = envelope(approval_id=None, digest=None) + assert request(service, unpinned, token='executor-token') == (400, {'error': 'invalid_text'}) + other = envelope() + other['approval_intent']['action'] = 'deploy' + assert request(service, other, token='executor-token') == (400, {'error': 'action_not_admitted'}) + assert created == [] and reads == [] + + +def test_existing_submitters_keep_create_path(setup): + service, store, created, reads, _ = setup + status, receipt = request(service, base_envelope(), token='hub-token') + assert status == 201 and len(created) == 1 and reads == [] + assert created[0]['binding']['actor'] == 'informed-decision' + assert store.intake_context(store.memo(receipt['memo_id']))['approval_requester'] == 'informed-decision' + # A create-path submitter still cannot pin an approval. + assert request(service, envelope(), token='hub-token')[0] == 400 + + +def test_validation_actor_comes_from_profile_not_body(): + out = validate_submission(envelope(), submitter_id='nk-bind-executor', grant=GRANT, present_existing=PROFILE) + assert out.profile == 'present_existing' + assert out.approval_binding == {'action': 'bounded-execution', 'actor': ACTOR, 'principal': 'admin', + 'purpose': 'Rotate one consumer bind password', 'target': TARGET} + with pytest.raises(IntakeRefused) as error: + validate_submission(envelope(), submitter_id='nk-bind-executor', grant=GRANT) + assert error.value.reason in ('unknown_field', 'approval_pinned_by_submitter') # BEA target or pin + + +@pytest.mark.parametrize('profile', [ + {'approval_actor': 'informed-decision', 'actions': ['bounded-execution']}, + {'approval_actor': 'service:informed-decision', 'actions': ['bounded-execution']}, + {'approval_actor': ACTOR, 'actions': []}, + {'approval_actor': ACTOR, 'actions': ['Bad Action']}, + {'approval_actor': ACTOR, 'actions': ['bounded-execution'], 'extra': True}, +]) +def test_registry_profile_is_closed(tmp_path, profile): + submitters, groups = tmp_path / 's.json', tmp_path / 'g.json' + submitters.write_text(json.dumps({EXECUTOR: {'submitter_id': 'x', 'memo_classes': [], 'review_groups': [], + 'daily_limit': 1, 'present_existing': profile}})) + groups.write_text('{}') + with pytest.raises(IntakeError): + configuration(submitters, groups) + + +def test_reader_adapter_is_get_only(): + from informed_decision.intake_approval import IntakeApprovalReader + calls = [] + class Transport: + def request(self, method, url, *, headers, body=None): + calls.append((method, url, body)) + return 200, approval() + reader = IntakeApprovalReader('https://engine.example', lambda: 'read-token', transport=Transport()) + assert reader(APPROVAL)['id'] == APPROVAL + assert calls == [('GET', 'https://engine.example/v1/approvals/' + APPROVAL, None)] + with pytest.raises(IntakeError): + reader('../consume') + + +def test_real_engine_object_is_presentable(setup, tmp_path): + """Opt-in: approval-engine's own store creates the object as the executor would.""" + source = os.environ.get('INFD_APPROVAL_ENGINE_SOURCE') + if not source: + pytest.skip('set INFD_APPROVAL_ENGINE_SOURCE for the real-engine contract check') + assert (Path(source) / 'approval_engine' / 'store.py').is_file() + sys.path.insert(0, source) + from approval_engine.store import Engine + service, store, created, _, _ = setup + engine = Engine(tmp_path / 'approval.db') + now = datetime.now(timezone.utc) + record = engine.create({'action': 'bounded-execution', 'actor': ACTOR, 'principal': 'admin', + 'purpose': 'Rotate one consumer bind password', 'target': dict(TARGET)}, + {'not_before': (now - timedelta(seconds=1)).isoformat(), + 'expires_at': (now + timedelta(minutes=30)).isoformat()}, + human_control=True, approval_id=APPROVAL) + real = engine.get(APPROVAL).as_dict() + service.reader = lambda approval_id: engine.get(approval_id).as_dict() + env = envelope(digest=real['binding']['digest']) + status, receipt = request(service, env, token='executor-token') + assert status == 201 and created == [] + assert receipt['approval_binding_digest'] == real['binding']['digest'] + assert engine.get(APPROVAL).as_dict()['entries'] == [] and record is not None + # A wrong digest from the submitter is refused against the real object. + other = envelope(digest='sha256:' + 'e' * 64) + other['idempotency_key'] = 'bea-2026-0001-r9' + other['memo']['id'] = 'bea-2026-0001-r9' + assert request(service, other, token='executor-token') == (409, {'error': 'approval_already_presented'}) diff --git a/workplans/INFD-WP-0010-present-existing-and-proportionate-decisions.md b/workplans/INFD-WP-0010-present-existing-and-proportionate-decisions.md new file mode 100644 index 0000000..de3f050 --- /dev/null +++ b/workplans/INFD-WP-0010-present-existing-and-proportionate-decisions.md @@ -0,0 +1,99 @@ +--- +id: INFD-WP-0010 +type: workplan +title: "Present existing approvals, proportionate C1 confirm and hub write-back" +domain: infotech +repo: informed-decision +status: proposed +owner: claude-code +topic_slug: netkingdom +created: "2026-10-31" +updated: "2026-10-31" +flavor: implementation +depends_on: [] +related: + - NK-WP-0056 + - INFD-WP-0005 + - APPROVAL-WP-0007 +--- + +# INFD-WP-0010: present existing approvals, proportionate decisions + +Owner answer to net-kingdom's decision services alignment review (2026-10-31): see +`docs/decision-services-alignment-answer.md`. Founder direction: informed-decision is the one human-facing decision +surface. It should be light for simple decisions and complete for risky ones, and its split with approval-engine +should be explicit: approval-engine holds the object, created by the actor who uses it, and informed-decision +presents it and records the human's act. + +## S1: present_existing intake profile + +```task +id: INFD-WP-0010-T01 +status: done +priority: high +``` + +A registered submitter's profile `present_existing` (`approval_actor`, `actions`) lets it pose a memo that pins an +approval it created itself. Intake GETs the approval and verifies: status requested, no entries, human_control, +actor == profile actor, digest == memo, binding == intent, required_count 1, not expired, one memo per approval. It +never creates, enters or consumes. The recorded requester flows to review and to the access-engine context. +Existing submitters are unchanged. + +**Done 2026-10-31 (local):** code in `intake.py`, `intake_service.py`, `intake_approval.py`, `requester.py`, +`store.py`; tests in `tests/test_intake_present_existing.py`. Not deployed. + +## S1 live enablement for nk-bind-executor + +```task +id: INFD-WP-0010-T02 +status: wait +priority: high +depends_on: [INFD-WP-0010-T01] +``` + +Waiting on owners outside this repository: + +- access-engine: a compact-sitting version that admits `approval_requester` `service:nk-bind-executor` for submitter + `nk-bind-executor` and class `bounded-execution`, at aal2 (v13 requires `informed-decision`); +- key-cape: `approval:read` on `informed-decision-sitting-requester`; +- net-kingdom: an executor projected token with audience `informed-decision-intake`, and a NetworkPolicy to the + private listener. + +Then here: the policy pin, a registry row, a release, and a native proof with synthetic BEA content. Optional: for +`bounded-execution`, require a packet item whose digest equals `target.admission_digest`, once the admission +digest's canonical form is confirmed. + +**Done when:** a founder aal2 accept of an executor-created approval is recorded, and the executor consumes it. + +## S4: C1 single-screen confirm + +```task +id: INFD-WP-0010-T03 +status: wait +priority: medium +``` + +A lighter memo profile with one question, one packet item, one required highlight and options. It needs a fresh +aal1 session and creates no approval object. It records view_hash and disposition and resolves the served hub +decision. Waits on gate-house doctrine for decision classes C0–C3 (review S5), an access-engine policy action for +an approval-less confirm, and the founder's answer on class cuts. + +**Done when:** a C1 decision is confirmed in the browser and resolves its hub decision with evidence. No approval +object is created. + +## S3: hub write-back enablement and structured links + +```task +id: INFD-WP-0010-T04 +status: wait +priority: medium +``` + +The write-back exists (INFD-WP-0005, `INFD_HUB_WRITEBACK`). Remaining work: + +- the founder's decision to enable it in production; +- with state-hub: structured decision links (`memo_id`, `approval_id`, `view_hash`, `evidence_ref`) that replace + the rationale-text idempotency check. + +**Done when:** a served hub decision is resolved by a human disposition in production, and its link fields are +populated.