Skip to content

Split the agent route from its durable key: mutable address, optional immutable id - #468

Merged
schickling-assistant merged 5 commits into
mainfrom
schickling-assistant/2026-09-05-delta-003-immutable-ids
Sep 6, 2026
Merged

Split the agent route from its durable key: mutable address, optional immutable id#468
schickling-assistant merged 5 commits into
mainfrom
schickling-assistant/2026-09-05-delta-003-immutable-ids

Conversation

@schickling-assistant

@schickling-assistant schickling-assistant commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Reopens the address-only lane. #456 carried this branch and cannot be reopened through the API (its
recorded head no longer exists after the trim force-push), so this PR continues it with the same head
branch and the same base.

Read #456 for the review history. What changed since its last review: the version-2 reader arms
(harness-state, harness-context, sent ledger) and the dead DeclaredAgent::id()/::address()
accessors are gone — 234 lines. What remains is the identity/address model only.

Coordination. #456 and #457 were closed at 15:18 UTC in favour of #464, which implements the whole
ID/address program as one 12.5k-line change. That is option B of decision Q23 — the option the
answer of record rejected: *"Land st2#456; cherry-pick set_address/refuse_address_collision/address_cmd

Problem

Decision 0015 and root
requirements R19, R24-R26 were accepted as contract in #453, with
DELTA-003 left open because nothing
implements them.

The load-bearing half of that problem statement is one string doing two jobs: positional identity
is simultaneously the human route and the durable key (ST_AGENT, task IDs, session socket paths,
declaration-parent state, supervisor edges). So a provisional semantic route — help-key.verifier
becomes permanent, and refining it either strands routing or creates a new subject.

Splitting the two needs one new field. This PR adds the grammar, the resolution accessors, the
uniqueness rules, and the read projections for it. No writer emits either field yet.

Change

  • Grammar. AgentSpec gains an optional address (mutable route) and an optional id
    (immutable catalog-global key), with KDL/TOML/JSON lowering parity and a once-only shape refusal
    matching identity and host (crates/agent-spec/src/spec.rs, kdl_format.rs, declared.rs).
  • Validation. validate_agent_address is R24's grammar verbatim (≤255 ASCII bytes, dotted
    1..=63-byte lowercase alphanumeric/hyphen segments); validate_agent_id is the closed safe set
    [A-Za-z0-9._-] with no leading or trailing dot — wider than the address grammar so a frozen
    legacy ID keeps its identity's case and underscores, and narrow enough that no shell
    metacharacter, separator, or non-ASCII byte can enter a value R26 reuses as a task ID, a socket
    path component, and shell-adjacent bus text (crates/agent-spec/src/spec.rs).
  • Resolution accessors. effective_address() falls back to the positional identity,
    bus_address() host-qualifies it, effective_id() answers with the value a later ID migration
    would freeze — so a partially migrated catalog stays coherent and an unmigrated one is unchanged.
  • Admission. dup-address is new: effective addresses are unique per resolved logical host among
    running and suspended subjects; retirement releases the address. dup-id keys on the effective
    agent ID. Both rules read one host key and register in one pass, so they judge the same physical
    subject and a declaration refused for a duplicate ID still claims its address.
    Explicit-vs-explicit and explicit-vs-identity-fallback are the same collision (src/validate.rs).
  • Projections. Roster and graph JSON append id, address, and nullable busAddress, preserving
    existing field order and meanings (src/agents.rs, src/catalog_graph.rs).

Why the version-2 reader arms are gone

