informed-decision/docs/decision-services-alignment-answer.md
tegwick 0e66a33389 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
2026-10-31 23:07:43 +01:00

172 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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".