Skip to content

Commit 2c6f67b

Browse files
Split the agent route from its durable key: mutable address, optional immutable id (#468)
* feat(agent-spec): admit an optional immutable agent id and mutable address Agent Spec gains the two fields decision 0015 separates from the positional `identity`: an explicit catalog-global immutable `id` and an optional mutable `address`. Both are optional during the DELTA-003 migration window; `id` is required in the target grammar once every live and archived subject is migrated. - `id` is opaque. Its only two producers are UUIDv7 for a new subject and the frozen `<host>.<identity>` bus identity of a migrated legacy subject, and R26 reuses it verbatim as the canonical task ID and therefore as a session socket path component, so validation refuses exactly what would stop being a usable task ID rather than imposing the address grammar. - `address` carries R24's grammar: at most 255 ASCII characters, dotted 1..=63-character segments of lowercase letters, digits, and hyphens, each beginning and ending with a letter or digit. - `effective_address()` falls back to the positional identity, `bus_address()` host-qualifies it, and `effective_id()` answers with the value catalog ID migration freezes, so a partially migrated catalog stays coherent. Declaring either field twice is the shape refusal `identity` and `host` already carry. No writer emits either field yet. * feat(catalog): validate agent id and address uniqueness and project both Catalog admission gains the two uniqueness rules R24 states, and the read projections gain the fields R24 and decision 0015 require them to publish. - `dup-id` now keys on the effective agent ID rather than the bus identity, so one check covers a legacy duplicate identity, two explicit ids colliding across hosts (an ID is catalog-global, never per-host), and an explicit id claiming another subject's still-unmigrated frozen bus identity. - `dup-address` is new: effective addresses are unique per resolved logical host among running and suspended subjects. A retired subject releases its address, so it neither claims nor collides. Explicit-vs-explicit and explicit-vs-identity-fallback are the same collision. - Roster and graph JSON append `id`, `address`, and nullable `busAddress`, preserving existing field order and meanings. `identity` keeps its meaning as the positional declaration key and legacy address fallback. - The graph re-keys `id`, `parentId`, `rootId`, and `ancestorIds` onto the effective agent ID so one namespace spans a partially migrated catalog; for an unmigrated subject that value is its bus identity, so output is unchanged. Archived-subject ID uniqueness joins these checks with the migration verb. * fix(agent-spec): stop admitting a lone id or address as a spec candidate `id` and `address` are two of the most common keys in arbitrary JSON/TOML, and this predicate exists to keep such files out of the spec plane. Admitting them turned a stray file beside a real declaration into a phantom agent — in the roster and in `catalog graph` — and, because such a file carries no launch, also stopped the catalog from admitting at all. Nothing needs the two arms: every real declaration carries an identity, a driver block, a launch, or `type = "service"`, and migration adds `id` to declarations that are candidates for other reasons. agent-identity: dev3.direct.omp.v6c4mkm2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.1.2 agent-runtime: OMP 18.1.2 tooling-profile: dotfiles@7534055 * fix(agent-spec): restrict the agent id grammar to the closed safe set The previous grammar admitted every printable ASCII byte except `/`, `\\`, and `:` — so backtick, `$`, `;`, `&`, `|`, `*`, `?`, and quotes were all legal in a value R26 reuses verbatim as the canonical task ID, as a session socket path component, and in shell-adjacent bus text, where a backtick has already executed a command on a live host (schickling/dotfiles#1614). The grammar is now `[A-Za-z0-9._-]`. It stays wider than the address grammar on purpose: a frozen legacy ID keeps the case and underscores its identity carried, so freezing an admissible identity can never be refused here. No writer emits `id` yet, so this narrows an unproduced namespace. agent-identity: dev3.direct.omp.v6c4mkm2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.1.2 agent-runtime: OMP 18.1.2 tooling-profile: dotfiles@7534055 * fix(catalog): register both duplicate keys in one pass on one host key Two asymmetries between the `dup-id` and `dup-address` rules: - `dup-address` keyed on the resolved logical host while `dup-id` keyed on an empty placeholder, so under `--host h` a host-less declaration and an explicit `host "h"` one — one physical subject — were reported only under the address code. Both rules now read the same host key. - Suppressing the address check for a declaration already refused for a duplicate ID also left that declaration's address unregistered, so a third subject could claim it undetected — including through `st2 agent address`, whose gate re-runs exactly this rule. Both keys now register in one pass before either is reported, and one physical conflict is still one diagnostic. agent-identity: dev3.direct.omp.v6c4mkm2 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.1.2 agent-runtime: OMP 18.1.2 tooling-profile: dotfiles@7534055
1 parent 9e77bf7 commit 2c6f67b

18 files changed

Lines changed: 1228 additions & 28 deletions

crates/agent-spec/src/declared.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,22 @@ pub fn parse_declared_document(source_name: &Path, source: &str) -> DeclaredPars
285285
));
286286
continue;
287287
}
288+
for field in node.children_named("id").skip(1) {
289+
diagnostics.push(shape_diagnostic(
290+
source_name,
291+
field.span,
292+
DeclaredDiagnosticCode::DuplicateRoutingField,
293+
"agent id must be declared exactly once".to_owned(),
294+
));
295+
}
296+
for field in node.children_named("address").skip(1) {
297+
diagnostics.push(shape_diagnostic(
298+
source_name,
299+
field.span,
300+
DeclaredDiagnosticCode::DuplicateRoutingField,
301+
"agent address must be declared exactly once".to_owned(),
302+
));
303+
}
288304
for field in node.children_named("identity").skip(1) {
289305
diagnostics.push(shape_diagnostic(
290306
source_name,

crates/agent-spec/src/discovery.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,10 @@ fn unobservable_entry_may_hide_declaration(
332332
/// gain fields without breaking readers.
333333
#[derive(Debug, Clone, Default, PartialEq, Eq)]
334334
pub struct Declared {
335+
/// `id` as written in the file. `None` on a legacy declaration with no explicit agent ID.
336+
pub id: Option<String>,
337+
/// `address` as written in the file. `None` when `identity` is the effective legacy address.
338+
pub address: Option<String>,
335339
/// `identity` as written in the file. `None` when the file relies on [`path_defaults`].
336340
pub identity: Option<String>,
337341
/// `host` as written in the file. `None` when the file relies on [`path_defaults`].
@@ -343,6 +347,8 @@ pub struct Declared {
343347
impl From<&RawSpec> for Declared {
344348
fn from(raw: &RawSpec) -> Self {
345349
Self {
350+
id: raw.id.clone(),
351+
address: raw.address.clone(),
346352
identity: raw.identity.clone(),
347353
host: raw.host.clone(),
348354
job_type: raw.job_type.clone(),

crates/agent-spec/src/kdl_format.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,11 @@ fn agent_node_to_raw(node: &DeclaredNode) -> anyhow::Result<RawSpec> {
7171

7272
for child in &node.children {
7373
match child.name.as_str() {
74+
"id" => parse_single_string_field(child, "id", &mut raw.id)?,
75+
"address" => parse_single_string_field(child, "address", &mut raw.address)?,
7476
"identity" => raw.identity = arg_string(child).or(raw.identity),
75-
"name" => parse_presentation(child, "name", &mut raw.name)?,
76-
"description" => parse_presentation(child, "description", &mut raw.description)?,
77+
"name" => parse_single_string_field(child, "name", &mut raw.name)?,
78+
"description" => parse_single_string_field(child, "description", &mut raw.description)?,
7779
"host" => raw.host = arg_string(child),
7880
"role" => raw.role = arg_string(child),
7981
"type" => raw.job_type = arg_string(child),
@@ -406,7 +408,11 @@ fn opencode_driver_node_to_raw(node: &DeclaredNode) -> anyhow::Result<OpenCodeDr
406408
})
407409
}
408410

409-
fn parse_presentation(
411+
/// Parse one direct child declaring exactly one positional string, at most once.
412+
///
413+
/// Shared by the presentation fields (`name`, `description`) and the routing fields (`id`,
414+
/// `address`), which have the same source shape and the same once-only rule.
415+
fn parse_single_string_field(
410416
node: &DeclaredNode,
411417
field: &str,
412418
destination: &mut Option<String>,

crates/agent-spec/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,12 @@ pub use discovery::{
4646
parse_declared, path_defaults,
4747
};
4848
pub use spec::{
49+
AGENT_ADDRESS_MAX_BYTES, AGENT_ADDRESS_SEGMENT_MAX_BYTES, AGENT_ID_MAX_BYTES,
4950
AgentDesiredState, AgentSpec, ClaudeDriver, CodexDriver, DeliveryReadiness, DeliveryTransport,
5051
Driver, JobType, OpenCodeDriver, PiDriver, Resource, Restart, RestartMode, STREAM_TASK_PREFIX,
5152
SessionDriver, Stream, StreamLaunch, Task, TaskKind, TaskLifecycle, parse_duration,
52-
stream_name_of_task, validate_desired_state_reason,
53+
stream_name_of_task, validate_agent_address, validate_agent_id,
54+
validate_desired_state_reason,
5355
};
5456
pub use profile::{
5557
DEFAULT_SELECTOR_LIMIT_BYTES, DescriptorValidationError, PROFILE_DESCRIPTOR_ABI_VERSION,

crates/agent-spec/src/spec.rs

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ pub const AGENT_NAME_MAX_CHARS: usize = 160;
2727
pub const AGENT_DESCRIPTION_MAX_CHARS: usize = 1_000;
2828
/// Maximum UTF-8 byte length for a non-running desired-state rationale.
2929
pub const AGENT_DESIRED_STATE_REASON_MAX_BYTES: usize = 160;
30+
/// Maximum ASCII length of an explicit agent address (R24).
31+
pub const AGENT_ADDRESS_MAX_BYTES: usize = 255;
32+
/// Maximum length of one dotted agent-address segment (R24).
33+
pub const AGENT_ADDRESS_SEGMENT_MAX_BYTES: usize = 63;
34+
/// Maximum ASCII length of an explicit immutable agent ID.
35+
///
36+
/// The two admitted producers are UUIDv7 for a new subject and the frozen `<host>.<identity>`
37+
/// bus identity of a migrated legacy subject, so the ID namespace shares the address budget.
38+
pub const AGENT_ID_MAX_BYTES: usize = 255;
3039

3140
/// Declarative whole-agent lifecycle intent.
3241
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -314,7 +323,13 @@ impl AgentDesiredState {
314323
/// A rendered agent job, lowered to the shared declaration fields st2 and other readers inspect.
315324
#[derive(Debug, Clone, PartialEq, Eq)]
316325
pub struct AgentSpec {
317-
/// Unique id; the bus id is `<host>.<identity>`.
326+
/// Explicit immutable catalog-global agent ID (R24). `None` on a legacy declaration that
327+
/// catalog ID migration has not reached yet; required in the target grammar.
328+
pub id: Option<String>,
329+
/// Explicit mutable agent address (R24). `None` means the positional `identity` below is the
330+
/// effective legacy address.
331+
pub address: Option<String>,
332+
/// The positional declaration key and legacy address fallback. Not immutable subject identity.
318333
pub identity: String,
319334
/// Optional mutable human-facing label. Never used as an automation selector.
320335
pub name: Option<String>,
@@ -594,6 +609,33 @@ impl AgentSpec {
594609
)
595610
}
596611

612+
/// The effective immutable subject ID during the ID migration window (R24).
613+
///
614+
/// The explicit `id` when the declaration carries one, otherwise the legacy
615+
/// `<host>.<identity>` bus identity — which is exactly the value catalog ID migration freezes
616+
/// as this subject's explicit ID, so a mixed catalog stays coherent while it is migrated.
617+
pub fn effective_id(&self, this_host: &str) -> String {
618+
self.id
619+
.clone()
620+
.unwrap_or_else(|| self.bus_id(this_host))
621+
}
622+
623+
/// The effective agent address (R24): the explicit `address` when present, otherwise the
624+
/// positional `identity` legacy fallback.
625+
pub fn effective_address(&self) -> &str {
626+
self.address.as_deref().unwrap_or(&self.identity)
627+
}
628+
629+
/// The human-routable bus address `<host>.<effective address>` (R24), using `this_host` when
630+
/// `host` is unset. This is a mutable route, never the immutable subject ID.
631+
pub fn bus_address(&self, this_host: &str) -> String {
632+
format!(
633+
"{}.{}",
634+
self.host.as_deref().unwrap_or(this_host),
635+
self.effective_address()
636+
)
637+
}
638+
597639
/// The host that should run this spec, defaulting to `this_host` when unset.
598640
pub fn resolved_host<'a>(&'a self, this_host: &'a str) -> &'a str {
599641
self.host.as_deref().unwrap_or(this_host)
@@ -658,6 +700,8 @@ pub fn parse_duration(s: &str) -> Result<Duration, String> {
658700
/// render-agnostic.
659701
#[derive(Debug, Default, Deserialize)]
660702
pub(crate) struct RawSpec {
703+
pub id: Option<String>,
704+
pub address: Option<String>,
661705
pub identity: Option<String>,
662706
pub name: Option<String>,
663707
pub description: Option<String>,
@@ -1208,6 +1252,12 @@ impl RawSpec {
12081252
/// A parsed file is a *spec candidate* when it carries an agent-shaped signal — an identity,
12091253
/// lifecycle intent, the supported `service` type, or task blocks. Random TOML/JSON in the tree
12101254
/// has none of these and is skipped.
1255+
///
1256+
/// `id` and `address` are deliberately not signals. They are two of the most common keys in
1257+
/// arbitrary JSON/TOML — a dropped GitHub payload, a task cache, a session record — and a file
1258+
/// whose only agent-shaped key is one of them cannot be a valid declaration anyway: it carries
1259+
/// no launch, so it would only ever join the roster as a phantom agent and stop the catalog
1260+
/// from admitting. Migration adds `id` to declarations that are already candidates.
12111261
pub(crate) fn looks_like_spec(&self) -> bool {
12121262
self.identity.is_some()
12131263
|| self.job_type.as_deref() == Some("service")
@@ -1247,6 +1297,12 @@ impl RawSpec {
12471297
self.description.as_deref(),
12481298
AGENT_DESCRIPTION_MAX_CHARS,
12491299
)?;
1300+
if let Some(id) = self.id.as_deref() {
1301+
validate_agent_id(id)?;
1302+
}
1303+
if let Some(address) = self.address.as_deref() {
1304+
validate_agent_address(address)?;
1305+
}
12501306
let retired = reject_explicit_null("retired", self.retired)?;
12511307
let desired_state_value = reject_explicit_null("desired_state", self.desired_state)?;
12521308
let desired_state_reason =
@@ -1444,6 +1500,8 @@ impl RawSpec {
14441500
let resources = self.resource.lower()?;
14451501

14461502
Ok(AgentSpec {
1503+
id: self.id,
1504+
address: self.address,
14471505
identity,
14481506
name: self.name,
14491507
description: self.description,
@@ -1571,6 +1629,82 @@ pub fn validate_presentation(
15711629
Ok(())
15721630
}
15731631

1632+
/// Validate an explicit immutable agent ID at the shared parse/authoring boundary (R24).
1633+
///
1634+
/// The ID is opaque, but not arbitrary: R26 reuses it verbatim as the canonical task ID and
1635+
/// therefore as a session socket path component, and IDs travel through shell-adjacent text (bus
1636+
/// messages, notices, journal lines), where a backtick has already executed a command on a live
1637+
/// host (schickling/dotfiles#1614). So the grammar is the closed safe set `[A-Za-z0-9._-]`, which
1638+
/// admits both admitted producers — a new subject's UUIDv7 and a migrated subject's frozen
1639+
/// `<host>.<identity>` bus identity — and refuses every shell metacharacter, path and host
1640+
/// separator, whitespace byte, and non-ASCII byte outright.
1641+
///
1642+
/// It stays wider than the address grammar on purpose: a frozen legacy ID keeps host-looking bytes
1643+
/// and whatever case and underscores its identity carried, so freezing an admissible identity can
1644+
/// never be refused here. Equal bytes in the two namespaces do not collide.
1645+
pub fn validate_agent_id(value: &str) -> anyhow::Result<()> {
1646+
anyhow::ensure!(!value.is_empty(), "agent `id` cannot be empty");
1647+
anyhow::ensure!(
1648+
value.len() <= AGENT_ID_MAX_BYTES,
1649+
"agent `id` '{value}' exceeds the {AGENT_ID_MAX_BYTES}-byte limit"
1650+
);
1651+
anyhow::ensure!(
1652+
value
1653+
.bytes()
1654+
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')),
1655+
"agent `id` '{value}' must match [A-Za-z0-9._-]+"
1656+
);
1657+
anyhow::ensure!(
1658+
!value.starts_with('.') && !value.ends_with('.'),
1659+
"agent `id` '{value}' cannot begin or end with `.`"
1660+
);
1661+
Ok(())
1662+
}
1663+
1664+
/// Validate an explicit mutable agent address at the shared parse/authoring boundary (R24).
1665+
///
1666+
/// An explicit address is at most [`AGENT_ADDRESS_MAX_BYTES`] ASCII characters and is a dotted
1667+
/// sequence of 1-to-[`AGENT_ADDRESS_SEGMENT_MAX_BYTES`]-character segments. Each segment contains
1668+
/// only lowercase letters, digits, and hyphens and begins and ends with a letter or digit.
1669+
pub fn validate_agent_address(value: &str) -> anyhow::Result<()> {
1670+
anyhow::ensure!(
1671+
!value.is_empty(),
1672+
"agent `address` cannot be empty; omit it to fall back to the positional identity"
1673+
);
1674+
anyhow::ensure!(
1675+
value.len() <= AGENT_ADDRESS_MAX_BYTES,
1676+
"agent `address` '{value}' exceeds the {AGENT_ADDRESS_MAX_BYTES}-character limit"
1677+
);
1678+
anyhow::ensure!(
1679+
value.is_ascii(),
1680+
"agent `address` '{value}' must be ASCII"
1681+
);
1682+
for segment in value.split('.') {
1683+
anyhow::ensure!(
1684+
!segment.is_empty(),
1685+
"agent `address` '{value}' has an empty dotted segment"
1686+
);
1687+
anyhow::ensure!(
1688+
segment.len() <= AGENT_ADDRESS_SEGMENT_MAX_BYTES,
1689+
"agent `address` '{value}' segment '{segment}' exceeds {AGENT_ADDRESS_SEGMENT_MAX_BYTES} characters"
1690+
);
1691+
anyhow::ensure!(
1692+
segment
1693+
.bytes()
1694+
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'),
1695+
"agent `address` '{value}' segment '{segment}' must match [a-z0-9-]+"
1696+
);
1697+
let first = segment.as_bytes()[0];
1698+
let last = segment.as_bytes()[segment.len() - 1];
1699+
anyhow::ensure!(
1700+
(first.is_ascii_lowercase() || first.is_ascii_digit())
1701+
&& (last.is_ascii_lowercase() || last.is_ascii_digit()),
1702+
"agent `address` '{value}' segment '{segment}' must begin and end with a letter or digit"
1703+
);
1704+
}
1705+
Ok(())
1706+
}
1707+
15741708
impl RawTask {
15751709
pub(crate) fn lower(
15761710
self,

crates/agent-spec/tests/declared_document.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ fn unnamed_tasks_and_reserved_schedules_are_causal_red_shape_errors() {
112112
#[test]
113113
fn duplicate_routing_fields_are_shape_errors() {
114114
let source = r#"agent "worker" {
115+
id "first"
116+
id "second"
117+
address "first"
118+
address "second"
115119
identity "first"
116120
identity "second"
117121
host "first"
@@ -125,7 +129,12 @@ fn duplicate_routing_fields_are_shape_errors() {
125129
.iter()
126130
.map(|diagnostic| diagnostic.code.as_str())
127131
.collect::<Vec<_>>(),
128-
["duplicate-routing-field", "duplicate-routing-field"]
132+
[
133+
"duplicate-routing-field",
134+
"duplicate-routing-field",
135+
"duplicate-routing-field",
136+
"duplicate-routing-field"
137+
]
129138
);
130139
assert!(!parsed.is_valid());
131140
}

0 commit comments

Comments
 (0)