informed-decision/docs/decision-services-alignment-answer.md
tegwick 8787e1a14a INFD-WP-0010: record founder decisions 2026-11-01; outcome read for State Hub's pull (T04)
Founder decisions relayed 2026-11-01: GH-DEC-2026-026 accepted as drafted
(unblocks S4 doctrine; S4 now waits only on access-engine policy), and the
hub pulls decision outcomes through its STATE-WP-0096 dispatch worker.

S3 re-scoped: GET /v1/memos/{id}/outcome on the private listener returns
informed-decision.decision-outcome v1 for memos the caller posed, only for
a submitter row with outcome_read: true. Read-only and value-free.
T05 (enable after STATE-WP-0101) and T06 (retire push write-back) added.

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-11-01 00:37:59 +01:00

223 lines
15 KiB
Markdown
Raw Permalink 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`.
## Founder decisions, 2026-11-01
Relayed by the net-kingdom coordinator on 2026-11-01 (quoted as relayed):
1. **gate-house GH-DEC-2026-026 accepted as drafted:** decision classes C0–C3; a decision is at least C2 when
anyone relies on it as authority for an act its standing mandate doesn't cover; C3 reserved; the actor that
files a C2 approval may never approve it; agents never approve at C1+. This answers open question 1 and removes
S4's doctrine dependency. S4's only remaining external blocker is access-engine policy work (below).
2. **State Hub write-back:** the hub's existing STATE-WP-0096 dispatch worker fetches decision outcomes from
informed-decision (informed-decision gets no hub credential); state-hub needs an outcome read, record
`informed-decision.decision-outcome` v1 (fields: state-hub `docs/decision-services-alignment-answer.md`
§5.2). Switch informed-decision's hub write-back on once STATE-WP-0101 lands. This answers open question 2
and re-scopes S3 (below).
3. access-engine (review policy for requester `service:nk-bind-executor`) and key-cape (`approval:read` for
`informed-decision-sitting-requester`) are being asked by separate workers (open questions 3 and 4).
## 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. Doctrine settled 2026-11-01; waits only on an access-engine policy action |
| S3 hub write-back | **Accepted, as a pull** (founder decision 2026-11-01) | Outcome read implemented locally and tested (WP-0010-T04); enablement waits on STATE-WP-0101 (T05) |
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). **Update 2026-11-01:** items 1 and 3 below are
answered by GH-DEC-2026-026 (accepted as drafted). A C1 confirm is never authority for an act outside a standing
mandate (such reliance makes the decision C2), so it needs no approval object and does not bind for execution.
Item 2, access-engine policy work, is the only remaining blocker. S4 is not being implemented now.
It was not built because three things had to 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
### Outcome read (founder decision 2026-11-01, implemented locally)
The hub pulls. `GET /v1/memos/{memo_id}/outcome` on the private workload listener:
- **Who:** TokenReview with audience `informed-decision-intake` (the worker's existing token), and a submitter row
with `outcome_read: true`. Only State Hub's row (`system:serviceaccount:state-hub:state-hub`) is meant to carry
it; the flag cannot be combined with `present_existing`. Not yet set in shipped deployment data (T05).
- **What:** only memos that submitter posed (anything else is 404 `memo_not_found`, including other submitters'
memos), serving a hub decision with `resolves: on-answer` (else 409 `memo_does_not_resolve`).
- **When:** the first terminal human disposition (accept or decline) on any revision is the answer. An accept is
reported only once its approval-engine entry is confirmed; before that the answer is 202 `pending`, and a lost
entry response is 409 `approval_entry_unresolved` (needs the existing recovery, never a guess).
- **Record** (`informed-decision.decision-outcome` v1, `status: decided`): `hub_decision_id`, `memo_id`,
`memo_revision`, `verb`, `subject {issuer, sub}`, `assurance {aal, auth_time}` (from the policy observation
recorded for that act; 409 `assurance_unrecorded` if absent), `decided_at`, `view_hash`, `presentation_id`,
`approval_id`, and for an accept `approval_entry {approved_at}`; `audit_ref` once audit-core has receipted the
disposition. Bounded at 8 KiB.
- **Deliberate differences from state-hub §5.2**, for state-hub to confirm:
- `approval_entry_id`: approval-engine entries have no id. The record carries `approval_entry.approved_at`; with
`approval_id` and `subject.sub` it is approval-engine's correlation triple.
- `option`: not emitted until S4 adds options.
- `display_name` and `note`: not emitted (value-free; the memo's principal display name is the review group's,
not the human's). The hub fills `decided_by` from `subject`.
- **Read-only:** no budget spent, no row written, no outbound call.
`INFD_HUB_WRITEBACK` (the push below) stays off under decision 2 and is retired after the pull is live (T06).
### Push write-back (INFD-WP-0005, superseded)
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.~~ Answered 2026-11-01:
GH-DEC-2026-026 accepted as drafted.
2. ~~**Founder:** enable hub write-back in production now (S3)?~~ Answered 2026-11-01: hub pulls; switch on once
STATE-WP-0101 lands.
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".