@@ -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 {
0 commit comments