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:
parent
d5cb527d4c
commit
0e66a33389
8 changed files with 731 additions and 24 deletions
172
docs/decision-services-alignment-answer.md
Normal file
172
docs/decision-services-alignment-answer.md
Normal 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".
|
||||||
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
250
tests/test_intake_present_existing.py
Normal file
250
tests/test_intake_present_existing.py
Normal 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'})
|
||||||
|
|
@ -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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue