DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/cr-2026-227-w1-agent-sessions-v0_2.md

CR-2026-227 — W1: sessions for native agents — v0_2

Version. 0.2 — supersedes v0_1 (which stands as sibling, per the never-overwrite rule). v0_1 opened blocked on Q1 and Q2; the Operator ruled on 2026-08-20. Changes in this version: Q1 RULED (identity table folded into scope, §3 items 1–3 are new); Q2 HELD (§7 — a naming fence, not a blocker); Q3 RULED (folded in, §3 item 10); Q4 answered (§6 — closes in a named CR, not in W1). §2 ground truth is carried forward unchanged. This CR is no longer blocked; it is fenced. Date. 2026-08-20 Status. Open, buildable, name-fenced. Second CR of the H0 lane (Boundaries handoff Step 4 item 2; scope from loomworks-cr-brief-h0-native-habitat-v0_1.md §2 W1). Predecessor: CR-2026-226 (W7), landed at engine 1b850d5 + b606309 (see its amendment v0_1). Runtime direction: no conflict, and one pre-existing conflict surfaced not created. Implements O9 (nothing may preclude sessions — W1 is the session); serves O1 by putting the session id on the existing named chokepoints rather than beside them; creates no standing credential (O2); adds no masquerader and removes one mislabel (O3, §3 item 10); keeps identity UUID-class (O4); adds no generic-execute surface (O5); shapes refusals as citable verdicts (O6); adds FORAY call sites at mint/close/refuse (O7); puts session and identity state in named tables (O8); forks no authorization seam (O10). Surfaced, not created: the own-key bypass in gated_room_complete — an O1 finding, closing in CR-2026-228 (§6). Rulings and decisions cited. R1, R2 (boundaries-record/arc/briefs/loomworks-handoff-step-4-v0_1.md); the 2026-08-20 Operator rulings on Q1, Q2, Q3, Q4 of this CR's v0_1; DR-01 (terminology — binding, and under amendment), DR-02 (zero standing authority), DR-05 (three laws — no one-way doors), DR-07 (the date is the builder's), DR-13 (declared persistence), DR-16 (build on stele-agentic-id, as re-scoped by R2), DR-18 (clocks). Findings cited. investigations/loomworks-boundaries-arc-cc-investigation-findings-v0_2.mdthe plural-chokepoint finding (Surprise 9); Surprise 11 (instruction_version nominal); Surprise 24 / D2 (stele-agentic-id implements identity roots + clamp/revoke lanes, imported by nothing).


1. What changed from v0_1

Q1 — RULED. W1 grows by one minimal agent identity table: permanent record, one row per agent, zero live power (DR-01, DR-02). Distinct from the principals table in the stele package — agents are not people, and the two tables must never be joined into one. The Operator's words: this is the design's floor, not an extension of it, so it is not a W0. Folded into §3 as items 1–3.

Q2 — HELD, as a fence. The engine's stele package keeps its name. The agent-identity term is what renames, and the replacement word is an Operator ruling landing as a DR-01 amendment in boundaries-record/decisions/. Until it arrives: no module is written under either name. Everything in W1 that does not turn on the name proceeds. §7 records exactly which work is fenced and which is not, because the honest answer is "less than it sounds, but not none."

Q3 — RULED. RENDER_AUTO_TRIGGER_SYSTEM_ACTOR (…0010) reclassifies to kind="system" inside W1, with a one-line note. Rationale in the Operator's words: substrate machinery should not appear in "which agents are running right now." §3 item 10.

Q4 — ANSWERED. The own-key ungated path does not close in W1. It closes in CR-2026-228, sequenced after W4 and before demo assembly. §6 states the shape and why it is not W1's.

2. Ground truth — verified at engine b606309 (carried forward from v0_1)

There is no agents table. No op.create_table in any of the 112 migrations creates one.

Agent identity is a fresh UUID in a module-level dict. agents/render_dispatch.py:213_DISPATCH_AGENT_REGISTRY: dict[UUID, RenderDispatchAgent] = {}. agents/render_specialist_bootstrap.py:77-84 mints the ActorRef with id=uuid.uuid4() at lazy registration. A different UUID every process, surviving no restart. This is what the Q1 ruling repairs, and §5 states its cost.

The engine's session word belongs to human logins, and is stateless. stele/session.py:52 — "Sessions are stateless cookies — there is no server-side session store." This is why W2's freeze is cheap: a server-side agent-session row can be killed in under a second (DR-18); the human cookie cannot be revoked server-side at all.

The chokepoints do not carry an actor id — the spend one carries only a kind. credit/room_gate.py:34-56RoomSpendContext holds person_id, engagement_id, actor_kind: str, key_source, turn_event_id. A string kind, not an identity. The plural-chokepoint finding made concrete: the commit-path family shares one predicate after CR-2026-226; the spend gate shares none of it.

