state-hub/docs/hub-write-attribution-design.md
tegwick 4a141d41e6
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
STATE-WP-0100: record founder decisions of 2026-10-25 (option A, observe-only phase 3, no backfill, no verifier)
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-25 00:22:24 +02:00

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

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. Founder decisions (decided 2026-10-25)

  1. Decided: Option A is phase 1 (claimed tier) with the header names above. No hub_core schema change; only the sidecar write_attribution table. Revisit hub_core if a per-record actor column is ever wanted.
  2. 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.
  3. 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.
  4. Decided: existing free-text authors are not backfilled. A read-only count of distinct author/decided_by/from_agent values may be run later to size a mapping.
  5. Decided: hub_core is 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.