Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion INVARIANTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ materialization, messaging, DING, or presence must preserve them.
| **Prompt catalog convergence** | A resident catalog supervisor observes cooperative transaction commits through a constant-cost catalog-generation watcher and authorized direct Agent Spec publication through an independent declaration watcher. Both channels coalesce into the serialized loop without waiting for the periodic audit interval; failure of one leaves the other and the timer fallback available. Runtime/control noise remains excluded. | `src/watch.rs::catalog_generation_commit_wakes_catalog_watch`; `src/watch.rs::atomic_agent_bundle_publication_wakes_production_shaped_catalog`; `src/run.rs::supervisor_wakes_and_launches_a_new_direct_declaration`; `src/run.rs::failed_watch_installation_keeps_supervisor_on_timer_cadence` |
| **Bounded DING PTY probe churn** | An unsafe or active composer retains its FIFO notice but deferred delivery retries use a bounded backoff, so each inbox poll cannot spawn another short-lived PTY probe. | `src/ding/mod.rs::deferred_delivery_backoff_bounds_short_lived_pty_attempts` |
| **Agent-declared presence discipline** | The shipped bus contract requires agents to declare `busy` before executing work, use `available` only while yielding or ready, and reserve `dnd` for an explicit hold. Both native harnesses materialize that contract. Busy remains observable but does not suppress DING; fresh `dnd` is the only delivery gate. | `tests/native_only.rs::clean_path_executes_the_maintained_native_authoring_guide`; `src/ding/mod.rs::pending_delivery_ignores_busy_but_respects_fresh_dnd_archive_and_retry` |
| **Stable roster JSON** | `st2 agents --json [--enrich]` preserves field names, order, null handling, presence, typed desired state and rationale, the retirement compatibility projection, opaque declared Resource descriptors, origin-timed activity, inbox counts, and the appended `observedState`, `driverDiagnostic`, and `context` objects. Declared presence, desired lifecycle, observed harness state, native-driver degradation, and harness context are independent axes: none is derived from another; a missing observed or context record is `null`, while missing diagnostic evidence is explicitly `absent`, never healthy. | `src/agents.rs::agents_json_has_stable_wire_shape`; `src/agents.rs::agents_json_preserves_opaque_declared_resource_descriptors`; `src/agents.rs::observed_state_joins_declared_presence_without_touching_either`; `src/agents.rs::driver_diagnostic_wire_exposes_failure_and_evidence_age_without_identity_payloads`; `src/agents.rs::context_is_a_fourth_axis_that_survives_an_indeterminate_observed_state`; `tests/status_agents.rs::roster_json_and_human_output_distinguish_retirement_from_presence`; `tests/status_agents.rs::roster_keeps_presence_separate_from_suspended_desired_state`; `tests/status_agents.rs::roster_uses_version_1_origin_time_for_last_activity`; `tests/status_agents.rs::roster_joins_a_real_context_record_independently_of_observed_state` |
| **Stable roster JSON** | `st2 agents --json [--enrich]` preserves field names, order, null handling, presence, typed desired state and rationale, the retirement compatibility projection, opaque declared Resource descriptors, origin-timed activity, inbox counts, and the appended immutable `id`, mutable `address`, nullable `busAddress`, `observedState`, `driverDiagnostic`, and `context` objects. Declared presence, desired lifecycle, observed harness state, native-driver degradation, and harness context are independent axes: none is derived from another; a missing observed or context record is `null`, while missing diagnostic evidence is explicitly `absent`, never healthy. | `src/agents.rs::agents_json_has_stable_wire_shape`; `src/agents.rs::agents_json_preserves_opaque_declared_resource_descriptors`; `src/agents.rs::observed_state_joins_declared_presence_without_touching_either`; `src/agents.rs::driver_diagnostic_wire_exposes_failure_and_evidence_age_without_identity_payloads`; `src/agents.rs::context_is_a_fourth_axis_that_survives_an_indeterminate_observed_state`; `tests/status_agents.rs::roster_json_and_human_output_distinguish_retirement_from_presence`; `tests/status_agents.rs::roster_keeps_presence_separate_from_suspended_desired_state`; `tests/status_agents.rs::roster_uses_version_1_origin_time_for_last_activity`; `tests/status_agents.rs::roster_joins_a_real_context_record_independently_of_observed_state` |
| **Agent-declared presence** | Refresh preserves non-DND declared status and advances the version 1 heartbeat. A missing status starts as `available`. Legacy DND migrates without renewing its hold. Version 1 DND is not refreshed. Stale, malformed, or implausibly future heartbeats read as `unknown`. The outer Codex, Claude, and pi session wrappers own a five-minute heartbeat while their provider remains alive. | `src/status.rs::refresh_preserves_value_and_changes_heartbeat_bytes`; `src/status.rs::refresh_upgrades_legacy_dnd_without_renewing_the_hold`; `src/status.rs::refresh_missing_writes_available_default`; `src/status.rs::version_1_staleness_and_future_skew_are_bounded`; `src/status.rs::malformed_versioned_record_is_unknown_without_mtime_fallback`; `src/claude_session.rs::idle_provider_refreshes_presence_without_mcp_input`; `src/pi_session.rs::idle_pi_provider_refreshes_presence_without_channel_input`; `src/codex_app_server.rs::inbox_fallback_does_not_write_a_fifteen_second_presence_heartbeat` |
| **Scoped delivery-input wakeups** | Native delivery pumps watch only their inputs: the agent's `resources/inbox` subtree and its `status` file. Runtime records written beside them by the pump's own process group — presence temp siblings, the `harness-state` and `harness-context` records with their locks and staged siblings, stream state — never wake delivery, so a producer that writes on every turn boundary cannot pump its own delivery loop. | `src/watch.rs::delivery_watcher_ignores_runtime_records_but_wakes_on_inbox_and_status` |
| **Observed harness state discipline** | The `harness-state` record is written only by the owning session's driver processes — wrapper, channel, or hooks — serialized by a cross-process lock and coalesced against the on-disk record, atomically and byte-distinct on every write that lands, with freshness from its embedded timestamp and never file mtime; restating an unchanged state touches the record only when the refresh cadence is due. `unknown` is derived, never written: staleness, future skew, malformation, an unsupported schema, and a provably dead pty session each read as `unknown` with a distinct reason; an indeterminate liveness probe downgrades nothing; a missing record is no observation rather than `unknown`; no absence derives a definite state. A writer that loses sight of its harness stops heartbeating instead of refreshing a state it cannot see, and a predecessor session's record is never re-stamped. A reaped provider yields a terminal `ended` record carrying its real exit — written before the stop path's SIGKILL escalation and rewritten from the escalation cover when a grace-window reap observes the real status, proven against the real wrapper binaries of both stop implementations — and never a live state. | `src/harness_state.rs::unknown_state_is_derived_and_cannot_be_written`; `src/harness_state.rs::malformed_record_is_unknown_without_mtime_fallback`; `src/harness_state.rs::staleness_and_future_skew_derive_unknown_with_distinct_reasons`; `src/harness_state.rs::a_dead_session_reads_unknown_even_while_fresh_but_ended_survives`; `src/harness_state.rs::every_landed_write_is_byte_distinct_and_fresh_restatements_do_not_write`; `src/harness_state.rs::a_chatty_producer_restating_its_state_causes_zero_writes`; `src/harness_state.rs::concurrent_writers_defer_to_the_on_disk_record_not_their_cache`; `src/harness_state.rs::a_predecessor_sessions_record_is_never_heartbeat_eligible`; `src/harness_state.rs::missing_record_reads_as_none_not_unknown`; `src/codex_app_server.rs::pump_publishes_observations_and_stops_heartbeating_on_evidence_loss`; `src/claude_session.rs::a_provider_killed_mid_turn_reads_ended_rather_than_active`; `src/claude_session.rs::a_clean_provider_exit_writes_the_terminal_record`; `tests/harness_state_teardown.rs::stop_escalation_writes_the_terminal_record_before_sigkill`; `tests/harness_state_teardown.rs::opencode_stop_escalation_writes_the_cover_record_before_sigkill`; `tests/harness_state_teardown.rs::opencode_graceful_stop_records_the_real_reaped_exit` |
Expand Down
72 changes: 72 additions & 0 deletions docs/vrs/.decisions/0015-immutable-agent-id-and-mutable-address.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,78 @@ host-qualified bus identity are the sole stable routing keys.
the open implementation delta prevents the target VRS from being mistaken for
shipped behavior.

## Amendment 1 — 2026-09-05: the address ships first, the ID half is staged

The decision above stands as the target. 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. Splitting a provisional
route from a durable key needs one new field; freezing an explicit ID buys
host-move invariance and live/archive collision attribution, both real and both
with no observed instance on any admitted host.

Shipped now: the optional `id` and `address` grammar with catalog-global `dup-id`
and host-local `dup-address` admission, `effective_id`/`effective_address`/
`bus_address`, roster and graph projection, `st2 agent address`, and the
fail-closed bare-or-qualified reference resolution — which every reference plane
now shares, including stream ingress. Positional `identity` remains the durable
key, so `ST_AGENT`, task IDs, session socket paths, declaration-parent state,
harness records, PTY tags, and supervisor edges are untouched by a cutover. A
`supervisor` value is a declaration key on both of its planes — the org-chart
walk and the notices that walk carries (crash-loop, protocol rejection) — so an
address is a routing alias for human and message references only, never a
supervisor reference.

Deferred: UUIDv7 creation, the `st2 catalog migrate-ids` freeze transaction,
ID-keyed durable records (message version 2, harness-state and harness-context
version 2, PTY schema 2), collision metadata and collision-aware attribution of
reassigned legacy endpoints, and the activation gate that would sequence them.
No part of it is contradicted by shipping the address first: `id` and `address`
are independent fields, and the freeze migration is unchanged by their order.