The verdict shape already exists. engagement/types.py:397-408AgentDecision(kind="agent", actor: ActorRef) inside the DecidedBy union. O6 by extension, not invention.

The third mislabel. agents/render_dispatch.py:95-113RENDER_AUTO_TRIGGER_SYSTEM_ACTOR (…0010), whose own comment reads "§12.3 calls this a 'system actor'… lands as kind="agent" here." CR-2026-226 swept the actors wearing contributor; this one wears agent, so that sweep passed over it correctly and its acceptance grep still returns zero.

3. Scope — what to build

Items 1–3 are new in v0_2 (the Q1 ruling). Item 10 is new in v0_2 (the Q3 ruling). Items marked FENCED wait on the DR-01 amendment — see §7.

  1. The agent identity table — the floor. (FENCED on name only.) One row per agent, permanent, append-only in spirit: id (UUID PK — the identity, DR-01's "referenced by the UUID"), purpose (text, inscribed at mint), status (active · frozen · retired — append-only vocabulary per DR-05), minted_at, minted_by (ActorRef-shaped: who created this identity). Nothing else. No token column, no key column, no secret column, no capability column, no scope column — the row is a register page and holds zero live power (DR-02). Retired is terminal; frozen is reversible only by the human-only confirm-step path (W2's business, not W1's).
  2. Clamp/revoke semantics adopted, not imported. Per R2's re-scoping of DR-16: status carries the two-lane shape stele-agentic-id models — tightening (activefrozen) is instant and machine-permitted; widening (frozenactive) is human-only (O10's confirm-step template, CR-2026-127/128 lineage). W1 lands the column and the constraint; W2 lands the switch that flips it. No code is imported from stele-agentic-id (R2: adopt the model, not the code).
  3. The existing agent acquires a durable identity. render_specialist_bootstrap.py:77-84 stops calling uuid.uuid4() and instead resolves-or-mints an identity row keyed on (engagement_id, role), returning a stable UUID across process restarts. This is the item that makes H0 acceptance 4 true, and it is the item with real cost — see §5. The in-process _DISPATCH_AGENT_REGISTRY stays as a runtime cache; it stops being the system of record.
  4. agent_sessions table + migration. (FENCED on name only.) id (session id, UUID PK), agent_identity_id (FK to item 1 — this is what Q1 unblocked), engagement_id, instruction_version (int NOT NULL), started_at, ended_at (nullable), end_reason (append-only: signed_out · killed · expired · abandoned), last_heartbeat_at (nullable; W4 populates, W1 only reserves so W4 needs no second migration). No column capable of carrying a secret — the session id is a record locator, not a bearer credential (O2, DR-02). Migrations are numbered at write time from the then-current head (0112 today); do not hardcode 0113 if anything lands first.
  5. Session modulemint_session, close_session, end_session(reason), get_live_sessions. Mint refuses when the identity is absent, frozen, or retired; the refusal is a boundary-record-form verdict (O6), not a bare False. The grantability check is W2's; W1 lands the single call site it will fill.
  6. Session lifetime, per DR-18. 8h maximum, config-driven, evaluated at use (the sweeper rides scheduler_loop in W4, per the Step 3 approval). Renewal is a fresh mint with a new id — there is no extend path and none is added (DR-02: an extension is a standing grant wearing a clock).
  7. session_id onto both chokepoints. RoomSpendContext gains actor_id: UUID | None and session_id: UUID | None (it currently knows neither); the commit-path family threads session_id alongside the ActorRef it already carries. Both nullable — human and system actors have no session and never will (O9: interfaces tolerate a session id; they do not demand one of everybody).
  8. AgentDecision gains session_id: UUID | None. Additive to the discriminated union; existing serialized payloads deserialize unchanged (DR-05).
  9. instruction_version pinned at mint, NOT NULL on the session row. This CR does not fix Surprise 11 — the live HTTP path still hardcodes instruction_version=1 (api/deps.py:1099-1104). W1 declines to depend on the nominal value: the session's pinned version is its version of record.
  10. …0010 reclassifies to kind="system" (Q3 ruling). One-line note at render_dispatch.py:95-113 replacing the paragraph that explains the agent costume, citing this CR. Not added to SANCTIONED_SYSTEM_WRITERS — it enqueues render_jobs rows and asks no commit-path gate for human authority, so it takes the honest kind without the allowance. Per R1, adding to that allowlist is an authority grant deserving CR-level scrutiny; this is the opposite act. Consequence, and the point of the ruling: get_live_sessions never returns substrate machinery, so "which agents are running right now" counts agents.
  11. FORAY call sites at session_minted, session_closed, session_refused, identity_minted, identity_frozen in foray/anchor_priority.py (O7). Priority standard, by the analogy the file already sets at anchor_priority.py:85-99 (the contribution-credential lifecycle — "a substrate-entry event, not a membership or engagement-committal event"). Emitter stays a no-op — the map is the point.
  12. Tests. Identity survives a simulated process restart and the same UUID returns; mint → action-carries-session-id → close reads as one thread; two sessions of one identity read by identity; 8h expiry refuses and does not extend; frozen identity refuses mint; refusal is a citable verdict, not a boolean; human and system actors pass both chokepoints with session_id=None unchanged; …0010 no longer appears in any live-agent query. Suite at zero failures, ruff + mypy clean, migrations up and down clean. Per CR-2026-226's amendment v0_1: confirm nothing writes an identity or session row before its migration in the staged 0033-bootstrap CI stand-up — that is exactly the class of failure the b606309 follow-up existed to fix.

