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
186 lines
12 KiB
Markdown
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.
|