state-hub/docs/hub-write-attribution-design.md
tegwick 0789f9b71b 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
2026-10-24 23:49:25 +02:00

186 lines
12 KiB
Markdown

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