Skip to content

Commit b99b063

Browse files
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 fab3e25 commit b99b063

2 files changed

Lines changed: 104 additions & 34 deletions

File tree

src/validate.rs

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -240,9 +240,12 @@ pub(crate) fn validate_discovered(
240240
// hosts is legal, and equal bytes in the ID namespace never collide with it. Keyed by
241241
// (resolved logical host, effective address).
242242
let mut seen_addresses: HashMap<(String, String), PathBuf> = HashMap::new();
243-
// Placeholder host for bus-id collision: catalogs carry explicit host, and an empty host still
244-
// makes two unset-host same-identity specs collide (which is the real bug).
245-
let collision_host = "";
243+
// One host key for both namespaces, so the two rules judge the same physical subject: under
244+
// `--host h` a host-less declaration and an explicit `host "h"` one resolve to one agent and
245+
// collide on both keys. Without a selected host the key is the empty placeholder, which still
246+
// makes two unset-host same-identity specs collide (the real bug) while keeping an explicit
247+
// host's own bytes.
248+
let collision_host = this_host.unwrap_or_default();
246249

247250
for s in &d.specs {
248251
let rp = rel(root, &s.path);
@@ -269,13 +272,33 @@ pub(crate) fn validate_discovered(
269272
// legacy/legacy, explicit/explicit across different hosts, and an explicit `id` that
270273
// collides with another subject's still-unmigrated frozen identity.
271274
//
272-
// DELTA-003: the structurally archived subject set joins this check once
275+
// Duplicate effective address, inside one resolved logical host. A retired subject does
276+
// not resolve, so it releases its address and neither claims nor collides; a suspended
277+
// subject still occupies the namespace. Explicit-vs-explicit and
278+
// explicit-vs-identity-fallback collisions are the same collision here, because both
279+
// sides are read through `effective_address`.
280+
//
281+
// Both keys register in one pass, before either is reported: a declaration refused for a
282+
// duplicate ID still claims its address, so the authoring gate — `refuse_address_collision`
283+
// re-runs exactly this rule over a prospective catalog — cannot admit a second claim on an
284+
// address an already-duplicated declaration holds. Only the first fault is reported: an
285+
// unmigrated duplicate identity is one authoring fault in one place, and reporting it
286+
// twice would only inflate a legacy catalog's diagnostics.
287+
//
288+
// DELTA-003: the structurally archived subject set joins the ID check once
273289
// `st2 catalog migrate-ids` lands (PR D2) — migration may freeze an archived subject's
274290
// legacy bytes only while they remain unique across the combined live-and-archived set,
275291
// and `st2 catalog unarchive` validates ID uniqueness against that prospective
276292
// live-and-archived set rather than the live catalog alone.
277293
let bid = s.effective_id(collision_host);
278294
let duplicate_id = seen.insert(bid.clone(), s.path.clone());
295+
let address_host = s.resolved_host(collision_host).to_string();
296+
let address = s.effective_address().to_string();
297+
let duplicate_address = (!s.desired_state.is_retired())
298+
.then(|| {
299+
seen_addresses.insert((address_host.clone(), address.clone()), s.path.clone())
300+
})
301+
.flatten();
279302
if let Some(prev) = &duplicate_id {
280303
issues.push(Issue::error(
281304
"dup-id",
@@ -287,36 +310,21 @@ pub(crate) fn validate_discovered(
287310
rel(root, prev)
288311
),
289312
));
290-
}
291-
292-
// Duplicate effective address within one resolved logical host. A retired subject does not
293-
// resolve, so it releases its address and neither claims nor collides; a suspended subject
294-
// still occupies the namespace. Explicit-vs-explicit and explicit-vs-identity-fallback
295-
// collisions are the same collision here, because both sides are read through
296-
// `effective_address`. A declaration already refused for a duplicate ID says nothing
297-
// further about its address: an unmigrated duplicate identity is one authoring fault in
298-
// one place, and reporting it twice would only inflate a legacy catalog's diagnostics.
299-
if duplicate_id.is_none() && !s.desired_state.is_retired() {
300-
let address_host = s.resolved_host(this_host.unwrap_or_default()).to_string();
301-
let address = s.effective_address().to_string();
302-
if let Some(prev) =
303-
seen_addresses.insert((address_host.clone(), address.clone()), s.path.clone())
304-
{
305-
issues.push(Issue::error(
306-
"dup-address",
307-
rp.clone(),
308-
ag.clone(),
309-
format!(
310-
"duplicate agent address '{address}' on host '{}' (also declared in {})",
311-
if address_host.is_empty() {
312-
"<default>"
313-
} else {
314-
&address_host
315-
},
316-
rel(root, &prev)
317-
),
318-
));
319-
}
313+
} else if let Some(prev) = &duplicate_address {
314+
issues.push(Issue::error(
315+
"dup-address",
316+
rp.clone(),
317+
ag.clone(),
318+
format!(
319+
"duplicate agent address '{address}' on host '{}' (also declared in {})",
320+
if address_host.is_empty() {
321+
"<default>"
322+
} else {
323+
&address_host
324+
},
325+
rel(root, prev)
326+
),
327+
));
320328
}
321329

322330
if let Some(Err(error)) = &compiled {

tests/validate.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,3 +1302,65 @@ fn a_legacy_catalog_produces_byte_identical_duplicate_diagnostics() {
13021302
r.issues
13031303
);
13041304
}
1305+
1306+
/// Both duplicate rules read the same host key, so one physical subject declared twice — once
1307+
/// host-less, once with an explicit `host "h"` — is a duplicate ID under `--host h`, not a
1308+
/// conflict reported only under the address code.
1309+
#[test]
1310+
fn a_host_less_and_an_explicit_host_declaration_collide_as_one_duplicate_id() {
1311+
let c = catalog(&[
1312+
(
1313+
"h/worker/agent.kdl",
1314+
r#"agent "worker" { host "h"; command "x" }"#,
1315+
),
1316+
("worker.kdl", r#"agent "worker" { command "x" }"#),
1317+
]);
1318+
1319+
let r = validate_for_host(c.path(), "h");
1320+
assert!(
1321+
r.issues.iter().any(|i| i.code == "dup-id"
1322+
&& i.severity == Severity::Error
1323+
&& i.message.contains("duplicate agent id 'h.worker'")),
1324+
"one resolved subject declared twice must collide on the id key: {:?}",
1325+
r.issues
1326+
);
1327+
assert!(
1328+
!has(&r, "dup-address", Severity::Error),
1329+
"one physical conflict is one diagnostic: {:?}",
1330+
r.issues
1331+
);
1332+
}
1333+
1334+
/// A declaration refused for a duplicate ID still claims its address. Otherwise a third subject
1335+
/// could take that address undetected — and `st2 agent address` admits exactly what this rule
1336+
/// admits, so the writer's gate would hand out a second claim on one route.
1337+
#[test]
1338+
fn a_declaration_refused_for_a_duplicate_id_still_claims_its_address() {
1339+
let c = catalog(&[
1340+
("h/a/agent.kdl", r#"agent "x" { host "h"; command "x" }"#),
1341+
(
1342+
"h/b/agent.kdl",
1343+
r#"agent "x" { host "h"; address "ops"; command "x" }"#,
1344+
),
1345+
(
1346+
"h/c/agent.kdl",
1347+
r#"agent "c" { host "h"; address "ops"; command "x" }"#,
1348+
),
1349+
]);
1350+
1351+
let r = validate(c.path());
1352+
assert!(
1353+
has(&r, "dup-id", Severity::Error),
1354+
"the duplicate identity must still be reported: {:?}",
1355+
r.issues
1356+
);
1357+
assert!(
1358+
r.issues.iter().any(|i| i.code == "dup-address"
1359+
&& i.severity == Severity::Error
1360+
&& i.message
1361+
.contains("duplicate agent address 'ops' on host 'h'")
1362+
&& i.message.contains("h/b/agent.kdl")),
1363+
"the address a duplicated-id declaration holds must still collide: {:?}",
1364+
r.issues
1365+
);
1366+
}

0 commit comments

Comments
 (0)