types: let nix own the reserved-name blacklist

One list, in nix/reserved-names.nix, handed to everything that needs it
as HIVE_RESERVED_NAMES. Keeping it current becomes a config change
rather than a rebuild, and hive names and agent names -- one namespace
going forward -- are checked against the same file: swarm-otel.nix's
hand-written reservedOwners is gone.

Whitespace-separated rather than JSON, deliberately, unlike the
structured env vars beside it. Every entry is an Ident ([a-z0-9-]), so
whitespace cannot occur inside a name and the encoding is provably
lossless; JSON would mean either a parser dependency in a crate whose
purpose is to have none, or a copy of the parse in every consumer.

An UNSET variable is not "nothing is reserved". Both creation sites log
an error and return a warning saying the check did not run, so a
misconfigured deployment says so instead of silently accepting every
name. A blank value folds into unset: nix always renders a non-empty
list, so present-but-empty is a rendering fault, not a declaration.

Two guards whose subject moved out of their own file now assert their
own case is still in it, because a guard that can be retired by an edit
elsewhere is not a guard:

- swarm-otel.nix asserts reserved-names.nix still contains its
  swarmTierName.
- hive-sh4re's sentinel drift test PANICS when the variable is missing
  rather than skipping -- a drift test that quietly does nothing still
  reports green. checks.nix and devshell.nix both export it so CI and a
  local cargo test agree. Verified as a pair: with the variable set, 8
  tests pass; with it unset, exactly the 4 drift tests fail and the
  unrelated ones still pass.
This commit is contained in:
atlas 2026-08-27 15:15:33 +02:00 committed by mara
commit 27932ec631
10 changed files with 326 additions and 87 deletions

View file

@ -137,19 +137,68 @@ mod reserved_name_tests {
use super::{
CHILDREN_RECIPIENT, MANAGER_AGENT, OPERATOR_RECIPIENT, PARENT_RECIPIENT, SYSTEM_SENDER,
};
use hive_types::{Ident, is_reserved_name};
use hive_types::{Ident, RESERVED_NAMES_ENV, is_reserved_name};
/// The sentinels declared here and the reserved-name list in
/// `hive-types` are two spellings of one fact, in crates that cannot
/// import each other's intent. This pins them together: adding a
/// sentinel without reserving it now fails here rather than years
/// later, when an agent takes the name.
/// The blacklist as nix rendered it for this test run.
///
/// **Panics when the variable is absent, deliberately.** The list lives
/// in `nix/reserved-names.nix` now, so this test can no longer read it
/// from the Rust tree; `nix/checks.nix` and `nix/devshell.nix` both
/// export it. Skipping instead would turn "nobody wired the variable"
/// into a green run — the same shape as a checker that reports clean
/// because it crashed, and the whole point of a drift test is that it
/// is the thing that notices.
fn reserved() -> Vec<String> {
let raw = hive_types::reserved_names_raw().unwrap_or_else(|| {
panic!(
"{RESERVED_NAMES_ENV} is unset or blank, so the drift test cannot run. \
nix/checks.nix and nix/devshell.nix are supposed to export it from \
nix/reserved-names.nix fix the plumbing rather than this test."
)
});
hive_types::parse_reserved_names(&raw)
.into_iter()
.map(str::to_owned)
.collect()
}
/// The sentinels declared here and the blacklist nix owns are two
/// spellings of one fact, in places that cannot import each other's
/// intent. This pins them together: adding a sentinel without reserving
/// it now fails here rather than years later, when an agent takes the
/// name.
#[test]
fn ident_shaped_sentinels_are_reserved() {
let owned = reserved();
let reserved: Vec<&str> = owned.iter().map(String::as_str).collect();
for sentinel in [OPERATOR_RECIPIENT, SYSTEM_SENDER] {
assert!(
is_reserved_name(sentinel),
"{sentinel:?} is a sentinel an agent could be named — it must be in RESERVED_NAMES"
is_reserved_name(sentinel, &reserved),
"{sentinel:?} is a sentinel an agent could be named — it must be in \
nix/reserved-names.nix"
);
}
}
/// Every entry nix hands us must be a name an agent could actually have
/// been given. One that `Ident::parse` rejects is dead weight — nothing
/// could ever have been created with it, so listing it implies a guard
/// doing nothing. `graceful-stop` is what makes this worth asserting: it
/// is hyphenated, and a charset tightening would silently retire it.
///
/// This assertion used to live beside the list in `hive-types`; it moved
/// here because here is where the real list is readable.
#[test]
fn every_reserved_name_is_a_valid_ident() {
let owned = reserved();
assert!(
!owned.is_empty(),
"the blacklist rendered empty — an empty list makes every assertion below vacuous"
);
for name in &owned {
assert!(
Ident::parse(name).is_ok(),
"{name:?} is reserved but not a parseable ident — one of the two is wrong"
);
}
}
@ -161,12 +210,14 @@ mod reserved_name_tests {
/// collision and this test is what notices.
#[test]
fn bracketed_recipients_cannot_be_agent_names() {
let owned = reserved();
let reserved: Vec<&str> = owned.iter().map(String::as_str).collect();
for sentinel in [PARENT_RECIPIENT, CHILDREN_RECIPIENT] {
assert!(
Ident::parse(sentinel).is_err(),
"{sentinel:?} parses as an ident now — it is reachable as an agent name and must be reserved"
);
assert!(!is_reserved_name(sentinel));
assert!(!is_reserved_name(sentinel, &reserved));
}
}
@ -176,7 +227,9 @@ mod reserved_name_tests {
/// enforced rather than remembered.
#[test]
fn manager_name_is_taken_not_reserved() {
let owned = reserved();
let reserved: Vec<&str> = owned.iter().map(String::as_str).collect();
assert!(Ident::parse(MANAGER_AGENT).is_ok());
assert!(!is_reserved_name(MANAGER_AGENT));
assert!(!is_reserved_name(MANAGER_AGENT, &reserved));
}
}