The ID half re-enters on any one of these observations, each of which turns a
latent argument into a live obligation:

- a completed cross-host seat move, or a concrete plan for one — the frozen ID is
the only thing that preserves `ST_AGENT`, task IDs, and socket paths across it;
- a live/archive identity collision on any admitted host, which is what the
reassignment record and legacy-endpoint attribution exist for; or
- a UUIDv7 creation call site — a generator that authors new subjects — because a
subject whose ID is in no address namespace cannot be reached without ID-keyed
resolution.

Two corrections to the premises this decision was implemented against:

- **Version-1 readers are additively tolerant, so a new field is not a version
bump.** `crates/st2-wire/src/lib.rs` states the opposite of the assumption as
policy: no type in the reader crate uses `deny_unknown_fields`, precisely so a
reader older than the binary it shells out to ignores unknown fields. The one
genuine cross-build hazard is single-field and about routing, not records: a
build that does not read `address` routes the positional identity and refuses
the new address, the exact inverse of a build that does. So the reader-first
obligation is "deploy an `address`-reading build on every admitted host before
authoring any address", and nothing more. A durable record that *does* reject
unknown fields — `SentRecord` — keeps its version-1 shape here, so its version-2
reader belongs with the writer that emits one.
- **Ownership keys are not addresses.** An exact selector answers to either
immutable key: the explicit `id`, or the positional `<host>.<identity>` bus
identity that migration would freeze into it. Both are unique by admission and
neither moves when an address does, which is what keeps a running seat, its
resync stream, and an interrupted send bound to their own subject across a
cutover.

Before the ID half can land, two defects found while auditing its
implementation must be fixed:

- `supervisor_chain::resolve_spec` resolves parents by `bus_id(host)` or bare
`identity` only, so it must accept `effective_id`; otherwise a subject born with
UUIDv7 loses its org-chart edge, which is the clause the migration transaction
exists to satisfy;
- `st2 catalog migrate-ids` must exempt `agent-id-missing` from its own
pre-admission gate. A partially migrated catalog is inadmissible by that
diagnostic, and the migration verb refuses to write into a non-admitting
catalog, so the rollout order the decision prescribes — teach the projection to
emit `id`, then migrate — deadlocks on the only command that can clear it.

## Options

| Option | Result | Reason |
Expand Down
86 changes: 56 additions & 30 deletions docs/vrs/.delta/DELTA-003-agent-address-not-implemented.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
# DELTA-003: immutable subject ID and mutable address are not implemented
# DELTA-003: the immutable subject ID is not implemented

Status: open

Narrowed by [0015 Amendment 1](../.decisions/0015-immutable-agent-id-and-mutable-address.md)
on 2026-09-05: the mutable address shipped, the immutable ID is staged behind the
triggers that amendment names.

## Divergence

[Decision 0015](../.decisions/0015-immutable-agent-id-and-mutable-address.md)
and root requirements R19 and R24-R26 define the accepted target identity
model. The implementation still uses positional `identity` plus current host as
logical subject ID, human route, ownership key, task prefix, state selector, and
`ST_AGENT`. `AgentSpec` has no explicit `id` or `address`; ordinary resolution,
roster output, graph output, supervisor edges, messages, authoring, and PTY
metadata all retain the pre-decision behavior.
model. `AgentSpec` now admits an optional `id` and an optional `address`;
`st2 agent address` authors the route; ordinary references, inbox and status
selection, recipients, and stream ingress all resolve through the fail-closed
bare-or-qualified address algorithm; roster and graph publish `id`, `address`,
and `busAddress`.

What remains divergent is the ID half. No writer emits `id`, so the effective ID
of every subject is still its positional `<host>.<identity>` bus identity, and
that value — not an explicit ID — is what ownership keys, task prefixes, durable
record endpoints, supervisor edges, PTY tags, and `ST_AGENT` carry. A subject
created after this delta closes would need UUIDv7 and ID-keyed resolution to be
reachable at all.

