Decision services alignment S3: owner answer and STATE-WP-0101 (proposed)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Accept S3 with adaptations: write-back via the dispatch worker to one
authenticated route, hub status is never execution authority, kept
portable for retirement. 6.4: typed columns for enforced fields plus
typed evidence_refs for the rest.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5502@bnt-lap001
Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196
This commit is contained in:
tegwick 2026-10-31 23:02:27 +01:00
parent 783a3d39c1
commit 042f3c9b71
2 changed files with 305 additions and 0 deletions

View file

@ -0,0 +1,218 @@
# Decision services alignment: State Hub owner answer (S3)
**Date:** 2026-10-31
**Answers:** hub message 59390db6 (net-kingdom), proposal
`net-kingdom/docs/proposals/2026-10-31-decision-services-alignment-review.md` (commit b764561),
sections 3, 4.4, 5 (S3) and open question 6.4.
**Status:** owner answer; design only. No API, schema or deployment change is made with this
document. Implementation is proposed as `workplans/STATE-WP-0101-decision-evidence-links.md`.
## 1. Answer in short
**S3 is accepted, with adaptations.**
- Hub decisions become the *index* of open questions. Decisions of class C1 or C2 are resolved
only by evidence from informed-decision, never by free text. C0 stays as today.
- **Adaptation 1, transport:** the write-back is carried by State Hub's existing private memo
dispatch worker (STATE-WP-0096), which pulls the decided outcome from informed-decision with its
existing intake-audience token and posts it to one new authenticated hub route. informed-decision
does not get a hub credential, and the hub authenticates exactly one writer.
- **Adaptation 2, authority:** a hub decision's `resolved` status is a projection of evidence held
by informed-decision, approval-engine and audit-core. Nothing may execute on the strength of a
hub decision status. A C2 executor checks the approval object, never the hub.
- **Adaptation 3, retirement:** State Hub is in retirement (`INTENT.md`, `prj-state-hub-retirement`).
The change is kept small and portable so it can move to the decisions owner in hub-core without
reshaping. The decisions capability needs a keep/move disposition in the retirement ledger; this
design does not claim permanent ownership.
**Answer to 6.4: both, split by purpose.** Typed columns for the fields the hub enforces, constrains
or queries on; one typed `evidence_refs` list for everything else. Details in section 3.
## 2. What exists today (state-hub evidence)
- `decisions` (`api/models/decision.py`): `status` open/resolved/escalated/superseded,
`decided_by` free text (100 chars), `rationale`, no link to a memo or approval.
- `POST /decisions/{id}/resolve` accepts `{rationale, decided_by, write_log}` from any caller. The
only gate is a required multi-owner review contract, if one is attached.
- `PATCH /decisions/{id}` can set `status`, `decided_by` and `decided_at` directly, which bypasses
even that gate. `POST /decisions/` can create a decision already `resolved`.
- `api/services/memo_forwarding.py` already contains a resolution ledger (`observe_resolution`,
`run_due_resolution`). It posts the free-text payload above to `/resolve` and recognises its own
earlier resolution by searching the rationale for `memo <id> `. No production caller invokes it;
that is the "disabled" write-back.
- The dispatch worker runs as `system:serviceaccount:state-hub:state-hub`, has no database
credential, and its NetworkPolicy allows egress only to DNS and informed-decision intake on 8081.
- The hub API has no authenticated-caller boundary (STATE-WP-0096 rationale). STATE-WP-0100 adds
claimed write attribution for all writes; its token verification is observe-only by founder
decision of 2026-10-25.
## 3. Data model (answer to 6.4)
### 3.1 Columns on `decisions` (enforced, queried)
| Column | Type | Purpose |
| --- | --- | --- |
| `decision_class` | enum `C0,C1,C2,C3`, nullable | NULL means legacy and behaves as C0. Raise-only once set |
| `memo_id` | text, nullable | informed-decision memo that answers this question. Replaces the rationale search for correlating a 409 |
| `approval_id` | uuid, nullable | approval-engine object; required to resolve C2 |
| `resolution_source` | enum `text,informed_decision`, nullable | how it was resolved |
| `resolution_evidence` | jsonb, nullable | the verified write-back record (section 5), stored as received |
Constraints (DB CHECK and API):
- `status = resolved AND decision_class IN (C1,C2)` requires `resolution_source = informed_decision`,
`memo_id` and `resolution_evidence` not null.
- `status = resolved AND decision_class = C2` additionally requires `approval_id`.
- Unique partial index on `memo_id` where not null: one memo answers at most one decision.
Why columns here: these fields carry rules (who may resolve, what must be present), the conflict
check needs an indexed lookup instead of a text search, and the dashboard and `needs_human` views
filter by class.
### 3.2 `evidence_refs` (descriptive, not enforced)
`evidence_refs`: jsonb list, default `[]`, items `{type, ref, digest?}` with a closed `type`
vocabulary validated by the API: `presentation`, `audit_record`, `ccr`, `workplan_task`, `adr`,
`commit`, `document`. Agents may add references to any decision class (for example the CCR or the
workplan task that a decision concerns). They do not resolve anything and have no constraint
attached. The vocabulary grows by owner change, not free form.
### 3.3 Why not only one of them
- Only typed refs: the C1/C2 rule becomes "a ref of type X must exist", which a DB constraint
cannot express cleanly, and the 409 correlation stays a scan.
- Only columns: every new evidence kind would be a migration in a retiring service.
## 4. Resolution rules
| Path | C0 / legacy (NULL) | C1 | C2 |
| --- | --- | --- | --- |
| `POST /decisions/{id}/resolve` (text) | allowed, as today | 409 `evidence_required` | 409 `evidence_required` |
| `PATCH` setting `status=resolved`, `decided_by`, `decided_at`, `rationale`, `memo_id`, `approval_id`, `resolution_*` | allowed for C0 (as today) | refused | refused |
| `PATCH` changing `decision_class` | may raise | may raise, never lower | never lower |
| `POST /decisions/` with `status=resolved` and class C1/C2 | allowed for C0 | refused | refused |
| Supersede (`DELETE`) | allowed | allowed (withdraws the question; records who and why) | allowed; does **not** revoke the approval object |
| `POST /decisions/{id}/resolve-evidence` (new) | refused (C0 needs no evidence) | authenticated writer only | authenticated writer only, `approval_id` required |
| MCP `resolve_decision` | as today | gets the same 409 from the API | same |
Who sets the class: the creator, at creation or later by raising it. Doctrine for which class a
question needs comes from gate-house (S5). Until S5, the hub suggests a floor only for the existing
financial/legal auto-escalation keywords (C1 minimum). Linking a decision to a task with
`needs_human: true` does not by itself raise the class.
The existing review-contract gate keeps applying on both resolve routes.
## 5. Write-back: who writes, how it authenticates, what informed-decision sends
### 5.1 Flow (pull through the existing dispatch worker)
1. A C1/C2 hub decision is posed as a memo through the existing STATE-WP-0096 path. The envelope
already carries `serves: {hub_decision_id, resolves: on-answer}` and the stable idempotency key
`state-hub.<decision_id>.<memo_id>`. At enqueue the worker records `memo_id` on the decision
through the new route's link mode (section 5.3), so the hub shows what is waiting where.
2. The founder decides in informed-decision (accept or decline, at the assurance the class needs).
3. The worker reads the outcome of its own submitted memos from informed-decision with its existing
intake-audience token. informed-decision answers only for memos this submitter posed.
4. The worker validates the outcome record (section 5.2) and posts it to
`POST /decisions/{id}/resolve-evidence`.
5. The hub verifies the caller, checks the record against the decision (class, `memo_id`,
`approval_id`, `hub_decision_id`), stores it, sets `resolved`, writes a `decision_resolved`
progress event and publishes `org.statehub.decision.resolved` with the evidence fields added.
Why pull rather than informed-decision pushing: informed-decision then needs no hub credential and
no egress to the hub; the hub admits one writer it already deploys; the worker already holds the
retry, replay and conflict ledger. If informed-decision prefers push, the same route works with
informed-decision's workload subject allowlisted instead; that is a founder choice (section 8).
### 5.2 What informed-decision must send (outcome record, `informed-decision.decision-outcome` v1)
| Field | Required | Notes |
| --- | --- | --- |
| `kind`, `version` | yes | `informed-decision.decision-outcome`, `1` |
| `hub_decision_id` | yes | from the memo's `serves`; must equal the route's id |
| `memo_id`, `memo_revision` | yes | the revision that was shown |
| `verb` | yes | `accept` or `decline` (terminal only; other verbs do not resolve) |
| `option` | C1 with options | the chosen option id |
| `subject` | yes | authenticated human subject: issuer + `sub` from the KeyCape session, not a display name |
| `assurance` | yes | `acr`/aal and authentication time of the deciding session |
| `decided_at` | yes | RFC 3339 UTC |
| `view_hash` | yes | `sha256:<64 hex>` of what was presented |
| `presentation_id` | yes | informed-decision presentation record |
| `approval_id`, `approval_entry_id` | C2 | the approval-engine object and the human entry |
| `audit_ref` | when available | audit-core record id |
| `display_name` | optional | used only to fill `decided_by` for display |
| `note` | optional | bounded human text (max 2 KiB), secret-scanned like memo content |
Rules: no secrets, no tokens, no packet content; the record is bounded at 8 KiB as the existing
ledger already enforces. A decline resolves the decision with outcome `declined`; it is an answer.
### 5.3 How the route authenticates (who may resolve with evidence)
- `POST /decisions/{id}/resolve-evidence` requires a Kubernetes projected ServiceAccount token with
a dedicated audience (`state-hub-decision-writeback`), verified by the API through TokenReview.
- The verified subject must equal an allowlist of exactly one entry:
`system:serviceaccount:state-hub:state-hub` (the dispatch worker's identity, selected by the
founder in STATE-WP-0096). No other caller, no agent and no human browser session can use it.
- The route has two modes: `link` (records `memo_id`, and `approval_id` for C2, while the
decision stays open) and `resolve` (section 5.1 step 5). Both are idempotent on
`(decision_id, memo_id)`; a second resolve with the same memo returns 200, a different memo
returns 409 with the linked `memo_id`.
- `decided_by` is filled from `display_name` (or `subject`) for compatibility, but the authoritative
fields are `resolution_evidence.subject` and `assurance`. The hub records the human subject as
*attested by informed-decision through the authenticated worker*; it does not authenticate the
human itself.
- This is an enforced route, distinct from STATE-WP-0100's claimed, observe-only attribution of
ordinary writes. When STATE-WP-0100 phase 3 lands, the same token verification can be shared.
Prerequisites outside this repository: TokenReview needs the API's ServiceAccount bound to
`system:auth-delegator` (cluster RBAC, railiance-cluster); the worker needs a second projected
token audience and NetworkPolicy egress to the hub API Service; the API's ingress policy must admit
the worker. If TokenReview RBAC is refused, the alternative is verifying the token signature
against the cluster's service-account issuer JWKS.
## 6. Migration and backward compatibility
- One additive Alembic migration: nullable columns, `evidence_refs` defaulting to `[]`, the CHECK
constraints and the partial unique index. Existing rows get `decision_class = NULL` and behave as
C0; no backfill, no reclassification of past decisions.
- `DecisionRead` gains optional fields; existing clients (MCP, dashboard, `statehub`, consistency
checks, recently-on-scope) keep working. `DecisionResolve` is unchanged for C0.
- New refusals apply only to decisions explicitly classed C1 or C2. Agents that resolve C0/legacy
decisions by text see no change.
- `memo_forwarding` resolution ledger: the text payload (`decided_by` + `rationale` search) is
replaced by the outcome record and the `memo_id` column. Its tests are rewritten accordingly.
The rationale-text correlation is retained only for reading pre-migration rows.
- Rollout order: schema and refusals first (inert until a decision is classed); then the route
with its token check; then worker pull and write-back; then classing the first real decisions.
Rollback leaves the columns and stored evidence in place.
- Event envelope `org.statehub.decision.resolved` gains `decision_class`, `memo_id`, `approval_id`,
`resolution_source`; existing attributes are kept.
- For retirement: the column set and the outcome record are the portable contract; hub-core's
decisions owner should adopt them unchanged.
## 7. What is not changed
- No public enqueue route and no automatic scanning of decisions: posing a memo for a decision
remains an explicit act, as in STATE-WP-0096.
- The hub does not create, read or consume approvals and gains no approval-engine scopes.
- The hub does not decide class doctrine (gate-house, S5) or present anything (informed-decision).
## 8. Founder decisions needed
1. **Pull or push:** the dispatch worker pulls outcomes (recommended) or informed-decision pushes
to the hub with its own workload identity.
2. **First enforced hub route:** admit TokenReview-based enforcement on this one route, ahead of
STATE-WP-0100's observe-only phase 3, including the `system:auth-delegator` binding.
3. **Class floor before S5:** whether the existing financial/legal keyword escalation should set
C1 as a minimum, or classes stay purely declared until gate-house doctrine exists.
4. **Retirement disposition:** confirm the decisions capability is "keep, move to hub-core" so this
work is not stranded.
## 9. Asks of other owners
- **informed-decision:** an outcome read for a submitter's own memos returning the record in
section 5.2; treat `serves.hub_decision_id` as the correlation key; keep `resolves: on-answer`
semantics (accept/decline only).
- **approval-engine:** none for S3. The hub only stores `approval_id`.
- **gate-house:** S5 class doctrine, including who may raise a class.
- **net-kingdom / railiance-cluster:** RBAC and NetworkPolicy prerequisites in section 5.3.

