STATE-WP-0100: design proposal for attributable actor and session on hub writes (SI-15)

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

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 328925@bnt-lap001
Assistant-Session: 14b795fd-c632-4c01-8d28-e7e490c7b0ed
This commit is contained in:
tegwick 2026-10-24 23:49:25 +02:00
parent 7f1b057c1c
commit 0789f9b71b
2 changed files with 281 additions and 0 deletions

View file

@ -0,0 +1,186 @@
# Attributable actor and session on State Hub writes (design proposal)
Status: proposal, 2026-10-24. No API or model code is changed by this document.
Origin: hub message 05ba80d7 from net-kingdom (rule SI-15 "Accountable automation" of
`net-kingdom/canon/standards/service-identity-privilege_v0.1.md`, draft-3; review
`net-kingdom/history/2026-10-24-security-identity-and-agent-review.md`).
Workplan: `workplans/STATE-WP-0100-hub-write-attribution.md`.
## 1. Problem
SI-15 asks that each state-changing action by an agent be reconstructable: actor, on whose
authority, run or session, operation, result, where the evidence is. Git commits already carry
`Assistant`, `Assistant-Session`, `Assistant-Process` trailers. Hub writes do not have an equivalent:
the actor is a free-text string chosen by the caller and nothing authenticates it.
## 2. Current state (read from the code)
| Write | Actor field today | Session field | Notes |
| --- | --- | --- | --- |
| `POST /progress/` | `author` (String 100, optional, free text) | `session_id` (optional) | model `api/models/progress_event.py` |
| `POST /decisions/`, resolve | `decided_by` (String 100) | none | `api/models/decision.py` |
| `POST /messages/` | `from_agent` (String 100, required) | none | reader on mark-read is a query param `?reader=` |
| `PATCH /tasks/{id}` | none on the row | `session_id` accepted by the schema (`api/schemas/task.py`) | status changes leave no actor |
| suggestions, intake, technical debt, interface changes | `author` free text | none | |
| review contracts, repository renames | `actor` (String 160) | none | closest to what SI-15 needs |
Other facts that shape the design:
- There is no API authentication. `docs/multi-user-access-model.md` defers it explicitly and lists
"automation needs per-consumer attribution and revocation independent of repo commits" as a
trigger. That trigger is now met in the attribution sense (not yet the exposure sense). The same
document already sketches the token shape: accept NetKingdom IAM Profile OIDC tokens, one
break-glass local admin token, map claims to roles, scopes `statehub:read|write|domain:<slug>:write|admin`.
- Middleware exists for writes (`WriteIdempotencyMiddleware`, `ETagMiddleware` in `api/main.py`),
so a cross-cutting hook point exists. Several routers are built from `hub_core.routers.*`
factories (progress is), so per-route column additions touch the shared `hub_core` package too.
- The edge relay and outbox (`api/edge/`, `docs/offline-write-buffer.md`) queue writes and replay
them later. Attribution must be captured at request time and survive replay.
- IAM Profile v0.3 principal types: `human`, `service`, `agent`. Agent tokens carry
`agent.id`, `agent.mode` (`autonomous` | `delegated`), and for delegated agents the delegating
actor in `actor_sub` or `act.sub`; `jti` is recommended.
## 3. Requirements
R1 Tell an agent's write from a person's and from another agent's.
R2 Record session or run, and for delegated agents the delegating person.
R3 Uniform across all mutating routes, including task status updates that have no actor column.
R4 Honest about trust: distinguish "claimed" from "verified". Never present free text as verified.
R5 Backward compatible: existing clients and the existing `author` field keep working.
R6 Respects the hub boundary: hub is a read model, not an identity authority; no secrets in the
hub; the hub records identity facts it was given or verified, it does not issue them.
R7 Works through the edge relay/outbox and the offline buffer.
## 4. Options
### Option A: server-recorded attribution from request headers (claimed tier), schema ready for verification
Clients send `X-Actor-Id`, `X-Actor-Type` (`human|service|agent`), `X-Actor-Session`,
optionally `X-Actor-Delegator`, `X-Actor-Run` (or the same set as one JSON header). A middleware
(or a FastAPI dependency) validates the shape and, for every successful mutating request, appends
a row to a new append-only table `write_attribution`:
`id, occurred_at, method, route, entity_type, entity_id, request_id, actor_id, principal_type,
agent_mode, delegator, session_id, run_id, commit_ref (optional), assurance` where
`assurance` is `claimed` (A), `token` (C) or `legacy` (backfill). Existing `author` columns stay
as display text. A sidecar table, not new columns on each entity, means one migration, no change
to the `hub_core` factories' schemas, and coverage of tasks and decisions that have no actor column.
- Trade-offs: cheap, uniform, immediately useful for correlation and for telling writers apart
when clients are honest; but a caller can claim any identity, so by itself it does not satisfy
"authenticated". It does close the gap that attribution exists at all, and it is the storage
that options B and C need anyway.
- Effort: about 3-4 days (migration, middleware, schema, read endpoint
`GET /attribution/?entity_id=`, CLI/skill helper to set headers, docs).
- Risks: (1) false sense of assurance, mitigated by the mandatory `assurance` column and by
surfacing it in every read; (2) header spoofing within the private network (accepted, declared);
(3) middleware body-reading cost, avoided by only reading headers and the response status/path;
(4) entity id for creates must be taken from the response, so the hook sits after the handler.
### Option B: link to commit trailers (correlation, optionally verified by the reader)
Accept `X-Actor-Commit` (repo + commit sha) or a `commit_ref` field; a verifier job (not the
request path) fetches the commit from Forgejo and checks that its `Assistant-Session` /
`Assistant-Process` trailers equal the claimed session, recording `verified_commit=true`.
Trailers are themselves unauthenticated text unless commits are signed, so this proves only
that the hub claim and the repo claim agree, and only for writes that have a commit.
- Trade-offs: reuses existing evidence, strong for audit joins ("this progress event
belongs to this commit"), weak as authentication; most hub writes (progress, message) have no commit.
- Effort: +2-3 days on top of A (verifier job, Forgejo read access which already exists via the
repo-manager path).
- Risks: signed-commit coverage is unknown; verification is asynchronous so a row is
`claimed` first; commit may be force-pushed away.
### Option C: authenticated principal from a bearer token (IAM Profile v0.3)
Require `Authorization: Bearer <JWT>` from the NetKingdom issuer on writes. Verify signature, `iss`,
`aud=state-hub`, `exp`; take `sub`, `principal_type`, `agent.id/mode`, `act.sub`, `jti` from claims and
record them in `write_attribution` with `assurance=token`. Optionally enforce
`statehub:write` and domain scopes (as in the access model).
- Trade-offs: the only option that is real authentication and revocation; maps one-to-one to
IAM Profile. Needs an issuer that mints agent tokens with `agent.id`, `mode` and delegation
(net-kingdom/key-cape own that; not established as delivered), a JWKS fetch with cache, client
token acquisition inside every agent runtime, edge-relay token handling (replay after expiry),
and a break-glass path. Large blast radius if it goes wrong: all writers (cron flows, CLI, MCP,
dashboard, relay) must carry tokens before enforcement.
- Effort: 2-3 weeks including client rollout, plus dependencies outside this repo.
- Risks: outage of the issuer blocks hub writes unless a fail-open observe mode exists;
token expiry versus queued outbox writes; credential custody for non-interactive jobs (OpenBao).
## 5. Recommendation
Adopt **A now, designed so C is an upgrade of the same table, with B as an optional correlation
field**, in three gated phases:
1. **Observe (claimed).** Option A. Header contract, `write_attribution` table, read endpoint,
helper in the `statehub` CLI and the MCP servers that sets headers from env
(`STATEHUB_ACTOR`, `STATEHUB_ACTOR_TYPE`, `STATEHUB_SESSION`). Unattributed writes are accepted
and recorded as `assurance=unattributed`, with a counter in `/state/health`-adjacent metrics.
2. **Correlate.** Add `commit_ref` and the asynchronous trailer check (B) only if founder wants
commit joins; independent of phase 3.
3. **Authenticate.** When net-kingdom's issuer mints agent tokens (their gate, not ours), verify bearer
tokens in observe mode first (token present and valid -> `assurance=token`, claims override
headers; invalid -> recorded and warned), then enforce per `docs/multi-user-access-model.md`
scopes. Enforcement is a separate founder decision.
Why: it satisfies SI-15's reconstructability today with an honest trust label, needs nothing
outside this repo, is reversible (additive table, no enforcement), and does not make the hub an
identity authority (R6). Going straight to C would block on an issuer capability that is not yet
delivered and would risk every cron and relay writer at once.
## 6. Mapping to IAM Profile principal types
| IAM Profile | Header / column | Rule |
| --- | --- | --- |
| `principal_type: human` | `principal_type=human`, `actor_id` = person handle | no delegator |
| `principal_type: service` | `service`, `actor_id` = service+environment name | e.g. hub flows, cron jobs |
| `principal_type: agent`, `agent.mode=autonomous` | `agent`, `agent_mode=autonomous`, `actor_id` = `agent.id` | |
| `principal_type: agent`, `agent.mode=delegated` | `agent`, `agent_mode=delegated`, `delegator` = `actor_sub`/`act.sub` | delegator required, else recorded as `incomplete` |
| `jti` | `token_jti` (phase 3) | audit/replay join |
| none (git trailers) | `session_id` = `Assistant-Session`, `run_id` = `Assistant-Process` | same values as commits so rows join |
## 7. Migration of existing free-text authors
- Do not rewrite history. `author`, `decided_by`, `from_agent` stay as they are.
- Backfill is optional and only produces `assurance=legacy` rows, derived from a reviewed mapping
file (`registry/` style) of known author strings (`codex`, `claude-code`, `custodian`,
`net-kingdom`, ...) to `{actor_id, principal_type}`; strings not in the mapping stay
`principal_type=unknown`. Legacy rows are never upgraded to claimed or token.
- Reads show the attribution tier next to the legacy text, so old and new rows are distinguishable.
- Counts first: a read-only query of distinct `author`/`decided_by`/`from_agent` values sizes the
mapping before any backfill is approved.
## 8. Test strategy
- Unit: header parser (valid, malformed, oversized, unknown type, delegated without delegator).
- API: for each mutating route family (progress, decisions create/resolve, messages
send/read/reply, task PATCH, suggestions, intake) one test that a row is written with the
right entity id; failed requests (4xx/5xx) write nothing; GET writes nothing.
- Compatibility: requests with no headers still succeed and record `unattributed`; existing
tests unchanged.
- Idempotency/replay: a replayed idempotent write does not duplicate the attribution row; outbox
replay preserves the original actor and `occurred_at`.
- Property: `assurance` is never `token` unless a token was verified (phase 3 tests with a
local test issuer, fixtures only, no real secrets).
- Migration: Alembic up/down on a scratch database only.
## 9. Risks (overall)
- Over-claiming: reports must say "claimed" unless `assurance=token`. Net-kingdom's standard
should keep hub authorship listed as a gap until phase 3.
- Table growth: one row per write; estimate from `progress` volume, add retention by
archival, not deletion.
- Hub as identity authority creep: the hub stores identifiers only, no credentials, and
verification keys come from the issuer's JWKS.
- Privacy of session ids: low sensitivity, but do not put prompts or paths into headers.
- Cross-repo drift: header names and `assurance` values become a contract; publish them in
`docs/schemas/` and tell net-kingdom, hub_core and MCP maintainers.
## 10. Decisions needed from the founder
1. Accept option A as phase 1 (claimed tier) and the header names above?
2. Is commit-trailer correlation (phase 2) wanted, or is session id equality enough?
3. Phase 3 timing: wait for net-kingdom/key-cape agent tokens, and enforce or only observe?
4. Backfill of legacy authors: map via reviewed file, or leave unmapped?
5. Whether `hub_core` (shared factory package) changes are acceptable in this repo's cycle.

View file

@ -0,0 +1,95 @@
---
id: STATE-WP-0100
type: workplan
title: "Attributable actor and session on State Hub writes (SI-15)"
domain: infotech
repo: state-hub
status: proposed
owner: claude-code
topic_slug: infotech
created: "2026-10-24"
updated: "2026-10-24"
flavor: implementation
related: [NK-WP-0054]
---
Proposed from hub message 05ba80d7 (net-kingdom, rule SI-15 accountable automation). Design and
recommendation: `docs/hub-write-attribution-design.md`. Nothing here starts until the founder
accepts the recommendation (option A now, token verification later). Phase 3 depends on
net-kingdom/key-cape minting agent tokens and is not ours to schedule.
## Founder decision on the design
```task
id: STATE-WP-0100-T01
status: todo
priority: high
```
Founder answers the five questions in section 10 of the design. Done when recorded in the
design document or as a hub decision. Blocks T02 onward.
## Header contract and schema
```task
id: STATE-WP-0100-T02
status: wait
priority: high
```
Prerequisite: T01. Publish the header names, `assurance` values and the `write_attribution`
table shape in `docs/schemas/`; agree with net-kingdom, hub_core and MCP maintainers.
## Sidecar table, middleware and read endpoint
```task
id: STATE-WP-0100-T03
status: wait
priority: high
```
Prerequisite: T02. Alembic migration, header validation, post-handler hook for all mutating
routes, `GET /attribution/`, tests per section 8 of the design. Unattributed writes are accepted.
## Client helpers
```task
id: STATE-WP-0100-T04
status: wait
priority: medium
```
Prerequisite: T03. `statehub` CLI, both MCP servers and the edge relay/outbox set and preserve
the actor headers (`STATEHUB_ACTOR`, `STATEHUB_ACTOR_TYPE`, `STATEHUB_SESSION`).
## Legacy author mapping (optional)
```task
id: STATE-WP-0100-T05
status: wait
priority: low
```
Prerequisite: T03 and founder decision 4. Read-only count of distinct author strings, reviewed
mapping file, `assurance=legacy` backfill. Never upgrade legacy rows.
## Commit-trailer correlation (optional)
```task
id: STATE-WP-0100-T06
status: wait
priority: low
```
Prerequisite: T03 and founder decision 2. `commit_ref` plus asynchronous trailer check.
## Token verification (gated)
```task
id: STATE-WP-0100-T07
status: wait
priority: medium
```
Prerequisite: net-kingdom/key-cape agent-token availability and founder decision 3. Observe-mode
bearer verification per IAM Profile v0.3, then a separate enforcement decision.