## VRS

Expand All @@ -37,48 +48,63 @@ the fail-closed bare-or-qualified address algorithm.
Agent endpoints persist an immutable ID plus a publication-time address
snapshot. Principal and external endpoints persist an explicit endpoint kind
and canonical typed address instead of pretending that address is an agent ID.
New durable message records use version 2 because strict version-1 readers
reject the new endpoint and snapshot fields.
That is a new durable record version for `SentRecord`, which rejects unknown
fields (`src/message.rs`). It is not one for harness-state or harness-context,
whose readers ignore unknown fields by policy
(`crates/st2-wire/src/lib.rs`) — the premise that strict version-1 readers reject
additive fields was wrong for every record but the sender ledger, and each
version-2 reader belongs in the pull request that adds the writer emitting it.

## Implementation

No runtime code changes are part of the VRS pull request that opens this delta.
Implementation must begin with tests at the Agent Spec and address-book
boundaries. It must then propagate one typed ID/address distinction through:
The address half is implemented. What it leaves is the ID half, and it must
propagate one typed ID distinction through:

- live and archived catalog validation, explicit-ID migration, unarchive, and
ID-keyed supervisor references;
- every agent-selecting CLI, generated hook, channel adapter, driver argument,
ambient `ST_AGENT` consumer, authoring command, graph, roster, and Doctor
projection;
ID-keyed supervisor references — `supervisor_chain::resolve_spec` resolves a
parent by `bus_id(host)` or bare `identity` only, so it must accept
`effective_id` before a UUIDv7-born subject can hold an org-chart edge, and
`st2 catalog migrate-ids` must exempt `agent-id-missing` from its own
pre-admission gate or the prescribed rollout order deadlocks;
- every ambient `ST_AGENT` consumer, generated hook, channel adapter, and driver
argument, which today carry the positional bus identity;
- runtime ownership, default task IDs, task inventory, socket admission, PTY
schema-2 metadata, and launch metadata while keeping declaration-parent state
and Resource paths stable;
- ordinary messages, replies, version-2 Sent records, typed non-Agent endpoints,
DING sender projection, stream ingress and ownership, resync subscriptions,
harness-state, and harness-context records; and
- version-2 Sent records, typed non-Agent endpoints, DING sender projection,
stream and resync ownership keys, harness-state, and harness-context records;
and
- all supported downstream evals and generators.

Activation is a reader-first transition, not a one-version flag day:
The remaining reader-first obligation of the shipped half is single-field and
about routing: a build that does not read `address` routes the positional
identity and refuses an authored address, so an `address`-reading build must be
deployed on every admitted host **before** any address is authored. That is
satisfied by the release carrying the grammar; no record version, downstream
reader survey, or catalog transaction is implied by it.

Activating the ID half is still a reader-first transition, not a one-version flag
day:

1. Deploy readers that accept legacy and target Agent Specs, message versions 1
and 2, PTY schemas 1 and 2, harness-state and harness-context schemas 1 and
2, and old and new projections. Keep every writer on legacy output.
and 2, and PTY schemas 1 and 2. Keep every writer on legacy output. The
harness-state and harness-context readers are additively tolerant already, so
their version-2 arms ship with their writers rather than ahead of them.
2. Prove reader readiness on every admitted host and supported downstream
consumer. An unreadable or unknown reader is not ready.
3. 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.
4. Re-prove reader readiness immediately before enabling target writers.
5. Activate UUIDv7 creation, mutable-address routing, raw-ID `ST_AGENT`, ID-keyed
runtime ownership, message version 2, harness-state and harness-context
version 2, and PTY schema 2 together.

No timeout substitutes for readiness. Until step 5 completes, existing identity
resolution and every current invariant remain normative implementation behavior.
After activation, an unmigrated archived declaration cannot re-enter the
catalog; unarchive validates ID uniqueness, and a transition from retired to
routable validates full-catalog address uniqueness.
5. Activate UUIDv7 creation, raw-ID `ST_AGENT`, ID-keyed runtime ownership,
message version 2, harness-state and harness-context version 2, and PTY
schema 2 together.

No timeout substitutes for readiness. Until step 5 completes, the positional bus
identity remains the normative durable key and every current invariant remains
normative implementation behavior. After activation, an unmigrated archived
declaration cannot re-enter the catalog; unarchive validates ID uniqueness, and a
transition from retired to routable validates full-catalog address uniqueness.

## Direction

Expand Down
Loading
Loading