View file

@ -0,0 +1,87 @@
---
id: STATE-WP-0101
type: workplan
title: "Decision classes, evidence links and informed-decision write-back (S3)"
domain: infotech
repo: state-hub
status: proposed
owner: claude-code
topic_slug: infotech
created: "2026-10-31"
updated: "2026-10-31"
flavor: implementation
related: [STATE-WP-0096, STATE-WP-0100, NK-WP-0056]
---
Proposed from hub message 59390db6 (net-kingdom, decision services alignment, S3). Design and
owner answer: `docs/decision-services-alignment-answer.md`. Hub decisions become the index of open
questions; C1/C2 decisions are resolved only by informed-decision evidence carried by the private
memo dispatch worker; C0 and legacy decisions stay as today. Nothing executes on hub decision
status. Kept small and portable because State Hub is in retirement.
## Founder decisions on the design
```task
id: STATE-WP-0101-T01
status: wait
priority: high
```
Waiting on the founder: the four questions in section 8 of the design (pull vs push, first
enforced route with TokenReview, class floor before gate-house S5, retirement disposition of the
decisions capability). Record the answers in the design document.
## Schema, read model and text-resolution refusals
```task
id: STATE-WP-0101-T02
status: wait
priority: high
```
Prerequisite: T01. Additive Alembic migration (`decision_class`, `memo_id`, `approval_id`,
`resolution_source`, `resolution_evidence`, `evidence_refs`, CHECK constraints, partial unique
index on `memo_id`). Optional fields on `DecisionRead`; typed `evidence_refs` validation. Refuse
text resolution, resolving PATCH fields, class lowering and resolved creation for C1/C2 with 409
`evidence_required`. Tests for C0/legacy unchanged behaviour and every refusal in section 4.
## Authenticated evidence route
```task
id: STATE-WP-0101-T03
status: wait
priority: high
```
Prerequisite: T02 and the RBAC decision in T01. `POST /decisions/{id}/resolve-evidence` with
`link` and `resolve` modes, projected-token verification (audience
`state-hub-decision-writeback`, exact subject allowlist), outcome record validation per section
5.2, idempotency on `(decision_id, memo_id)`, progress event and enriched resolved event. Tests use
a fake token reviewer; no live token.
## Dispatch worker: outcome pull and write-back
```task
id: STATE-WP-0101-T04
status: wait
priority: high
```
Prerequisite: T03 and informed-decision's outcome read (owner: informed-decision). Replace the
free-text resolution payload in `memo_forwarding` with the outcome record and `memo_id`
correlation; link at enqueue; pull outcomes for the worker's own memos; keep retry and conflict
ledger. Rewrite `tests/test_memo_forwarding.py` resolution cases.
## Deployment candidate and first classed decision
```task
id: STATE-WP-0101-T05
status: wait
priority: medium
```
Prerequisite: T04 and cluster prerequisites (auth-delegator binding, second projected audience,
NetworkPolicy egress worker to API and API ingress from worker). Server dry-run of changed
objects; live rollout is a separate founder-approved change. Acceptance: one real C1 decision
resolved by founder answer in informed-decision with stored evidence, and a text resolve of it
refused.