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

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

Version. 0.4 — supersedes v0_3 (sibling, per the never-overwrite rule). Absorbs the 2026-08-20 natural-key and minting ruling. Changes: §3 items 1–3 rewritten (role-keyed identity, seeded not minted); §2 carries a ground-truth CORRECTION — v0_1–v0_3 said "there is no agents table," and that was half wrong; new §11 (the native-agent census, which the seed list depends on); new §12 (the unique constraint, confirmed); §5 restated with the number. Section numbers 4–10 held stable. Date. 2026-08-20 Status. Open. Items 4–12 unblocked. Item 1 needs one census disposition (§11 Q5) before its migration can be written — the ruling says seed the known native agent identities, and the set is not currently enumerated anywhere in the engine. Runtime direction: no conflict, and the ruling strengthens three obligations. O2 — seeding removes the last runtime path that could create an identity, so nothing at rest is creatable by a machine. O5 — a seeded roster is the enumeration; capabilities and identities become listable by construction rather than by convention. O10 — "a process minting its own governed identity record is authority-shaped" is exactly the self-widening this obligation forbids; resolve-only closes it. Also: implements O9; serves O1; adds no masquerader and removes one (§3 item 10, shipped); keeps identity UUID-class (O4); citable verdicts (O6) — the fail-closed refusal is itself a boundary-record-form verdict, not a log line; FORAY call sites (O7); named tables (O8); no seam fork (O10). Rulings cited. R1, R2; the 2026-08-20 rulings on Q1–Q4 of v0_1; the 2026-08-20 natural-key + SEED ruling (this version); the DR-01 amendment (boundaries-record d829a2a); DR-01 as amended, DR-02, DR-05, DR-07, DR-13, DR-16, DR-18.


1. The ruling, and what it settles

Natural key. The agent identity's key must not derive from configuration or the instruction set. Identity resolves on a stable declared role identifier. Instruction/config version stays on the session, per the W1 spec — identity is permanent and must survive an instruction edit, or acceptance 4 breaks the first time an agent's instructions change.

Minting. SEED. Known native agent identities are seeded in the migration; the runtime resolves only. An absent identity fails closed with a cited verdict and no session. Rationale as ruled: a process minting its own governed identity record is authority-shaped and sits against human-only widening; the native agent set is enumerable per O5; W2's freeze semantics are cleaner over a known list. Foreign-agent registration is Boundaries contract work, out of H0 scope.

What the ruling settles that v0_3 had wrong. v0_3 §3 item 3 keyed identity on (engagement_id, role) and used resolve-or-mint. Both halves are replaced. The consequence is stated plainly in §3 item 1: identity is per-role and platform-wide, not per-engagement — because a migration cannot seed rows for engagements that do not exist yet, and engagements are created continuously at runtime. Engagement scoping moves to agent_sessions.engagement_id, where it already was. This reading is forced by the ruling rather than chosen; §3 item 1 states its one operational consequence, which the Operator should see before W2.

2. Ground truth at d275df6 — including a correction to v0_1–v0_3

CORRECTION. v0_1 §2 said "There is no agents table… agent identity is a fresh UUID in a module-level dict," and v0_2/v0_3 carried it forward. That was true of the render path and false as a general claim, and the difference matters to this ruling.

What is actually true: there is no table named agents, but there is a durable, per-engagement, enumerable agent rostercontributors rows with kind='agent', created from agents/standard_agents.py::STANDARD_AGENTS at engagement instantiation (engagement/commit_orchestration.py:100-108). The roster is resolved idempotently by ensure_standard_agents (CR-2026-223, B-113).

And the engine already believes what the ruling just ruled. agents/standard_agents.py docstring, unprompted: "the durable identity that survives a re-instantiation is the display name + purpose, not the row id." Each StandardAgent carries display_name (operator-facing) and purpose (a stable programmatic tag — today "shaping" and "rendering"). purpose is the stable declared role identifier the ruling asks for. It exists, it is already stable, and it was never given a row.

