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
14 KiB
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
resolvedstatus 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):statusopen/resolved/escalated/superseded,decided_byfree text (100 chars),rationale, no link to a memo or approval.POST /decisions/{id}/resolveaccepts{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 setstatus,decided_byanddecided_atdirectly, which bypasses even that gate.POST /decisions/can create a decision alreadyresolved.api/services/memo_forwarding.pyalready contains a resolution ledger (observe_resolution,run_due_resolution). It posts the free-text payload above to/resolveand recognises its own earlier resolution by searching the rationale formemo <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)requiresresolution_source = informed_decision,memo_idandresolution_evidencenot null.status = resolved AND decision_class = C2additionally requiresapproval_id.- Unique partial index on
memo_idwhere 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)
- 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 keystate-hub.<decision_id>.<memo_id>. At enqueue the worker recordsmemo_idon the decision through the new route's link mode (section 5.3), so the hub shows what is waiting where. - The founder decides in informed-decision (accept or decline, at the assurance the class needs).
- 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.
- The worker validates the outcome record (section 5.2) and posts it to
POST /decisions/{id}/resolve-evidence. - The hub verifies the caller, checks the record against the decision (class,
memo_id,approval_id,hub_decision_id), stores it, setsresolved, writes adecision_resolvedprogress event and publishesorg.statehub.decision.resolvedwith 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-evidencerequires 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(recordsmemo_id, andapproval_idfor C2, while the decision stays open) andresolve(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 linkedmemo_id. decided_byis filled fromdisplay_name(orsubject) for compatibility, but the authoritative fields areresolution_evidence.subjectandassurance. 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_refsdefaulting to[], the CHECK constraints and the partial unique index. Existing rows getdecision_class = NULLand behave as C0; no backfill, no reclassification of past decisions. DecisionReadgains optional fields; existing clients (MCP, dashboard,statehub, consistency checks, recently-on-scope) keep working.DecisionResolveis 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_forwardingresolution ledger: the text payload (decided_by+rationalesearch) is replaced by the outcome record and thememo_idcolumn. 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.resolvedgainsdecision_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
- Pull or push: the dispatch worker pulls outcomes (recommended) or informed-decision pushes to the hub with its own workload identity.
- 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-delegatorbinding. - 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.
- 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_idas the correlation key; keepresolves: on-answersemantics (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.