From 5263be1552d19f1fce081c9a11597c9bb02a10b4 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:39:58 +0200 Subject: [PATCH 1/9] feat(identity): add the typed agent selector and address resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `.` 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 --- src/identity.rs | 430 ++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 2 files changed, 431 insertions(+) create mode 100644 src/identity.rs diff --git a/src/identity.rs b/src/identity.rs new file mode 100644 index 00000000..63ffb4be --- /dev/null +++ b/src/identity.rs @@ -0,0 +1,430 @@ +//! Agent selection: immutable IDs and mutable addresses. +//! +//! Decision 0015 splits one overloaded string into two typed namespaces. The immutable **agent ID** +//! is catalog-global and never routes for humans; the mutable **agent address** is unique per +//! logical host and is the only thing an ordinary human reference resolves against. Equal bytes in +//! the two namespaces do not collide, so an exact-ID selector performs only ID lookup and never +//! falls through to address lookup — that is what keeps an existing semantic ID from silently +//! staying alive as a route after a rename. +//! +//! Ordinary references are decided by a fail-closed candidate set rather than a precedence rule, +//! because a dotted semantic address and a host-qualified bus address are indistinguishable by +//! shape: `dotfiles.fractal.chat` is a legal bare address and a legal `.
` split. +//! Collecting both readings and requiring exactly one surviving subject makes the question +//! decidable without guessing which dot is the separator. +//! +//! Every subject's ID is its effective ID: the explicit `id` a declaration carries, else the +//! `.` bus identity, which is what an unmigrated catalog answers with. Nothing +//! here reads an activation gate — the address model is normative on any catalog. + +use std::collections::{BTreeMap, BTreeSet}; + +/// How a caller named one agent. +/// +/// The two forms are mutually exclusive by construction. Every agent-selecting command exposes +/// both, and a command that defaults from `ST_AGENT` consumes it through [`Self::Id`] — an ambient +/// actor is an exact subject, never a route to re-resolve. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentSelector { + /// Exact subject lookup on the two immutable keys. Never falls through to address lookup. + Id(String), + /// An ordinary human reference, resolved by [`resolve_address`]. + Address(String), +} + +/// One routable subject in the address book. +/// +/// Retired subjects are absent: retirement releases the address and makes the subject +/// non-routable, so it neither resolves nor occupies the namespace. Suspended subjects are present. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddressBookEntry { + /// The immutable catalog-global agent ID: the explicit `id`, else the positional bus identity + /// a later ID migration freezes into it. + pub id: String, + /// The positional `.` declaration key. Immutable in practice — nothing in st2 + /// rewrites it — and the value every durable surface is keyed on today, so an exact selector + /// answers to it as well as to `id`. + pub bus_identity: String, + /// The resolved logical host. + pub host: String, + /// The effective address: explicit `address`, else the positional `identity` fallback. + pub address: String, +} + +impl AddressBookEntry { + /// The human-routable bus address `.
`. + pub fn bus_address(&self) -> String { + format!("{}.{}", self.host, self.address) + } +} + +/// Why an ordinary reference did not name exactly one subject. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolveError { + /// No routable subject carries this address, in any admitted reading. + Unknown { reference: String }, + /// More than one distinct subject survives, so the reference is undecidable. + Ambiguous { + reference: String, + /// The surviving subjects' IDs, sorted, so a diagnostic can name them. + ids: Vec, + }, +} + +impl std::fmt::Display for ResolveError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unknown { reference } => write!( + formatter, + "no routable agent has the address '{reference}'; a retired subject releases its address and does not resolve" + ), + Self::Ambiguous { reference, ids } => write!( + formatter, + "the reference '{reference}' is ambiguous: it names {} subjects ({}); qualify it with a host or select the subject by its exact id", + ids.len(), + ids.join(", ") + ), + } + } +} + +impl std::error::Error for ResolveError {} + +/// Resolve one exact agent selector, on either immutable key: the catalog-global agent ID, or the +/// positional bus identity that a later ID migration freezes into it. +/// +/// Never an address lookup. Equal bytes in the address namespace do not answer here, which is what +/// keeps a renamed subject's released semantic address from silently staying alive as a selector; +/// and because neither key moves when an address does, `ST_AGENT`, a task ID, or a durable +/// record's endpoint still names its own subject after a cutover. +/// +/// Both keys are unique per catalog by admission (`dup-id`), but this does not assume admission +/// ran: a key that names more than one subject is ambiguous, not a first match. +pub fn resolve_id<'a>( + entries: &'a [AddressBookEntry], + id: &str, +) -> std::result::Result<&'a AddressBookEntry, ResolveError> { + let mut matches = entries + .iter() + .filter(|entry| entry.id == id || entry.bus_identity == id); + let selected = matches.next().ok_or_else(|| ResolveError::Unknown { + reference: id.to_owned(), + })?; + let mut ids = std::iter::once(selected) + .chain(matches) + .map(|entry| entry.id.clone()) + .collect::>(); + if ids.len() == 1 { + return Ok(selected); + } + ids.sort(); + ids.dedup(); + Err(ResolveError::Ambiguous { + reference: id.to_owned(), + ids, + }) +} + +/// Resolve one ordinary human reference through the fail-closed candidate set (R24). +/// +/// 1. When the caller pins a host, treat the complete input as an address in that host, and also +/// try the qualified split whose prefix equals the pinned host. +/// 2. Otherwise treat the complete input as a bare address across the selected catalog, and also +/// try every dotted split whose prefix is an admitted logical host and whose suffix is an +/// effective address in that host. +/// 3. Deduplicate by agent ID and succeed only when exactly one subject remains. +pub fn resolve_address<'a>( + entries: &'a [AddressBookEntry], + reference: &str, + pinned_host: Option<&str>, +) -> std::result::Result<&'a AddressBookEntry, ResolveError> { + let hosts: BTreeSet<&str> = entries.iter().map(|entry| entry.host.as_str()).collect(); + let mut candidates: BTreeMap<&str, &AddressBookEntry> = BTreeMap::new(); + + let mut consider = |host: &str, address: &str| { + for entry in entries { + if entry.host == host && entry.address == address { + candidates.insert(entry.id.as_str(), entry); + } + } + }; + + match pinned_host { + Some(pinned) => { + consider(pinned, reference); + // Only the pinned host's own prefix is an admitted split: a caller that pinned a host + // cannot reach another one by spelling it into the reference. + if let Some(suffix) = reference + .strip_prefix(pinned) + .and_then(|rest| rest.strip_prefix('.')) + { + consider(pinned, suffix); + } + } + None => { + for host in &hosts { + consider(host, reference); + } + for (index, _) in reference.match_indices('.') { + let (prefix, suffix) = (&reference[..index], &reference[index + 1..]); + if hosts.contains(prefix) { + consider(prefix, suffix); + } + } + } + } + + match candidates.len() { + 1 => Ok(candidates.into_values().next().expect("one candidate")), + 0 => Err(ResolveError::Unknown { + reference: reference.to_owned(), + }), + _ => Err(ResolveError::Ambiguous { + reference: reference.to_owned(), + ids: candidates.into_keys().map(str::to_owned).collect(), + }), + } +} + +/// Resolve either selector form against one coherent address book. +pub fn resolve<'a>( + entries: &'a [AddressBookEntry], + selector: &AgentSelector, + pinned_host: Option<&str>, +) -> std::result::Result<&'a AddressBookEntry, ResolveError> { + match selector { + AgentSelector::Id(id) => resolve_id(entries, id), + AgentSelector::Address(reference) => resolve_address(entries, reference, pinned_host), + } +} + +/// The routable address book of a discovered catalog. +/// +/// A subject with no explicit `id` contributes its effective ID — the legacy bus identity migration +/// freezes — so resolution works identically before and after migration. +pub fn address_book(specs: &[agent_spec::AgentSpec], this_host: &str) -> Vec { + specs + .iter() + .filter(|spec| !spec.desired_state.is_retired()) + .map(|spec| AddressBookEntry { + id: spec.effective_id(this_host), + bus_identity: spec.bus_id(this_host), + host: spec.resolved_host(this_host).to_owned(), + address: spec.effective_address().to_owned(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(id: &str, host: &str, address: &str) -> AddressBookEntry { + AddressBookEntry { + id: id.to_owned(), + bus_identity: format!("{host}.{address}"), + host: host.to_owned(), + address: address.to_owned(), + } + } + + #[test] + fn a_bare_address_resolves_within_the_selected_catalog() { + let entries = vec![entry("id-1", "dev3", "chat"), entry("id-2", "dev4", "notes")]; + assert_eq!(resolve_address(&entries, "chat", None).unwrap().id, "id-1"); + assert_eq!(resolve_address(&entries, "notes", None).unwrap().id, "id-2"); + } + + /// A host-qualified bus address and a dotted semantic address are the same shape, so both + /// readings are collected and the answer is the surviving subject. + #[test] + fn a_dotted_reference_is_decided_by_the_candidate_set_not_by_precedence() { + let entries = vec![ + entry("id-1", "dev3", "fractal.chat"), + entry("id-2", "dev4", "notes"), + ]; + // Only the bare reading exists. + assert_eq!( + resolve_address(&entries, "fractal.chat", None).unwrap().id, + "id-1" + ); + // Only the qualified reading exists. + assert_eq!( + resolve_address(&entries, "dev4.notes", None).unwrap().id, + "id-2" + ); + // The qualified reading of a dotted address also resolves, because `dev3` is an admitted + // host and `fractal.chat` is an address in it. + assert_eq!( + resolve_address(&entries, "dev3.fractal.chat", None) + .unwrap() + .id, + "id-1" + ); + } + + /// Both readings naming different subjects is undecidable, not first-wins. + #[test] + fn two_readings_of_one_reference_fail_closed() { + let entries = vec![ + // The bare reading: host `dev3` has the dotted address `dev4.notes`. + entry("id-1", "dev3", "dev4.notes"), + // The qualified reading: host `dev4` has the address `notes`. + entry("id-2", "dev4", "notes"), + ]; + let error = resolve_address(&entries, "dev4.notes", None).unwrap_err(); + match error { + ResolveError::Ambiguous { ids, .. } => assert_eq!(ids, vec!["id-1", "id-2"]), + other => panic!("expected ambiguity, got {other:?}"), + } + } + + /// The same address on two hosts is legal, so an unqualified reference to it is ambiguous and a + /// qualified one is exact. + #[test] + fn one_address_on_two_hosts_needs_a_host_to_be_decidable() { + let entries = vec![entry("id-1", "dev3", "chat"), entry("id-2", "dev4", "chat")]; + assert!(matches!( + resolve_address(&entries, "chat", None), + Err(ResolveError::Ambiguous { .. }) + )); + assert_eq!( + resolve_address(&entries, "dev3.chat", None).unwrap().id, + "id-1" + ); + assert_eq!( + resolve_address(&entries, "chat", Some("dev4")).unwrap().id, + "id-2" + ); + } + + /// A pinned host is a boundary: a reference cannot reach another host by spelling it. + #[test] + fn a_pinned_host_admits_only_its_own_qualified_split() { + let entries = vec![entry("id-1", "dev3", "chat"), entry("id-2", "dev4", "chat")]; + assert_eq!( + resolve_address(&entries, "dev3.chat", Some("dev3")) + .unwrap() + .id, + "id-1" + ); + assert!(matches!( + resolve_address(&entries, "dev4.chat", Some("dev3")), + Err(ResolveError::Unknown { .. }) + )); + } + + /// Deduplication is by agent ID, so one subject reachable through both readings is not + /// ambiguous with itself. + #[test] + fn one_subject_reached_twice_is_not_ambiguous() { + // `dev3` has an address that literally reads `dev3.chat`, so the bare reading and the + // qualified reading are the same subject only if the host also has `chat`. + let entries = vec![entry("id-1", "dev3", "chat")]; + assert_eq!( + resolve_address(&entries, "dev3.chat", None).unwrap().id, + "id-1" + ); + assert_eq!( + resolve_address(&entries, "dev3.chat", Some("dev3")) + .unwrap() + .id, + "id-1" + ); + } + + #[test] + fn an_unknown_address_fails_with_an_address_specific_diagnostic() { + let entries = vec![entry("id-1", "dev3", "chat")]; + let error = resolve_address(&entries, "ghost", None).unwrap_err(); + assert!( + format!("{error}").contains("no routable agent has the address 'ghost'"), + "{error}" + ); + } + + /// Exact ID selection never falls through to address lookup, and equal bytes across the two + /// namespaces do not collide. + #[test] + fn exact_id_selection_never_falls_through_to_address_lookup() { + let entries = vec![ + entry("0199b8f4-8d3a-7c21-9a44-6f85b7320ea1", "dev3", "chat"), + entry("id-2", "dev4", "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1"), + ]; + // The ID namespace answers with the subject that owns the ID. + assert_eq!( + resolve_id(&entries, "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1") + .unwrap() + .host, + "dev3" + ); + // The address namespace answers with the subject that owns the address. + assert_eq!( + resolve_address(&entries, "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1", None) + .unwrap() + .host, + "dev4" + ); + // An ID that is not an ID does not become an address. + assert!(matches!( + resolve_id(&entries, "chat"), + Err(ResolveError::Unknown { .. }) + )); + } + + /// An exact selector answers to either immutable key, so a cutover cannot orphan the values + /// every durable surface is keyed on: `ST_AGENT`, a task ID, a record endpoint. + #[test] + fn an_exact_selector_answers_to_the_positional_key_after_an_address_moves() { + let moved = AddressBookEntry { + id: "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1".to_owned(), + bus_identity: "dev3.verifier".to_owned(), + host: "dev3".to_owned(), + address: "keymap.verifier".to_owned(), + }; + let entries = vec![moved]; + // The route moved, so the old spelling is not an address any more. + assert!(matches!( + resolve_address(&entries, "dev3.verifier", None), + Err(ResolveError::Unknown { .. }) + )); + // Both immutable keys still name the subject exactly. + for key in [ + "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1", + "dev3.verifier", + ] { + assert_eq!( + resolve(&entries, &AgentSelector::Id(key.to_owned()), None) + .unwrap() + .address, + "keymap.verifier" + ); + } + // The new route resolves, bare and host-qualified. + for reference in ["keymap.verifier", "dev3.keymap.verifier"] { + assert_eq!( + resolve_address(&entries, reference, None).unwrap().id, + "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1" + ); + } + } + + #[test] + fn the_selector_forms_route_to_their_own_namespace() { + let entries = vec![entry("id-1", "dev3", "chat")]; + assert_eq!( + resolve(&entries, &AgentSelector::Id("id-1".to_owned()), None) + .unwrap() + .address, + "chat" + ); + assert_eq!( + resolve(&entries, &AgentSelector::Address("chat".to_owned()), None) + .unwrap() + .id, + "id-1" + ); + assert!(resolve(&entries, &AgentSelector::Id("chat".to_owned()), None).is_err()); + } + +} diff --git a/src/lib.rs b/src/lib.rs index adfd10f9..a9a1f117 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,7 @@ pub mod harness_state; pub mod harness_version; pub mod hooks; pub mod host_lock; +pub mod identity; pub mod isolate; pub mod materialize; pub mod message; From 72c082a5b85ec6b9d91f5b2ed86e78d8853054d4 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:40:07 +0200 Subject: [PATCH 2/9] feat(authoring): assign and clear an agent's mutable address 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 --- src/agent_author.rs | 263 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 254 insertions(+), 9 deletions(-) diff --git a/src/agent_author.rs b/src/agent_author.rs index 566d757b..a08d95c4 100644 --- a/src/agent_author.rs +++ b/src/agent_author.rs @@ -72,6 +72,32 @@ impl PresentationField { } } +/// One single-positional-string child node these source-preserving edits may rewrite. +/// +/// Address is not presentation — it is the mutable route (R24/R25), and it carries authority +/// presentation never has — but it is edited by exactly the same span-bounded machinery: find, +/// replace, insert, or remove one child node while every other byte of the declaration survives. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DeclaredField { + Presentation(PresentationField), + Address, +} + +impl DeclaredField { + fn as_str(self) -> &'static str { + match self { + Self::Presentation(field) => field.as_str(), + Self::Address => "address", + } + } +} + +impl From for DeclaredField { + fn from(field: PresentationField) -> Self { + Self::Presentation(field) + } +} + /// Whether a request changed declaration bytes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] @@ -90,6 +116,23 @@ pub struct PresentationReceipt { pub retired: bool, } +/// Stable machine-readable receipt from one agent-address cutover. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AddressReceipt { + pub result: AuthorOutcome, + /// The subject's immutable agent ID (R24) — the value an address cutover must not touch. + pub id: String, + /// The positional declaration key, also unchanged: it stays the legacy address fallback. + pub identity: String, + /// The declared `address` after the edit. `None` means the positional fallback is effective. + pub address: Option, + /// `.` after the cutover. `None` for a retired subject, which is + /// non-routable and released its address. + pub bus_address: Option, + pub retired: bool, +} + /// Stable authored desired-state selector. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] @@ -196,6 +239,9 @@ impl std::error::Error for AuthorError {} #[derive(Debug)] struct AgentTarget { identity: String, + /// The subject's immutable catalog-global agent ID (R24): the explicit `id`, else the legacy + /// `.` bus identity that migration freezes as this subject's ID. + agent_id: String, source_host: String, source_identity: String, declaration: PathBuf, @@ -663,7 +709,7 @@ pub fn set_presentation( &target.identity, &target.source_host, &target.source_identity, - field, + field.into(), requested.as_deref(), || {}, )?; @@ -676,6 +722,125 @@ pub fn set_presentation( }) } +/// Assign or clear one subject's mutable agent address — one atomic address-book cutover (R25). +/// +/// The old address stops resolving as soon as the new catalog generation is visible; st2 stores no +/// rename history, redirect, implicit alias, or time-bounded compatibility route, so a stale +/// caller fails loudly and refreshes the roster. The edit rewrites exactly the `address` child +/// node, which is what makes the cutover nondisruptive by construction: the declaration-parent +/// state anchor, ID-keyed supervisor edges, task IDs, launch fingerprints, workspace, inbox, +/// archive, context, Resource state, and runtime ownership are all keyed off values this edit +/// never touches. `None` restores the positional `identity` fallback and is admitted only while +/// that fallback address is itself still unique on the resolved host. +pub fn set_address( + catalog_root: &Path, + selector: &str, + this_host: &str, + actor: Option<&str>, + requested: Option<&str>, +) -> Result { + let catalog_lock = CatalogLock::exclusive(catalog_root).map_err(|error| { + AuthorError::new( + "catalog-lock-failed", + format!("acquire catalog-authoring lock: {error:#}"), + ) + })?; + let found = crate::discover(catalog_root); + if let Some(error) = found.errors.first() { + return Err(AuthorError::new( + "catalog-malformed", + format!( + "cannot prove an exact address target while {} is malformed: {}", + error.path.display(), + error.message + ), + )); + } + let target = resolve_target(&found.specs, selector, this_host)?; + authorize_actor( + &found.specs, + &target.identity, + this_host, + actor, + "address-not-authorized", + )?; + if let Some(value) = requested { + agent_spec::validate_agent_address(value) + .map_err(|error| AuthorError::new("invalid-address", error.to_string()))?; + } + refuse_address_collision(catalog_root, &found.specs, this_host, &target, requested)?; + let result = edit_declaration( + &catalog_lock, + catalog_root, + &crate::catalog_transaction::retained_dir_path(catalog_lock.control()) + .map_err(|error| AuthorError::new("declaration-write-failed", error.to_string()))?, + &target.declaration, + &target.identity, + &target.source_host, + &target.source_identity, + DeclaredField::Address, + requested, + || {}, + )?; + let effective = requested.unwrap_or(&target.source_identity); + Ok(AddressReceipt { + result, + // A retired subject is non-routable and released its address, so null is the honest bus + // address here — exactly what the roster projects for the same subject. + bus_address: (!target.retired).then(|| format!("{}.{effective}", target.source_host)), + address: requested.map(str::to_owned), + id: target.agent_id, + identity: target.identity, + retired: target.retired, + }) +} + +/// Refuse an effective address that would not be unique on the target's resolved logical host. +/// +/// The prospective catalog is the discovery this command already holds with exactly this subject's +/// `address` replaced, so `validate.rs`'s `dup-address` rule — the same rule whole-catalog +/// validation enforces — decides explicit/explicit and explicit/identity-fallback collisions +/// alike, including the `--clear` case where the restored fallback is the candidate address. Any +/// duplicate address in the prospective catalog refuses: an address book with two claims on one +/// route cannot answer an ordinary reference, so there is no cutover to admit. +fn refuse_address_collision( + catalog_root: &Path, + specs: &[crate::AgentSpec], + this_host: &str, + target: &AgentTarget, + requested: Option<&str>, +) -> Result<(), AuthorError> { + let mut prospective = crate::Discovered { + specs: specs.to_vec(), + ..Default::default() + }; + for spec in &mut prospective.specs { + if spec.bus_id(this_host) == target.identity { + spec.address = requested.map(str::to_owned); + } + } + let report = crate::validate::validate_discovered(catalog_root, Some(this_host), &prospective); + match report + .issues + .iter() + .find(|issue| issue.code == "dup-address") + { + None => Ok(()), + Some(issue) => Err(AuthorError::new( + "address-conflict", + format!( + "{} is not unique on host {:?}: {}", + requested.map_or_else( + || format!("identity fallback address {:?}", target.source_identity), + |value| format!("address {value:?}") + ), + target.source_host, + issue.message + ), + )), + } +} + fn resolve_target( specs: &[crate::AgentSpec], selector: &str, @@ -700,6 +865,7 @@ fn resolve_target( )), [spec] => Ok(AgentTarget { identity: spec.bus_id(this_host), + agent_id: spec.effective_id(this_host), source_host: spec.resolved_host(this_host).to_owned(), source_identity: spec.identity.clone(), declaration: spec.path.clone(), @@ -795,7 +961,7 @@ fn edit_declaration_for_test( expected_identity, expected_host, expected_agent, - field, + field.into(), requested, before_commit, ) @@ -1470,7 +1636,7 @@ fn edit_declaration( expected_identity: &str, expected_host: &str, expected_agent: &str, - field: PresentationField, + field: DeclaredField, requested: Option<&str>, before_commit: impl FnOnce(), ) -> Result { @@ -1524,6 +1690,11 @@ fn edit_declaration( ), )); } + // No span-bounded field edit may change the subject's immutable agent ID (R24). Address, name, + // and description are all mutable; `id` is the one value that identifies the subject across + // every one of those changes, so the candidate must read back with the exact same bytes — or + // with none, on a declaration ID migration has not reached yet. + let expected_id = declared_id(target); let Some(replacement) = presentation_edit(text, target, field, requested)? else { return Ok(AuthorOutcome::Unchanged); }; @@ -1534,6 +1705,7 @@ fn edit_declaration( expected_agent, field, requested, + expected_id.as_deref(), )?; atomic_replace_checked( catalog_lock, @@ -1793,6 +1965,17 @@ fn agent_identity_parts(node: &KdlNode) -> (Option, Option) { (host, identity) } +/// The declaration's explicit immutable `id`, if it carries one. +fn declared_id(node: &KdlNode) -> Option { + node.children()? + .nodes() + .iter() + .find(|child| child.name().value() == "id") + .and_then(|child| child.get(0)) + .and_then(|value| value.as_string()) + .map(str::to_owned) +} + fn is_nix_managed(node: &KdlNode) -> bool { node.children().is_some_and(|children| { children @@ -1809,7 +1992,7 @@ fn is_nix_managed(node: &KdlNode) -> bool { fn presentation_edit( text: &str, target: &KdlNode, - field: PresentationField, + field: DeclaredField, requested: Option<&str>, ) -> Result, AuthorError> { let fields = target @@ -1834,7 +2017,7 @@ fn presentation_edit( } } -fn parse_field_value(node: &KdlNode, field: PresentationField) -> Result<&str, AuthorError> { +fn parse_field_value(node: &KdlNode, field: DeclaredField) -> Result<&str, AuthorError> { if node.children().is_some() || node.entries().len() != 1 || node.entries()[0].name().is_some() { return Err(AuthorError::new( @@ -1867,7 +2050,7 @@ fn quoted(value: &str) -> Result { fn replace_field( text: &str, node: &KdlNode, - field: PresentationField, + field: DeclaredField, value: &str, ) -> Result, AuthorError> { if parse_field_value(node, field)? == value { @@ -1890,7 +2073,7 @@ fn replace_field( fn insert_field( text: &str, target: &KdlNode, - field: PresentationField, + field: DeclaredField, value: &str, ) -> Result { insert_node( @@ -2053,16 +2236,26 @@ fn verify_candidate( expected_identity: &str, expected_host: &str, expected_agent: &str, - field: PresentationField, + field: DeclaredField, expected: Option<&str>, + expected_id: Option<&str>, ) -> Result<(), AuthorError> { let document = KdlDocument::parse(candidate).map_err(|error| { AuthorError::new( "unsafe-source-edit", - format!("presentation edit did not produce valid KDL: {error}"), + format!("field edit did not produce valid KDL: {error}"), ) })?; let target = exact_agent_node(&document, expected_identity, expected_host, expected_agent)?; + if declared_id(target).as_deref() != expected_id { + return Err(AuthorError::new( + "agent-id-immutable", + format!( + "edit would change the immutable agent id of {expected_identity:?}; `id` is the \ + one declared value no authoring command may rewrite" + ), + )); + } let fields = target .children() .into_iter() @@ -3164,4 +3357,56 @@ mod tests { AuthorOutcome::Unchanged ); } + + /// Direct ID mutation is the one thing no span-bounded field edit may do. `id` is what makes + /// a subject the same subject across an address, name, description, host, or graph change, so + /// the read-back gate compares it rather than trusting the edit that produced the candidate. + #[test] + fn no_field_edit_may_rewrite_the_immutable_agent_id() { + let tampered = + "agent \"worker\" { id \"b\"; host \"h\"; command \"true\"; name \"Owner\" }\n"; + let error = verify_candidate( + tampered, + "h.worker", + "h", + "worker", + DeclaredField::Presentation(PresentationField::Name), + Some("Owner"), + Some("a"), + ) + .unwrap_err(); + assert_eq!(error.code(), "agent-id-immutable"); + + // Dropping the ID entirely is the same refusal: an unmigrated declaration is not a place + // to park a subject whose ID the catalog already froze. + let dropped = "agent \"worker\" { host \"h\"; command \"true\"; address \"ops\" }\n"; + assert_eq!( + verify_candidate( + dropped, + "h.worker", + "h", + "worker", + DeclaredField::Address, + Some("ops"), + Some("a"), + ) + .unwrap_err() + .code(), + "agent-id-immutable" + ); + + // The identical edit with the ID carried through is admitted. + let honest = + "agent \"worker\" { id \"a\"; host \"h\"; command \"true\"; address \"ops\" }\n"; + verify_candidate( + honest, + "h.worker", + "h", + "worker", + DeclaredField::Address, + Some("ops"), + Some("a"), + ) + .unwrap(); + } } From 08d53d0c71b4979e1d208ef08fc81f97616d68c8 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:40:22 +0200 Subject: [PATCH 3/9] feat(agent): mutable address routing behind one typed selector `st2 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 --- src/claude_mcp.rs | 2 +- src/claude_session.rs | 6 +- src/codex_app_server.rs | 20 +- src/event.rs | 238 +++++++++++++--- src/main.rs | 608 ++++++++++++++++++++++++++++++++++------ src/message.rs | 324 +++++++++++++++++---- src/omp_session.rs | 2 +- src/opencode_session.rs | 2 +- src/pi_channel.rs | 2 +- src/pi_session.rs | 2 +- src/run.rs | 6 +- tests/agent_address.rs | 415 +++++++++++++++++++++++++++ tests/message.rs | 20 +- 13 files changed, 1445 insertions(+), 202 deletions(-) create mode 100644 tests/agent_address.rs diff --git a/src/claude_mcp.rs b/src/claude_mcp.rs index e6d7317e..6089df6b 100644 --- a/src/claude_mcp.rs +++ b/src/claude_mcp.rs @@ -26,7 +26,7 @@ fn channel_content(subject: Option<&str>, body: &str) -> String { } pub fn run(catalog_root: &Path, identity: &str) -> Result<()> { - let agent_dir = message::resolve_agent_dir(catalog_root, identity, &crate::run::detect_host())? + let agent_dir = message::resolve_actor_dir(catalog_root, identity, &crate::run::detect_host())? .with_context(|| format!("Claude MCP agent '{identity}' is not declared"))?; let inbox = message::inbox_dir(&agent_dir); let (input_tx, input_rx) = mpsc::channel(); diff --git a/src/claude_session.rs b/src/claude_session.rs index b87f4a2c..4286b1d6 100644 --- a/src/claude_session.rs +++ b/src/claude_session.rs @@ -30,7 +30,7 @@ pub fn run( claude_argv: Vec, ) -> Result<()> { let agent_dir = - message::resolve_agent_dir(catalog_root, &identity, &crate::run::detect_host())? + message::resolve_actor_dir(catalog_root, &identity, &crate::run::detect_host())? .with_context(|| format!("Claude driver agent '{identity}' is not declared"))?; anyhow::ensure!( !claude_argv.is_empty(), @@ -159,7 +159,7 @@ pub fn run_observe( runtime_id: Option<&str>, event: &str, ) -> Result<()> { - let agent_dir = message::resolve_agent_dir(catalog_root, identity, &crate::run::detect_host())? + let agent_dir = message::resolve_actor_dir(catalog_root, identity, &crate::run::detect_host())? .with_context(|| format!("Claude driver agent '{identity}' is not declared"))?; // Counted only once the invocation has its application target: a hook for an undeclared // agent errors out before any state is applied and must not inflate `hook_invocations_total`. @@ -464,7 +464,7 @@ pub fn run_statusline(catalog_root: &Path, identity: &str) -> Result<()> { fn record_statusline(catalog_root: &Path, identity: &str, raw: &[u8]) -> Result<()> { let payload: serde_json::Value = serde_json::from_slice(raw).unwrap_or(serde_json::Value::Null); - let agent_dir = message::resolve_agent_dir(catalog_root, identity, &crate::run::detect_host())? + let agent_dir = message::resolve_actor_dir(catalog_root, identity, &crate::run::detect_host())? .with_context(|| format!("Claude driver agent '{identity}' is not declared"))?; // Deliberately uncounted. The tee builds no telemetry pipeline at all (`DQ-C13`, see // `main`), so a `record_hook_invocation` here could never reach a collector — and a metric diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 73b69b43..5d59a080 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -31,7 +31,7 @@ use serde_json::{Value, json}; use sha2::{Digest as _, Sha256}; use tungstenite::{Message as WebSocketMessage, WebSocket}; -use crate::{ding, driver_diagnostic, harness_context, harness_state, message, run, status}; +use crate::{ding, driver_diagnostic, harness_context, harness_state, identity::AgentSelector, message, run, status}; const REQUIRED_CODEX_CLIENT_REQUESTS: &[&str] = &[ "hooks/list", @@ -390,7 +390,7 @@ struct CodexDeliveryConfig { impl CodexDeliveryConfig { fn resolve(catalog_root: &Path, identity: &str) -> Result { let this_host = run::detect_host(); - let agent_dir = message::resolve_agent_dir(catalog_root, identity, &this_host)? + let agent_dir = message::resolve_actor_dir(catalog_root, identity, &this_host)? .with_context(|| { format!( "Codex native delivery agent '{identity}' is not declared in {}", @@ -431,11 +431,23 @@ impl CodexDeliveryConfig { key_hash.update(body.as_bytes()); let idempotency_key = format!("st2.codex-protocol-rejection.v1:{:x}", key_hash.finalize()); let tags = ["codex-protocol".to_string(), "launch-rejected".to_string()]; + // This runtime names itself by exact key, never through its own mutable address. + let sender = match message::actor_selector(&self.catalog_root, &self.identity, &self.this_host) + { + Ok(sender) => sender, + Err(resolve_error) => { + eprintln!( + "st2 codex: failed to resolve agent '{}' as the sender of a protocol rejection report: {resolve_error:#}", + self.identity + ); + return; + } + }; if let Err(report_error) = message::send_to_resolved_inbox( &self.catalog_root, - supervisor, + &AgentSelector::Address(supervisor.to_owned()), &self.this_host, - &self.identity, + &sender, Some(&subject), None, &tags, diff --git a/src/event.rs b/src/event.rs index 035b90cf..befa216e 100644 --- a/src/event.rs +++ b/src/event.rs @@ -17,6 +17,7 @@ use anyhow::Context as _; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; +use crate::identity::AgentSelector; use crate::message; const EVENT_VERSION: u32 = 1; @@ -91,7 +92,12 @@ pub enum EventReceiptStatus { } struct ResolvedStream { + /// The agent key this publication is owned by and persisted under: the positional + /// `.` bus identity, which an address cutover never moves. recipient: String, + /// How to reach that subject's own directories again — an exact selector, so a publication + /// keeps working across an address cutover. + selector: AgentSelector, } enum StreamAdmission { @@ -299,13 +305,25 @@ pub(crate) fn refusal_kind(error: &anyhow::Error) -> Option { .map(|refusal| refusal.kind) } +/// Resolve one publication's recipient. +/// +/// Ordinary references go through [`crate::identity::resolve_address`] — the same fail-closed +/// bare-or-qualified algorithm every other reference uses, with dedupe by agent ID — rather than +/// a hand-rolled precedence filter. The local host is tried first, so a bare address still names +/// this host's subject when another host declares the same address; a reference that only a +/// foreign host answers still resolves, and then meets the owner refusal below with its own +/// diagnostic instead of an unhelpful absence. fn resolve_stream( root: &Path, this_host: &str, - recipient: &str, + recipient: &AgentSelector, stream: &str, admission: StreamAdmission, ) -> anyhow::Result { + let reference = match recipient { + AgentSelector::Id(id) => id.as_str(), + AgentSelector::Address(address) => address.as_str(), + }; let discovered = crate::discover_strict(root); anyhow::ensure!( discovered.errors.is_empty(), @@ -317,36 +335,44 @@ fn resolve_stream( .collect::>() .join("; ") ); - let mut matches = discovered - .specs - .into_iter() - .filter(|spec| { - spec.bus_id(this_host) == recipient - || (spec.resolved_host(this_host) == this_host && spec.identity == recipient) - }) - .collect::>(); - anyhow::ensure!( - !matches.is_empty(), - "no agent '{recipient}' found in catalog {}", - root.display() - ); - if matches.len() > 1 { - return Err(StreamRefusal::new( + // Two books, as everywhere else a reference resolves: retirement releases the address, so a + // retired subject must not make a live claimant ambiguous — but it still answers to its own + // address when nothing routable does, which is what turns "no such agent" into the + // `RecipientNotRunning` refusal a supervisor can act on. + let book = crate::identity::address_book(&discovered.specs, this_host); + let selected = match pinned_then_unpinned(&book, recipient, this_host) { + Err(crate::identity::ResolveError::Unknown { .. }) => { + let every_subject = discovered + .specs + .iter() + .map(|spec| crate::identity::AddressBookEntry { + id: spec.effective_id(this_host), + bus_identity: spec.bus_id(this_host), + host: spec.resolved_host(this_host).to_owned(), + address: spec.effective_address().to_owned(), + }) + .collect::>(); + pinned_then_unpinned(&every_subject, recipient, this_host) + .map(|entry| (entry.id.clone(), entry.bus_identity.clone())) + } + other => other.map(|entry| (entry.id.clone(), entry.bus_identity.clone())), + }; + let (agent_id, bus_identity) = selected.map_err(|error| match error { + crate::identity::ResolveError::Unknown { .. } => anyhow::anyhow!( + "no agent '{reference}' found in catalog {}", + root.display() + ), + error @ crate::identity::ResolveError::Ambiguous { .. } => StreamRefusal::new( RefusalKind::Permanent, - format!( - "agent recipient '{recipient}' is ambiguous; matched {} declarations: {}", - matches.len(), - matches - .iter() - .map(|spec| spec.path.display().to_string()) - .collect::>() - .join(", ") - ), - )); - } - let spec = matches - .pop() - .context("exactly one matching agent expected")?; + format!("agent recipient '{reference}' is ambiguous; {error}"), + ), + })?; + let spec = discovered + .specs + .iter() + .find(|spec| spec.bus_id(this_host) == bus_identity) + .context("the resolved agent left the discovery it was resolved against")?; + let key = bus_identity; if spec.resolved_host(this_host) != this_host { return Err(StreamRefusal::new( RefusalKind::Permanent, @@ -385,10 +411,31 @@ fn resolve_stream( )); } Ok(ResolvedStream { - recipient: spec.bus_id(this_host), + // The exact selector, not the address just resolved: the subject's own directories must + // stay reachable through a later address cutover. + selector: AgentSelector::Id(agent_id), + recipient: key, }) } +/// Try the local host first, then the whole catalog. +/// +/// A bare address names this host's subject even when another host declares the same address — +/// today's behavior — while a reference only a foreign host answers still resolves, so the caller +/// can refuse it by name. +fn pinned_then_unpinned<'a>( + book: &'a [crate::identity::AddressBookEntry], + recipient: &AgentSelector, + this_host: &str, +) -> std::result::Result<&'a crate::identity::AddressBookEntry, crate::identity::ResolveError> { + match crate::identity::resolve(book, recipient, Some(this_host)) { + Err(crate::identity::ResolveError::Unknown { .. }) => { + crate::identity::resolve(book, recipient, None) + } + other => other, + } +} + pub fn render_event( from: &str, subject: Option<&str>, @@ -430,7 +477,7 @@ pub fn emit( emit_admitted( root, this_host, - recipient, + &AgentSelector::Address(recipient.to_owned()), stream, event_id, key, @@ -455,7 +502,7 @@ pub(crate) fn emit_builtin_resync( emit_admitted( root, this_host, - recipient, + &AgentSelector::Id(recipient.to_owned()), crate::resync::RESYNC_STREAM, event_id, key, @@ -470,7 +517,7 @@ pub(crate) fn emit_builtin_resync( fn emit_admitted( root: &Path, this_host: &str, - recipient: &str, + recipient: &AgentSelector, stream: &str, event_id: &str, key: Option<&str>, @@ -491,13 +538,15 @@ fn emit_admitted( // suspension edit owns this lock, no later emit can publish from a stale running observation. let catalog_lock = crate::catalog_lock::CatalogLock::shared(root)?; validate_owner_binding(root, this_host, &catalog_lock)?; - let resolved = resolve_stream(root, this_host, recipient, stream, admission)?; - let canonical_recipient = resolved.recipient; + let ResolvedStream { + recipient: canonical_recipient, + selector, + } = resolve_stream(root, this_host, recipient, stream, admission)?; let from = format!("{canonical_recipient}/{stream}"); let rendered = render_event(&from, subject, stream, event_id, key, body); message::with_resolved_state_dir( root, - &canonical_recipient, + &selector, this_host, &["resources", "streams", stream], true, @@ -525,7 +574,7 @@ fn emit_admitted( .expect("different pending event was just observed"); let materialized = message::with_resolved_message_boxes( root, - &canonical_recipient, + &selector, this_host, |inbox, archive| { let inbox_bytes = read_message_entry(inbox, &pending.filename)?; @@ -608,7 +657,7 @@ fn emit_admitted( let predecessor = if supersede { message::with_resolved_message_boxes( root, - &canonical_recipient, + &selector, this_host, |inbox, archive| { for entry in record.recent.iter().filter(|entry| { @@ -644,7 +693,7 @@ fn emit_admitted( let created = message::with_resolved_message_boxes( root, - &canonical_recipient, + &selector, this_host, |inbox, archive| { // Publish before compacting. If predecessor archival fails or the process @@ -1191,3 +1240,112 @@ impl Drop for StreamLock { unsafe { libc::flock(self.0.as_raw_fd(), libc::LOCK_UN) }; } } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + fn declare_worker(root: &Path, extra: &str) -> PathBuf { + let directory = root.join("agents/hetz/worker"); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write( + directory.join("agent.kdl"), + format!( + "agent \"worker\" {{\n host \"hetz\"\n{extra} desired-state \"running\"\n command \"agent\"\n}}\n" + ), + ) + .unwrap(); + publish_owner_binding_for_test(root, "hetz").unwrap(); + directory + } + + fn stream_state(agent: &Path, stream: &str) -> StreamRecord { + let path = agent + .join("resources/streams") + .join(stream) + .join("state.json"); + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() + } + + /// `st2 event emit` names its recipient by ordinary bus address, so a declared `address` is + /// what routes — bare or host-qualified — and the released identity spelling refuses at once. + /// Ownership stays on the positional bus identity, which the cutover never moved. + #[test] + fn a_declared_address_routes_an_event_while_ownership_stays_on_the_bus_identity() { + let root = tempfile::tempdir().unwrap(); + let agent = declare_worker( + root.path(), + " address \"chat\"\n stream \"gh-ci\" {}\n", + ); + + for reference in ["hetz.chat", "chat"] { + let receipt = emit( + root.path(), + "hetz", + reference, + "gh-ci", + &format!("run-{reference}"), + None, + None, + "{}", + false, + ) + .expect("the declared address routes"); + assert_eq!(receipt.recipient, "hetz.worker"); + } + assert_eq!(stream_state(&agent, "gh-ci").recipient, "hetz.worker"); + + let refused = emit( + root.path(), + "hetz", + "hetz.worker", + "gh-ci", + "run-legacy", + None, + None, + "{}", + false, + ) + .expect_err("the identity spelling is not an address once one is declared"); + assert!( + format!("{refused:#}").contains("no agent 'hetz.worker' found"), + "{refused:#}" + ); + } + + /// Built-in resync names its recipient by the agent key reconciliation holds — the positional + /// bus identity — so an address cutover cannot disconnect a running seat from its own resync + /// stream. This is the nondisruptive half of the cutover, and it is why the exact selector + /// answers to the positional key as well as to an explicit `id`. + #[test] + fn resync_still_reaches_a_subject_whose_address_moved() { + let root = tempfile::tempdir().unwrap(); + let agent = declare_worker(root.path(), " address \"chat\"\n"); + + let receipt = emit_builtin_resync( + root.path(), + "hetz", + "hetz.worker", + "resync-1", + None, + None, + "{}", + false, + ) + .expect("the agent key reaches its own subject after a cutover"); + assert_eq!(receipt.recipient, "hetz.worker"); + assert_eq!(receipt.status, EventReceiptStatus::Created); + assert_eq!( + stream_state(&agent, crate::resync::RESYNC_STREAM).recipient, + "hetz.worker" + ); + let inbox = crate::message::list_inbox(&crate::message::inbox_dir(&agent)).unwrap(); + assert_eq!(inbox.len(), 1); + assert_eq!( + inbox[0].from.as_deref(), + Some(format!("hetz.worker/{}", crate::resync::RESYNC_STREAM).as_str()) + ); + } +} diff --git a/src/main.rs b/src/main.rs index bf1be025..c5cd7080 100644 --- a/src/main.rs +++ b/src/main.rs @@ -115,6 +115,9 @@ enum Command { /// target when no positional session is given. #[arg(long)] identity: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with `--identity`. + #[arg(long = "agent-id", conflicts_with = "identity")] + agent_id: Option, /// Catalog root. Defaults to `$CATALOG`. #[arg(long, conflicts_with = "catalog_path")] root: Option, @@ -149,6 +152,9 @@ enum Command { Status { /// Whose status — bus id or identity. Defaults to you (`--as` / `$ST_AGENT`). identity: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with the positional reference. + #[arg(long = "id", conflicts_with = "identity")] + agent_id: Option, /// Set your status to this state instead of printing it. #[arg(long = "set")] set: Option, @@ -277,6 +283,9 @@ enum Command { /// Select one exact Agent Spec by its fully qualified `.`. #[arg(long, value_name = "HOST.IDENTITY")] identity: Option, + /// Select one exact subject by its immutable agent ID (R24). + #[arg(long = "id", conflicts_with = "identity")] + agent_id: Option, /// Machine-readable JSON array, including retirement and declared Resource bindings. #[arg(long)] json: bool, @@ -419,11 +428,20 @@ enum DriverCmd { enum AgentCmd { /// Author reversible whole-agent lifecycle intent in one canonical KDL declaration. DesiredState { - /// Exact bus identity, or a bare stable identity only when unique. - identity: String, - /// Desired whole-agent lifecycle state. - #[arg(value_parser = ["running", "suspended", "retired"])] - state: String, + /// Ordinary agent reference — an exact bus address, or a bare address unique in the + /// catalog — or the desired state when `--id` names the subject. + #[arg(value_name = "IDENTITY_OR_STATE")] + first: Option, + /// The desired state, when the first positional is the agent reference. + #[arg(value_name = "STATE")] + second: Option, + /// Exact immutable agent ID (R24). The first positional is then the desired state. + /// + /// Both positionals stay optional so the exact-ID form can shift them; clap refuses a + /// non-required positional ahead of a required one, which is why the state is validated in + /// the handler rather than by a positional `value_parser`. + #[arg(long = "id", conflicts_with = "second")] + agent_id: Option, /// Required rationale for suspended/retired; forbidden for running. #[arg(long)] reason: Option, @@ -434,6 +452,9 @@ enum AgentCmd { #[arg(long)] json: bool, }, + /// Assign or clear an agent's mutable address — one atomic address-book cutover with no + /// alias, redirect, or rename history. `--clear` restores the positional identity fallback. + Address(PresentationArgs), /// Compute the authoritative digest bound by `agent publish --input-sha256`. Digest { /// A canonical KDL file containing exactly one top-level `agent` node. @@ -649,17 +670,23 @@ struct MsgCtx { host: Option, } +/// `[] ` plus the mutually exclusive exact-ID form. +/// +/// `--id` takes the agent off the positional list, so the first positional is then the value — +/// the same `[identity] ` convention `st2 message read` and `st2 resource read` already +/// use, and the reason clap's exclusion is expressed against the second positional. #[derive(Args)] struct PresentationArgs { - /// Exact bus identity, or a bare stable identity only when unique in the selected catalog. - identity: String, - /// Presentation text. Use --clear to remove the field. - #[arg( - value_name = "TEXT", - required_unless_present = "clear", - conflicts_with = "clear" - )] - value: Option, + /// Ordinary agent reference — an exact bus address, or a bare address unique in the catalog — + /// or the new value when `--id` names the subject. + #[arg(value_name = "IDENTITY_OR_TEXT")] + first: Option, + /// The new value, when the first positional is the agent reference. + #[arg(value_name = "TEXT")] + second: Option, + /// Exact immutable agent ID (R24). The first positional is then the new value. + #[arg(long = "id", conflicts_with = "second")] + agent_id: Option, /// Remove the optional field. #[arg(long)] clear: bool, @@ -671,6 +698,37 @@ struct PresentationArgs { host: Option, } +impl PresentationArgs { + /// The selected subject and the requested value, where `None` is the cleared representation. + fn selection(self) -> Result<(st2::identity::AgentSelector, Option, bool, Option)> + { + let (selector, value) = match self.agent_id { + Some(id) => (st2::identity::AgentSelector::Id(id), self.first), + None => ( + st2::identity::AgentSelector::Address( + self.first + .context("no agent selected: pass an agent reference or the `--id` form")?, + ), + self.second, + ), + }; + anyhow::ensure!( + !(self.clear && value.is_some()), + "--clear removes the field and takes no value" + ); + anyhow::ensure!( + self.clear || value.is_some(), + "a value is required unless --clear" + ); + Ok(( + selector, + if self.clear { None } else { value }, + self.json, + self.host, + )) + } +} + #[derive(Subcommand)] enum ServiceCmd { /// Write the `st2.service` systemd-user unit, enable it (start on boot), and start it now. @@ -745,6 +803,9 @@ enum ResourceCmd { Ls { /// Whose declaration to read — bus id or bare identity. Defaults to you (`$ST_AGENT`). identity: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with the positional reference. + #[arg(long = "id", conflicts_with = "identity")] + agent_id: Option, /// Emit the bindings as a JSON array. #[arg(long)] json: bool, @@ -755,6 +816,9 @@ enum ResourceCmd { Read { first: String, second: Option, + /// Exact immutable agent ID (R24); `first` is then the binding name. + #[arg(long = "id", conflicts_with = "second")] + agent_id: Option, /// Emit the binding as a JSON object. #[arg(long)] json: bool, @@ -770,6 +834,9 @@ enum ResourceCmd { /// Exact target agent; defaults to --as / $ST_AGENT. #[arg(long, conflicts_with = "second")] agent: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with `--agent`. + #[arg(long = "agent-id", conflicts_with_all = ["second", "agent"])] + agent_id: Option, /// Client-only wait bound in seconds. Expiry never cancels or retracts queued demand. #[arg(long, default_value_t = 30)] wait: u64, @@ -798,6 +865,9 @@ enum ResourceCmd { /// Exact target agent; defaults to --as / $ST_AGENT. #[arg(long)] agent: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with `--agent`. + #[arg(long = "agent-id", conflicts_with = "agent")] + agent_id: Option, /// Emit a stable JSON receipt. #[arg(long)] json: bool, @@ -811,6 +881,9 @@ enum ResourceCmd { /// Exact target agent; defaults to --as / $ST_AGENT. #[arg(long)] agent: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with `--agent`. + #[arg(long = "agent-id", conflicts_with = "agent")] + agent_id: Option, /// Emit a stable JSON receipt. #[arg(long)] json: bool, @@ -826,6 +899,9 @@ enum ResourceCmd { /// Exact target agent; defaults to --as / $ST_AGENT. #[arg(long)] agent: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with `--agent`. + #[arg(long = "agent-id", conflicts_with = "agent")] + agent_id: Option, /// Emit a stable JSON receipt. #[arg(long)] json: bool, @@ -840,6 +916,9 @@ enum ContextCmd { Read { /// Whose context — bus id or identity. Defaults to you (`$ST_AGENT`). identity: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with the positional reference. + #[arg(long = "id", conflicts_with = "identity")] + agent_id: Option, /// Print the decision log instead of the working state. #[arg(long)] decisions: bool, @@ -855,12 +934,18 @@ enum ContextCmd { /// Overwrite an agent's working state (`now.md`) from stdin. Write { identity: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with the positional reference. + #[arg(long = "id", conflicts_with = "identity")] + agent_id: Option, #[command(flatten)] ctx: MsgCtx, }, /// Append a single decision (with its reasoning) to the log. Append { identity: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with the positional reference. + #[arg(long = "id", conflicts_with = "identity")] + agent_id: Option, /// The decision — a single line. #[arg(long)] decision: String, @@ -876,8 +961,12 @@ enum ContextCmd { enum MessageCmd { /// Send a new message to a recipient's inbox. Send { - /// Recipient: a bus id (`.`) or a bare identity in the catalog. - to: String, + /// Recipient: a bus address (`.
`) or a bare address in the catalog. + #[arg(required_unless_present = "to_id", conflicts_with = "to_id")] + to: Option, + /// Recipient by exact immutable agent ID (R24). Mutually exclusive with the positional. + #[arg(long = "to-id")] + to_id: Option, /// The message body. Read from stdin when omitted. #[arg(short = 'm', long = "message")] body: Option, @@ -914,6 +1003,9 @@ enum MessageCmd { Ls { /// Whose inbox — bus id or identity. Defaults to you (`--as` / `$ST_AGENT`). identity: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with the positional reference. + #[arg(long = "id", conflicts_with = "identity")] + agent_id: Option, /// List the archive instead of the inbox. #[arg(long)] archive: bool, @@ -942,6 +1034,9 @@ enum MessageCmd { Sent { /// Whose sent index — bus id or identity. Defaults to you (`--as` / `$ST_AGENT`). identity: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with the positional reference. + #[arg(long = "id", conflicts_with = "identity")] + agent_id: Option, /// Print only the indexed message count. Refuses unavailable or partial coverage. #[arg(long)] count: bool, @@ -966,6 +1061,9 @@ enum MessageCmd { first: String, /// The message filename (when `first` is an identity). second: Option, + /// Exact immutable agent ID (R24) owning the box; `first` is then the filename. + #[arg(long = "id", conflicts_with = "second")] + agent_id: Option, /// Read from the archive instead of the inbox. #[arg(long)] archive: bool, @@ -984,6 +1082,9 @@ enum MessageCmd { first: String, /// The message filename (when `first` is an identity). second: Option, + /// Exact immutable agent ID (R24) owning the box; `first` is then the filename. + #[arg(long = "id", conflicts_with = "second")] + agent_id: Option, #[command(flatten)] ctx: MsgCtx, }, @@ -1006,8 +1107,12 @@ enum MessageCmd { enum EventCmd { /// Emit one producer-identified event into a declared agent stream. Emit { - /// Owning agent: `.` or a bare local identity. - recipient: String, + /// Owning agent: a bus address (`.
`) or a bare local address. + #[arg(required_unless_present = "recipient_id", conflicts_with = "recipient_id")] + recipient: Option, + /// Owning agent by exact immutable agent ID (R24). + #[arg(long = "recipient-id")] + recipient_id: Option, /// Declared stream name. #[arg(long)] stream: String, @@ -1042,6 +1147,9 @@ enum StreamCmd { /// Exact target agent; defaults to --as / $ST_AGENT. #[arg(long)] agent: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with `--agent`. + #[arg(long = "agent-id", conflicts_with = "agent")] + agent_id: Option, /// Adapter command run under `sh -c`; omit both launch forms for external ingress. #[arg(long, conflicts_with = "adapter_argv")] command: Option, @@ -1059,6 +1167,9 @@ enum StreamCmd { /// Exact target agent; defaults to --as / $ST_AGENT. #[arg(long)] agent: Option, + /// Exact immutable agent ID (R24). Mutually exclusive with `--agent`. + #[arg(long = "agent-id", conflicts_with = "agent")] + agent_id: Option, #[arg(long)] json: bool, #[command(flatten)] @@ -1194,10 +1305,11 @@ fn dispatch(command: Command, catalog_path: Option<&std::path::Path>) -> Result< Command::Ding { session, identity, + agent_id, root, host, interval, - } => ding_cmd(session, identity, root, host, interval), + } => ding_cmd(session, identity, agent_id, root, host, interval), Command::CodexAppServer { identity, runtime_id, @@ -1299,18 +1411,38 @@ fn dispatch(command: Command, catalog_path: Option<&std::path::Path>) -> Result< let catalog = catalog_arg(None)?; driver_expand_cmd(&catalog, &spec, agent.as_deref(), host.as_deref()) } - Command::Status { identity, set, ctx } => status_cmd(identity, set, ctx), + Command::Status { + identity, + agent_id, + set, + ctx, + } => status_cmd(identity, agent_id, set, ctx), Command::Rename(args) => presentation_cmd(st2::agent_author::PresentationField::Name, args), Command::Describe(args) => { presentation_cmd(st2::agent_author::PresentationField::Description, args) } + Command::Agent(AgentCmd::Address(args)) => address_cmd(args), Command::Agent(AgentCmd::DesiredState { - identity, - state, + first, + second, + agent_id, reason, host, json, - }) => desired_state_cmd(identity, state, reason, host, json), + }) => { + let (identity, state) = match &agent_id { + Some(_) => (None, first), + None => (first, second), + }; + let state = state.context( + "a desired state is required: `running`, `suspended`, or `retired`", + )?; + anyhow::ensure!( + matches!(state.as_str(), "running" | "suspended" | "retired"), + "desired state must be `running`, `suspended`, or `retired`, not '{state}'" + ); + desired_state_cmd(identity, agent_id, state, reason, host, json) + } Command::Agent(AgentCmd::Publish { spec, bundle, @@ -1563,10 +1695,11 @@ fn dispatch(command: Command, catalog_path: Option<&std::path::Path>) -> Result< catalog, status, identity, + agent_id, json, enrich, ctx, - } => agents_cmd(catalog, status, identity, json, enrich, ctx), + } => agents_cmd(catalog, status, identity, agent_id, json, enrich, ctx), Command::Tasks { host, json } => { if !json { anyhow::bail!("`st2 tasks` v1 requires --json"); @@ -2406,19 +2539,14 @@ fn presentation_cmd( field: st2::agent_author::PresentationField, args: PresentationArgs, ) -> Result<()> { - let PresentationArgs { - identity, - value, - clear, - json, - host, - } = args; + let (selector, requested, json, host) = args.selection()?; let root = catalog_arg(None)?; let host = host.unwrap_or_else(detect_host); + let identity = resolve_declaration(&root, &host, selector)?; let actor = std::env::var("ST_AGENT") .ok() .filter(|value| !value.is_empty()); - let requested = if clear { None } else { value.as_deref() }; + let requested = requested.as_deref(); match st2::agent_author::set_presentation( &root, &identity, @@ -2465,8 +2593,66 @@ fn presentation_cmd( } } +/// Assign or clear one agent's mutable address (R25) — the third authoring sibling of `st2 rename` +/// and `st2 describe`, classified by exactly the same receipt and refusal vocabulary. +fn address_cmd(args: PresentationArgs) -> Result<()> { + let (selected, requested, json, host) = args.selection()?; + let root = catalog_arg(None)?; + let host = host.unwrap_or_else(detect_host); + let selector = resolve_declaration(&root, &host, selected)?; + let actor = std::env::var("ST_AGENT") + .ok() + .filter(|value| !value.is_empty()); + let requested = requested.as_deref(); + match st2::agent_author::set_address(&root, &selector, &host, actor.as_deref(), requested) { + Ok(receipt) => { + if json { + println!("{}", serde_json::to_string(&receipt)?); + } else { + let state = match (receipt.result, receipt.address.as_deref()) { + (st2::agent_author::AuthorOutcome::Changed, Some(value)) => { + format!("set to {value:?}") + } + (st2::agent_author::AuthorOutcome::Changed, None) => "cleared".to_owned(), + (st2::agent_author::AuthorOutcome::Unchanged, Some(value)) => { + format!("already {value:?}") + } + (st2::agent_author::AuthorOutcome::Unchanged, None) => { + "already clear".to_owned() + } + }; + println!( + "{} address: {state} (bus address {})", + receipt.id, + receipt + .bus_address + .as_deref() + .unwrap_or("none — the subject is retired and non-routable") + ); + } + Ok(()) + } + Err(error) => { + if json { + println!( + "{}", + serde_json::json!({ + "result": "error", + "code": error.code(), + "identity": selector, + "field": "address", + "error": error.to_string(), + }) + ); + } + Err(error.into()) + } + } +} + fn desired_state_cmd( - identity: String, + identity: Option, + agent_id: Option, state: String, reason: Option, host: Option, @@ -2480,6 +2666,7 @@ fn desired_state_cmd( }; let root = catalog_arg(None)?; let host = host.unwrap_or_else(detect_host); + let identity = resolve_declaration(&root, &host, one_selector(identity, agent_id)?)?; let actor = std::env::var("ST_AGENT") .ok() .filter(|value| !value.is_empty()); @@ -2530,11 +2717,16 @@ fn desired_state_cmd( } } -fn status_cmd(identity: Option, set: Option, ctx: MsgCtx) -> Result<()> { +fn status_cmd( + identity: Option, + agent_id: Option, + set: Option, + ctx: MsgCtx, +) -> Result<()> { let (root, host) = resolve_ctx(&ctx)?; - let id = match identity { - Some(i) => i, - None => acting_id(&ctx)?, + let id = match selected_route(&root, &host, identity, agent_id)? { + Some(id) => id, + None => acting_route(&root, &host, &ctx)?, }; let sp = st2::status::status_path(&agent_dir_of(&root, &id, &host)?); match set { @@ -2543,7 +2735,7 @@ fn status_cmd(identity: Option, set: Option, ctx: MsgCtx) -> Res let state = st2::status::State::parse_settable(&word).with_context(|| { format!("invalid state '{word}' (settable: offline|available|busy|away|dnd)") })?; - message::with_resolved_agent_dir(&root, &id, &host, |agent| { + message::with_resolved_agent_dir(&root, &route_selector(&id), &host, |agent| { st2::status::set_state(&st2::status::status_path(agent), state) })?; println!("status: {}", state.as_str()); @@ -2556,6 +2748,7 @@ fn agents_cmd( catalog: Option, status_filter: Option, identity: Option, + agent_id: Option, json: bool, enrich: bool, mut ctx: MsgCtx, @@ -2570,12 +2763,13 @@ fn agents_cmd( let (root, host) = resolve_ctx(&ctx)?; let _catalog_lock = st2::CatalogLock::shared(&root) .context("acquire shared catalog-authoring lock for agent roster")?; - let found = if identity.is_some() { + let selected = identity.is_some() || agent_id.is_some(); + let found = if selected { st2::discover_strict(&root) } else { st2::discover(&root) }; - if identity.is_some() && !found.errors.is_empty() { + if selected && !found.errors.is_empty() { let errors = found .errors .iter() @@ -2596,6 +2790,16 @@ fn agents_cmd( rows.len() ); } + // An exact ID roster query is catalog-global and answers on the appended immutable `id`, not + // on the positional declaration key — equal bytes in the two namespaces never collide. + if let Some(agent_id) = &agent_id { + rows.retain(|row| row.id == *agent_id); + anyhow::ensure!( + rows.len() == 1, + "expected exactly one Agent Spec with agent id `{agent_id}`, found {}", + rows.len() + ); + } if let Some(f) = &status_filter { rows.retain(|r| r.status.as_str() == f); if let Some(identity) = &identity { @@ -2687,6 +2891,7 @@ fn context_column(context: Option<&st2::harness_context::Observed>) -> String { fn ding_cmd( session: Option, identity: Option, + agent_id: Option, root: Option, host: Option, interval: u64, @@ -2697,14 +2902,28 @@ fn ding_cmd( host, }; let (catalog_root, this_host) = resolve_ctx(&ctx)?; - let id = acting_id(&ctx)?; + // The poked pty session is a runtime task ID, so it keeps the exact bytes the caller named: + // under activation `ST_AGENT` carries the agent ID and the canonical agent task ID equals it. + // The inbox and status are declaration-parent state, so they resolve through the route. + let named = match &agent_id { + Some(id) => id.clone(), + None => acting_id(&ctx)?, + }; + let id = match agent_id { + Some(id) => resolve_route( + &catalog_root, + &this_host, + st2::identity::AgentSelector::Id(id), + )?, + None => acting_route(&catalog_root, &this_host, &ctx)?, + }; // The pty to poke defaults to the identity — an agent IS its pty, so the session id == the agent // id. So `st2 ding --identity mix.worker` pokes pty `mix.worker` (the redundant positional is now // optional). An explicit positional still overrides for the rare non-agent case. - let session = session.unwrap_or_else(|| id.clone()); + let session = session.unwrap_or(named); // Flat-bus aware: a native catalog agent → its resources/inbox; a catalog-LESS bus (an eval's // ST_ROOT) → the flat //inbox. Status lives beside it either way. - let agent_dir = message::resolve_agent_dir(&catalog_root, &id, &this_host)? + let agent_dir = message::resolve_agent_dir(&catalog_root, &route_selector(&id), &this_host)? .unwrap_or_else(|| catalog_root.join(&id)); let inbox = resolve_message_inbox(&catalog_root, &id, &this_host)?; let status_path = st2::status::status_path(&agent_dir); @@ -2747,12 +2966,168 @@ fn acting_id(ctx: &MsgCtx) -> Result { .context("no acting identity: pass --as or set $ST_AGENT") } +/// The acting subject's route. +/// +/// `--as` is an ordinary address reference; `$ST_AGENT` carries the exact immutable agent ID +/// (`docs/vrs/03-message/spec.md`: selection follows one total order, and neither an address nor +/// `ST_AGENT` is heuristically retyped). So the two are not interchangeable strings once a subject +/// declares an explicit address. An ID that names no declared subject keeps its raw bytes, which +/// is what leaves flat, orphan, and `$ST2_EVAL_REQUESTER` mailboxes resolving exactly as they do +/// today; ambiguity cannot arise for a catalog-global ID, so absence is the only miss. +fn acting_route(root: &Path, host: &str, ctx: &MsgCtx) -> Result { + if let Some(address) = ctx.as_id.clone().filter(|value| !value.is_empty()) { + return Ok(address); + } + let id = acting_id(ctx)?; + match resolve_selected(root, host, st2::identity::AgentSelector::Id(id.clone())) { + Ok(selected) => Ok(selected.route), + Err(_) => Ok(id), + } +} + +/// One already-resolved route as the selector every message-plane entry point takes. +/// +/// A route is an effective address by construction — `resolve_route` produced it from the address +/// book — so this states that namespace explicitly instead of letting a bare string be retyped. +fn route_selector(reference: &str) -> st2::identity::AgentSelector { + st2::identity::AgentSelector::Address(reference.to_owned()) +} + /// Resolve a recipient/identity to its agent folder in the catalog, or a clear error. fn agent_dir_of(root: &Path, id: &str, host: &str) -> Result { - message::resolve_agent_dir(root, id, host)? + message::resolve_agent_dir(root, &route_selector(id), host)? .with_context(|| format!("no agent '{id}' found in catalog {}", root.display())) } +/// The one typed agent selector a CLI reference pair carries (R24). +/// +/// Every agent-selecting command exposes both forms and clap's `conflicts_with` keeps them +/// mutually exclusive, so at most one arrives here: an ordinary address reference, or an exact +/// immutable agent ID. +fn agent_selector( + reference: Option, + id: Option, +) -> Option { + match (reference, id) { + (_, Some(id)) => Some(st2::identity::AgentSelector::Id(id)), + (Some(reference), None) => Some(st2::identity::AgentSelector::Address(reference)), + (None, None) => None, + } +} + +/// [`agent_selector`] for a command that requires a target rather than defaulting to the actor. +fn one_selector( + reference: Option, + id: Option, +) -> Result { + agent_selector(reference, id) + .context("no agent selected: pass an agent reference or the exact `--id` form") +} + +/// The one subject a typed selector names, in both namespaces its consumers accept. +/// +/// These are two different strings once a subject declares an explicit `address`, and handing the +/// wrong one onward is exactly the ID-through-a-mutable-address hop decision 0015 forbids: +/// declaration-selecting commands match the positional key and never look at `address`, while +/// reference resolution for inboxes, status, context, and recipients answers on the address. +struct SelectedAgent { + /// `.` — the positional declaration key, which no address cutover changes. + declaration: String, + /// `.` — the current route. + route: String, +} + +/// Resolve one typed selector against the catalog. +/// +/// The address form stays on st2's existing reference resolution, which DELTA-003 keeps normative +/// until the identity model activates, so it carries the caller's bytes through unchanged. The ID +/// form is a catalog-global exact lookup that never falls through to address lookup — that is what +/// stops a renamed subject's old semantic ID from silently staying alive as a route. +fn resolve_selected( + root: &Path, + host: &str, + selector: st2::identity::AgentSelector, +) -> Result { + let id = match selector { + st2::identity::AgentSelector::Address(reference) => { + return Ok(SelectedAgent { + declaration: reference.clone(), + route: reference, + }); + } + st2::identity::AgentSelector::Id(id) => id, + }; + let found = discover(root); + // Retired subjects are present here and absent from the routable address book on purpose: + // retirement releases the address, never the ID, so an exact-ID selector still names its + // subject and `st2 describe` can still edit a retired declaration. + let entries = found + .specs + .iter() + .map(|spec| st2::identity::AddressBookEntry { + id: spec.effective_id(host), + bus_identity: spec.bus_id(host), + host: spec.resolved_host(host).to_owned(), + address: spec.effective_address().to_owned(), + }) + .collect::>(); + let resolved = &st2::identity::resolve_id(&entries, &id)?.id; + let spec = found + .specs + .iter() + .find(|spec| &spec.effective_id(host) == resolved) + .context("the resolved agent id left the discovery it was resolved against")?; + Ok(SelectedAgent { + declaration: spec.bus_id(host), + route: spec.bus_address(host), + }) +} + +/// [`resolve_selected`] for a command that selects a declaration to read or author. +fn resolve_declaration( + root: &Path, + host: &str, + selector: st2::identity::AgentSelector, +) -> Result { + resolve_selected(root, host, selector).map(|selected| selected.declaration) +} + +/// [`resolve_selected`] for a command that resolves a route: an inbox, status, or recipient. +fn resolve_route( + root: &Path, + host: &str, + selector: st2::identity::AgentSelector, +) -> Result { + resolve_selected(root, host, selector).map(|selected| selected.route) +} + +/// The declaration selector for one CLI reference pair, or `None` when the command must fall back +/// to the caller's own ambient actor. +fn selected_declaration( + root: &Path, + host: &str, + reference: Option, + id: Option, +) -> Result> { + match agent_selector(reference, id) { + None => Ok(None), + Some(selector) => resolve_declaration(root, host, selector).map(Some), + } +} + +/// [`selected_declaration`] for a command that resolves a route rather than a declaration. +fn selected_route( + root: &Path, + host: &str, + reference: Option, + id: Option, +) -> Result> { + match agent_selector(reference, id) { + None => Ok(None), + Some(selector) => resolve_route(root, host, selector).map(Some), + } +} + /// Resolve ordinary declared messaging authority plus the exact external requester capability /// injected only into canonical eval seats. fn resolve_message_inbox(root: &Path, id: &str, host: &str) -> Result { @@ -2783,11 +3158,17 @@ fn body_or_stdin(body: Option) -> Result { } /// `[identity] ` positionals: if `second` is present, `first` is the identity; otherwise -/// `first` is the filename and the box belongs to the acting identity. -fn box_target(first: String, second: Option, ctx: &MsgCtx) -> Result<(String, String)> { - match second { - Some(filename) => Ok((first, filename)), - None => Ok((acting_id(ctx)?, first)), +/// `first` is the filename and the box belongs to `--id`, else to the acting identity. +fn box_target( + first: String, + second: Option, + owner: Option, + mine: impl FnOnce() -> Result, +) -> Result<(String, String)> { + match (second, owner) { + (Some(thing), _) => Ok((first, thing)), + (None, Some(owner)) => Ok((owner, first)), + (None, None) => Ok((mine()?, first)), } } @@ -2795,6 +3176,7 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { match cmd { MessageCmd::Send { to, + to_id, body, subject, in_reply_to, @@ -2803,7 +3185,8 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let from = acting_id(&ctx)?; + let to = resolve_route(&root, &host, one_selector(to, to_id)?)?; + let from = acting_route(&root, &host, &ctx)?; let body = body_or_stdin(body)?; let filename = send_resolved_message( &root, @@ -2827,7 +3210,7 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let from = acting_id(&ctx)?; + let from = acting_route(&root, &host, &ctx)?; let my_inbox = resolve_message_inbox(&root, &from, &host)?; let original = message::read_msg(&my_inbox, &filename) .or_else(|inbox_error| { @@ -2838,6 +3221,8 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { message::read_msg(&my_archive, &filename) }) .with_context(|| format!("no message '{filename}' in {}'s inbox", from))?; + // The reply target is the message's own `from`: an ordinary address reference, which + // is what every published record carries. let to = original .from .clone() @@ -2860,6 +3245,7 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { } MessageCmd::Sent { identity, + agent_id, count, include_body, to, @@ -2868,8 +3254,12 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let id = identity.unwrap_or(acting_id(&ctx)?); - let mut view = message::with_resolved_agent_dir(&root, &id, &host, |agent_dir| { + let id = match selected_route(&root, &host, identity, agent_id)? { + Some(id) => id, + None => acting_route(&root, &host, &ctx)?, + }; + let mut view = + message::with_resolved_agent_dir(&root, &route_selector(&id), &host, |agent_dir| { message::list_sent(agent_dir, include_body) })?; if let Some(recipient) = &to { @@ -2916,6 +3306,7 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { } MessageCmd::Ls { identity, + agent_id, archive, orphan, count, @@ -2926,9 +3317,9 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let id = match identity { + let id = match selected_route(&root, &host, identity, agent_id)? { Some(id) => id, - None => acting_id(&ctx)?, + None => acting_route(&root, &host, &ctx)?, }; let dir = message::resolve_list_box(&root, &id, &host, archive, orphan)?; let mut msgs = if archive { @@ -2970,13 +3361,16 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { MessageCmd::Read { first, second, + agent_id, archive, raw, json, ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let (id, filename) = box_target(first, second, &ctx)?; + let owner = selected_route(&root, &host, None, agent_id)?; + let (id, filename) = + box_target(first, second, owner, || acting_route(&root, &host, &ctx))?; let dir = if archive { message::resolve_archive(&root, &id, &host) } else { @@ -3005,10 +3399,17 @@ fn message_cmd(cmd: MessageCmd) -> Result<()> { print!("{}", m.body); Ok(()) } - MessageCmd::Archive { first, second, ctx } => { + MessageCmd::Archive { + first, + second, + agent_id, + ctx, + } => { let (root, host) = resolve_ctx(&ctx)?; - let (id, filename) = box_target(first, second, &ctx)?; - message::archive_resolved_message(&root, &id, &host, &filename)?; + let owner = selected_route(&root, &host, None, agent_id)?; + let (id, filename) = + box_target(first, second, owner, || acting_route(&root, &host, &ctx))?; + message::archive_resolved_message(&root, &route_selector(&id), &host, &filename)?; println!("archived"); Ok(()) } @@ -3063,9 +3464,9 @@ fn send_resolved_message( .transpose()?; message::send_to_resolved_inbox( root, - to, + &route_selector(to), host, - from, + &route_selector(from), subject, in_reply_to, tags, @@ -3079,6 +3480,7 @@ fn event_cmd(cmd: EventCmd) -> Result<()> { match cmd { EventCmd::Emit { recipient, + recipient_id, stream, event_id, key, @@ -3089,6 +3491,8 @@ fn event_cmd(cmd: EventCmd) -> Result<()> { ctx, } => { let (root, host) = resolve_ctx(&ctx)?; + let recipient = + resolve_route(&root, &host, one_selector(recipient, recipient_id)?)?; let body = body_or_stdin(body)?; let receipt = st2::event::emit( &root, @@ -3112,10 +3516,11 @@ fn event_cmd(cmd: EventCmd) -> Result<()> { } fn stream_cmd(cmd: StreamCmd) -> Result<()> { - let (name, agent, json, ctx, launch, remove) = match cmd { + let (name, agent, agent_id, json, ctx, launch, remove) = match cmd { StreamCmd::Add { name, agent, + agent_id, command, adapter_argv, json, @@ -3127,14 +3532,15 @@ fn stream_cmd(cmd: StreamCmd) -> Result<()> { (None, true) => None, (Some(_), false) => anyhow::bail!("stream add got both --command and adapter argv"), }; - (name, agent, json, ctx, launch, false) + (name, agent, agent_id, json, ctx, launch, false) } StreamCmd::Rm { name, agent, + agent_id, json, ctx, - } => (name, agent, json, ctx, None, true), + } => (name, agent, agent_id, json, ctx, None, true), }; let (root, host) = resolve_ctx(&ctx)?; let actor = ctx @@ -3142,9 +3548,12 @@ fn stream_cmd(cmd: StreamCmd) -> Result<()> { .clone() .or_else(|| std::env::var("ST_AGENT").ok()) .filter(|value| !value.is_empty()); - let target = agent - .or_else(|| actor.clone()) - .context("no stream target: pass --agent, --as, or set $ST_AGENT")?; + let target = match selected_declaration(&root, &host, agent, agent_id)? { + Some(target) => target, + None => actor + .clone() + .context("no stream target: pass --agent, --agent-id, --as, or set $ST_AGENT")?, + }; if remove { let receipt = st2::agent_author::remove_stream(&root, &target, &host, actor.as_deref(), &name)?; @@ -3390,12 +3799,13 @@ fn context_cmd(cmd: ContextCmd) -> Result<()> { match cmd { ContextCmd::Read { identity, + agent_id, decisions, full, fresh_within, ctx, } => { - let dir = resolve_context_dir(identity, &ctx)?; + let dir = resolve_context_dir(identity, agent_id, &ctx)?; let view = if full { View::Full } else if decisions { @@ -3413,17 +3823,21 @@ fn context_cmd(cmd: ContextCmd) -> Result<()> { print!("{content}"); Ok(()) } - ContextCmd::Write { identity, ctx } => { + ContextCmd::Write { + identity, + agent_id, + ctx, + } => { let (root, host) = resolve_ctx(&ctx)?; - let id = match identity { + let id = match selected_route(&root, &host, identity, agent_id)? { Some(identity) => identity, - None => acting_id(&ctx)?, + None => acting_route(&root, &host, &ctx)?, }; let content = std::io::read_to_string(std::io::stdin()).context("reading context from stdin")?; message::with_resolved_state_dir( &root, - &id, + &route_selector(&id), &host, &["resources", "context"], true, @@ -3434,18 +3848,19 @@ fn context_cmd(cmd: ContextCmd) -> Result<()> { } ContextCmd::Append { identity, + agent_id, decision, why, ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let id = match identity { + let id = match selected_route(&root, &host, identity, agent_id)? { Some(identity) => identity, - None => acting_id(&ctx)?, + None => acting_route(&root, &host, &ctx)?, }; let filename = message::with_resolved_state_dir( &root, - &id, + &route_selector(&id), &host, &["resources", "context", "decisions"], true, @@ -3572,12 +3987,13 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { match cmd { ResourceCmd::Ls { identity, + agent_id, json, ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let id = match identity { - Some(i) => i, + let id = match selected_declaration(&root, &host, identity, agent_id)? { + Some(id) => id, None => acting_id(&ctx)?, }; let (identity, bindings) = resource_bindings(&root, &id, &host)?; @@ -3606,11 +4022,13 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { ResourceCmd::Read { first, second, + agent_id, json, ctx, } => { let (root, host) = resolve_ctx(&ctx)?; - let (id, name) = box_target(first, second, &ctx)?; + let owner = selected_declaration(&root, &host, None, agent_id)?; + let (id, name) = box_target(first, second, owner, || acting_id(&ctx))?; let (identity, bindings) = resource_bindings(&root, &id, &host)?; let binding = bindings .iter() @@ -3635,6 +4053,7 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { first, second, agent, + agent_id, wait, json, ctx, @@ -3643,7 +4062,7 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { let (selector, name) = match second { Some(name) => (first, name), None => { - let selector = match agent { + let selector = match selected_declaration(&root, &host, agent, agent_id)? { Some(agent) => agent, None => acting_id(&ctx)?, }; @@ -3729,6 +4148,7 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { inactive_reason, selector_json, agent, + agent_id, json, ctx, } => { @@ -3737,7 +4157,7 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { .map(serde_json::from_str) .transpose() .map_err(|error| anyhow::anyhow!("--selector-json is not valid JSON: {error}"))?; - let (root, host, actor, target) = resource_author_target(agent, &ctx)?; + let (root, host, actor, target) = resource_author_target(agent, agent_id, &ctx)?; let receipt = st2::agent_author::add_resource_with_selector( &root, &target, @@ -3762,10 +4182,11 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { ResourceCmd::Remove { name, agent, + agent_id, json, ctx, } => { - let (root, host, actor, target) = resource_author_target(agent, &ctx)?; + let (root, host, actor, target) = resource_author_target(agent, agent_id, &ctx)?; let receipt = st2::agent_author::remove_resource(&root, &target, &host, actor.as_deref(), &name)?; if json { @@ -3782,10 +4203,11 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { old, new, agent, + agent_id, json, ctx, } => { - let (root, host, actor, target) = resource_author_target(agent, &ctx)?; + let (root, host, actor, target) = resource_author_target(agent, agent_id, &ctx)?; let receipt = st2::agent_author::rename_resource( &root, &target, @@ -3810,6 +4232,7 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { /// The catalog root, host, acting actor, and authored target for one mediated binding edit. fn resource_author_target( agent: Option, + agent_id: Option, ctx: &MsgCtx, ) -> Result<(PathBuf, String, Option, String)> { let (root, host) = resolve_ctx(ctx)?; @@ -3818,18 +4241,25 @@ fn resource_author_target( .clone() .or_else(|| std::env::var("ST_AGENT").ok()) .filter(|value| !value.is_empty()); - let target = agent - .or_else(|| actor.clone()) - .context("no resource binding target: pass --agent, --as, or set $ST_AGENT")?; + let target = match selected_declaration(&root, &host, agent, agent_id)? { + Some(target) => target, + None => actor.clone().context( + "no resource binding target: pass --agent, --agent-id, --as, or set $ST_AGENT", + )?, + }; Ok((root, host, actor, target)) } /// Resolve an agent's context dir (`/resources/context`). Identity defaults to `$ST_AGENT`. -fn resolve_context_dir(identity: Option, ctx: &MsgCtx) -> Result { +fn resolve_context_dir( + identity: Option, + agent_id: Option, + ctx: &MsgCtx, +) -> Result { let (root, host) = resolve_ctx(ctx)?; - let id = match identity { - Some(i) => i, - None => acting_id(ctx)?, + let id = match selected_route(&root, &host, identity, agent_id)? { + Some(identity) => identity, + None => acting_route(&root, &host, ctx)?, }; Ok(st2::context::context_dir(&agent_dir_of(&root, &id, &host)?)) } diff --git a/src/message.rs b/src/message.rs index 5b81ccce..ac2a961a 100644 --- a/src/message.rs +++ b/src/message.rs @@ -24,6 +24,8 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use st2_wire::message::{SentCoverage, SentMessageRow, SentMessages}; +use crate::identity::{AgentSelector, ResolveError}; + const SENT_VERSION: u32 = 1; const SENT_DIR: &str = "sent"; const SENT_HEAD: &str = "index.json"; @@ -959,7 +961,7 @@ pub fn resolve_list_box( return Ok(flat()); } if apply_incomplete(root) { - return resolve_agent_dir(root, id, host)? + return resolve_agent_dir(root, &AgentSelector::Address(id.to_owned()), host)? .map(|agent_dir| { if archive { archive_dir(&agent_dir) @@ -972,17 +974,22 @@ pub fn resolve_list_box( }); } let discovered = crate::discover(root); - if let Some(agent_dir) = discovered - .specs - .iter() - .find(|spec| spec.bus_id(host) == id || spec.identity == id) - .and_then(|spec| spec.path.parent()) - { - return Ok(if archive { - archive_dir(agent_dir) - } else { - inbox_dir(agent_dir) - }); + match select_spec( + &discovered.specs, + &AgentSelector::Address(id.to_owned()), + host, + ) { + Ok(spec) => { + if let Some(agent_dir) = spec.path.parent() { + return Ok(if archive { + archive_dir(agent_dir) + } else { + inbox_dir(agent_dir) + }); + } + } + Err(ResolveError::Unknown { .. }) => {} + Err(error) => return Err(error.into()), } if discovered.specs.is_empty() && discovered.errors.is_empty() { @@ -991,35 +998,92 @@ pub fn resolve_list_box( anyhow::bail!("no agent '{id}' found in catalog {}", root.display()) } -/// Resolve a recipient (a bus id `.` or a bare identity) to its agent folder in the -/// catalog, via content discovery. Returns `None` if no agent matches. +/// Resolve one selected agent — an ordinary bare or host-qualified address reference, or an exact +/// immutable agent ID — to its agent folder in the catalog, via content discovery. `None` when no +/// subject answers; an ambiguous reference is an error, never a silent absence. pub fn resolve_agent_dir( catalog_root: &Path, - recipient: &str, + selector: &AgentSelector, + this_host: &str, +) -> anyhow::Result> { + Ok(optional_agent_handle(catalog_root, selector, this_host)?.map(|agent| agent.path)) +} + +/// The admitted readings of a runtime's own agent reference, in order. +/// +/// `st2 driver … --identity` has always accepted the positional identity as well as the +/// `.` bus identity, and an identity may itself contain dots — so rather than +/// guessing which dot is a separator, both readings are tried in the order today's resolution used: +/// the reference as a whole key first, then the same reference qualified by this host. +/// +/// Both readings are exact keys. A runtime never selects itself through the mutable address, so an +/// address cutover cannot disconnect a seat from its own directories, workspace, or message boxes. +fn actor_readings(reference: &str, this_host: &str) -> Vec { + let qualified = format!("{this_host}.{reference}"); + if qualified == reference { + return vec![AgentSelector::Id(reference.to_owned())]; + } + vec![ + AgentSelector::Id(reference.to_owned()), + AgentSelector::Id(qualified), + ] +} + +/// [`resolve_agent_dir`] for a runtime resolving its own agent from a driver argument. +pub fn resolve_actor_dir( + catalog_root: &Path, + reference: &str, this_host: &str, ) -> anyhow::Result> { - Ok(resolve_agent_handle(catalog_root, recipient, this_host)?.map(|agent| agent.path)) + for selector in actor_readings(reference, this_host) { + if let Some(agent) = optional_agent_handle(catalog_root, &selector, this_host)? { + return Ok(Some(agent.path)); + } + } + Ok(None) +} + +/// The exact selector a runtime uses to name itself as a message endpoint. +/// +/// An unresolvable reference keeps its own bytes, so a caller that is not a declared subject — a +/// flat compat box, an external requester — still fails with today's diagnostic. +pub fn actor_selector( + catalog_root: &Path, + reference: &str, + this_host: &str, +) -> anyhow::Result { + for selector in actor_readings(reference, this_host) { + if optional_agent_handle(catalog_root, &selector, this_host)?.is_some() { + return Ok(selector); + } + } + Ok(AgentSelector::Id(reference.to_owned())) } pub fn with_resolved_agent_dir( catalog_root: &Path, - identity: &str, + selector: &AgentSelector, this_host: &str, operation: impl FnOnce(&Path) -> anyhow::Result, ) -> anyhow::Result { - with_resolved_state_dir(catalog_root, identity, this_host, &[], true, operation) + with_resolved_state_dir(catalog_root, selector, this_host, &[], true, operation) } +/// Run an operation against one selected agent's state directory. +/// +/// An exact-ID selector performs only ID lookup, so a subject whose address differs from its ID +/// still reaches its own state; an ordinary reference answers on the current address. pub fn with_resolved_state_dir( catalog_root: &Path, - identity: &str, + selector: &AgentSelector, this_host: &str, components: &[&str], create: bool, operation: impl FnOnce(&Path) -> anyhow::Result, ) -> anyhow::Result { - match resolve_agent_handle(catalog_root, identity, this_host)? { - Some(agent) => { + let identity = selector_reference(selector); + match find_agent_handle(catalog_root, selector, this_host)? { + Ok(agent) => { test_capability_checkpoint(); let path = match agent.capability.as_ref() { Some(capability) if components.is_empty() => { @@ -1037,14 +1101,18 @@ pub fn with_resolved_state_dir( }; operation(&path) } - None => { + Err(error) => { + // The one caller that reads absence as a decision rather than a fault: a provably + // fresh root is the legacy flat bus, whose state directory is created on first use. + // Ambiguity is never that decision, so it keeps the address diagnostic. let discovered = crate::discover(catalog_root); anyhow::ensure!( - crate::catalog_transaction::catalog_transition(catalog_root)?.is_none() + matches!(error, ResolveError::Unknown { .. }) + && crate::catalog_transaction::catalog_transition(catalog_root)?.is_none() && !catalog_root.join(crate::catalog_lock::CONTROL_DIR).exists() && discovered.specs.is_empty() && discovered.errors.is_empty(), - "no agent '{identity}' found in catalog {}", + "no agent '{identity}' found in catalog {}: {error}", catalog_root.display() ); operation( @@ -1065,16 +1133,11 @@ pub fn with_resolved_state_dir( /// operation outside the catalog after recipient resolution. pub(crate) fn with_resolved_message_boxes( catalog_root: &Path, - identity: &str, + selector: &AgentSelector, this_host: &str, operation: impl FnOnce(&Path, &Path) -> anyhow::Result, ) -> anyhow::Result { - let agent = resolve_agent_handle(catalog_root, identity, this_host)?.with_context(|| { - format!( - "no agent '{identity}' found in catalog {}", - catalog_root.display() - ) - })?; + let agent = require_agent_handle(catalog_root, selector, this_host)?; let capability = agent .capability .as_ref() @@ -1089,26 +1152,134 @@ pub(crate) fn with_resolved_message_boxes( ) } -fn resolve_agent_handle( +/// Locate one agent on a single coherent address book, reporting why an ordinary reference did not +/// name exactly one subject (R24). +/// +/// The catalog-generation and transition fence is sampled before and after the walk, and the walk +/// is retried when it moved, so the answer always comes from one before-or-after snapshot of the +/// address book. That is exactly what an atomic address cutover needs: a lookup sees the old +/// address book or the new one, never a torn mixture in which a cut-over address resolves twice or +/// not at all. +fn find_agent_handle( catalog_root: &Path, - recipient: &str, + selector: &AgentSelector, this_host: &str, -) -> anyhow::Result> { +) -> anyhow::Result> { for _ in 0..3 { let before = address_fence(catalog_root)?; - let mut candidates = addressable_agent_dirs(catalog_root, this_host, before.1.as_ref())? - .into_iter() - .filter(|candidate| candidate.bus_id == recipient || candidate.identity == recipient) - .collect::>(); + let candidates = addressable_agent_dirs(catalog_root, this_host, before.1.as_ref())?; let after = address_fence(catalog_root)?; if before != after { continue; } - candidates.sort_by(|left, right| left.path.cmp(&right.path)); - candidates.dedup_by(|left, right| left.path == right.path); - return Ok((candidates.len() == 1).then(|| candidates.remove(0))); + return Ok(select_agent(candidates, selector)); } - anyhow::bail!("catalog address book changed repeatedly while resolving {recipient:?}") + anyhow::bail!("catalog address book changed repeatedly while resolving {selector:?}") +} + +/// Resolve or fail with the address-specific diagnostic attached to the caller's own message. +fn require_agent_handle( + catalog_root: &Path, + selector: &AgentSelector, + this_host: &str, +) -> anyhow::Result { + find_agent_handle(catalog_root, selector, this_host)? + .map_err(anyhow::Error::new) + .with_context(|| { + format!( + "no agent '{}' found in catalog {}", + selector_reference(selector), + catalog_root.display() + ) + }) +} + +/// Absence-tolerant lookup for callers whose next step depends on "no such subject" — an external +/// requester mailbox, a flat compat box. An ambiguous reference is not absence and stays an error. +fn optional_agent_handle( + catalog_root: &Path, + selector: &AgentSelector, + this_host: &str, +) -> anyhow::Result> { + match find_agent_handle(catalog_root, selector, this_host)? { + Ok(agent) => Ok(Some(agent)), + Err(ResolveError::Unknown { .. }) => Ok(None), + Err(error) => Err(error.into()), + } +} + +/// The literal bytes a caller named, for diagnostics only. +fn selector_reference(selector: &AgentSelector) -> &str { + match selector { + AgentSelector::Id(id) => id, + AgentSelector::Address(reference) => reference, + } +} + +fn select_agent( + candidates: Vec, + selector: &AgentSelector, +) -> std::result::Result { + let index = select_index( + &candidates, + selector, + AddressableAgent::entry, + |candidate| candidate.retired, + )?; + Ok(candidates.into_iter().nth(index).expect("selected index")) +} + +/// Resolve a selector against a discovered catalog's specs. +fn select_spec<'a>( + specs: &'a [crate::AgentSpec], + selector: &AgentSelector, + this_host: &str, +) -> std::result::Result<&'a crate::AgentSpec, ResolveError> { + let index = select_index( + specs, + selector, + |spec| crate::identity::AddressBookEntry { + id: spec.effective_id(this_host), + bus_identity: spec.bus_id(this_host), + host: spec.resolved_host(this_host).to_owned(), + address: spec.effective_address().to_owned(), + }, + |spec| spec.desired_state.is_retired(), + )?; + Ok(&specs[index]) +} + +/// Pick the one candidate a selector names, through the address algorithm rather than a precedence +/// rule (R24). +/// +/// Two books, not a tie-break: retirement releases the address, so a retired subject must not make +/// a live claimant's reference ambiguous. It answers to its own declaration address only when no +/// routable subject answers at all, which keeps its retained state — status, context, message +/// boxes — reachable by name exactly as it is today. +fn select_index( + candidates: &[T], + selector: &AgentSelector, + entry: impl Fn(&T) -> crate::identity::AddressBookEntry, + retired: impl Fn(&T) -> bool, +) -> std::result::Result { + let book = candidates.iter().map(&entry).collect::>(); + let routable = candidates + .iter() + .zip(&book) + .filter(|(candidate, _)| !retired(candidate)) + .map(|(_, entry)| entry.clone()) + .collect::>(); + let resolved = match crate::identity::resolve(&routable, selector, None) { + Err(ResolveError::Unknown { .. }) if routable.len() != book.len() => { + crate::identity::resolve(&book, selector, None) + } + other => other, + }; + let id = &resolved?.id; + Ok(book + .iter() + .position(|candidate| &candidate.id == id) + .expect("the resolved entry came from this candidate set")) } fn address_fence( @@ -1154,12 +1325,32 @@ fn test_address_fence_checkpoint() {} #[derive(Debug)] struct AddressableAgent { + /// The immutable catalog-global agent ID: the explicit `id`, else the `.` bus + /// identity a later ID migration would freeze. + id: String, + /// The legacy `.` bus identity, which is the canonical endpoint of every + /// durable record and is never moved by an address cutover. bus_id: String, - identity: String, + host: String, + /// The effective mutable address: declared `address`, else the positional identity. + address: String, + /// A retired subject is non-routable and has released its address. + retired: bool, path: PathBuf, capability: Option, } +impl AddressableAgent { + fn entry(&self) -> crate::identity::AddressBookEntry { + crate::identity::AddressBookEntry { + id: self.id.clone(), + bus_identity: self.bus_id.clone(), + host: self.host.clone(), + address: self.address.clone(), + } + } +} + fn addressable_agent_dirs( catalog_root: &Path, this_host: &str, @@ -1177,8 +1368,11 @@ fn addressable_agent_dirs( .to_path_buf(); let capability = crate::catalog_transaction::open_dir_beneath(catalog_root, &path)?; Ok(AddressableAgent { + id: spec.effective_id(this_host), bus_id: spec.bus_id(this_host), - identity: spec.identity, + host: spec.resolved_host(this_host).to_owned(), + address: spec.effective_address().to_owned(), + retired: spec.desired_state.is_retired(), path, capability: Some(capability), }) @@ -1212,9 +1406,18 @@ fn addressable_agent_dirs( let retained_state = transition.original_agents.contains(&key) && marker_state_exists(&retained)?; if current_spec || retained_state { + // Keyed on the legacy `/` pair, not on a declared address: mid + // transition the declaration bytes under this directory are not readable, so the + // positional pair is the only coherent key available. This branch locates a + // retained *state* directory for a catalog being applied; it is not a route being + // resolved, and a subject whose declaration is mid-apply has no observable + // desired state to call retired. result.push(AddressableAgent { + id: format!("{}.{}", key.host, key.identity), bus_id: format!("{}.{}", key.host, key.identity), - identity: key.identity, + host: key.host, + address: key.identity, + retired: false, path, capability: Some(capability), }); @@ -1487,13 +1690,14 @@ fn catalogless(root: &Path) -> bool { fn resolve_delivery_endpoint( root: &Path, - recipient: &str, + recipient: &AgentSelector, host: &str, external: Option<&ExternalInbox>, ) -> anyhow::Result { - if let Some(agent) = resolve_agent_handle(root, recipient, host)? { + if let Some(agent) = optional_agent_handle(root, recipient, host)? { return Ok(DeliveryEndpoint::Agent(agent)); } + let recipient = selector_reference(recipient); if let Some(external) = external && external.root == root && external.identity == recipient @@ -1515,12 +1719,16 @@ fn resolve_delivery_endpoint( anyhow::bail!("no agent '{recipient}' found in catalog {}", root.display()) } +/// Send one message between two selected endpoints. +/// +/// Both endpoints are selectors, not bare strings: an exact-ID endpoint is the form an +/// `ST_AGENT`-defaulted sender uses, and it must not be re-resolved through a mutable address. #[allow(clippy::too_many_arguments)] pub fn send_to_resolved_inbox( catalog_root: &Path, - recipient: &str, + recipient: &AgentSelector, this_host: &str, - from: &str, + sender: &AgentSelector, subject: Option<&str>, in_reply_to: Option<&str>, tags: &[String], @@ -1531,8 +1739,9 @@ pub fn send_to_resolved_inbox( if let Some(key) = idempotency_key { validate_idempotency_key(key)?; } + let from = selector_reference(sender); let recipient = resolve_delivery_endpoint(catalog_root, recipient, this_host, external)?; - let sender = resolve_agent_handle(catalog_root, from, this_host)?; + let sender = optional_agent_handle(catalog_root, sender, this_host)?; let external_sender = external.is_some_and(|external| external.root == catalog_root && external.identity == from); if matches!(&recipient, DeliveryEndpoint::External { .. }) || external_sender { @@ -1788,7 +1997,15 @@ fn recover_active( (Some(_), []) => anyhow::bail!("active sent intent has no recoverable pending record"), _ => unreachable!(), }; - let recipient = resolve_delivery_endpoint(catalog_root, &record.to, this_host, external)?; + // A durable record's `to` is the canonical endpoint — the legacy bus identity, which is also + // the subject's effective immutable ID — so recovery selects by ID. An address cutover between + // the pending write and the retry is a nondisruptive route change, not a changed recipient. + let recipient = resolve_delivery_endpoint( + catalog_root, + &AgentSelector::Id(record.to.clone()), + this_host, + external, + )?; anyhow::ensure!( recipient.bus_id() == record.to, "pending recipient identity changed" @@ -2189,7 +2406,7 @@ fn test_capability_checkpoint() {} pub fn archive_resolved_message( catalog_root: &Path, - identity: &str, + selector: &AgentSelector, this_host: &str, filename: &str, ) -> anyhow::Result<()> { @@ -2197,7 +2414,8 @@ pub fn archive_resolved_message( is_message_filename(filename), "invalid message filename {filename:?}" ); - let agent = match resolve_agent_handle(catalog_root, identity, this_host)? { + let identity = selector_reference(selector); + let agent = match optional_agent_handle(catalog_root, selector, this_host)? { Some(agent) => agent, None => { let discovered = crate::discover(catalog_root); diff --git a/src/omp_session.rs b/src/omp_session.rs index fd494a67..95379810 100644 --- a/src/omp_session.rs +++ b/src/omp_session.rs @@ -78,7 +78,7 @@ pub fn run( omp_argv: Vec, ) -> Result<()> { let agent_dir = - message::resolve_agent_dir(catalog_root, &identity, &crate::run::detect_host())? + message::resolve_actor_dir(catalog_root, &identity, &crate::run::detect_host())? .with_context(|| format!("omp driver agent '{identity}' is not declared"))?; anyhow::ensure!( !omp_argv.is_empty(), diff --git a/src/opencode_session.rs b/src/opencode_session.rs index 056c18f8..2f9594c5 100644 --- a/src/opencode_session.rs +++ b/src/opencode_session.rs @@ -85,7 +85,7 @@ pub fn run( opencode_argv: Vec, ) -> Result<()> { let this_host = crate::run::detect_host(); - let agent_dir = message::resolve_agent_dir(catalog_root, &identity, &this_host)? + let agent_dir = message::resolve_actor_dir(catalog_root, &identity, &this_host)? .with_context(|| format!("opencode driver agent '{identity}' is not declared"))?; anyhow::ensure!( !opencode_argv.is_empty(), diff --git a/src/pi_channel.rs b/src/pi_channel.rs index 11d2859e..46cb1baf 100644 --- a/src/pi_channel.rs +++ b/src/pi_channel.rs @@ -119,7 +119,7 @@ pub fn run_omp(catalog_root: &Path, identity: &str) -> Result<()> { } fn run_for(catalog_root: &Path, identity: &str, kind: &ChannelKind) -> Result<()> { - let agent_dir = message::resolve_agent_dir(catalog_root, identity, &crate::run::detect_host())? + let agent_dir = message::resolve_actor_dir(catalog_root, identity, &crate::run::detect_host())? .with_context(|| format!("{} channel agent '{identity}' is not declared", kind.label))?; let inbox = message::inbox_dir(&agent_dir); // Composed here rather than in the extension: what a restarted agent is told is st2's contract, diff --git a/src/pi_session.rs b/src/pi_session.rs index bb59c25c..52e5f901 100644 --- a/src/pi_session.rs +++ b/src/pi_session.rs @@ -64,7 +64,7 @@ pub fn run( pi_argv: Vec, ) -> Result<()> { let agent_dir = - message::resolve_agent_dir(catalog_root, &identity, &crate::run::detect_host())? + message::resolve_actor_dir(catalog_root, &identity, &crate::run::detect_host())? .with_context(|| format!("pi driver agent '{identity}' is not declared"))?; anyhow::ensure!( !pi_argv.is_empty(), diff --git a/src/run.rs b/src/run.rs index fafed45a..1f7ddb92 100644 --- a/src/run.rs +++ b/src/run.rs @@ -3270,7 +3270,11 @@ pub fn surface_crash_loop(catalog_root: &Path, this_host: &str, cl: &CrashLoop) ); return; }; - let Ok(Some(agent_dir)) = message::resolve_agent_dir(catalog_root, supervisor, this_host) + let Ok(Some(agent_dir)) = message::resolve_agent_dir( + catalog_root, + &crate::identity::AgentSelector::Address(supervisor.to_owned()), + this_host, + ) else { tracing::warn!( "st2: crash-loop '{}': supervisor '{supervisor}' not found in the catalog to notify.", diff --git a/tests/agent_address.rs b/tests/agent_address.rs new file mode 100644 index 00000000..23bea715 --- /dev/null +++ b/tests/agent_address.rs @@ -0,0 +1,415 @@ +#![cfg(unix)] +//! `st2 agent address` — the mutable agent address (R24/R25) as one atomic address-book cutover. +//! +//! Address is the third authoring sibling of `st2 rename` and `st2 describe`: it holds the same +//! catalog-authoring lock, resolves exactly one declaration, applies the same `ST_AGENT` +//! self/descendant guardrail, refuses Nix-owned declarations and non-KDL formats, and never +//! touches the subject's immutable `id`. What it adds is host-local address uniqueness, decided +//! against the complete prospective catalog rather than the one declaration being edited. + +use std::fs; +use std::path::Path; +use std::process::{Command, Output}; + +fn write(root: &Path, relative: &str, contents: &str) { + let path = root.join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); +} + +/// One canonical declaration. `extra` carries the address/supervisor/id lines under test. +fn declaration(identity: &str, host: &str, managed_by: &str, extra: &str) -> String { + format!( + "// unrelated comment\nagent {identity:?} {{\n host {host:?}\n meta {{ managed-by {managed_by:?}; keep \"unchanged\" }}\n{extra} command \"sleep 300\"\n}}\n" + ) +} + +fn run(root: &Path, args: &[&str], actor: Option<&str>) -> Output { + let mut process = Command::new(env!("CARGO_BIN_EXE_st2")); + process + .args(["--catalog", root.to_str().unwrap()]) + .args(args) + .env_remove("ST_AGENT") + .env_remove("CATALOG"); + if let Some(actor) = actor { + process.env("ST_AGENT", actor); + } + process.output().unwrap() +} + +fn address(root: &Path, args: &[&str], actor: Option<&str>) -> Output { + let mut full = vec!["agent", "address"]; + full.extend_from_slice(args); + run(root, &full, actor) +} + +fn receipt(output: &Output) -> serde_json::Value { + serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "stdout is not JSON ({error}):\nstdout={}\nstderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + }) +} + +#[test] +fn address_is_set_changed_and_cleared_with_a_classified_receipt() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write(root, "h/alpha/agent.kdl", &declaration("alpha", "h", "catalog", "")); + + let set = address( + root, + &["h.alpha", "ops.alpha", "--host", "h", "--json"], + None, + ); + assert!( + set.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&set.stderr) + ); + let set = receipt(&set); + assert_eq!(set["result"], "changed"); + assert_eq!(set["id"], "h.alpha", "the immutable ID is what did not change"); + assert_eq!(set["identity"], "h.alpha"); + assert_eq!(set["address"], "ops.alpha"); + assert_eq!(set["busAddress"], "h.ops.alpha"); + assert_eq!(set["retired"], false); + + // Restating the same address is a proven no-op, not a rewrite. + let same = receipt(&address( + root, + &["h.alpha", "ops.alpha", "--host", "h", "--json"], + None, + )); + assert_eq!(same["result"], "unchanged"); + assert_eq!(same["address"], "ops.alpha"); + + let changed = receipt(&address( + root, + &["h.alpha", "ops.beta", "--host", "h", "--json"], + None, + )); + assert_eq!(changed["result"], "changed"); + assert_eq!(changed["busAddress"], "h.ops.beta"); + + // Clearing restores the positional identity fallback as the effective address. + let cleared = receipt(&address( + root, + &["h.alpha", "--clear", "--host", "h", "--json"], + None, + )); + assert_eq!(cleared["result"], "changed"); + assert!(cleared["address"].is_null()); + assert_eq!(cleared["busAddress"], "h.alpha"); + + let again = receipt(&address( + root, + &["h.alpha", "--clear", "--host", "h", "--json"], + None, + )); + assert_eq!(again["result"], "unchanged"); + assert!(again["address"].is_null()); +} + +#[test] +fn a_cutover_rewrites_only_the_address_and_preserves_every_other_byte() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + let original = declaration("alpha", "h", "catalog", " id \"0199b8f4-8d3a-7c21-9a44-6f85b7320ea1\"\n"); + write(root, "h/alpha/agent.kdl", &original); + + assert!( + address(root, &["h.alpha", "ops.alpha", "--host", "h"], None) + .status + .success() + ); + + let after = fs::read_to_string(root.join("h/alpha/agent.kdl")).unwrap(); + assert!( + after.contains("address \"ops.alpha\""), + "the cutover landed:\n{after}" + ); + assert!( + after.contains("id \"0199b8f4-8d3a-7c21-9a44-6f85b7320ea1\""), + "the immutable id survives an address change:\n{after}" + ); + // Removing exactly the inserted node must reproduce the original bytes: the comment, the + // `meta` block, the host, and the command all survive untouched. + let restored = after + .lines() + .filter(|line| line.trim() != "address \"ops.alpha\"") + .map(|line| format!("{line}\n")) + .collect::(); + assert_eq!(restored, original); +} + +#[test] +fn a_colliding_address_refuses_on_the_same_host_and_is_admitted_on_another() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write(root, "h/alpha/agent.kdl", &declaration("alpha", "h", "catalog", "")); + write(root, "h/beta/agent.kdl", &declaration("beta", "h", "catalog", "")); + write(root, "g/gamma/agent.kdl", &declaration("gamma", "g", "catalog", "")); + + // `alpha` has no explicit address, so its positional identity is its effective address — an + // explicit-vs-fallback collision is the same collision. + let refused = address( + root, + &["h.beta", "alpha", "--host", "h", "--json"], + None, + ); + assert!(!refused.status.success()); + let refused = receipt(&refused); + assert_eq!(refused["result"], "error"); + assert_eq!(refused["code"], "address-conflict"); + assert!( + !fs::read_to_string(root.join("h/beta/agent.kdl")) + .unwrap() + .contains("address"), + "a refused cutover writes nothing" + ); + + // The same address on another logical host is legal: addresses are unique per host. + let admitted = receipt(&address( + root, + &["g.gamma", "alpha", "--host", "h", "--json"], + None, + )); + assert_eq!(admitted["result"], "changed"); + assert_eq!(admitted["busAddress"], "g.alpha"); +} + +#[test] +fn clearing_refuses_when_the_identity_fallback_would_collide() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + // `beta` claimed the bytes that are `alpha`'s identity fallback, which is legal while alpha + // carries an explicit address. Clearing alpha's address would put two subjects on one route. + write( + root, + "h/alpha/agent.kdl", + &declaration("alpha", "h", "catalog", " address \"ops\"\n"), + ); + write( + root, + "h/beta/agent.kdl", + &declaration("beta", "h", "catalog", " address \"alpha\"\n"), + ); + + let refused = address(root, &["h.alpha", "--clear", "--host", "h", "--json"], None); + assert!(!refused.status.success()); + let refused = receipt(&refused); + assert_eq!(refused["code"], "address-conflict"); + assert!( + fs::read_to_string(root.join("h/alpha/agent.kdl")) + .unwrap() + .contains("address \"ops\""), + "a refused clear leaves the explicit address in place" + ); +} + +#[test] +fn address_refuses_invalid_grammar_nix_ownership_non_kdl_and_ambiguity() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write(root, "h/alpha/agent.kdl", &declaration("alpha", "h", "catalog", "")); + write(root, "h/nix/agent.kdl", &declaration("nix", "h", "nix", "")); + write( + root, + "h/legacy/agent.toml", + "identity = \"legacy\"\nhost = \"h\"\ncommand = \"sleep 300\"\n", + ); + // One bare identity declared on two hosts: an ordinary reference cannot name one subject. + write(root, "h/twin/agent.kdl", &declaration("twin", "h", "catalog", "")); + write(root, "g/twin/agent.kdl", &declaration("twin", "g", "catalog", "")); + + for (target, value, code) in [ + ("h.alpha", "Ops Alpha", "invalid-address"), + ("h.alpha", "ops..alpha", "invalid-address"), + ("h.nix", "ops.nix", "nix-managed-declaration"), + ("h.legacy", "ops.legacy", "unsupported-declaration-format"), + ("twin", "ops.twin", "target-ambiguous"), + ] { + let refused = address(root, &[target, value, "--host", "h", "--json"], None); + assert!( + !refused.status.success(), + "{target} {value} was admitted: {}", + String::from_utf8_lossy(&refused.stdout) + ); + assert_eq!(receipt(&refused)["code"], code, "for {target} {value}"); + } +} + +#[test] +fn the_actor_guardrail_admits_a_descendant_and_refuses_a_stranger() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write(root, "h/root/agent.kdl", &declaration("root", "h", "catalog", "")); + write( + root, + "h/child/agent.kdl", + &declaration("child", "h", "catalog", " supervisor \"h.root\"\n"), + ); + write(root, "h/stranger/agent.kdl", &declaration("stranger", "h", "catalog", "")); + + let refused = address( + root, + &["h.stranger", "ops.stranger", "--host", "h", "--json"], + Some("h.root"), + ); + assert!(!refused.status.success()); + assert_eq!(receipt(&refused)["code"], "address-not-authorized"); + + let admitted = address( + root, + &["h.child", "ops.child", "--host", "h", "--json"], + Some("h.root"), + ); + assert!( + admitted.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&admitted.stderr) + ); + assert_eq!(receipt(&admitted)["address"], "ops.child"); +} + +#[test] +fn the_exact_id_form_selects_by_id_and_never_falls_through_to_address_lookup() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + // The two subjects cross their bytes: `one`'s address is `two`, and `two`'s address is `one`. + // An exact-ID selector is catalog-global ID lookup only, so `--id h.two` must name the + // declaration whose positional identity is `two` — never the subject that owns the address + // bytes `two`. + write( + root, + "h/one/agent.kdl", + &declaration("one", "h", "catalog", " address \"two\"\n"), + ); + write( + root, + "h/two/agent.kdl", + &declaration("two", "h", "catalog", " address \"one\"\n"), + ); + + let renamed = run( + root, + &["rename", "--id", "h.two", "Renamed", "--host", "h", "--json"], + None, + ); + assert!( + renamed.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&renamed.stderr) + ); + assert_eq!(receipt(&renamed)["identity"], "h.two"); + assert!( + fs::read_to_string(root.join("h/two/agent.kdl")) + .unwrap() + .contains("name \"Renamed\""), + "the ID named its own subject" + ); + assert!( + !fs::read_to_string(root.join("h/one/agent.kdl")) + .unwrap() + .contains("name \"Renamed\""), + "the subject holding the address bytes was not touched" + ); + + // The same form drives the address cutover. + let cutover = receipt(&address( + root, + &["--id", "h.one", "ops.one", "--host", "h", "--json"], + None, + )); + assert_eq!(cutover["identity"], "h.one"); + assert_eq!(cutover["busAddress"], "h.ops.one"); + + // `two` is a live effective address, but it is not an agent ID, and an exact-ID selector + // never retries its input as an address — so this refuses instead of naming `h/one`. + let unknown = run( + root, + &["rename", "--id", "two", "Nope", "--host", "h", "--json"], + None, + ); + assert!(!unknown.status.success()); + let stderr = String::from_utf8_lossy(&unknown.stderr); + assert!(stderr.contains("'two'"), "stderr:\n{stderr}"); + for identity in ["one", "two"] { + assert!( + !fs::read_to_string(root.join(format!("h/{identity}/agent.kdl"))) + .unwrap() + .contains("Nope"), + "an exact-ID miss must not fall through to address lookup" + ); + } + + // Supplying both forms is a clap conflict, not a precedence rule: with `--id`, the reference + // is off the positional list, so a second positional is exactly what the exclusion catches. + let both = run( + root, + &["rename", "h.one", "Nope", "--id", "h.one", "--host", "h"], + None, + ); + assert!(!both.status.success()); + assert!( + String::from_utf8_lossy(&both.stderr).contains("cannot be used with"), + "stderr:\n{}", + String::from_utf8_lossy(&both.stderr) + ); +} + +/// One exact-ID selector feeds two different resolvers: authoring matches the positional +/// declaration key, while inbox/status resolution answers on the current address. Handing either +/// resolver the other's string is the ID-through-a-mutable-address hop decision 0015 forbids, so +/// the same `--id` must work on both sides once a subject's address diverges from its identity. +#[test] +fn the_exact_id_form_serves_declaration_and_route_resolution_alike() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write( + root, + "h/one/agent.kdl", + &declaration("one", "h", "catalog", " address \"chat\"\n"), + ); + fs::create_dir_all(root.join("h/one/resources/inbox")).unwrap(); + + // Declaration side: the authoring receipt names the declaration, not the address. + let described = run( + root, + &["describe", "--id", "h.one", "Owns chat", "--host", "h", "--json"], + None, + ); + assert!( + described.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&described.stderr) + ); + assert_eq!(receipt(&described)["identity"], "h.one"); + + // Route side: the same ID resolves the subject's inbox through its current address. + let listed = run( + root, + &["message", "ls", "--id", "h.one", "--count", "--host", "h"], + None, + ); + assert!( + listed.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&listed.stderr) + ); + assert_eq!(String::from_utf8_lossy(&listed.stdout).trim(), "0"); + + let status = run( + root, + &["status", "--id", "h.one", "--host", "h"], + None, + ); + assert!( + status.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&status.stderr) + ); +} diff --git a/tests/message.rs b/tests/message.rs index 44dc8009..c6acec0a 100644 --- a/tests/message.rs +++ b/tests/message.rs @@ -6,11 +6,17 @@ use std::fs; use std::path::Path; +use st2::identity::AgentSelector; use st2::message::{ archive_dir, archive_msg, collect_thread, inbox_dir, list_dir, read_msg, reply_subject, resolve_agent_dir, send_to_inbox, }; +/// Every reference in these tests is an ordinary address reference. +fn address(reference: &str) -> AgentSelector { + AgentSelector::Address(reference.to_owned()) +} + fn write(root: &Path, rel: &str, contents: &str) { let path = root.join(rel); fs::create_dir_all(path.parent().unwrap()).unwrap(); @@ -49,10 +55,10 @@ fn send_by_bus_id_lands_in_recipient_inbox() { ); // Resolve the recipient's agent folder by its bus id, then by bare identity — both must match. - let dir_by_bus = resolve_agent_dir(root, "hetz.st2-claude", "hetz") + let dir_by_bus = resolve_agent_dir(root, &address("hetz.st2-claude"), "hetz") .unwrap() .expect("resolve by bus id"); - let dir_by_ident = resolve_agent_dir(root, "st2-claude", "hetz") + let dir_by_ident = resolve_agent_dir(root, &address("st2-claude"), "hetz") .unwrap() .expect("resolve by identity"); assert_eq!(dir_by_bus, dir_by_ident); @@ -90,7 +96,7 @@ fn unknown_recipient_does_not_resolve() { &agent_kdl("st2-claude", "hetz"), ); assert!( - resolve_agent_dir(root, "hetz.nobody", "hetz") + resolve_agent_dir(root, &address("hetz.nobody"), "hetz") .unwrap() .is_none() ); @@ -127,10 +133,10 @@ fn thread_walks_the_reply_chain_across_both_agents() { "h/bob-claude/agent.kdl", &agent_kdl("bob-claude", "h"), ); - let alice = resolve_agent_dir(root, "h.alice-claude", "h") + let alice = resolve_agent_dir(root, &address("h.alice-claude"), "h") .unwrap() .unwrap(); - let bob = resolve_agent_dir(root, "h.bob-claude", "h") + let bob = resolve_agent_dir(root, &address("h.bob-claude"), "h") .unwrap() .unwrap(); @@ -206,10 +212,10 @@ fn reply_threads_back_to_the_original_sender() { &agent_kdl("cos-claude", "hetz"), ); - let me = resolve_agent_dir(root, "hetz.st2-claude", "hetz") + let me = resolve_agent_dir(root, &address("hetz.st2-claude"), "hetz") .unwrap() .unwrap(); - let cos = resolve_agent_dir(root, "hetz.cos-claude", "hetz") + let cos = resolve_agent_dir(root, &address("hetz.cos-claude"), "hetz") .unwrap() .unwrap(); From 480a563dda1859db9f7a46f276cd3c5f79f11552 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:40:33 +0200 Subject: [PATCH 4/9] docs(vrs): stage the immutable-id half of decision 0015 behind named triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- INVARIANTS.md | 2 +- ...-immutable-agent-id-and-mutable-address.md | 68 +++++++++++++++ ...DELTA-003-agent-address-not-implemented.md | 86 ++++++++++++------- docs/vrs/02-agent-spec/spec.md | 11 ++- docs/vrs/spec.md | 12 ++- 5 files changed, 142 insertions(+), 37 deletions(-) diff --git a/INVARIANTS.md b/INVARIANTS.md index d54d4dec..5824b68a 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -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` | diff --git a/docs/vrs/.decisions/0015-immutable-agent-id-and-mutable-address.md b/docs/vrs/.decisions/0015-immutable-agent-id-and-mutable-address.md index a14cc2ec..b85e4865 100644 --- a/docs/vrs/.decisions/0015-immutable-agent-id-and-mutable-address.md +++ b/docs/vrs/.decisions/0015-immutable-agent-id-and-mutable-address.md @@ -101,6 +101,74 @@ 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. + +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 `.` 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 | diff --git a/docs/vrs/.delta/DELTA-003-agent-address-not-implemented.md b/docs/vrs/.delta/DELTA-003-agent-address-not-implemented.md index 6a592d30..228050a0 100644 --- a/docs/vrs/.delta/DELTA-003-agent-address-not-implemented.md +++ b/docs/vrs/.delta/DELTA-003-agent-address-not-implemented.md @@ -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 `.` 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 @@ -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 diff --git a/docs/vrs/02-agent-spec/spec.md b/docs/vrs/02-agent-spec/spec.md index e68a216c..67515ff2 100644 --- a/docs/vrs/02-agent-spec/spec.md +++ b/docs/vrs/02-agent-spec/spec.md @@ -227,10 +227,15 @@ replacement, or state migration. Positional `identity` remains the declaration key and address fallback; it is not immutable subject identity. Authoring: future canonical `id` plus the pinned legacy -[discovery and identity contract][evals-discovery]. Current st2 source: -[`AgentSpec::identity`](../../../crates/agent-spec/src/spec.rs). Evidence: +[discovery and identity contract][evals-discovery]. st2 source: +[`AgentSpec::{id, address, effective_id, effective_address}`](../../../crates/agent-spec/src/spec.rs), +[selection and reference resolution](../../../src/identity.rs), +[address authoring](../../../src/agent_author.rs). Evidence: +[address proof](../../../tests/agent_address.rs), [reconciliation](../../../src/reconcile.rs). -This target remains fenced by +`id` is optional in the shipped grammar, required in the target one, and no +writer emits it yet; positional `identity` is still the durable key. That gap is +fenced by [DELTA-003](../.delta/DELTA-003-agent-address-not-implemented.md).

F03 host

diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index a690cee6..aeb44cae 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -62,9 +62,15 @@ legacy control in `tests/eval_run_e2e.rs`. ## Immutable agent ID, mutable address, and presentation (R02, R08, R11, R13, R19, R24-R26) -This section is the accepted target contract. The current implementation remains -on the pre-decision identity model until -[DELTA-003](.delta/DELTA-003-agent-address-not-implemented.md) closes. +This section is the accepted target contract. Its mutable-address half is +implemented: the grammar, host-local address uniqueness, `st2 agent address`, +and the fail-closed bare-or-qualified reference resolution. Its immutable-ID half +is not: no writer emits `id`, so the positional `.` bus identity +remains the durable key every ownership, task-identity, and record surface uses. +[0015 Amendment 1](.decisions/0015-immutable-agent-id-and-mutable-address.md) +stages that half behind named triggers and +[DELTA-003](.delta/DELTA-003-agent-address-not-implemented.md) records what it +still requires. An Agent Spec separates four values that the current implementation overloads: From ddc94598baebd6747200f443dd96bcffd35e3297 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:16:24 +0200 Subject: [PATCH 5/9] fix(agent): resolve a supervisor edge on the declaration key, not the address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- ...-immutable-agent-id-and-mutable-address.md | 6 +- src/claude_mcp.rs | 2 +- src/claude_session.rs | 6 +- src/codex_app_server.rs | 27 ++-- src/message.rs | 34 +++-- src/omp_session.rs | 2 +- src/opencode_session.rs | 2 +- src/pi_channel.rs | 2 +- src/pi_session.rs | 2 +- src/run.rs | 11 +- tests/agent_address.rs | 123 ++++++++++++++++++ 11 files changed, 181 insertions(+), 36 deletions(-) diff --git a/docs/vrs/.decisions/0015-immutable-agent-id-and-mutable-address.md b/docs/vrs/.decisions/0015-immutable-agent-id-and-mutable-address.md index b85e4865..3579433b 100644 --- a/docs/vrs/.decisions/0015-immutable-agent-id-and-mutable-address.md +++ b/docs/vrs/.decisions/0015-immutable-agent-id-and-mutable-address.md @@ -116,7 +116,11 @@ and host-local `dup-address` admission, `effective_id`/`effective_address`/ 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. +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 diff --git a/src/claude_mcp.rs b/src/claude_mcp.rs index 6089df6b..8eef902b 100644 --- a/src/claude_mcp.rs +++ b/src/claude_mcp.rs @@ -26,7 +26,7 @@ fn channel_content(subject: Option<&str>, body: &str) -> String { } pub fn run(catalog_root: &Path, identity: &str) -> Result<()> { - let agent_dir = message::resolve_actor_dir(catalog_root, identity, &crate::run::detect_host())? + let agent_dir = message::resolve_declared_dir(catalog_root, identity, &crate::run::detect_host())? .with_context(|| format!("Claude MCP agent '{identity}' is not declared"))?; let inbox = message::inbox_dir(&agent_dir); let (input_tx, input_rx) = mpsc::channel(); diff --git a/src/claude_session.rs b/src/claude_session.rs index 4286b1d6..73f9b41d 100644 --- a/src/claude_session.rs +++ b/src/claude_session.rs @@ -30,7 +30,7 @@ pub fn run( claude_argv: Vec, ) -> Result<()> { let agent_dir = - message::resolve_actor_dir(catalog_root, &identity, &crate::run::detect_host())? + message::resolve_declared_dir(catalog_root, &identity, &crate::run::detect_host())? .with_context(|| format!("Claude driver agent '{identity}' is not declared"))?; anyhow::ensure!( !claude_argv.is_empty(), @@ -159,7 +159,7 @@ pub fn run_observe( runtime_id: Option<&str>, event: &str, ) -> Result<()> { - let agent_dir = message::resolve_actor_dir(catalog_root, identity, &crate::run::detect_host())? + let agent_dir = message::resolve_declared_dir(catalog_root, identity, &crate::run::detect_host())? .with_context(|| format!("Claude driver agent '{identity}' is not declared"))?; // Counted only once the invocation has its application target: a hook for an undeclared // agent errors out before any state is applied and must not inflate `hook_invocations_total`. @@ -464,7 +464,7 @@ pub fn run_statusline(catalog_root: &Path, identity: &str) -> Result<()> { fn record_statusline(catalog_root: &Path, identity: &str, raw: &[u8]) -> Result<()> { let payload: serde_json::Value = serde_json::from_slice(raw).unwrap_or(serde_json::Value::Null); - let agent_dir = message::resolve_actor_dir(catalog_root, identity, &crate::run::detect_host())? + let agent_dir = message::resolve_declared_dir(catalog_root, identity, &crate::run::detect_host())? .with_context(|| format!("Claude driver agent '{identity}' is not declared"))?; // Deliberately uncounted. The tee builds no telemetry pipeline at all (`DQ-C13`, see // `main`), so a `record_hook_invocation` here could never reach a collector — and a metric diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 5d59a080..6f1bdefe 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -31,7 +31,7 @@ use serde_json::{Value, json}; use sha2::{Digest as _, Sha256}; use tungstenite::{Message as WebSocketMessage, WebSocket}; -use crate::{ding, driver_diagnostic, harness_context, harness_state, identity::AgentSelector, message, run, status}; +use crate::{ding, driver_diagnostic, harness_context, harness_state, message, run, status}; const REQUIRED_CODEX_CLIENT_REQUESTS: &[&str] = &[ "hooks/list", @@ -390,7 +390,7 @@ struct CodexDeliveryConfig { impl CodexDeliveryConfig { fn resolve(catalog_root: &Path, identity: &str) -> Result { let this_host = run::detect_host(); - let agent_dir = message::resolve_actor_dir(catalog_root, identity, &this_host)? + let agent_dir = message::resolve_declared_dir(catalog_root, identity, &this_host)? .with_context(|| { format!( "Codex native delivery agent '{identity}' is not declared in {}", @@ -431,13 +431,24 @@ impl CodexDeliveryConfig { key_hash.update(body.as_bytes()); let idempotency_key = format!("st2.codex-protocol-rejection.v1:{:x}", key_hash.finalize()); let tags = ["codex-protocol".to_string(), "launch-rejected".to_string()]; - // This runtime names itself by exact key, never through its own mutable address. - let sender = match message::actor_selector(&self.catalog_root, &self.identity, &self.this_host) - { - Ok(sender) => sender, + // Both endpoints are declaration keys, never routes: this runtime names itself by exact + // key, and `supervisor` is the positional edge the org chart walks, so a parent that + // declares an `address` still receives the report. + let endpoints = + message::declared_selector(&self.catalog_root, &self.identity, &self.this_host) + .and_then(|sender| { + let recipient = message::declared_selector( + &self.catalog_root, + supervisor, + &self.this_host, + )?; + Ok((sender, recipient)) + }); + let (sender, recipient) = match endpoints { + Ok(endpoints) => endpoints, Err(resolve_error) => { eprintln!( - "st2 codex: failed to resolve agent '{}' as the sender of a protocol rejection report: {resolve_error:#}", + "st2 codex: failed to resolve the endpoints of agent '{}' protocol rejection report: {resolve_error:#}", self.identity ); return; @@ -445,7 +456,7 @@ impl CodexDeliveryConfig { }; if let Err(report_error) = message::send_to_resolved_inbox( &self.catalog_root, - &AgentSelector::Address(supervisor.to_owned()), + &recipient, &self.this_host, &sender, Some(&subject), diff --git a/src/message.rs b/src/message.rs index ac2a961a..6a5263d7 100644 --- a/src/message.rs +++ b/src/message.rs @@ -1009,16 +1009,21 @@ pub fn resolve_agent_dir( Ok(optional_agent_handle(catalog_root, selector, this_host)?.map(|agent| agent.path)) } -/// The admitted readings of a runtime's own agent reference, in order. +/// The admitted readings of a *declaration-key* reference, in order. /// -/// `st2 driver … --identity` has always accepted the positional identity as well as the -/// `.` bus identity, and an identity may itself contain dots — so rather than -/// guessing which dot is a separator, both readings are tried in the order today's resolution used: -/// the reference as a whole key first, then the same reference qualified by this host. +/// Two kinds of reference are declaration keys rather than routes: a runtime's own +/// `st2 driver … --identity`, and the `supervisor` value a declaration carries. Both have always +/// accepted the positional identity as well as the `.` bus identity, and an +/// identity may itself contain dots — so rather than guessing which dot is a separator, both +/// readings are tried in the order today's resolution used: the reference as a whole key first, +/// then the same reference qualified by this host. That is exactly the pair +/// [`crate::supervisor_chain::resolve_spec`] matches, so the org chart and the notifications it +/// carries read one namespace. /// -/// Both readings are exact keys. A runtime never selects itself through the mutable address, so an -/// address cutover cannot disconnect a seat from its own directories, workspace, or message boxes. -fn actor_readings(reference: &str, this_host: &str) -> Vec { +/// Both readings are exact keys. Neither a runtime nor a supervisor edge selects through the +/// mutable address, so an address cutover cannot disconnect a seat from its own directories, +/// workspace, or message boxes, and cannot break a parent's notification path. +fn declaration_readings(reference: &str, this_host: &str) -> Vec { let qualified = format!("{this_host}.{reference}"); if qualified == reference { return vec![AgentSelector::Id(reference.to_owned())]; @@ -1029,13 +1034,13 @@ fn actor_readings(reference: &str, this_host: &str) -> Vec { ] } -/// [`resolve_agent_dir`] for a runtime resolving its own agent from a driver argument. -pub fn resolve_actor_dir( +/// [`resolve_agent_dir`] for a declaration key: a runtime's own agent, or a declared `supervisor`. +pub fn resolve_declared_dir( catalog_root: &Path, reference: &str, this_host: &str, ) -> anyhow::Result> { - for selector in actor_readings(reference, this_host) { + for selector in declaration_readings(reference, this_host) { if let Some(agent) = optional_agent_handle(catalog_root, &selector, this_host)? { return Ok(Some(agent.path)); } @@ -1043,16 +1048,17 @@ pub fn resolve_actor_dir( Ok(None) } -/// The exact selector a runtime uses to name itself as a message endpoint. +/// The exact selector for a declaration key used as a message endpoint — a runtime naming itself +/// as the sender, or a declared `supervisor` as the recipient. /// /// An unresolvable reference keeps its own bytes, so a caller that is not a declared subject — a /// flat compat box, an external requester — still fails with today's diagnostic. -pub fn actor_selector( +pub fn declared_selector( catalog_root: &Path, reference: &str, this_host: &str, ) -> anyhow::Result { - for selector in actor_readings(reference, this_host) { + for selector in declaration_readings(reference, this_host) { if optional_agent_handle(catalog_root, &selector, this_host)?.is_some() { return Ok(selector); } diff --git a/src/omp_session.rs b/src/omp_session.rs index 95379810..1cacb4af 100644 --- a/src/omp_session.rs +++ b/src/omp_session.rs @@ -78,7 +78,7 @@ pub fn run( omp_argv: Vec, ) -> Result<()> { let agent_dir = - message::resolve_actor_dir(catalog_root, &identity, &crate::run::detect_host())? + message::resolve_declared_dir(catalog_root, &identity, &crate::run::detect_host())? .with_context(|| format!("omp driver agent '{identity}' is not declared"))?; anyhow::ensure!( !omp_argv.is_empty(), diff --git a/src/opencode_session.rs b/src/opencode_session.rs index 2f9594c5..b8cf0923 100644 --- a/src/opencode_session.rs +++ b/src/opencode_session.rs @@ -85,7 +85,7 @@ pub fn run( opencode_argv: Vec, ) -> Result<()> { let this_host = crate::run::detect_host(); - let agent_dir = message::resolve_actor_dir(catalog_root, &identity, &this_host)? + let agent_dir = message::resolve_declared_dir(catalog_root, &identity, &this_host)? .with_context(|| format!("opencode driver agent '{identity}' is not declared"))?; anyhow::ensure!( !opencode_argv.is_empty(), diff --git a/src/pi_channel.rs b/src/pi_channel.rs index 46cb1baf..1e1370d1 100644 --- a/src/pi_channel.rs +++ b/src/pi_channel.rs @@ -119,7 +119,7 @@ pub fn run_omp(catalog_root: &Path, identity: &str) -> Result<()> { } fn run_for(catalog_root: &Path, identity: &str, kind: &ChannelKind) -> Result<()> { - let agent_dir = message::resolve_actor_dir(catalog_root, identity, &crate::run::detect_host())? + let agent_dir = message::resolve_declared_dir(catalog_root, identity, &crate::run::detect_host())? .with_context(|| format!("{} channel agent '{identity}' is not declared", kind.label))?; let inbox = message::inbox_dir(&agent_dir); // Composed here rather than in the extension: what a restarted agent is told is st2's contract, diff --git a/src/pi_session.rs b/src/pi_session.rs index 52e5f901..84d26557 100644 --- a/src/pi_session.rs +++ b/src/pi_session.rs @@ -64,7 +64,7 @@ pub fn run( pi_argv: Vec, ) -> Result<()> { let agent_dir = - message::resolve_actor_dir(catalog_root, &identity, &crate::run::detect_host())? + message::resolve_declared_dir(catalog_root, &identity, &crate::run::detect_host())? .with_context(|| format!("pi driver agent '{identity}' is not declared"))?; anyhow::ensure!( !pi_argv.is_empty(), diff --git a/src/run.rs b/src/run.rs index 1f7ddb92..c8820d5c 100644 --- a/src/run.rs +++ b/src/run.rs @@ -3261,6 +3261,11 @@ fn up_loop_until( /// be watching (the exact miss that let a 45-min outage run). Best-effort — a missing supervisor, /// an unresolvable supervisor, or a send failure is logged, never fatal. Dedup (once per park) is the /// caller's job. +/// +/// `supervisor` is a declaration key, never a route: it is resolved through the same two exact +/// readings [`crate::supervisor_chain::resolve_spec`] walks the org chart with, so a parent that +/// declares an `address` keeps receiving its children's crash-loop notices. An address is a +/// routing alias for humans and messages, and this edge is neither. pub fn surface_crash_loop(catalog_root: &Path, this_host: &str, cl: &CrashLoop) { let agent = cl.agent_bus_id(this_host); let Some(supervisor) = cl.supervisor.as_deref() else { @@ -3270,11 +3275,7 @@ pub fn surface_crash_loop(catalog_root: &Path, this_host: &str, cl: &CrashLoop) ); return; }; - let Ok(Some(agent_dir)) = message::resolve_agent_dir( - catalog_root, - &crate::identity::AgentSelector::Address(supervisor.to_owned()), - this_host, - ) + let Ok(Some(agent_dir)) = message::resolve_declared_dir(catalog_root, supervisor, this_host) else { tracing::warn!( "st2: crash-loop '{}': supervisor '{supervisor}' not found in the catalog to notify.", diff --git a/tests/agent_address.rs b/tests/agent_address.rs index 23bea715..56f9ff8e 100644 --- a/tests/agent_address.rs +++ b/tests/agent_address.rs @@ -413,3 +413,126 @@ fn the_exact_id_form_serves_declaration_and_route_resolution_alike() { String::from_utf8_lossy(&status.stderr) ); } + +/// A **parent's** address cutover must leave the org chart and everything the org chart carries +/// intact: `supervisor` is the positional declaration key, so no child declaration changes, and +/// crash-loop notices still reach the renamed parent — even when another subject has since taken +/// the address bytes the parent's children spell. +#[test] +fn a_parents_address_cutover_keeps_the_org_chart_and_its_notifications() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write( + root, + "h/root/agent.kdl", + &declaration("root", "h", "catalog", ""), + ); + write( + root, + "h/child/agent.kdl", + &declaration("child", "h", "catalog", " supervisor \"h.root\"\n"), + ); + // The bare reading of the same edge, and a stranger that claims the bytes it spells. + write( + root, + "h/bare/agent.kdl", + &declaration("bare", "h", "catalog", " supervisor \"root\"\n"), + ); + write( + root, + "h/impostor/agent.kdl", + &declaration( + "impostor", + "h", + "catalog", + " address \"root\"\n supervisor \"h.root\"\n", + ), + ); + + let cutover = receipt(&address( + root, + &["h.root", "ops.root", "--host", "h", "--json"], + None, + )); + assert_eq!(cutover["address"], "ops.root"); + assert_eq!(cutover["busAddress"], "h.ops.root"); + + // 1. The org chart still validates with both children's `supervisor` values unedited. + let validated = run(root, &["validate", "--host", "h"], None); + assert!( + validated.status.success(), + "stdout:\n{}stderr:\n{}", + String::from_utf8_lossy(&validated.stdout), + String::from_utf8_lossy(&validated.stderr) + ); + let report = String::from_utf8_lossy(&validated.stdout); + assert!(report.contains("0 errors"), "{report}"); + for child in ["h/child/agent.kdl", "h/bare/agent.kdl"] { + let declaration = fs::read_to_string(root.join(child)).unwrap(); + assert!( + declaration.contains("supervisor"), + "{child} lost its supervisor edge" + ); + } + + // 2. Messages route on the new address. + let sent = run( + root, + &[ + "message", + "send", + "ops.root", + "--host", + "h", + "--as", + "h.child", + "-m", + "the parent is still reachable", + ], + None, + ); + assert!( + sent.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&sent.stderr) + ); + + // 3. Crash-loop notices reach the renamed parent through the positional edge — both the + // qualified and the bare spelling — and never the subject holding the released bytes. + for (task, identity, supervisor) in [ + ("h.child-agent", "child", "h.root"), + ("h.bare-agent", "bare", "root"), + ] { + st2::run::surface_crash_loop( + root, + "h", + &st2::run::CrashLoop { + pty_id: task.to_owned(), + identity: identity.to_owned(), + host: Some("h".to_owned()), + supervisor: Some(supervisor.to_owned()), + }, + ); + } + + let parent = st2::message::list_dir(&st2::message::inbox_dir(&root.join("h/root"))).unwrap(); + assert_eq!( + parent.len(), + 3, + "one message plus two crash-loop notices: {parent:?}" + ); + assert_eq!( + parent + .iter() + .filter(|message| message.tags.contains(&"crash-loop".to_owned())) + .count(), + 2, + "both supervisor spellings notified the renamed parent: {parent:?}" + ); + let impostor = + st2::message::list_dir(&st2::message::inbox_dir(&root.join("h/impostor"))).unwrap(); + assert!( + impostor.is_empty(), + "the address book must not answer a supervisor edge: {impostor:?}" + ); +} From ee6ac5e9fbbf814d472b76c5a45dd16e61f532eb Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:18:06 +0200 Subject: [PATCH 6/9] fix(cli): say what the authoring positional actually selects 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 --- src/identity.rs | 6 ++++- src/main.rs | 9 ++++--- tests/agent_address.rs | 60 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/identity.rs b/src/identity.rs index 63ffb4be..dbd5fc20 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -28,7 +28,11 @@ use std::collections::{BTreeMap, BTreeSet}; pub enum AgentSelector { /// Exact subject lookup on the two immutable keys. Never falls through to address lookup. Id(String), - /// An ordinary human reference, resolved by [`resolve_address`]. + /// An ordinary human reference, resolved by [`resolve_address`] on every plane that routes: + /// messages, events, inboxes, status, context. Authoring commands must select exactly one + /// declaration to edit, so they carry these bytes through to their own declaration-key + /// resolver (`agent_author::resolve_target`: an exact bus identity, or a bare stable identity + /// unique in the catalog) and never consult the address book. Address(String), } diff --git a/src/main.rs b/src/main.rs index c5cd7080..75db5b95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -428,8 +428,8 @@ enum DriverCmd { enum AgentCmd { /// Author reversible whole-agent lifecycle intent in one canonical KDL declaration. DesiredState { - /// Ordinary agent reference — an exact bus address, or a bare address unique in the - /// catalog — or the desired state when `--id` names the subject. + /// Exact bus identity, or a bare stable identity only when unique — or the desired state + /// when `--id` names the subject. Authoring selects the declaration, never the address. #[arg(value_name = "IDENTITY_OR_STATE")] first: Option, /// The desired state, when the first positional is the agent reference. @@ -677,8 +677,9 @@ struct MsgCtx { /// use, and the reason clap's exclusion is expressed against the second positional. #[derive(Args)] struct PresentationArgs { - /// Ordinary agent reference — an exact bus address, or a bare address unique in the catalog — - /// or the new value when `--id` names the subject. + /// Exact bus identity, or a bare stable identity only when unique in the selected catalog — + /// or the new value when `--id` names the subject. Authoring selects the declaration, never + /// the address. #[arg(value_name = "IDENTITY_OR_TEXT")] first: Option, /// The new value, when the first positional is the agent reference. diff --git a/tests/agent_address.rs b/tests/agent_address.rs index 56f9ff8e..2e1461ff 100644 --- a/tests/agent_address.rs +++ b/tests/agent_address.rs @@ -536,3 +536,63 @@ fn a_parents_address_cutover_keeps_the_org_chart_and_its_notifications() { "the address book must not answer a supervisor edge: {impostor:?}" ); } + +/// Authoring selects the declaration, never the address — which is what the positional's help +/// text says. After a cutover the subject's own new address does not name it; its declaration key +/// and the exact `--id` form still do. +#[test] +fn authoring_selects_the_declaration_key_and_not_the_current_address() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write( + root, + "h/alpha/agent.kdl", + &declaration("alpha", "h", "catalog", ""), + ); + + let cutover = receipt(&address( + root, + &["h.alpha", "ops.alpha", "--host", "h", "--json"], + None, + )); + assert_eq!(cutover["address"], "ops.alpha"); + + for selector in ["ops.alpha", "h.ops.alpha"] { + let refused = address( + root, + &[selector, "back.alpha", "--host", "h", "--json"], + None, + ); + assert!( + !refused.status.success(), + "{selector} selected a declaration: {}", + String::from_utf8_lossy(&refused.stdout) + ); + assert_eq!( + receipt(&refused)["code"], + "target-not-found", + "for {selector}" + ); + + let renamed = run( + root, + &["rename", selector, "Renamed", "--host", "h", "--json"], + None, + ); + assert_eq!( + receipt(&renamed)["code"], + "target-not-found", + "for {selector}" + ); + } + + // Both declaration-key spellings still select, before and after the cutover. + for selector in ["alpha", "h.alpha"] { + let admitted = receipt(&address( + root, + &[selector, "back.alpha", "--host", "h", "--json"], + None, + )); + assert_eq!(admitted["address"], "back.alpha", "for {selector}"); + } +} From 550065fb764b8b4d9e487d42f7180997cfbc8834 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:26:07 +0200 Subject: [PATCH 7/9] fix(message): one routing algorithm, and no first match under a duplicate 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). --- src/event.rs | 38 +++++++---------- src/identity.rs | 51 ++++++++++++++++------ src/message.rs | 35 ++++++++++++--- tests/agent_address.rs | 97 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 40 deletions(-) diff --git a/src/event.rs b/src/event.rs index befa216e..81eea0be 100644 --- a/src/event.rs +++ b/src/event.rs @@ -340,7 +340,7 @@ fn resolve_stream( // address when nothing routable does, which is what turns "no such agent" into the // `RecipientNotRunning` refusal a supervisor can act on. let book = crate::identity::address_book(&discovered.specs, this_host); - let selected = match pinned_then_unpinned(&book, recipient, this_host) { + let selected = match crate::identity::resolve_local_first(&book, recipient, this_host) { Err(crate::identity::ResolveError::Unknown { .. }) => { let every_subject = discovered .specs @@ -352,7 +352,7 @@ fn resolve_stream( address: spec.effective_address().to_owned(), }) .collect::>(); - pinned_then_unpinned(&every_subject, recipient, this_host) + crate::identity::resolve_local_first(&every_subject, recipient, this_host) .map(|entry| (entry.id.clone(), entry.bus_identity.clone())) } other => other.map(|entry| (entry.id.clone(), entry.bus_identity.clone())), @@ -367,11 +367,23 @@ fn resolve_stream( format!("agent recipient '{reference}' is ambiguous; {error}"), ), })?; - let spec = discovered + let mut claimants = discovered .specs .iter() - .find(|spec| spec.bus_id(this_host) == bus_identity) + .filter(|spec| spec.bus_id(this_host) == bus_identity); + let spec = claimants + .next() .context("the resolved agent left the discovery it was resolved against")?; + // Two declarations under one key answer nothing decidably: the resolved subject and the spec + // this walk would publish against could be different files. + if claimants.next().is_some() { + return Err(StreamRefusal::new( + RefusalKind::Permanent, + format!( + "agent recipient '{reference}' names more than one declaration of '{bus_identity}'" + ), + )); + } let key = bus_identity; if spec.resolved_host(this_host) != this_host { return Err(StreamRefusal::new( @@ -418,24 +430,6 @@ fn resolve_stream( }) } -/// Try the local host first, then the whole catalog. -/// -/// A bare address names this host's subject even when another host declares the same address — -/// today's behavior — while a reference only a foreign host answers still resolves, so the caller -/// can refuse it by name. -fn pinned_then_unpinned<'a>( - book: &'a [crate::identity::AddressBookEntry], - recipient: &AgentSelector, - this_host: &str, -) -> std::result::Result<&'a crate::identity::AddressBookEntry, crate::identity::ResolveError> { - match crate::identity::resolve(book, recipient, Some(this_host)) { - Err(crate::identity::ResolveError::Unknown { .. }) => { - crate::identity::resolve(book, recipient, None) - } - other => other, - } -} - pub fn render_event( from: &str, subject: Option<&str>, diff --git a/src/identity.rs b/src/identity.rs index dbd5fc20..26592c16 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -38,8 +38,11 @@ pub enum AgentSelector { /// One routable subject in the address book. /// -/// Retired subjects are absent: retirement releases the address and makes the subject -/// non-routable, so it neither resolves nor occupies the namespace. Suspended subjects are present. +/// Retired subjects are absent: retirement releases the address for *claiming*, so a retired +/// subject never occupies the namespace and never makes a live claimant's reference ambiguous. +/// It still answers on its own declared address when no routable subject does, which is what +/// keeps its retained state — status, context, message boxes — reachable by name (Q3: retirement +/// keeps the bytes). Suspended subjects are fully present. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AddressBookEntry { /// The immutable catalog-global agent ID: the explicit `id`, else the positional bus identity @@ -67,10 +70,12 @@ impl AddressBookEntry { pub enum ResolveError { /// No routable subject carries this address, in any admitted reading. Unknown { reference: String }, - /// More than one distinct subject survives, so the reference is undecidable. + /// More than one subject survives, so the reference is undecidable. Ambiguous { reference: String, - /// The surviving subjects' IDs, sorted, so a diagnostic can name them. + /// One key per surviving subject, sorted, so a diagnostic can name and count them: the + /// agent ID, or the declaration key where two declarations share one ID and the ID would + /// name the same bytes twice. ids: Vec, }, } @@ -80,14 +85,17 @@ impl std::fmt::Display for ResolveError { match self { Self::Unknown { reference } => write!( formatter, - "no routable agent has the address '{reference}'; a retired subject releases its address and does not resolve" - ), - Self::Ambiguous { reference, ids } => write!( - formatter, - "the reference '{reference}' is ambiguous: it names {} subjects ({}); qualify it with a host or select the subject by its exact id", - ids.len(), - ids.join(", ") + "no routable agent has the address '{reference}'; a retired subject releases its address for claiming and answers only when nothing routable does" ), + Self::Ambiguous { reference, ids } => { + let count = ids.len(); + let subjects = if count == 1 { "subject" } else { "subjects" }; + write!( + formatter, + "the reference '{reference}' is ambiguous: it names {count} {subjects} ({}); qualify it with a host or select the subject by its exact id", + ids.join(", ") + ) + } } } } @@ -121,8 +129,9 @@ pub fn resolve_id<'a>( if ids.len() == 1 { return Ok(selected); } + // Not deduplicated: two declarations sharing one effective ID are two subjects, and reporting + // one name for them would say "names 1 subject" about an undecidable reference. ids.sort(); - ids.dedup(); Err(ResolveError::Ambiguous { reference: id.to_owned(), ids, @@ -202,6 +211,24 @@ pub fn resolve<'a>( } } +/// Resolve a reference on the local host first, then across the whole catalog. +/// +/// A bare address names this host's subject even when another host declares the same address — +/// today's behavior — while a reference only a foreign host answers still resolves, so a caller +/// that owns only local subjects can refuse it by name instead of reporting an absence. Every +/// plane that routes uses this, so `st2 message send chat` and `st2 event emit chat` decide one +/// reference the same way. +pub fn resolve_local_first<'a>( + entries: &'a [AddressBookEntry], + selector: &AgentSelector, + this_host: &str, +) -> std::result::Result<&'a AddressBookEntry, ResolveError> { + match resolve(entries, selector, Some(this_host)) { + Err(ResolveError::Unknown { .. }) => resolve(entries, selector, None), + other => other, + } +} + /// The routable address book of a discovered catalog. /// /// A subject with no explicit `id` contributes its effective ID — the legacy bus identity migration diff --git a/src/message.rs b/src/message.rs index 6a5263d7..a04434b1 100644 --- a/src/message.rs +++ b/src/message.rs @@ -1178,7 +1178,7 @@ fn find_agent_handle( if before != after { continue; } - return Ok(select_agent(candidates, selector)); + return Ok(select_agent(candidates, selector, this_host)); } anyhow::bail!("catalog address book changed repeatedly while resolving {selector:?}") } @@ -1225,10 +1225,12 @@ fn selector_reference(selector: &AgentSelector) -> &str { fn select_agent( candidates: Vec, selector: &AgentSelector, + this_host: &str, ) -> std::result::Result { let index = select_index( &candidates, selector, + this_host, AddressableAgent::entry, |candidate| candidate.retired, )?; @@ -1244,6 +1246,7 @@ fn select_spec<'a>( let index = select_index( specs, selector, + this_host, |spec| crate::identity::AddressBookEntry { id: spec.effective_id(this_host), bus_identity: spec.bus_id(this_host), @@ -1262,9 +1265,12 @@ fn select_spec<'a>( /// a live claimant's reference ambiguous. It answers to its own declaration address only when no /// routable subject answers at all, which keeps its retained state — status, context, message /// boxes — reachable by name exactly as it is today. +/// +/// The local host is the pin, so this plane and stream ingress decide one reference identically. fn select_index( candidates: &[T], selector: &AgentSelector, + this_host: &str, entry: impl Fn(&T) -> crate::identity::AddressBookEntry, retired: impl Fn(&T) -> bool, ) -> std::result::Result { @@ -1275,17 +1281,34 @@ fn select_index( .filter(|(candidate, _)| !retired(candidate)) .map(|(_, entry)| entry.clone()) .collect::>(); - let resolved = match crate::identity::resolve(&routable, selector, None) { + let resolved = match crate::identity::resolve_local_first(&routable, selector, this_host) { Err(ResolveError::Unknown { .. }) if routable.len() != book.len() => { - crate::identity::resolve(&book, selector, None) + crate::identity::resolve_local_first(&book, selector, this_host) } other => other, }; let id = &resolved?.id; - Ok(book + // Resolution deduplicates by agent ID, so two declarations sharing one effective ID collapse + // into one surviving entry — and then a positional lookup could hand back the other one. Two + // candidates under one ID are two subjects, not a first match. + let mut positions = book .iter() - .position(|candidate| &candidate.id == id) - .expect("the resolved entry came from this candidate set")) + .enumerate() + .filter(|(_, candidate)| &candidate.id == id); + let (index, _) = positions + .next() + .expect("the resolved entry came from this candidate set"); + if positions.next().is_some() { + return Err(ResolveError::Ambiguous { + reference: selector_reference(selector).to_owned(), + ids: book + .iter() + .filter(|candidate| &candidate.id == id) + .map(|candidate| candidate.bus_identity.clone()) + .collect(), + }); + } + Ok(index) } fn address_fence( diff --git a/tests/agent_address.rs b/tests/agent_address.rs index 2e1461ff..05dbb7f4 100644 --- a/tests/agent_address.rs +++ b/tests/agent_address.rs @@ -596,3 +596,100 @@ fn authoring_selects_the_declaration_key_and_not_the_current_address() { assert_eq!(admitted["address"], "back.alpha", "for {selector}"); } } + +/// Message resolution deduplicates candidates by agent ID, so two declarations sharing one +/// effective ID must refuse rather than deliver into whichever file came first in path order. +#[test] +fn two_declarations_sharing_one_effective_id_refuse_a_reference() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write( + root, + "h/alpha/agent.kdl", + &declaration( + "alpha", + "h", + "catalog", + " id \"dup\"\n address \"chat\"\n", + ), + ); + write( + root, + "h/beta/agent.kdl", + &declaration("beta", "h", "catalog", " id \"dup\"\n"), + ); + + let refused = run( + root, + &[ + "message", "send", "chat", "--host", "h", "--as", "h.beta", "-m", "who am I", + ], + None, + ); + assert!( + !refused.status.success(), + "one id naming two declarations must not deliver: {}", + String::from_utf8_lossy(&refused.stdout) + ); + let stderr = String::from_utf8_lossy(&refused.stderr); + assert!( + stderr.contains("ambiguous") && stderr.contains("2 subjects"), + "{stderr}" + ); +} + +/// Both routing planes pin the local host first, so one reference decides identically whether it +/// arrives through `message send` or through stream ingress. +#[test] +fn a_bare_address_declared_on_two_hosts_resolves_to_the_local_subject() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + write( + root, + "h/local/agent.kdl", + &declaration("local", "h", "catalog", " address \"chat\"\n"), + ); + write( + root, + "g/remote/agent.kdl", + &declaration("remote", "g", "catalog", " address \"chat\"\n"), + ); + write( + root, + "h/sender/agent.kdl", + &declaration("sender", "h", "catalog", ""), + ); + + let sent = run( + root, + &[ + "message", + "send", + "chat", + "--host", + "h", + "--as", + "h.sender", + "-m", + "local wins", + ], + None, + ); + assert!( + sent.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&sent.stderr) + ); + assert_eq!( + st2::message::list_dir(&st2::message::inbox_dir(&root.join("h/local"))) + .unwrap() + .len(), + 1 + ); + assert!( + st2::message::list_dir(&st2::message::inbox_dir(&root.join("g/remote"))) + .unwrap() + .is_empty(), + "the foreign host's subject must not receive a locally pinned reference" + ); +} From f74c9e41601229597aed4bf59d2a508b41aa3426 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:26:07 +0200 Subject: [PATCH 8/9] fix(authoring): name the incumbent claimant in an address conflict 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. --- src/agent_author.rs | 52 ++++++++++++++++++++++++++++++------------ tests/agent_address.rs | 5 ++++ 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/agent_author.rs b/src/agent_author.rs index a08d95c4..3113473f 100644 --- a/src/agent_author.rs +++ b/src/agent_author.rs @@ -820,25 +820,49 @@ fn refuse_address_collision( } } let report = crate::validate::validate_discovered(catalog_root, Some(this_host), &prospective); - match report + if !report .issues .iter() - .find(|issue| issue.code == "dup-address") + .any(|issue| issue.code == "dup-address") { - None => Ok(()), - Some(issue) => Err(AuthorError::new( - "address-conflict", + return Ok(()); + } + // Name the incumbent, not the first declaration in path order: the forwarded diagnostic often + // pointed at the candidate's own file, because that is where `dup-address` first saw the + // address. The claimant is the *other* subject reading the same effective address on this + // host. + let candidate = requested.unwrap_or(&target.source_identity); + let claimant = prospective + .specs + .iter() + .find(|spec| { + spec.bus_id(this_host) != target.identity + && !spec.desired_state.is_retired() + && spec.resolved_host(this_host) == target.source_host + && spec.effective_address() == candidate + }) + .map(|spec| { format!( - "{} is not unique on host {:?}: {}", - requested.map_or_else( - || format!("identity fallback address {:?}", target.source_identity), - |value| format!("address {value:?}") - ), - target.source_host, - issue.message + "{} declared in {}", + spec.bus_id(this_host), + spec.path + .strip_prefix(catalog_root) + .unwrap_or(&spec.path) + .display() + ) + }); + Err(AuthorError::new( + "address-conflict", + format!( + "{} is not unique on host {:?}: already claimed by {}", + requested.map_or_else( + || format!("identity fallback address {:?}", target.source_identity), + |value| format!("address {value:?}") ), - )), - } + target.source_host, + claimant.unwrap_or_else(|| "another declaration in this catalog".to_owned()) + ), + )) } fn resolve_target( diff --git a/tests/agent_address.rs b/tests/agent_address.rs index 05dbb7f4..52ee4ae4 100644 --- a/tests/agent_address.rs +++ b/tests/agent_address.rs @@ -164,6 +164,11 @@ fn a_colliding_address_refuses_on_the_same_host_and_is_admitted_on_another() { let refused = receipt(&refused); assert_eq!(refused["result"], "error"); assert_eq!(refused["code"], "address-conflict"); + let message = refused["error"].as_str().unwrap_or_default(); + assert!( + message.contains("h.alpha") && message.contains("h/alpha/agent.kdl"), + "the refusal must name the incumbent claimant, not the edited file: {message}" + ); assert!( !fs::read_to_string(root.join("h/beta/agent.kdl")) .unwrap() From 591ca3c5d4a5621204bef8b527728d2450e507da Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:26:07 +0200 Subject: [PATCH 9/9] docs(cli): name the namespace the `--as` flag consumes `--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". --- src/main.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 75db5b95..9fc8e305 100644 --- a/src/main.rs +++ b/src/main.rs @@ -661,8 +661,9 @@ struct MsgCtx { /// default st2 catalog. #[arg(long, conflicts_with = "catalog_path")] root: Option, - /// The acting identity — who the message is `from` / whose inbox is "mine". Defaults to - /// `$ST_AGENT`. + /// The acting identity — who the message is `from` / whose inbox is "mine". An ordinary + /// address reference, unlike `$ST_AGENT`, which carries the exact agent ID; the two are + /// different strings once a subject declares an explicit `address`. Defaults to `$ST_AGENT`. #[arg(long = "as")] as_id: Option, /// Host used to resolve `.` bus ids. Defaults to the local hostname.