contributors already forbids agent credentials. contributors/models.pyck_contributors_token_presence: (kind='human' AND bearer_token_hash IS NOT NULL) OR (kind='agent' AND bearer_token_hash IS NULL). An agent contributor structurally cannot hold a bearer token. O2 has a precedent in the schema, and agent_identities inherits the posture by having no such column at all.

Where identity is genuinely process-local (the v0_1 claim, correctly scoped): agents/render_specialist_bootstrap.py:80 and :294 mint id=uuid.uuid4(); _DISPATCH_AGENT_REGISTRY (render_dispatch.py:213) is a module dict. This is the render path only, and it is what §3 item 3 repairs.

Two identity schemes coexist today, which is the deeper reason the seed list needs a census (§11): the per-engagement contributors row id (used by api/routers/material.py:77, commit_orchestration.py), and process-local uuid4 (render dispatch, render specialists). Plus well-known sentinel UUIDs for machinery.

The chokepoints (carried forward): RoomSpendContext (credit/room_gate.py:34-56) carries actor_kind: str — a kind, no actor id. The commit-path family shares one predicate after CR-2026-226; the spend gate shares none of it.

3. Scope — items 1–3 rewritten under the ruling

  1. agent_identities, role-keyed and seeded. Model class AgentIdentityRow. Columns: id (UUID PK — the identity; no id-of-the-id, per the DR-01 amendment's refusal of AgentUUID), role (text — the stable declared role identifier; the natural key; agents/standard_agents.py::StandardAgent.purpose is its existing source), display_name (operator-facing, editable, explicitly not the key), status (active · frozen · retired, append-only per DR-05), minted_at, minted_by. No instruction_version, no config version, no engagement_id, no token, key, secret, capability, or scope column. The absence of instruction_version here is the ruling's point: an instruction edit must not disturb identity.

Operational consequence of per-role identity, stated for W2: one identity per role platform-wide means freezing the shaping agent freezes shaping on every engagement at once. That is the correct blast radius for "grant nothing to this identity" and it is bigger than a per-engagement freeze would be. W2's demo should say it out loud rather than discover it.

  1. Seeded in the migration; resolved at runtime; never minted. The migration inserts one row per role in the census-approved set (§11), status='active'. The runtime has no INSERT path into agent_identities at all — not resolve-or-mint, not upsert, not get-or-create. Resolution is by role. Absent role → fail closed: no session, and a boundary-record-form verdict naming the missing role as the deciding condition (O6). Adding a role is a migration, which is a human act with a review — which is precisely the human-only-widening posture (O10, DR-02).

Clamp/revoke, per R2's re-scoping of DR-16: status carries the two-lane shape — tightening (activefrozen) instant and machine-permitted; widening (frozenactive) human-only, confirm-step (CR-2026-127/128 lineage). W1 lands the column and constraint; W2 lands the switch. No code imported from stele-agentic-id.

  1. **The render path resolves instead of minting — and gets simpler, not harder. render_specialist_bootstrap.py:80 and :294 stop calling uuid.uuid4(). The seeded roster is loaded once at process startup into a frozen role → UUID map, so resolution is an in-process lookup and ensure_render_dispatch_agent_for_engagement keeps its synchronous signature (it takes no db today; only 2 call sites exist, both in engagement/render.py). A frozen cache is sound only because the ruling forbids runtime minting — under resolve-or-mint the roster changes during the process and could not be cached safely. This is the concrete way SEED is less disruptive than the design it replaced.** _DISPATCH_AGENT_REGISTRY stays a runtime cache of live agent objects and stops being the system of record for identity.

Items 4–9 and 11–12 are unchanged from v0_3, except that agent_sessions.agent_identity_id now FKs a seeded row, and instruction_version on the session (item 9) is now load-bearing rather than incidental: it is the only place a config version lives, by ruling.

Item 10 — SHIPPED. RENDER_AUTO_TRIGGER_SYSTEM_ACTOR (…0010) reclassified to kind="system", instruction_version dropped, not added to SANCTIONED_SYSTEM_WRITERS. Engine b606309..d275df6 (work 39733d9, merge d275df6). Suite 3838 passed / 0 failed; ruff clean; mypy 536 all baseline-covered, baseline unchanged.

4. Out of scope

Unchanged from v0_3, plus: foreign-agent registration (Boundaries contract work, per the ruling); any runtime identity-creation path; any change to contributors, STANDARD_AGENTS, or ensure_standard_agents — the contributor roster keeps doing its job and agent_identities does not replace it (the seam between them is W2-or-later, not W1). The stele package remains untouched in every respect.

5. The date — 2026-09-09 holds. The number is +2 days.

Owning it with the number, as asked. The ruling itself does not cost days — seeding is cheaper than resolve-or-mint on the live render path, for the reason in §3 item 3: a frozen startup roster needs no DB read, so the sync ensure_render_dispatch_agent_for_engagement signature and both its call sites are untouched. Resolve-or-mint would have forced a DB read into a sync path, or async-ified it.

The +2 days is the census (§11), not the seeding. The ruling says seed the known native agent identities; that set is not enumerated anywhere today, and ~10 distinct kind="agent" construction sites exist, of which at least two are substrate machinery that should follow …0010 rather than receive identities. Enumerating and disposing of them is 2 days.

Running total against the date: 3–4 days of margin consumed (1–2 for §3 item 3, 2 for the census), of the 20 calendar days to 2026-09-09. The date holds. It is no longer comfortable. Next trip-wire, stated now rather than absorbed: if the census finds materially more than the ~10 sites already enumerated, or finds any real agent that genuinely requires per-engagement identity (which SEED cannot express), I say so the day it appears — not at the demo.

6–10

Unchanged from v0_3. §6 Q4 (own-key path → CR-2026-228, before demo assembly). §7 the released naming fence and the two constraints it carries. §8 acceptance. §9 the plural-chokepoint carry-forward. §10 the substitution rule.

One addition to §8 acceptance under this ruling: a grep for any runtime INSERT into agent_identities returns zero, and no column on agent_identities derives from instruction or config version.

11. The native-agent census — what the seed list waits on

Enumerated at d275df6. Classification is the open part.

Substrate machinery wearing kind="agent" — the …0010 pattern again, twice more:

Genuine agents (candidates for seeded identities): the shaping agent (purpose="shaping"), the rendering agent (purpose="rendering") — both already declared in STANDARD_AGENTS; the render dispatch agent; render specialists; the seed-induction agent (engagement/seed_induction.py:80,216).

Q5 — the one disposition needed to write item 1's migration: do …0731 and …0051 follow …0010 to kind="system"? I am not applying the precedent unilaterally: the Operator ruled …0010 individually rather than delegating the class, and …0731 touches the scheduler. Recommendation: yes for both, on the ruling's own reasoning — substrate machinery should not appear in "which agents are running right now" — and neither goes on SANCTIONED_SYSTEM_WRITERS (neither asks a commit-path gate for human authority; per R1 that allowlist is an authority grant and this is the opposite act). Until Q5 is ruled, the seed list is not final and item 1's migration is not written. Items 4–9 and 11–12 do not wait on it.

12. The unique constraint — confirmed, and the precedent that proves it necessary

Confirmed: UNIQUE (role) on agent_identities — a single-column unique on the declared role identifier. Single-column, not composite, because identity is platform-wide per role (§1); a composite with engagement_id would re-admit exactly the per-engagement shape the ruling forecloses.

Under this ruling the race cannot occur at all. Concurrent starts resolve; they do not insert. There is no runtime INSERT path (§3 item 2), so two concurrent starts cannot produce two rows because neither produces any row. The constraint is the structural backstop that makes that guarantee survive a future careless patch, not the thing doing the work today.

The precedent proving it is required rather than decorative. contributors has exactly one unique constraint — UniqueConstraint("engagement_id", "id", name="uq_contributors_engagement_id")which the primary key already implies. There is no unique constraint on the agent roster's natural key. The result is on the record: CR-2026-223's ensure_standard_agents docstring reports duplicates observed live"the Stage 1 walk's engagement, which carries every standard agent twice — pre-commit manual registration plus instantiate's unconditional one" — and had to add an application-level oldest-row-wins resolver to cope.

That is the failure this constraint exists to prevent, already run once in this codebase, one table over. An application-level idempotent resolver is not a uniqueness guarantee; it is a repair for the absence of one.