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
13 KiB
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.mddefers 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, scopesstatehub:read|write|domain:<slug>:write|admin. - Middleware exists for writes (
WriteIdempotencyMiddleware,ETagMiddlewareinapi/main.py), so a cross-cutting hook point exists. Several routers are built fromhub_core.routers.*factories (progress is), so per-route column additions touch the sharedhub_corepackage 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 carryagent.id,agent.mode(autonomous|delegated), and for delegated agents the delegating actor inactor_suboract.sub;jtiis 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
assurancecolumn 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
claimedfirst; 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,modeand 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:
- Observe (claimed). Option A. Header contract,
write_attributiontable, read endpoint, helper in thestatehubCLI and the MCP servers that sets headers from env (STATEHUB_ACTOR,STATEHUB_ACTOR_TYPE,STATEHUB_SESSION). Unattributed writes are accepted and recorded asassurance=unattributed, with a counter in/state/health-adjacent metrics. - Correlate. Add
commit_refand the asynchronous trailer check (B) only if founder wants commit joins; independent of phase 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 perdocs/multi-user-access-model.mdscopes. 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_agentstay as they are. - Backfill is optional and only produces
assurance=legacyrows, 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 stayprincipal_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_agentvalues 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:
assuranceis nevertokenunless 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
progressvolume, 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
assurancevalues become a contract; publish them indocs/schemas/and tell net-kingdom, hub_core and MCP maintainers.
10. Founder decisions (decided 2026-10-25)
- Decided: Option A is phase 1 (claimed tier) with the header names above. No
hub_coreschema change; only the sidecarwrite_attributiontable. Revisithub_coreif a per-record actor column is ever wanted. - Decided: correlation with commits by equal session id and run id only. The asynchronous
commit verifier and
commit_ref(Option B) are deferred; revisit if signed commits are introduced. - Decided: phase 3 verifies key-cape agent tokens (KEY-WP-0043) in OBSERVE mode only:
record
assurance=token, never reject. Enforcement is a separate later founder decision. - Decided: existing free-text authors are not backfilled. A read-only count of distinct
author/decided_by/from_agentvalues may be run later to size a mapping. - Decided:
hub_coreis not changed in phase 1.
Sections 5 and 7 are read accordingly: phase 2 (commit_ref and trailer check) is deferred and the backfill in section 7 is not planned.