[deferred] Freeze every legacy subject's immutable agent id in one catalog transaction - #457
Conversation
R24 gives every new agent subject a UUIDv7, and ID migration gives one to an archived subject whose legacy bus identity another subject already claimed. The layout is RFC 9562 section 5.7 and the whole implementation is forty lines, so it lives here rather than adding a dependency and a lockfile revision. Entropy is `getrandom(2)` on Linux and a character-device-checked `/dev/urandom` elsewhere. There is deliberately no clock- or pid-derived fallback: immutable agent ID uniqueness rests on these bits, and a caller inside a catalog transaction needs a refusal before its first write, not a weak id.
An archived subject's immutable ID has to survive outside the live plane: R24 gives an archived collision a UUIDv7 "in its declaration and tombstone", and `st2 catalog graph --json` must be able to show an archived subject's ID. - `Tombstone` gains optional `agentId`, and `ArchivedEntry` carries it in the archive receipt. The tombstone schema stays v1: an added optional field changes no existing field's meaning, and the record does not deny unknown fields, so readers of both vintages parse both shapes. `id` keeps its meaning as the legacy `<host>.<identity>` key, and its coherence assertion stays. - Archival never mints identity. A legacy declaration archives with no `agentId`; freezing one is the migration verb's job. - `unarchive` refuses when a tombstone's recorded ID and the archived declaration's explicit `id` disagree, before anything moves. - The graph's archived row appends nullable `agentId`. Refusing an unmigrated archive after activation, and validating ID uniqueness against the prospective live-and-archived set, land with the writer PR.
One atomic transaction that makes an existing catalog expressible in decision 0015's identity model without re-keying any durable state. - A live subject freezes its existing `<host>.<identity>` bus identity as its explicit `id`, so every runtime identifier, task ID, socket path, and declaration-anchored state path keeps its exact bytes. - A structurally archived subject freezes the same bytes when they remain unique across the combined live-and-archived set. An archived collision — the shape a re-projecting generator and an archiving supervisor produce together — receives a UUIDv7 in both its declaration and its tombstone, and the reassignment is recorded in `.st2/agent-id-migration.json` so a reader of a version-1 durable record never retypes colliding bytes into the wrong subject. - Supervisor references resolve against the combined *pre-migration* index and are rewritten to the parent's migrated ID in the same transaction. A missing or ambiguous reference refuses before any write with `legacy-supervisor-unresolved`. - The catalog must already admit: migration re-admits the whole live plane after its writes, and a plane that already fails admission would fail that re-admission for a reason migration did not cause, leaving every declaration rewritten and the generation unmoved. A dry run still reports its full plan. - `--resume` finishes an interrupted run and may only apply work the interrupted transaction planned. A plain rerun over a half-migrated plane refuses. Edits are span-bounded over exact source bytes through the authoring module's existing insertion helper, so comments, formatting, and every unrelated byte survive. TOML and JSON declarations refuse. A Nix-owned declaration IS migrated — the marker guards interactive authoring, not the one transaction that has to reach the whole plane — and the receipt names it so its generator can be taught to emit `id` before the next activation re-projects the file without one.
|
@codex review 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. |
|
Superseded by #464, which keeps the reader, migration, writer, runtime, and routing transition in one coherent change so no independently mergeable partial identity state exists. Posted on behalf of @schickling
|
|
Deferred, not rejected. The branch stays; nothing here is contradicted by what shipped. Why. The Re-entry conditions (now written into Two defects to fix before this lands, both found by audit and recorded in the amendment and in the narrowed DELTA-003:
Also worth revisiting when this returns: Posted on behalf of @schickling
|
Stacked on #456 (
schickling-assistant/2026-09-05-delta-003-immutable-ids). Review that first; thisbranch's base is its head, not
main.Problem
#456 made an explicit immutable
idexpressible and taught every reader the target shapes. Nothingassigns one. Until something does,
effective_idanswers with the legacy bus identity everywhere,and step 3 of DELTA-003's reader-first
rollout — "in one catalog transaction, add migrated unique IDs to live and structurally archived
declarations, update archived tombstones, and rewrite every supervisor reference to its
already-resolved migrated ID" — has no implementation.
That step cannot be a loop over declarations. R24 makes it one transaction for three reasons: an
archived subject may only freeze bytes that are unique across the combined live-and-archived
set; supervisor references must resolve against the pre-migration index, not against a plane
that is half-migrated underneath them; and a reassigned legacy bus identity must be recorded
durably in the same commit, because a version-1 durable message row's
from/tobytes then denotetwo subjects (
docs/vrs/03-message/spec.md:88-107).Change
st2 catalog migrate-ids [--host <host>] [--dry-run] [--resume] [--json], insrc/catalog_migrate_ids.rs. It is the same transaction shapesrc/catalog_archive.rsalreadyuses — one exclusive authoring lock, one strict discovery, a pure plan, every write inside one
generation commit — and reuses the existing primitives rather than adding machinery:
CatalogLock::exclusive,begin_generation_commit,catalog_archive::observe,catalog_transaction::{validate_full_catalog, persist_tempfile_from_control, open_dir_beneath}, andthe authoring module's
insert_node/exact_agent_node/quoted(widened topub(crate), nobehavior change).
Assignment. Live subjects first, so a live claim is decided before any archived subject can
compete for the same bytes:
<host>.<identity>bus identity, frozenidA live subject that cannot freeze its own bytes (because a migrated subject already owns them)
refuses — that is a duplicate-declaration fault for the operator, not something to reassign.
Reassignment record.
<catalog>/.st2/agent-id-migration.json(
st2.agent-id-migration.v1) records every reassigned legacy bus identity, the subject that keptit, and the reassigned subject's new ID. It is written only when something was reassigned. The
module exposes
read_migration_recordplusattribute_legacy_endpoint, which implementsMESSAGE-R04: an untouched legacy endpoint is its own migrated ID; a reassigned one resolves onlyfor the row's own state owner (sender for a sender-owned row, recipient for an inbox row) and is
otherwise unattributed, so a reader renders historical bytes rather than addressing the live
replacement. Wiring it into the message reader is PR D3's writer work; the function and its proof
land here with the record that makes them possible.
Supervisor references resolve against the combined pre-migration index — a full bus identity,
or a bare identity on the referring declaration's own resolved host, exactly as
supervisor_chain::resolve_specreads them — and are rewritten to the parent's migrated ID.Absence and ambiguity both refuse before any write with
legacy-supervisor-unresolved, naming thedeclaration and the reference. This matters on real data: 501 of dev3's 714 supervisor references
are bare identities that contain dots, so "which dot is the separator" is a real question and
answering it by precedence would be a guess.
Pre-admission gate. The catalog must already admit before a byte is rewritten. Migration
re-admits the whole live plane after its writes; a plane that already fails admission would fail
that re-admission for a reason migration did not cause, leaving every declaration rewritten, the
generation unmoved, and a marker whose resume can only fail the same way.
--dry-runstill reportsits complete plan against a non-admitting catalog, which is what makes it useful as a pre-flight.
Crash recovery.
<catalog>/.st2/migrate-ids-incompleterecords the planned assignment setbefore the first write. A plain rerun over a half-migrated plane refuses and names
--resume;--resumere-plans from current bytes, refuses any ID the interrupted transaction did not plan orany subject an outside writer gave a different ID, then finishes exactly the remainder.
Edits are span-bounded over exact source bytes: comments, formatting, and every unrelated byte
survive. TOML and JSON declarations refuse with
unsupported-declaration-format. A Nix-owneddeclaration is migrated — the
meta { managed-by "nix" }marker guards interactive authoring(
st2 rename,st2 describe), not the one transaction that has to reach the whole plane — and thereceipt lists it under
nixOwnedso its generator can be taught to emitidbefore the nextactivation re-projects the file without one. This is the single place the verb deliberately differs
from the authoring commands.
Also here: in-tree UUIDv7 (RFC 9562 §5.7,
getrandom(2)on Linux and a character-device-checked/dev/urandomelsewhere, no weak fallback — forty lines instead of a dependency and a lockfilerevision), and
Tombstone.agentIdplus itsunarchivecoherence check and graph projection.Gates and evidence
nix develop -c cargo test --no-fail-fast:tests/catalog_migrate_ids.rssrc/catalog_migrate_ids.rsunit testssrc/uuid_v7.rsunit teststests/catalog_archive.rstests/catalog_graph.rsst2libagent-spec(lib + 2 integration)tests/agent_publish.rstests/catalog_apply.rsBoth red files are the standing lane #438/#446 tracks on
main: an independent worktree atcompoundingtech/st2main(0d22352) fails the same 9 and the same 14 by name, and theagent_publishnine were additionally proved identical at this stack's baseb30bc54by stashingevery change and re-running. Nothing in this PR touches publication or apply.
Proof corpus
tests/catalog_migrate_ids.rs, against a fixture shaped like a real catalog at migration time —one counted root, a five-deep supervisor chain, dotted identities, 40 flat live subjects, 602
structurally archived subjects, and one identity claimed by both planes:
the colliding archived subject received a UUIDv7 (length, version nibble, and RFC 9562 variant
all asserted) while the live claimant kept the bytes; the tombstone carries the same ID; the
reassignment record names the keeper and the reassigned ID; every supervisor reference — including
an archived subject supervised by another archived subject — names a migrated ID; a second run is
a true no-op that advances no generation;
--dry-runreports the whole plan and writes nothing (no ids, no marker, no record, generationunchanged);
legacy-supervisor-unresolved, naming declaration andreference, with nothing written;
a.breadable as hosta'sband as hosth's dotted identitya.b,both present) refuses rather than picking one;
unsupported-declaration-format;nixOwned, and keeps itsmetablock;idis left alone and blocks a freeze that would claim it;marker, refuses a plain rerun, and resumes exactly the remaining four declarations, advancing one
generation;
--resumewith no marker refuses;otherwise lose its bytes.
Against a copy of dev3's real declaration plane
st2 catalog snapshot --raw-preimage(read-only, shared lock, generation 1034 before and after)produced a 722-declaration copy; every run below is against that copy, never the live catalog.
migrate-ids --host dev3 --dry-run --json: 722 assigned, allfrozen-bus-identity, 714supervisor rewrites, 0 reassignments, 18
nixOwned, 0 already migrated.migrate-ids --host dev3 --jsonon the unrepaired copy: refused by the pre-admission gate,naming 4 pre-existing
socket-path-too-longdeclarations (fix(validate): reject an unbindable session socket path at admission #442's rule against identities thatpredate it) and one unresolvable profile module (an artifact of the raw preimage, which does not
capture
_profiles/**). Zero declarations were rewritten and no marker was left.pty-rootand profile envelope: 722 ids frozen, 714 referencesrewritten, generation 0 → 1, marker cleared, 722/722 declarations carrying
id; a rerun reportsunchangedand leaves the generation at 1;catalog graph --jsonthen reads 722 agents,complete: true, 0 conflicts, everyidequal to its frozen bus identity, andparentId/rootIdID-keyed.
Evidence file:
tmp/agent-cleanup/dev3-evidence.md(gitignored) carries the before/aftermeasurements and the full shape characterization.
Operator sequence for the live dev3 migration
Not run by this PR. It needs the dotfiles catalog projection to emit
idfirst, plus aserialized cutover window. Exact sequence, in order:
socket-path-too-longdeclarations.dev3's live plane does not currentlyadmit its own rules, so the gate refuses before anything else:
2026-09-02-new-agent-sam.schickling-assistant-202.request.{2psw1i,2ynlcp,nguieu}andglobal.coding-agents.session-creation.interview-1785674581013-613809-0. Each needs a shorteridentity/task id or a shorter declared
pty-root. Verify withst2 validate --strict.idfor every seat it projects, using each seat'sexisting
<host>.<identity>as the frozen value, and to preserve it across re-projection. Untilthis lands, the 18 Nix-owned declarations this verb migrates are re-projected without an
idatthe next activation. Order matters: projection-emits-
idbefore the live migration, or themigration is undone for those 18 on the next
nix-convergence activate.systemctl --user stop st2) so itsauto-archive pass (feat(supervisor): archive retired seats after a grace period #449, ≤25 per pass, 7-day grace) cannot take the exclusive authoring lock
mid-transaction, and hold the
st2-catalogactivation so nocatalog applyraces it. This isone serialized catalog transaction; nothing else may write the declaration plane.
st2 catalog snapshot --output <path> --json, and recordrootSha256plus.st2/catalog-generation.st2 catalog migrate-ids --host dev3 --dry-run --json > plan.json. Confirmreassignedis empty (dev3 has zero archived subjects today, so no UUIDv7 path is exercised),assignedequals the declaration count, andnixOwnedis the set step 2 covered.st2 catalog migrate-ids --host dev3 --json. On an interruption, rerun with--resume; never with a plain rerun.st2 catalog graph --host dev3 --jsoniscomplete: truewith 0 conflicts and everyidequal to the seat's former bus identity;st2 validate --strictis clean; the generationadvanced by exactly one; no
.st2/migrate-ids-incompleteremains.PID, creation identity, and generation — an ID freeze changes no runtime identifier, and that is
the property the whole "freeze, do not re-key" design exists to preserve.
st2 catalog applythe step-4 snapshotunder its recorded digests. The frozen IDs are additive declaration state; removing them returns
the plane to its pre-migration bytes.
Intentionally left
ST_AGENT, default task IDs, PTY presentation tags, message provenance, andruntime ownership still use the legacy bus identity. Freezing an ID equal to that identity is
precisely what makes this PR safe to land before them.
attribute_legacy_endpointinto the message reader, and the version-2 writers. PR D3.unarchiverefusing an unmigrated archive after activation, and validating ID uniquenessagainst the prospective live-and-archived set. Both are activation-gated behavior; the tombstone
coherence check that does not depend on activation is here.
new-agent/authoring paths. The generator exists and is proved;the creation paths move in D3.
address. Positionalidentityremains everysubject's effective address, exactly as R24's fallback specifies.
Stack created with GitHub Stacks CLI • Give Feedback 💬
Posted on behalf of @schickling
agent_identitysessionagent_personaagent_supervisoragent_toolagent_tool_versionagent_runtimetooling_profile