feat(agent): mutable address, immutable positional identity - #467
Merged
schickling-assistant merged 9 commits intoSep 6, 2026
Merged
Conversation
This was referenced Sep 5, 2026
schickling-assistant
force-pushed
the
schickling-assistant/2026-09-05-agent-address-writer
branch
from
September 5, 2026 18:09
78bc1ea to
95dd512
Compare
schickling-assistant
marked this pull request as ready for review
September 5, 2026 18:09
Contributor
Author
|
@codex review Stacked on #456 (review that first — its branch was rewritten to the identity/address model only). This PR makes Places worth the most scrutiny:
Evidence, including the live cutover proof and a whole-suite diff against the stack base, is in the description. Posted on behalf of @schickling
|
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
schickling-assistant
force-pushed
the
schickling-assistant/2026-09-05-delta-003-immutable-ids
branch
from
September 5, 2026 18:14
286094b to
5f090d2
Compare
schickling-assistant
force-pushed
the
schickling-assistant/2026-09-05-agent-address-writer
branch
from
September 5, 2026 18:14
95dd512 to
5a31c64
Compare
This was referenced Sep 5, 2026
This was referenced Sep 6, 2026
schickling-assistant
force-pushed
the
schickling-assistant/2026-09-05-agent-address-writer
branch
from
September 6, 2026 08:39
5a31c64 to
e8c148e
Compare
schickling-assistant
force-pushed
the
schickling-assistant/2026-09-05-delta-003-immutable-ids
branch
from
September 6, 2026 12:04
9d313ab to
b99b063
Compare
schickling-assistant
changed the base branch from
schickling-assistant/2026-09-05-delta-003-immutable-ids
to
main
September 6, 2026 12:09
Decision 0015 splits one overloaded string into two namespaces, so selection needs a type: an ordinary human reference resolves against the mutable address, and an exact selector names a subject by a key no cutover moves. - `resolve_address` is R24's fail-closed candidate set rather than a precedence rule: a dotted semantic address and a host-qualified bus address are indistinguishable by shape, so both readings are collected and exactly one surviving subject decides, deduplicated by agent ID and optionally host-pinned. - `resolve_id` answers on either immutable key — the explicit `id`, or the positional `<host>.<identity>` bus identity a later ID migration freezes into it — and never falls through to address lookup, which is what stops a released semantic address from staying alive as an exact selector. Both keys are unique by admission (`dup-id`), and a key that names two subjects refuses rather than answering with a first match. - `address_book` projects a discovered catalog: retired subjects are absent, because retirement releases the address and makes the subject non-routable. agent-identity: dev3.direct.omp.v6c4mkm2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.1.2 agent-runtime: OMP 18.1.2 tooling-profile: dotfiles@7534055
One atomic address-book cutover (R25) on the existing source-preserving, authority-scoped, transactional publication path `st2 rename` and `st2 describe` already use: the same catalog-authoring lock, the same exact-declaration resolution, the same `ST_AGENT` self/descendant guardrail, the same Nix and non-KDL refusals. The edit rewrites exactly the `address` child node, so every byte of the declaration around it survives. What address adds beyond presentation: - host-local effective-address uniqueness, decided by building the complete prospective catalog and re-running `validate`'s own `dup-address` rule rather than re-deriving it, which covers explicit/explicit and explicit/identity-fallback collisions and the `--clear` case alike; - the R24 grammar check before any write; - a read-back gate that refuses if the candidate's immutable `id` moved. `id` is the one declared value no authoring command may rewrite, so the gate compares it rather than trusting the edit that produced the candidate. The receipt names the unchanged `id` and `identity` beside the new `address` and `busAddress`; a retired subject projects a null bus address, exactly as the roster does, because it released its address. agent-identity: dev3.direct.omp.v6c4mkm2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.1.2 agent-runtime: OMP 18.1.2 tooling-profile: dotfiles@7534055
`st2 agent address <agent> <address>` becomes reachable, and every reference plane resolves through the one algorithm decision 0015 mandates instead of each site's own precedence filter. - `message`'s resolution surface takes `&AgentSelector`, not `&str`: recipients, inboxes, state directories, message boxes, and archival all state which namespace they were named in. There is no `&str` arm left to fall back to, so a route can never be silently retyped as an exact subject or the reverse. Recipient matching moves from `bus_id == r || identity == r` to the effective address, with retirement handled by two books rather than a tie-break: a retired subject cannot make a live claimant ambiguous, and still answers to its own address when nothing routable does, keeping its retained state reachable. - Every command that names an agent gains the exact `--id` / `--to-id` form, which resolves without consulting the address namespace at all. - Drivers, channels, and hooks select their own subject by exact key, so an address cutover cannot disconnect a running seat from its own directories. A supervisor reference stays an ordinary address reference. - `event::resolve_stream` routes through `identity::resolve` instead of a hand-rolled three-arm filter, which is also a fix: stream ingress had no dedupe-by-ID and no host pinning, so it did not implement the mandated algorithm. Built-in resync names its recipient by the agent key reconciliation holds, so publication survives a cutover; `st2 event emit` names an ordinary address, so the released spelling refuses at once. - An interrupted send recovers by its record's canonical endpoint, which is an immutable key: a cutover between the pending write and the retry is a route change, not a changed recipient. Durable state is untouched by design. `ST_AGENT`, default task IDs, session socket paths, declaration-parent state, harness records, PTY tags, message provenance, and supervisor edges are all keyed on values this cutover never writes. agent-identity: dev3.direct.omp.v6c4mkm2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.1.2 agent-runtime: OMP 18.1.2 tooling-profile: dotfiles@7534055
…triggers 0015 keeps its target. Amendment 1 records that its two halves ship separately, because only one of them answers the Context: the mutable address does, with no migration, no record version, and no activation gate. The ID half buys host-move invariance and live/archive collision attribution — both real, both with no observed instance — so it re-enters on an observation rather than on a schedule: a completed cross-host seat move, a live/archive identity collision, or a UUIDv7 creation call site. The amendment also corrects two premises the implementation was built against. Version-1 readers are additively tolerant by documented policy (`crates/st2-wire/src/lib.rs`), so an additive field is not a version bump; the one genuine cross-build hazard is single-field and about routing. And an ownership key is not an address: an exact selector answers to either immutable key, which is what keeps a running seat, its resync stream, and an interrupted send bound to their own subject across a cutover. DELTA-003 narrows to the ID half and records the two defects that block it: `supervisor_chain::resolve_spec` must accept `effective_id`, and `migrate-ids` must exempt `agent-id-missing` from its own pre-admission gate or the prescribed rollout order deadlocks on the only command that can clear it. agent-identity: dev3.direct.omp.v6c4mkm2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.1.2 agent-runtime: OMP 18.1.2 tooling-profile: dotfiles@7534055
… address `supervisor_chain::resolve_spec` reads a declared `supervisor` positionally — an exact bus id, or a bare identity on the local host — so reading the same value as a mutable address gave one string two namespaces. After a parent declared an address, neither spelling satisfied both planes: the org chart kept validating while every child crash-loop notice was dropped with a `tracing::warn!` only, silently, in exactly the situation M2.4 exists for. `codex_app_server` reported protocol rejections the same way. Both sites now resolve through the two exact readings the org chart walks, which this stack already introduced for runtimes naming themselves. Those helpers are renamed from `actor_*` to declaration-key names, since a supervisor edge is a declaration key too: an address is a routing alias for humans and messages, and neither a runtime nor an org-chart edge is one. agent-identity: dev3.direct.omp.v6c4mkm2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.1.2 agent-runtime: OMP 18.1.2 tooling-profile: dotfiles@7534055
The new help text on `agent address`, `agent desired-state`, `rename`, and `describe` promised address resolution: `agent_author::resolve_target` matches `bus_id` then the positional `identity`, never `effective_address`, so after a cutover the subject own new address could not select it while the released spelling still could. Two candidate fixes: teach `resolve_target` the address book, or restore the accurate wording origin/main carried. The second is smaller (four doc lines against a second address-resolving path) and it narrows rather than widens the authoring plane, which must select exactly one declaration to edit. Chosen, plus a test pinning the contract in both directions. agent-identity: dev3.direct.omp.v6c4mkm2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.1.2 agent-runtime: OMP 18.1.2 tooling-profile: dotfiles@7534055
…cate id Three fail-open edges on the routing planes: - `select_index` resolved candidates deduplicated by agent ID and then took the *first* position for the winning ID, so on a catalog with a duplicate ID the resolved subject and the delivered directory could be different declarations. `event.rs` had the same shape on the declaration key. Both now refuse. - `Ambiguous` deduplicated its names after deciding, so two declarations sharing one ID rendered as "names 1 subjects". The names are now one per surviving subject, and the noun agrees with the count. - Message and state resolution passed no pinned host while stream ingress pinned the local host first, so `st2 message send chat` and `st2 event emit chat` disagreed on a catalog where two hosts declare one address. The local-first step now lives in `identity` and both planes call it; `event.rs` loses its copy. Also states the retired-address rule as the code implements it: retirement releases the address for claiming, and a retired subject answers on its own address only when nothing routable does - which is what keeps its retained state reachable by name (Q3).
The refusal forwarded `validate`'s `dup-address` message, which names the first declaration in path order - usually the candidate's own file, the one subject the operator knows is not the conflict. This function already holds the prospective specs, so it names the other claimant: the subject reading the same effective address on this host whose declaration key differs from the target.
`--as` is an ordinary address reference while `$ST_AGENT` carries the exact agent ID, and this stack is what makes the two strings diverge - so a script passing `--as "$ST_AGENT"` breaks the moment its subject declares an address. The flag help now says which namespace it reads instead of reading as "the same value, with a default".
schickling-assistant
force-pushed
the
schickling-assistant/2026-09-05-agent-address-writer
branch
from
September 6, 2026 12:09
e8c148e to
591ca3c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Stacked on #468 (which continues #456 after its trim; #456 cannot be reopened through the API). That
PR adds the
addressfield — this one makes it do something.Coordination: #456 and #457 were closed at 15:18 UTC in favour of #464, a single 12.5k-line
implementation of the whole ID/address program. That is option B of decision
Q23, and the answer ofrecord chose option A: land the trimmed reader PR, lift the address writer out of #463, defer the ID
half. This PR and #468 execute that answer; #464 is untouched and needs a human call, because the two
lanes overlap the same contract completely.
#453 accepted decision 0015:
a provisional semantic route becomes permanent because the route and the durable key are the same
string. #456 splits the two in the grammar, but nothing writes an address and nothing resolves one,
so
dev3.dotfiles.fractal.help-key.verifierstill cannot becomedev3.dotfiles.fractal.keymap.verifierwithout stranding the subject.The remaining work is one writer plus one resolution algorithm. The immutable-
idhalf of 0015 —UUIDv7 creation, the freeze migration, ID-keyed durable records, the activation gate — is deferred
behind named triggers (
0015 Amendment 1, in this PR). #457 and the remainder of #463 are closed asdeferred with their branches kept.
Change
st2 agent address <agent> <address>/--clear(src/agent_author.rs,src/main.rs) — oneatomic address-book cutover on the publication path
st2 renameandst2 describealready use: sameauthoring lock, same exact-declaration resolution, same
ST_AGENTself/descendant guardrail, sameNix and non-KDL refusals. It rewrites exactly the
addresschild node. What it adds:re-running
validate's owndup-addressrule, so explicit/explicit, explicit/fallback, and--clearcollisions are one rule, not three;idmoved —idis the one declaredvalue no authoring command may rewrite, so the gate compares it instead of trusting the edit.
One typed selector, one algorithm (
src/identity.rs) —AgentSelectoris either an ordinaryaddress reference or an exact subject.
resolve_addressis R24's fail-closed candidate set ratherthan a precedence rule: a dotted semantic address and a host-qualified bus address are
indistinguishable by shape, so both readings are collected, deduplicated by agent ID, and exactly one
survivor decides.
resolve_idanswers on either immutable key — the explicitid, or the positional<host>.<identity>bus identity a later migration freezes into it — and never falls through toaddress lookup. Both keys are unique by admission (
dup-id); a key naming two subjects refusesrather than answering with a first match.
The message plane is cut over, not wrapped (
src/message.rs) — recipients, inboxes, statedirectories, message boxes, and archival take
&AgentSelector. No&strarm survives, so a routecannot be silently retyped as an exact subject or the reverse. Recipient matching moves from
bus_id == r || identity == rto the effective address, with retirement handled by two booksinstead of a tie-break: a retired subject cannot make a live claimant ambiguous, and still answers to
its own address when nothing routable does, so its retained state stays reachable by name.
Exact forms where a route is not enough — every agent-selecting command gains
--id(--to-idfor
message send), which performs ID lookup only and never retries its input as an address.Drivers, channels, and hooks name their own subject by exact key through
message::resolve_declared_dir/declared_selector, so a cutover cannot disconnect a running seatfrom its own directories, workspace, or message boxes — and so a relaunch still works, since
reconciliation hands a driver the positional bus identity. Because
st2 driver … --identityhasalways accepted the bare positional identity too, and an identity may itself contain dots, both
readings are tried in today's order (whole key first, then qualified by this host) rather than
guessing which dot is a separator. Both are immutable keys; neither is the address namespace.
A declared
supervisoris a declaration key on both of its planes — the org-chart walk(
supervisor_chain::resolve_spec) and the notices that walk carries — so it resolves through thesame two exact readings, never through the address book. An address is a routing alias for human and
message references only.
event::resolve_streamroutes throughidentity::resolve(src/event.rs) — also a correctnessfix, not just deduplication: stream ingress hand-rolled a three-arm filter with no dedupe-by-ID and
no host pinning, so it did not implement the algorithm 0015 mandates. The local host is tried first
(today's bare-name behavior), then the whole catalog, so a foreign subject still resolves and meets
its own "owned by host X" refusal instead of an unhelpful absence. Built-in resync names its
recipient by the agent key reconciliation holds, so publication survives a cutover;
st2 event emitnames an ordinary address, so a released spelling refuses at once. An interrupted send recovers by
its record's canonical endpoint — an immutable key — because a cutover between the pending write and
the retry is a route change, not a changed recipient.
Contract (
0015 Amendment 1, DELTA-003,docs/vrs/spec.md,docs/vrs/02-agent-spec/spec.md,INVARIANTS.md) — records that the address ships first; that the ID half re-enters on an observedtrigger (a completed cross-host seat move, a live/archive identity collision, or a UUIDv7 creation
call site); that the "strict version-1 readers reject additive fields" premise is false by documented
policy (
crates/st2-wire/src/lib.rs) for every record but the sender ledger; and that two defectsblock the ID half —
supervisor_chain::resolve_specmust accepteffective_id, andmigrate-idsmust exempt
agent-id-missingfrom its own pre-admission gate or the prescribed rollout orderdeadlocks on the only command that can clear it.
Size: +2,243/−253 over 19 files — ≈1,265 production lines, ≈410 inline test lines, 428 in
tests/,142 docs.
Review round 2 — the independent review's findings
Four commits on top of the reviewed head, plus three on the base PR (#468). Every fix removes or
narrows surface; none adds a mechanism.
Blocking —
supervisorwas two namespaces at once (src/run.rs,src/codex_app_server.rs).surface_crash_loopread the declaredsupervisorvalue as a mutable address whilesupervisor_chain::resolve_spec— untouched by this PR — reads it positionally, so once a parentdeclared an address neither spelling satisfied both planes: the org chart kept validating while every
child's crash-loop notice was dropped with a
tracing::warn!only, silently, in exactly thesituation M2.4 exists for.
codex_app_server's protocol-rejection report had the same shape. Bothsites now resolve through the two exact declaration-key readings this stack already introduced for
runtimes, so one field has one namespace. Those helpers are renamed
actor_*→resolve_declared_dir/
declared_selector/declaration_readings, because an org-chart edge is a declaration key too.0015 Amendment 1now states the rule. The alternative — makingsupervisoran address — is asupervisor_chaincontract change and stays deferred with the ID half.Blocking — the authoring positional says what it selects (
src/main.rs,src/identity.rs). Thenew help text promised address resolution on four authoring commands, but
agent_author::resolve_targetmatchesbus_idthen the positionalidentityand nevereffective_address, so after a cutover the subject's own new address could not select it while thereleased spelling still could. Two candidate fixes: teach
resolve_targetthe address book, orrestore the accurate wording
origin/maincarried. Chosen: the wording — four doc lines againsta second address-resolving path, and it narrows the authoring plane instead of widening it (authoring
must select exactly one declaration to edit;
--idis the exact form). A test pins the contract inboth directions.
Non-blocking, fail-open edges (
src/message.rs,src/event.rs,src/identity.rs):select_indexresolved candidates deduplicated by agent ID and then took the first position forthe winning ID, so on a catalog with a duplicate ID the resolved subject and the delivered
directory could be different declarations;
event.rshad the same shape on the declaration key.Both refuse now.
Ambiguousdeduplicated its names after deciding, so two declarations sharing one ID rendered as"names 1 subjects". One name per surviving subject, and the noun agrees.
st2 message send chatandst2 event emit chatdisagreed on a catalog where two hosts declareone address. The local-first step moved into
identity::resolve_local_first; both planes call itand
event.rslost its copy.validate'sdup-addressmessage, which pointed at the first declaration in path order — usually thecandidate's own file.
--as's flag help names the namespace it reads (an ordinary address reference), since this stack iswhat makes it diverge from
$ST_AGENT's exact ID.Retired subjects and new mail. The review is right that the doc overclaimed; the behaviour is
origin/main's (which had no retirement filter at all in recipient resolution), so this PR did notintroduce it. Restricting the retired book to read paths means threading a read/write distinction
through four resolution entry points and changing behaviour relative to
main, which is a separatechange. Fixed here instead:
AddressBookEntry's doc andResolveError::Unknown's text now state therule the code implements — retirement releases the address for claiming, and a retired subject
answers on its own address only when nothing routable does, which is what keeps its retained state
reachable by name (Q3).
Evidence for the fixes
Each new test was run against the unfixed source (source hunks reverted, test kept) and fails there.
Live proof of the supervisor fix, scratch catalog, isolated
PTY_ROOT,dev3.rootsupervisingdev3.verifierand adev3.flapperwhosecommand "false"withrestart { attempts 1; mode "fail" }crash-loops:
So the crash-loop notice reaches the renamed parent, which is exactly what the reviewed head
dropped. No live catalog or supervisor was touched.
Gates and evidence
Whole-suite comparison against the stack base.
cargo test --workspace --no-fail-fast(clean env,PTY_ROOT/ST_AGENTunset) run twice — here, and atb30bc54in a detached worktree — then thefailing test names diffed:
So the 48 are pre-existing on this machine:
agent_publish(9-10,candidate fails full-catalog validationplus checkpoint timeouts),catalog_apply(14),catalog_diff(2,native-driver-missingand
root-countfixtures),eval_up(4),eval_run_e2e(2),native_only(3),invariants(1),materialize(1),nomad_survival(1),status_agents(1),targeted_reconcile(1) and the rest.One regression this comparison did catch and this PR fixes: the three
harness_state_teardowntestslaunch the real wrapper with a bare
--identity worker, which is what forced the two-readingactor resolution above rather than a single spelling.
cargo fmt --checkdisagrees with committed code in 40+ untouched files (resync.rs42 hunks,run.rs29), so formatting is left alone.Live cutover proof on a scratch catalog (
h.rootsupervisingh.verifier, both launched byst2 up --once, isolatedPTY_ROOT):So the route moved and nothing else did: task IDs,
ST_AGENT-derived keys, PTYagent.actor.path(still schema 1), the supervisor edge, and every durable record endpoint are byte-identical across
the cutover, while the old route and the old bus identity both fail loudly as routes and the exact
selector keeps working.
New tests:
tests/agent_address.rs(8 — receipt classification, byte-preservation, collisionrefusal on the same host and admission on another,
--clearfallback collision, actor guardrail,grammar/Nix/non-KDL/ambiguity refusals, exact-ID form for declaration and route);
src/identity.rs(10 — candidate-set resolution, host pinning, fail-closed ambiguity, exactselection after an address moves);
src/event.rs(2 — a declared address routes an event whileownership stays on the bus identity; resync still reaches a subject whose address moved).
Intentionally left
idhalf. No writer emitsid;effective_idstill answers with the busidentity, so ownership keys, task IDs, socket paths, record endpoints, and PTY tags are unchanged.
st2 catalog migrate-ids([deferred] Freeze every legacy subject's immutable agent id in one catalog transaction #457), UUIDv7 creation, version-2 records, and the activation gate aredeferred with their branches kept.
decision requires and what the proof above shows.
by its own address is
origin/main's behaviour, not something this PR introduces; changing itmeans threading a read/write distinction through
find_agent_handle,require_agent_handle,with_resolved_state_dir, andselect_index, and is its own change. The doc and error text nowdescribe what the code does.
uniqueness keep their
// DELTA-003:notes — both need the deferred migration's durablemetadata.
GraphArchivedaddress fields. A tombstone has no address field and an archived subject isnon-routable.
main.rsandmessage.rsshapes the audit flagged as boilerplate (oneAgentRefclapflatten, one
selected_or_acting_*helper for the 10-copy idiom) are left for a follow-up: theyare pure refactors of code this PR is introducing, and doing both at once would hide the semantic
change under a restructure.
Refs #453, schickling/dotfiles#2408.
Posted on behalf of @schickling
agent_identitysessionagent_personaagent_supervisoragent_toolagent_tool_versionagent_runtimetooling_profile