4. Out of scope — explicitly

Grantability and the freeze switch (W2 — W1 lands the column and the call site). Heartbeat, silent-session alarm, scheduler_loop sweeper (W4 — W1 reserves the column). Envelope (W3), workspace (W5), session events as boundary records (W6). Surprise 11's hardcoded instruction_version. Any change to stele/session.py or principals. The own-key ungated path — CR-2026-228, §6. Any container, sandbox, preflight, feed, or contract work (H0 brief §3).

5. The date — plainly

2026-09-09 holds. One dependency can move it, and it is not the identity table.

The identity table itself does not move the date. Items 1 and 2 are one table, five columns, one constraint — genuinely the floor, as ruled.

Item 3 has real cost and is absorbed. Making the live RenderDispatchAgent resolve-or-mint a durable identity changes a running path (render_specialist_bootstrap.py) that has existing tests written against per-process UUIDs. Estimated at 1–2 days of the margin, not beyond it.

The dependency that can move the date is Q2's arrival, not Q1's ruling. A table name and a module name are precisely what an identity table turns on, and the DR-01 amendment has no stated date. Trip-wire, stated now rather than absorbed silently into the margin (Step 4 item 4): if the DR-01 amendment has not landed in boundaries-record/decisions/ by end of day 2026-08-31, the date moves, and I will say so that day rather than at the demo. Between now and then the fenced items are the only ones waiting; §7 lists what proceeds regardless, which is most of the CR.

6. Q4 answered — the own-key path closes in CR-2026-228, before demo assembly

The finding. credit/room_gate.py:175-183: when ctx.key_source != "system", gated_room_complete returns fallback_llm.complete(...) directly — "unmetered, ungated." An agent on an own-key engagement reaches a model through no chokepoint. This falsifies H0 acceptance 1 — "an unlabeled agent action is impossible, not just unusual" — for the demo's own work step, and the Operator's ruling requires it named before demo assembly.

Which CR: CR-2026-228, sequenced W4 → CR-2026-228 → demo assembly.

Why not W1. The fix is not a session fix. Its shape is: the gate is consulted on every path and returns a verdict; own-key returns permit — own-key, unmetered instead of being skipped. That is a change to what "gated" means at the spend chokepoint, and it touches metering semantics and their tests on a path W1 otherwise never enters. Folding it into a CR that is already growing by an identity table is how a date dies quietly. Kept separate, it is small, independently testable, and lands with margin to spare.

What W1 does about it in the meantime: nothing, and says so. No partial labeling of a path that has no chokepoint to label at. The honest state until CR-2026-228 lands is that acceptance 1 holds for system-key work and not for own-key work.

7. The naming fence — what is blocked and what is not

Blocked until the DR-01 amendment lands (boundaries-record/decisions/): the identity table's name, its module's name, the identity model class name, and the migration filename that creates it. No module is written under either stele or the retired agent-identity term, per the ruling.

Not blocked, and proceeding now: items 4–9 and 11–12 of §3 in every respect that is not the identity table's own name — the agent_sessions table (its name was approved at Step 3 and does not turn on the DR-01 word), the session module, lifetime and no-extend semantics, both chokepoint signatures, AgentDecision, the instruction_version pin, the FORAY call sites, and the whole test plan. Item 10 (…0010 reclassification) is unblocked and independent — it can land first, on its own, as it touches nothing named.

Working note, not a proposal: the fenced surface is one table, one module, one class, one filename. A rename across four identifiers is an hour, not a week — which is why §5's trip-wire is about the ruling's arrival, not the rename's cost.

8. Acceptance

9. One thing carried forward

The plural-chokepoint finding is why §3 item 7 is two pieces of work, not one. The commit-path family was unified onto one predicate by CR-2026-226; the spend gate was not, and it carries a kind where the others carry an actor. Any later work that says "the gate" in the singular is describing something that does not exist.