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

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

Version. 0.3 — supersedes v0_2 (which stands as sibling, per the never-overwrite rule). The naming fence is RELEASED. Changes in this version: the ruled term agent_identity is filled in wherever v0_2 wrote "FENCED on name only"; §7 records the release and the trip-wire that did not fire; §10 records the substitution rule and what it does not touch. Scope, acceptance, and the Q4 answer are carried forward from v0_2 unchanged. Section numbers are held stable from v0_2 so existing citations keep resolving. Date. 2026-08-20 Status. Open and unfenced — buildable in full. 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 — and the DR-01 amendment reaffirms it by refusing AgentUUID and any table named for its own primary key); adds no generic-execute surface (O5); shapes refusals as citable verdicts (O6); adds FORAY call sites at mint/close/refuse (O7); puts identity and session 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 v0_1; boundaries-record/decisions/boundaries-dr-01-amendment-agent-identity-v0_1.md (filed at boundaries-record d829a2a); DR-01 as amended, DR-02, DR-05, DR-07, DR-13, DR-16 (as re-scoped by R2), DR-18. 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_2

The fence is released; nothing else moved. v0_2 was buildable except for four identifiers. The DR-01 amendment supplies them, and this version writes them down. No scope item was added, removed, or re-shaped by the release.

The ruled term is agent_identity. Table agent_identities; foreign key agent_identity_id on agent_sessions; model class AgentIdentityRow; prose form agent identity. The definition is DR-01's, unchanged: one permanent record per agent, zero live power, never expires, never edited, referenced by its UUID, freezable.

The stele package is untouched by the ruling in every respect — its name, its subject (people), its repo, its alembic chain, its mount contract. This CR changes nothing in stele/ and nothing that imports it.

Carried forward unchanged from v0_2: the Q1 ruling (identity table is W1's floor, not a W0), the Q3 ruling (…0010 reclassifies to system), the Q4 answer (own-key path closes in CR-2026-228, before demo assembly), and the whole of §2, §3, §4, §6, §8, §9.

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

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 §3 item 3 repairs.

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: an agent_sessions 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 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."

3. Scope — what to build

Every "FENCED on name only" marker from v0_2 is resolved. Nothing here waits on anything.

  1. agent_identities — the floor. One row per agent, permanent. Model class AgentIdentityRow. Columns: id (UUID PK — the identity itself; per the amendment, the record is the identity and is referenced by the record's UUID, so there is no separate id-of-the-id), purpose (text, inscribed at mint), status (active · frozen · retired — append-only vocabulary per DR-05), minted_at, minted_by (ActorRef-shaped). Nothing else. No token, key, secret, capability, or 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).
  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. No code imported from stele-agentic-id.
  3. The existing agent acquires a durable identity. render_specialist_bootstrap.py:77-84 stops calling uuid.uuid4() and resolves-or-mints an agent_identities row keyed on (engagement_id, role), returning a stable UUID across process restarts. This is what makes H0 acceptance 4 true, and it is the item with real cost — see §5. _DISPATCH_AGENT_REGISTRY stays as a runtime cache and stops being the system of record.
  4. agent_sessions table + migration. id (session id, UUID PK), agent_identity_id (FK → agent_identities.id), 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 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). Number the migration(s) from the then-current head at write time (0112 today); do not hardcode.
  5. Session modulemint_session, close_session, end_session(reason), get_live_sessions. Mint refuses when the agent 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; 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).
  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.
  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 that allowlist is an authority grant and this is the opposite act. Consequence: get_live_sessions never returns substrate machinery, so "which agents are running right now" counts agents. This is the first item built (§7).
  11. FORAY call sites at session_minted, session_closed, session_refused, agent_identity_minted, agent_identity_frozen in foray/anchor_priority.py (O7). Priority standard, by the analogy the file already sets at anchor_priority.py:85-99. Emitter stays a no-op — the map is the point.
  12. Tests. Agent 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; human and system actors pass both chokepoints with session_id=None unchanged; …0010 returns from no 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 agent_identities or agent_sessions 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 whatsoever to the stele package, stele/session.py, or principals — the DR-01 amendment leaves it untouched in every respect and so does this CR. The own-key ungated path — CR-2026-228, §6. Any container, sandbox, preflight, feed, or contract work (H0 brief §3). Any rename pass over committed arc documents — §10.

5. The date — 2026-09-09 holds, and the trip-wire did not fire

v0_2 §5 stated: if the DR-01 amendment has not landed in boundaries-record/decisions/ by end of day 2026-08-31, the date moves. It landed 2026-08-20, eleven days inside the wire. The wire did not fire and is now spent.

What remains of the date's cost is item 3, unchanged from v0_2: making the live RenderDispatchAgent resolve-or-mint a durable row changes a running path with existing tests written against per-process UUIDs. 1–2 days of margin, absorbed.

No dependency on anyone else's ruling remains in W1. Every open question this CR carried is answered. If the date moves from here it will be for build reasons found in the building, and it gets said the day it appears (Step 4 item 4).

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.

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

Why not W1. 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 changes what "gated" means at the spend chokepoint and touches metering semantics and their tests on a path W1 never enters. Folding it into a CR already growing by an identity table is how a date dies quietly.

Until it lands, W1 states the honest position rather than a partial one: acceptance 1 holds for system-key work and not for own-key work.

7. The naming fence — RELEASED

Released by boundaries-record/decisions/boundaries-dr-01-amendment-agent-identity-v0_1.md, filed at boundaries-record d829a2a on 2026-08-20. Effective on filing.

The four fenced identifiers, now supplied: table agent_identities; FK agent_identity_id; class AgentIdentityRow; prose agent identity.

Two constraints the amendment adds that W1 must honour, beyond the name itself:

Citation note, recorded not corrected: the amendment's header cites "the naming fence recorded in CR-2026-227 §5." The fence was recorded in §7 of v0_2; §5 was the date and the trip-wire. Both sections are on the same page of the same CR and the reference resolves without ambiguity, so nothing is amended over it — logged here so a later reader following the citation is not left hunting.

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.

10. The substitution rule — what is not renamed

No committed arc document is rewritten. Not solution design v0_4, not the R-H set, not component-map v0_3 where DR-01 itself lives, not loomworks-standing-note-runtime-direction-v0_1.

The rule, per the amendment §4: where a committed arc document uses Stele in the agent-identity sense, **read *agent identity***. Where it uses stele as a package, module, or import, it means the human authentication substrate and is already correct and stays.

New documents use agent_identity from the start; amendments to old documents adopt it in the amended passages only. This CR is a new document and uses it throughout.

Practical effect on W1: the rename is four identifiers in code, not a pass over a frozen design line. That is the whole of the cost the fence was holding.