INFD-WP-0010: present_existing intake profile (S1) and owner answer to decision services alignment

A registered submitter with a present_existing profile may pose a memo that
pins an approval it created itself in approval-engine. Intake verifies the
object read-only (requested, no entries, human_control, actor == profile
actor, digest == memo, binding == intent, required_count 1, unexpired, one
memo per approval) and never creates, enters or consumes. The recorded
requester reaches review and the access-engine context. Existing submitters
are unchanged.

Answer doc accepts S1 (profile, not route; packet carries the admission),
S4 in principle (waits on gate-house classes and policy) and S3 (exists
behind INFD_HUB_WRITEBACK). Workplan INFD-WP-0010 proposed.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5502@bnt-lap001
Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196
This commit is contained in:
tegwick 2026-10-31 23:07:43 +01:00
parent d5cb527d4c
commit 0e66a33389
8 changed files with 731 additions and 24 deletions

View file

@ -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:<slug>` 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: <actor>`. 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: "<reviewer> 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".

View file

@ -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}$") _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}$") _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}$") _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 (§3.3.6). Shapes, not values; deliberately broad.
SECRET_MARKERS: tuple[tuple[str, re.Pattern[str]], ...] = ( SECRET_MARKERS: tuple[tuple[str, re.Pattern[str]], ...] = (
@ -74,6 +75,18 @@ class SubmitterGrant:
review_groups: frozenset[str] 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) @dataclass(frozen=True)
class ValidatedSubmission: class ValidatedSubmission:
submitter_id: str submitter_id: str
@ -86,6 +99,7 @@ class ValidatedSubmission:
approval_binding: dict approval_binding: dict
memo: Memo memo: Memo
packet: dict[str, bytes] packet: dict[str, bytes]
profile: str = "create"
def _closed(value: Any, keys: set[str], required: set[str], path: str) -> dict: 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") 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") memo = _closed(memo, MEMO_KEYS, MEMO_KEYS - {"locale", "ui_release"}, "$.memo")
_text(memo["id"], "$.memo.id", max_len=63, pattern=_MEMO_ID_RE) _text(memo["id"], "$.memo.id", max_len=63, pattern=_MEMO_ID_RE)
if memo["version"] != 1 or isinstance(memo["version"], bool): if memo["version"] != 1 or isinstance(memo["version"], bool):
raise IntakeRefused("draft_version_must_be_1", "$.memo.version") raise IntakeRefused("draft_version_must_be_1", "$.memo.version")
if memo["sealed"] is not False: if memo["sealed"] is not False:
raise IntakeRefused("draft_must_be_unsealed", "$.memo.sealed") raise IntakeRefused("draft_must_be_unsealed", "$.memo.sealed")
for field in ("approval_id", "approval_binding_digest"): if pinned:
if memo[field] is not None: # present_existing: the memo must name the existing approval exactly.
raise IntakeRefused("approval_pinned_by_submitter", f"$.memo.{field}") _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")) check_one_question(_text(memo["question"], "$.memo.question"))
_text(memo["requested_act"], "$.memo.requested_act", max_len=40, pattern=_SLUG_RE) _text(memo["requested_act"], "$.memo.requested_act", max_len=40, pattern=_SLUG_RE)
_text(memo["brief"], "$.memo.brief", max_len=20000) _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 raise IntakeRefused("memo_unparseable", "$.memo") from None
def validate_submission(envelope: Any, *, submitter_id: str, grant: SubmitterGrant | None) -> ValidatedSubmission: def _existing_target(value: Any, path: str) -> dict:
"""Validate one intake envelope for an already-authenticated submitter.""" """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: if grant is None or grant.submitter_id != submitter_id:
raise IntakeRefused("submitter_unknown", "$") raise IntakeRefused("submitter_unknown", "$")
env = _closed(envelope, ENVELOPE_KEYS, ENVELOPE_REQUIRED, "$") 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") intent = _closed(env["approval_intent"], INTENT_KEYS, INTENT_KEYS, "$.approval_intent")
for field in ("action", "principal", "purpose"): for field in ("action", "principal", "purpose"):
_text(intent[field], f"$.approval_intent.{field}", max_len=400) _text(intent[field], f"$.approval_intent.{field}", max_len=400)
target = _closed(intent["target"], TARGET_KEYS, TARGET_KEYS, "$.approval_intent.target") if present_existing is not None:
for field in TARGET_KEYS: target = _existing_target(intent["target"], "$.approval_intent.target")
_text(target[field], f"$.approval_intent.target.{field}", max_len=200) 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"] packet = env["packet"]
if not isinstance(packet, list) or not 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 contents[item["item_id"]] = data
hashes[item["item_id"]] = "sha256:" + hashlib.sha256(data).hexdigest() 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: if memo_class not in grant.memo_classes:
raise IntakeRefused("memo_class_not_admitted", "$.memo_class") 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, drafted_by=drafted_by,
serves=dict(serves) if serves else None, serves=dict(serves) if serves else None,
approval_binding={**{k: intent[k] for k in ("action", "principal", "purpose")}, 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, memo=memo,
packet=contents, packet=contents,
profile="present_existing" if present_existing else "create",
) )

View file

@ -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 from datetime import datetime, timedelta, timezone
import re
from .http_transport import JSONTransport, fixed_origin from .http_transport import JSONTransport, fixed_origin
from .intake import canonical from .intake import canonical
@ -32,3 +36,34 @@ class IntakeApprovalClient:
return result return result
except Exception: except Exception:
raise IntakeError(503, 'approval_create_unresolved') from None 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

View file

@ -12,13 +12,18 @@ from pathlib import Path
import re import re
import uuid 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 .memo import Principal
from .records import dumps from .records import dumps
from .store import Conflict from .store import Conflict
AUDIENCE = "informed-decision-intake" AUDIENCE = "informed-decision-intake"
MAX_BODY = 1024 * 1024 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): class IntakeError(Exception):
@ -66,7 +71,8 @@ def configuration(submitters_path, groups_path):
raise ValueError() raise ValueError()
for username, row in submitters.items(): for username, row in submitters.items():
if (not re.fullmatch(r"system:serviceaccount:[a-z0-9-]+:[a-z0-9-]+", username) 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 isinstance(row["submitter_id"], str)
or not row["submitter_id"] or type(row["daily_limit"]) is not int or not row["submitter_id"] or type(row["daily_limit"]) is not int
or row["daily_limit"] < 1): or row["daily_limit"] < 1):
@ -74,6 +80,15 @@ def configuration(submitters_path, groups_path):
for field in ("memo_classes", "review_groups"): for field in ("memo_classes", "review_groups"):
if not isinstance(row[field], list) or any(not isinstance(x, str) for x in row[field]): if not isinstance(row[field], list) or any(not isinstance(x, str) for x in row[field]):
raise ValueError() 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): if len({r["submitter_id"] for r in submitters.values()}) != len(submitters):
raise ValueError() raise ValueError()
for row in groups.values(): for row in groups.values():
@ -84,10 +99,51 @@ def configuration(submitters_path, groups_path):
raise IntakeError(503, "intake_configuration_unavailable") from None 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: 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.store, self.authenticator, self.creator = store, authenticator, creator
self.submitters_path, self.groups_path = submitters_path, groups_path self.submitters_path, self.groups_path = submitters_path, groups_path
self.reader = reader
def pose(self, token, envelope): def pose(self, token, envelope):
username = self.authenticator.authenticate(token) username = self.authenticator.authenticate(token)
@ -96,12 +152,21 @@ class IntakeService:
if row is None: if row is None:
raise IntakeError(403, "submitter_unknown") raise IntakeError(403, "submitter_unknown")
grant = SubmitterGrant(row["submitter_id"], frozenset(row["memo_classes"]), frozenset(row["review_groups"])) 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) recipient = groups.get(submission.review_group)
if recipient is None: if recipient is None:
raise IntakeError(503, "review_group_unconfigured") raise IntakeError(503, "review_group_unconfigured")
metadata = {"memo_class": submission.memo_class, "review_group": submission.review_group, metadata = {"memo_class": submission.memo_class, "review_group": submission.review_group,
"drafted_by": submission.drafted_by, "serves": submission.serves} "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 recovered = None
# Reserve before any network side effect. SQLite serializes concurrent poses. # Reserve before any network side effect. SQLite serializes concurrent poses.
with self.store._transaction() as db: with self.store._transaction() as db:
@ -115,8 +180,9 @@ class IntakeService:
return 200, json.loads(previous["receipt"]) return 200, json.loads(previous["receipt"])
if previous["state"] == "created": if previous["state"] == "created":
recovered = json.loads(previous["receipt"]) recovered = json.loads(previous["receipt"])
else: elif profile is None:
raise IntakeError(409, "approval_create_unresolved") 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() 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()): or db.execute("SELECT 1 FROM intake_ledger WHERE memo_id=?", (submission.memo.id,)).fetchone()):
raise IntakeError(409, "memo_id_taken") raise IntakeError(409, "memo_id_taken")
@ -126,12 +192,32 @@ class IntakeService:
(submission.submitter_id, day)).fetchone()[0] (submission.submitter_id, day)).fetchone()[0]
if not previous and count >= row["daily_limit"]: if not previous and count >= row["daily_limit"]:
raise IntakeError(429, "submitter_budget_exhausted") 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 metadata["day"] = day
if not previous: if not previous:
db.execute("INSERT INTO intake_ledger VALUES (?,?,?,?,?,?,NULL)", db.execute("INSERT INTO intake_ledger VALUES (?,?,?,?,?,?,NULL)",
(submission.submitter_id, submission.idempotency_key, submission.submission_digest, (submission.submitter_id, submission.idempotency_key, submission.submission_digest,
submission.memo.id, "creating", dumps(metadata))) 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: try:
approval = self.creator({"binding": submission.approval_binding, approval = self.creator({"binding": submission.approval_binding,
"human_control": True, "required_count": 1}) "human_control": True, "required_count": 1})
@ -162,6 +248,8 @@ class IntakeService:
(submission.submitter_id, submission.idempotency_key)).fetchone() (submission.submitter_id, submission.idempotency_key)).fetchone()
if current["state"] == "ready": if current["state"] == "ready":
return 200, json.loads(current["receipt"]) 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"]: for item in envelope["packet"]:
data = submission.packet[item["item_id"]] data = submission.packet[item["item_id"]]
document_digest = "sha256:" + hashlib.sha256(data).hexdigest() document_digest = "sha256:" + hashlib.sha256(data).hexdigest()
@ -187,6 +275,16 @@ class IntakeService:
(submission.submitter_id, submission.idempotency_key)) (submission.submitter_id, submission.idempotency_key))
return 201, receipt 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): def __call__(self, environ, start_response):
try: try:
if environ.get("PATH_INFO") != "/v1/memos": if environ.get("PATH_INFO") != "/v1/memos":

View file

@ -20,7 +20,10 @@ class RequesterError(Exception):
class RequesterTokenProvider: 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.issuer = https_origin(issuer)
self.secret_file = Path(secret_file) self.secret_file = Path(secret_file)
if not self.secret_file.is_absolute(): if not self.secret_file.is_absolute():
@ -44,7 +47,7 @@ class RequesterTokenProvider:
headers={"Content-Type": "application/x-www-form-urlencoded", headers={"Content-Type": "application/x-www-form-urlencoded",
"Authorization": "Basic " + credentials}, "Authorization": "Basic " + credentials},
body=urlencode({"grant_type": "client_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) if (status != 200 or not isinstance(result.get("token_type"), str)
or result["token_type"].lower() != "bearer" or result["token_type"].lower() != "bearer"
or "refresh_token" in result): or "refresh_token" in result):
@ -71,7 +74,7 @@ class RequesterTokenProvider:
or claims.get("tenant") != "tenant:platform" or claims.get("tenant") != "tenant:platform"
or claims.get("principal_type") != "service" or claims.get("principal_type") != "service"
or claims.get("roles") != [CLIENT_ID] 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 type(claims["iat"]) is not int or type(claims["exp"]) is not int
or claims["iat"] > now or claims["exp"] <= now or claims["iat"] > now or claims["exp"] <= now
or not 0 < claims["exp"] - claims["iat"] <= 900): 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 This factory grants no submitter and starts no socket. The separate private
listener and deployment remain reviewed owner configuration. listener and deployment remain reviewed owner configuration.
""" """
from .intake_approval import IntakeApprovalClient from .intake_approval import IntakeApprovalClient, IntakeApprovalReader
from .intake_service import IntakeService, TokenReview from .intake_service import IntakeService, TokenReview
provider = RequesterTokenProvider(issuer, secret_file, transport=transport) provider = RequesterTokenProvider(issuer, secret_file, transport=transport)
creator = IntakeApprovalClient(approval_origin, provider, allow_internal_http=True) 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, 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)

View file

@ -282,7 +282,9 @@ class Store:
or receipt['approval_binding_digest'] != memo.approval_binding_digest): or receipt['approval_binding_digest'] != memo.approval_binding_digest):
raise Conflict('intake receipt does not match memo') raise Conflict('intake receipt does not match memo')
return {'review_group': metadata['review_group'], 'memo_class': metadata['memo_class'], 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} 'approval_human_control': True}
def intake_context(self, memo): def intake_context(self, memo):

View file

@ -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'})

View file

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