This PR previously also reserved version-2 readers for harness-state, harness-context, and the sent
ledger. They were dropped after an audit of the whole three-PR stack, and after the decision to ship
the mutable address first and stage the immutable-id half behind observed triggers (recorded as
0015 Amendment 1 in the follow-up PR). Reasons, in order of weight:

  1. No writer will emit them. All three versions exist only to announce a meaning change in
    agent/from/to that arrives with the ID writers. With those deferred, the arms guard record
    versions nothing in the program produces.
  2. The premise at DELTA-003:42-46 — "strict version-1 readers reject the new fields" — is false for
    these records.
    crates/st2-wire/src/lib.rs:18-23 documents the opposite as policy ("No type here
    uses deny_unknown_fields"), and the new sent-row fields were skip_serializing_if. The single
    genuine cross-build hazard is routing, and it is caused by address, not by a record version:
    an old binary routes verifier and refuses keymap.verifier where a new one does the inverse. So
    the remaining reader-first obligation is "deploy an address-reading build everywhere before
    authoring any address" — a single-field requirement this PR alone satisfies.
  3. The harness arms bought nothing observable. Their v2 shape is byte-identical to v1 (the dropped
    commit's own test asserted exactly that), and record.agent is written but never read for any
    decision: read_raw_at does not look at it.
  4. The sent-ledger arm is load-bearing when a v2 sent writer exists (SentRecord is
    deny_unknown_fields, src/message.rs:38), which is precisely why it belongs in the PR that adds
    that writer, not here.

Also dropped: DeclaredAgent::id() / ::address(), which had no production caller in this PR or in
the writers branch — src/agent_author.rs walks KDL through its own exact_agent_node.

Net effect of the trim: −222 lines of reader arms and −12 lines of dead ring-1 surface; what remains
is the identity/address model itself (~360 production lines) plus its proof.

Review round 2 — the independent review's findings

Three commits on top of the reviewed head; git diff against it is 3 files of production code and
3 of tests.

Blocking: a lone id or address no longer makes a file a spec candidate
(crates/agent-spec/src/spec.rs). They are two of the most common keys in arbitrary JSON/TOML, and
looks_like_spec exists to keep such files out of the spec plane. Admitting them turned a stray file
beside a real declaration — a dropped GitHub payload, a task cache — into a phantom agent in the
roster and in catalog graph, and, because such a file carries no launch, stopped the catalog from
admitting at all. Both arms are deleted; nothing needed them, since every real declaration carries an
identity, a driver block, a launch, or type = "service", and migration adds id to declarations
that are already candidates. The reviewer's control case is now a test: a valid agents/h/root/agent.kdl
plus a notes.toml holding only id = "gh-123" discovers exactly one agent, with no errors.

The id grammar is the closed safe set. It admitted every printable ASCII byte except /, \,
and : — so backtick, $, ;, &, |, *, ?, and quotes were legal in a value R26 reuses
verbatim as the canonical task ID and as a session socket path component, and which travels through
shell-adjacent bus text (the class of schickling/dotfiles#1614). Now [A-Za-z0-9._-]. It stays wider
than the address grammar deliberately: a frozen legacy ID keeps whatever case and underscores its
identity carried, so freezing an admissible identity can never be refused here. No writer emits id,
so this narrows an unproduced namespace.

One registration pass over both duplicate keys (src/validate.rs). Two asymmetries the review
found:

  • dup-address keyed on the resolved logical host while dup-id keyed on an empty placeholder, so
    under --host h a host-less declaration and an explicit host "h" one — one physical subject —
    were reported only under the address code. Both rules now read the same host key.
  • suppressing the address check for a declaration already refused for a duplicate ID also left that
    declaration's address unregistered, so a third subject could claim it undetected — including
    through st2 agent address, whose gate re-runs exactly this rule. Both keys now register before
    either is reported, and one physical conflict is still one diagnostic.

Each fix ships with a test that fails on the reviewed head and passes here (verified by reverting the
source hunks and re-running: a_lone_id_or_address_is_not_a_spec_candidate,
the_id_grammar_admits_uuidv7_and_frozen_bus_identities_only,
a_host_less_and_an_explicit_host_declaration_collide_as_one_duplicate_id,
a_declaration_refused_for_a_duplicate_id_still_claims_its_address — 4 failed before, all pass now).

Gates and evidence

nix develop -c cargo build --workspace --tests                              # clean
nix develop -c cargo test -p agent-spec                                     # 97 passed (17 + 5 + 75)
nix develop -c cargo test -p st2 --lib                                      # 711 passed, 1 ignored
nix develop -c cargo test --test validate --test catalog_graph --test status_agents
                                                                            # 64 / 10 / 12 passed

cargo fmt --check disagrees with committed code in 40+ untouched files, so formatting is left
alone; the lines this PR adds are rustfmt-shaped (verified per-hunk against the pre-change drift
set). cargo clippy --workspace --all-targets reports no finding in the changed surface; its 2
errors are pre-existing in src/resync.rs (from_mode(0) in two tests, byte-identical on
origin/main).

Behaviour on an existing catalog is unchanged: with no declaration carrying either field,
effective_id answers the bus identity and effective_address answers the positional identity, so
roster, graph, and admission output are byte-identical to main.

Intentionally left

  • Every writer. ST_AGENT, default task IDs, PTY presentation tags, message provenance,
    supervisor edge keys, and runtime ownership are untouched by this PR.
  • st2 agent address authoring and address resolution. The stacked follow-up PR adds
    agent_author::set_address, st2 agent address, AgentSelector, the fail-closed bare-or-qualified
    identity::resolve_address, and the recipient-resolution cutover to effective_address().
  • The immutable-id halfst2 catalog migrate-ids, UUIDv7 creation, ID-keyed durable records,
    and the activation gate — is deferred behind named triggers (a completed cross-host seat move, a
    live/archive identity collision, or a UUIDv7 creation call site). [deferred] Freeze every legacy subject's immutable agent id in one catalog transaction #457 and the remainder of [deferred] Activate the immutable-id identity model behind a catalog migration gate #463
    are closed as deferred with their branches kept; 0015 Amendment 1 records the staging and the two
    defects that must be fixed before that half lands (supervisor_chain::resolve_spec must accept
    effective_id; migrate-ids must exempt agent-id-missing from its own pre-admission gate).
  • Archived-subject ID uniqueness and collision-aware attribution of a reassigned legacy
    endpoint
    keep their // DELTA-003: notes: both need the deferred migration's combined index and
    durable collision metadata.
  • GraphArchived address fields. The archived row is projected verbatim from a tombstone that has
    no address field, and an archived subject is non-routable by definition.
Posted on behalf of @schickling
field value
agent_identity dev3.direct.omp.v6c4mkm2
session dev3.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

@schickling-assistant

Copy link
Copy Markdown
Contributor Author

@codex review

This continues #456 (which cannot be reopened after the trim force-push). The branch was rewritten since your last pass: the reserved version-2 reader arms (harness-state, harness-context, sent ledger) and the dead DeclaredAgent::id()/::address() accessors are removed — 234 lines — and it is rebased onto current main. What remains is the identity/address model only: optional id + address grammar, validate_agent_id/validate_agent_address, effective_id/effective_address/bus_address, dup-id/dup-address admission, and the roster/graph projection.

Prior comments about the record version arms are moot. The description explains why they went; the short version is that crates/st2-wire/src/lib.rs documents additive tolerance as policy, so an appended field is not a version bump, and the only record that rejects unknown fields (SentRecord) keeps its version-1 shape here.

Worth checking: whether dup-id's effective-ID keying is right for an explicit id that claims another subject's still-unmigrated frozen bus identity, and whether dup-address's retirement release can strand an address that a retired subject still occupies on disk.

Gates: cargo test -p agent-spec 97 passed; cargo test -p st2 --test validate --test catalog_graph --test status_agents 62 + 10 + 12 passed.

Posted on behalf of @schickling
field value
agent_identity dev3.direct.omp.v6c4mkm2
session dev3.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

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Comment thread crates/agent-spec/src/spec.rs Outdated
Comment thread crates/agent-spec/src/spec.rs Outdated
Comment thread src/validate.rs Outdated
Comment thread src/validate.rs Outdated
…dress

Agent Spec gains the two fields decision 0015 separates from the positional
`identity`: an explicit catalog-global immutable `id` and an optional mutable
`address`. Both are optional during the DELTA-003 migration window; `id` is
required in the target grammar once every live and archived subject is migrated.

- `id` is opaque. Its only two producers are UUIDv7 for a new subject and the
  frozen `<host>.<identity>` bus identity of a migrated legacy subject, and R26
  reuses it verbatim as the canonical task ID and therefore as a session socket
  path component, so validation refuses exactly what would stop being a usable
  task ID rather than imposing the address grammar.
- `address` carries R24's grammar: at most 255 ASCII characters, dotted
  1..=63-character segments of lowercase letters, digits, and hyphens, each
  beginning and ending with a letter or digit.
- `effective_address()` falls back to the positional identity, `bus_address()`
  host-qualifies it, and `effective_id()` answers with the value catalog ID
  migration freezes, so a partially migrated catalog stays coherent.

Declaring either field twice is the shape refusal `identity` and `host` already
carry. No writer emits either field yet.
Catalog admission gains the two uniqueness rules R24 states, and the read
projections gain the fields R24 and decision 0015 require them to publish.

- `dup-id` now keys on the effective agent ID rather than the bus identity, so
  one check covers a legacy duplicate identity, two explicit ids colliding
  across hosts (an ID is catalog-global, never per-host), and an explicit id
  claiming another subject's still-unmigrated frozen bus identity.
- `dup-address` is new: effective addresses are unique per resolved logical
  host among running and suspended subjects. A retired subject releases its
  address, so it neither claims nor collides. Explicit-vs-explicit and
  explicit-vs-identity-fallback are the same collision.
- Roster and graph JSON append `id`, `address`, and nullable `busAddress`,
  preserving existing field order and meanings. `identity` keeps its meaning as
  the positional declaration key and legacy address fallback.
- The graph re-keys `id`, `parentId`, `rootId`, and `ancestorIds` onto the
  effective agent ID so one namespace spans a partially migrated catalog; for an
  unmigrated subject that value is its bus identity, so output is unchanged.

Archived-subject ID uniqueness joins these checks with the migration verb.
`id` and `address` are two of the most common keys in arbitrary JSON/TOML, and
this predicate exists to keep such files out of the spec plane. Admitting them
turned a stray file beside a real declaration into a phantom agent — in the
roster and in `catalog graph` — and, because such a file carries no launch, also
stopped the catalog from admitting at all.

Nothing needs the two arms: every real declaration carries an identity, a driver
block, a launch, or `type = "service"`, and migration adds `id` to declarations
that are candidates for other reasons.

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 previous grammar admitted every printable ASCII byte except `/`, `\\`, and
`:` — so backtick, `$`, `;`, `&`, `|`, `*`, `?`, and quotes were all legal in a
value R26 reuses verbatim as the canonical task ID, as a session socket path
component, and in shell-adjacent bus text, where a backtick has already executed
a command on a live host (schickling/dotfiles#1614).

The grammar is now `[A-Za-z0-9._-]`. It stays wider than the address grammar on
purpose: a frozen legacy ID keeps the case and underscores its identity carried,
so freezing an admissible identity can never be refused here. No writer emits
`id` yet, so this narrows an unproduced namespace.

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
Two asymmetries between the `dup-id` and `dup-address` rules:

- `dup-address` keyed on the resolved logical host while `dup-id` keyed on an
  empty placeholder, so under `--host h` a host-less declaration and an explicit
  `host "h"` one — one physical subject — were reported only under the address
  code. Both rules now read the same host key.
- Suppressing the address check for a declaration already refused for a
  duplicate ID also left that declaration's address unregistered, so a third
  subject could claim it undetected — including through `st2 agent address`,
  whose gate re-runs exactly this rule. Both keys now register in one pass
  before either is reported, and one physical conflict is still one diagnostic.

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
@schickling-assistant
schickling-assistant force-pushed the schickling-assistant/2026-09-05-delta-003-immutable-ids branch from 9d313ab to b99b063 Compare September 6, 2026 12:04
@schickling-assistant
schickling-assistant merged commit 2c6f67b into main Sep 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants