Version. 0.1
Date. 2026-08-20
Status. Open, and blocked on two Operator rulings (Q1, Q2 in §6). Second CR of the H0 lane (Boundaries handoff Step 4; work order boundaries-record/arc/briefs/loomworks-handoff-step-4-v0_1.md §2 item 2; scope from loomworks-cr-brief-h0-native-habitat-v0_1.md §2 W1). Scope is W1 only. 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. This CR implements O9 (nothing may preclude sessions — W1 is the session) and serves O1 by putting the session id on the existing named chokepoints rather than beside them. It creates no standing credential (O2), adds no masquerader (O3), keeps identity UUID-class (O4), adds no generic-execute surface (O5), shapes its refusals as citable verdicts (O6), adds FORAY call sites at mint/close/refuse (O7), puts session state in one named table (O8), and forks no authorization seam (O10). Surfaced, not resolved: the own-key bypass in gated_room_complete (§6 Q4) is a pre-existing act path that reaches a model with no gate — O1-shaped, older than this CR, and named here because W1 is the first work that has to label every agent action.
Rulings and decisions cited. R1 and R2 (loomworks-handoff-step-4-v0_1.md); DR-01 (terminology — binding), DR-02 (zero standing authority), DR-05 (three laws — no one-way doors), DR-13 (declared persistence), DR-16 (build on stele-agentic-id, ratify-at-seed), DR-18 (clocks: session lifetime 8h max, renewal by fresh request), all from boundaries-record/arc/briefs/loomworks-handoff-brief-2026-08-20-v0_1.md.
Findings cited. investigations/loomworks-boundaries-arc-cc-investigation-findings-v0_2.md — the plural-chokepoint finding (Surprise 9: _AGENT_KINDS aliases {"agent","companion"}, so the spend gate is a distinct chokepoint from the commit-path family, and "the gate" is not one object); Surprise 11 (instruction_version documented-required, unenforced, hardcoded to 1 on the live HTTP path); Surprise 24 / D2 (stele-agentic-id implements identity roots + clamp/revoke lanes and is imported by nothing).
The work order asks for: a session object minted when an agent's work begins, carrying session id, agent identity (UUID), instruction/config version, started-at; every agent-initiated action through the named chokepoints carrying (agent UUID, session id); clean completion closing the session; a frozen identity refusing new sessions and terminating the live one (W2's switch, W1's hook); and renewal as a fresh request, never an extension (DR-18).
Every clause of that is buildable except the second word of "agent identity." There is no durable agent identity in this engine to mint a session against — see §2. That is Q1, and it is the ruling this CR is blocked on.
b606309
There is no agents table. No op.create_table in any of the 112 migrations creates one; there is no agent registry row, no identity record, no name.
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 agent's ActorRef with id=uuid.uuid4() at lazy registration. The RenderDispatchAgent's UUID is different in every process, and survives no restart. H0 acceptance 4 — "the same agent's story across sessions reads by its UUID. Independent records, one identity" — is not merely unbuilt today; it is unbuildable against this identity, because there is no identity, only a per-process handle.
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." The human session is a Fernet-encrypted payload validated with no DB read. The Step 3 ruling that agent sessions take a distinct agent_sessions name was approved without comment, and the ground confirms why: these are not two flavours of one object. It also hands W2 a gift worth stating: a server-side row can be killed in under a second (DR-18); the human cookie cannot be revoked server-side at all. Freeze semantics for agents are cheap precisely because agent sessions are not modelled on human ones.
The chokepoints do not currently carry an actor id — the spend one carries only a kind. credit/room_gate.py:34-56 — RoomSpendContext holds person_id, engagement_id, actor_kind: str, key_source, turn_event_id. actor_kind is a string kind, not an identity. The spend gate today cannot name which agent spent. This is the plural-chokepoint finding made concrete: the commit-path family (engagement/assertions.py, engagement/creation.py, api/deps.py) now shares one predicate after CR-2026-226, and the spend gate shares none of it.
The verdict shape W1 needs already exists. engagement/types.py:397-408 — AgentDecision(kind="agent", actor: ActorRef) inside the DecidedBy discriminated union, carried by BoundaryRecord.decided_by. O6 is satisfied by extension, not invention.
A third mislabeled system actor exists, wearing agent. agents/render_dispatch.py:95-113 — RENDER_AUTO_TRIGGER_SYSTEM_ACTOR (…0010), whose own comment reads "§12.3 calls this a 'system actor'… The CR's textual actor_kind='system' value therefore lands as kind="agent" here." CR-2026-226 swept the actors wearing contributor; this one wears agent, so the sweep passed over it correctly and its acceptance grep still returns zero. It is not an O3 violation — agent is not a human kind, so nothing here dresses a machine as a person. It matters to W1 for a different reason: see Q3.
Blocked items are marked. Nothing below is written until Q1 and Q2 are ruled.
agent_sessions table + migration 0113 (next revision; head is 0112). Columns: id (session id, UUID PK), agent_identity_id (UUID — FK target is Q1), engagement_id, instruction_version (int, NOT NULL — see item 6), started_at, ended_at (nullable), end_reason (enum, append-only per DR-05: signed_out · killed · expired · abandoned), last_heartbeat_at (nullable; W4 populates it, W1 only reserves the column so W4 needs no second migration). No token, no secret, no key column anywhere in this table — the session id is a record locator, not a bearer credential (O2, DR-02).agent_sessions.py module with mint_session, close_session, end_session(reason), and get_live_sessions. Mint refuses when the identity is absent or not active — the refusal is shaped as a boundary-record-form verdict (O6), not a bare False. The grantability check itself is W2's; W1 lands the single call site it will fill, defaulting to permit-if-identity-active so W1 is testable alone.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: authority is requested, granted, and expiring; an extension is a standing grant wearing a clock).session_id onto the chokepoints, at both of them. RoomSpendContext gains actor_id: UUID | None and session_id: UUID | None (the spend chokepoint currently knows neither); the commit-path family threads session_id alongside the ActorRef it already carries. Both nullable, because human and system actors have no session and never will — the field is present for all callers and populated for agents (O9: interfaces tolerate a session id; they do not require one of everybody).AgentDecision gains session_id: UUID | None. Additive to the discriminated union; existing serialized payloads deserialize unchanged (DR-05 — no one-way doors).instruction_version becomes real on the session, and only there. The session row stores the version pinned at mint, NOT NULL. This CR does not fix Surprise 11 (the live HTTP path hardcodes instruction_version=1 at api/deps.py:1099-1104) — that is pre-existing and out of W1's scope; W1 declines to depend on the nominal value by pinning its own at mint and citing that as the session's version of record.session_minted, session_closed, session_refused in foray/anchor_priority.py (O7). Priority standard, by the analogy the file already sets: the contribution-credential lifecycle entries (anchor_priority.py:85-99) are "a substrate-entry event, not a membership or engagement-committal event," and a session mint is the same shape. Emitter stays a no-op — the map is the point.system actor at either chokepoint passes with session_id=None unchanged. Suite at zero failures, ruff + mypy clean, 0113 applies and downgrades cleanly. Per CR-2026-226's amendment: confirm nothing writes an agent_sessions row before 0113 in the staged CI stand-up.
Grantability and freeze semantics (W2). Heartbeat emission, the silent-session alarm, and the scheduler_loop sweeper (W4) — W1 reserves the column and nothing more. The envelope (W3), workspace (W5), and session events as boundary records (W6). Any fix to Surprise 11's hardcoded instruction_version. Any change to the human stele/session.py cookie. Any container, sandbox, preflight, feed, or contract work (H0 brief §3). Migrating RENDER_AUTO_TRIGGER_SYSTEM_ACTOR — raised as Q3, not done here.
(agent identity UUID, session id); a grep for an agent-kind actor reaching either without one returns zero.agent_sessions holds no column capable of carrying a secret (inspection — O2).
Q1 (blocking) — W1 presumes a durable agent identity that does not exist. Does W1 grow to include it, or does a W0 open?
agent_sessions.agent_identity_id needs an FK target and there is none: agent UUIDs are uuid4() into a process-local dict (§2), so a session minted before a restart and one minted after belong, as far as any record can tell, to different agents. H0 acceptance 4 cannot be met by W1, W2, or W4 in any order until this is decided. Three shapes, and the CR does not choose between them: (a) W1 grows by one minimal agent_identities table — id, purpose, active/frozen/retired, minted-at — which is the Stele's register page and nothing more; (b) a W0 opens for it ahead of W1, moving the demo date; (c) sessions key on a stable configured identity (engagement + role) and true identity waits for the Boundaries engagement, which makes the demo's "one identity across sessions" a narrower claim than acceptance 4 states. **This is where DR-16 lands: stele-agentic-id already implements identity roots with clamp/revoke lanes and is imported by nothing (Surprise 24), and ruling R2 re-scoped DR-16 to adopt its model — the clamp/revoke two-lane semantics — not its code. Under R2, shape (a) is the reading this CR would recommend if asked. It has not been asked.**
Q2 (blocking) — the word stele already names something else in this process. The engine imports a package called stele (from stele.registry import Principal, stele/session.py, stele/models.py:47 principals) — the human authentication product: principals, WebAuthn credentials, recovery codes, person sessions. DR-01 binds Stele to "the one permanent identity record per agent." Two meanings, one word, inside one import namespace, and DR-01's own words are "one word, one meaning, everywhere." The engine's stele/ predates the ruling and is a shipped Apache-2.0 product with its own repo. This needs an Operator ruling on the name before W1 writes a module, not after. (Precedent in the record for exactly this shape: loomworks-standing-note-organization-name-collision-v0_1.)
Q3 (non-blocking, needs a decision before W1's acceptance grep is meaningful) — is RENDER_AUTO_TRIGGER_SYSTEM_ACTOR (…0010) an agent or substrate machinery? Its comment says system, its kind says agent (§2). If it stays agent, then "every agent-kind action carries a session id" obliges W1 to mint sessions for an auto-trigger that is not an agent in any sense the Habitat means — which would make the session count meaningless on day one of the demo. **Recommendation to surface: migrate …0010 to kind="system" as a small follow-on to CR-2026-226, and not onto SANCTIONED_SYSTEM_WRITERS — it enqueues render_jobs rows and asks no commit-path gate for human authority, so it needs the honest kind without the allowance.** Not done in this CR: it is W7's territory reopened, and R1 says adding to that allowlist is an authority grant deserving CR-level scrutiny.
Q4 (non-blocking, pre-existing, flagged against O1) — the own-key path reaches a model with no gate. credit/room_gate.py:175-183: when ctx.key_source != "system", gated_room_complete returns fallback_llm.complete(...) directly — "unmetered, ungated." An agent working on an own-key engagement therefore takes an action that passes through no chokepoint, and W1 can label nothing there because there is nothing to label at. This is older than this CR and W1 does not create it. Naming it because the demo script's "work" step will run through this function, and the acceptance claim "an unlabeled agent action is impossible, not just unusual" (H0 brief §4.1) is false while this branch exists. Whether that is in H0's scope or a separate CR is the Operator's call.
The plural-chokepoint finding is not a historical note — it is the reason item 4 above 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.