types: reserve the protocol names an agent must not be called

An agent's name was checked for shape and never for meaning:
`Ident::parse` is 1-63 chars of [a-z0-9-] and there was no reserved-name
list anywhere in the tree. So an agent could be called `operator`,
`forge` or `todo` -- names the message layer already produces as a
sender -- and a wake from that component became indistinguishable, at the
broker, from a message sent by the agent.

Adds `RESERVED_NAMES` + `is_reserved_name` to `hive-types`, the zero-dep
leaf both `hive-c0re` and `swarm-controller` already depend on, so
neither grows a dependency to use it.

Every entry is a value some component actually produces as a message
`from`/`to`, taken from `hive-sh4re`'s own sentinel constants rather than
guessed: operator, system, reminder, forge, scheduled, todo, compact,
graceful-stop. Two sentinels are deliberately absent -- `<parent>` and
`<children>` are unreachable as agent names because the charset rejects
them, and `ruth` is a real agent, so wanting that name is a name being
*taken*, which the roster answers.

Deliberately not enforced inside `Ident::parse`: parsing runs on every
read of an already-created name, so rejecting there would make existing
agents unreadable rather than un-creatable -- and it would be a refusal,
which is a stronger action than the warning this is used for today.

`create_agent` now warns on both halves -- a reserved name, and a name
that is also a hive in the roster -- and does not refuse. The warnings
ride on `CreateAgentResponse` rather than only the daemon's log, because
the person who can still fix the name in one keystroke is holding the
response, not reading the journal. `skip_serializing_if` keeps the
no-warning JSON byte-identical to before, so this is a non-breaking first
step toward refusing later.

`hive-sh4re` gains a drift test tying its sentinel constants to the list:
two crates that cannot import each other's intent now fail loudly if a
sentinel is added without being reserved. Mutation-verified -- forcing
the predicate false, forcing it true, and dropping a single entry each
turn a different test red.
This commit is contained in:
atlas 2026-08-27 11:02:35 +02:00 committed by mara
commit 5202e5c5ba
3 changed files with 215 additions and 2 deletions

View file

@ -6,6 +6,62 @@
//! and get serde-validated parsing at the socket boundary for free — with
//! no cross-crate coupling and without growing `hive-sh4re`.
/// Names that already mean something to the message layer, and so are not
/// available as an agent name.
///
/// Every entry is a value some component *produces* as a message `from` or
/// `to`, not a word that merely looked risky. An agent holding one of these
/// is indistinguishable, at the broker, from the thing that normally sends
/// it: a wake from `forge` and a wake from an agent named `forge` are the
/// same row.
///
/// Deliberately **not** enforced inside [`Ident::parse`]. Parsing runs on
/// every read of an already-created name, so rejecting there would make
/// existing agents unreadable rather than un-creatable, and it would be a
/// refusal — which is a stronger action than the warning this list is
/// currently used for. Creation sites call [`is_reserved_name`]; readers do
/// not.
pub const RESERVED_NAMES: &[&str] = &[
// The human at the dashboard. Both a broker recipient (the T4LK box
// sends `{from: "operator", to, body}`) and the fallback attribution
// for an answered question.
"operator",
// Helper events (`approval_resolved`, `container_crash`, …) — the
// sender an agent is told to treat as hyperhive itself rather than as
// a peer. Named in `hive_sh4re::manager::SYSTEM_SENDER`.
"system",
// A due self-scheduled reminder arrives as its own sender, so that a
// wake I asked for last week is distinguishable from a peer message.
"reminder",
// Forge notification wakes, delivered by the notify daemon.
"forge",
// A scheduled prompt firing, pushed as a trusted sender.
"scheduled",
// Three synthetic wakes the harness itself produces: an in-container
// todo, the follow-up turn after a self-requested compaction, and the
// single flush turn before a graceful stop.
"todo",
"compact",
"graceful-stop",
];
// Two sentinels are deliberately absent. `<parent>` and `<children>` are
// routing recipients that `Ident::parse` already rejects on charset, so no
// name can ever equal them — listing them would imply a guard that never
// fires. And `ruth` (the manager) is a real agent, not a literal: a second
// agent wanting that name is a name that is *taken*, which is the roster
// check's job, not this list's. `hive-sh4re` pins both claims in a test.
/// Whether `name` is already a protocol literal — see [`RESERVED_NAMES`].
///
/// Call at **creation** sites only. A caller that is reading or routing an
/// existing name must not consult this: the name is already in use, and the
/// question there is where it goes, not whether it should exist.
#[must_use]
pub fn is_reserved_name(name: &str) -> bool {
RESERVED_NAMES.contains(&name)
}
/// A validated hive identifier: 1-63 chars of `[a-z0-9-]`.
///
/// The single ident type for agent names, forge labels, and matrix / github
@ -98,7 +154,7 @@ impl<'de> serde::Deserialize<'de> for Ident {
#[cfg(test)]
mod ident_tests {
use super::Ident;
use super::{Ident, RESERVED_NAMES, is_reserved_name};
#[test]
fn accepts_canonical_shapes() {
@ -135,6 +191,34 @@ mod ident_tests {
}
}
#[test]
fn reserved_names_are_flagged_and_ordinary_names_are_not() {
// Presence arm: every entry must actually be reported.
for name in RESERVED_NAMES {
assert!(is_reserved_name(name), "{name:?} should be reserved");
}
// Absence arm, and the reason this test can fail: without it a
// predicate that always returns `true` passes the loop above.
for ok in ["atlas", "damocles", "iris", "operator-2", "sys", "forged"] {
assert!(!is_reserved_name(ok), "{ok:?} must NOT be reserved");
}
}
#[test]
fn every_reserved_name_is_a_valid_ident() {
// A reserved name that `Ident::parse` already rejects is dead
// weight — nothing could ever have been created with it, so
// listing it implies a guard that is doing nothing. `graceful-stop`
// is the one that makes this worth asserting: it is hyphenated, and
// a charset tightening would silently retire it.
for name in RESERVED_NAMES {
assert!(
Ident::parse(name).is_ok(),
"{name:?} is reserved but not a parseable ident — one of the two is wrong"
);
}
}
#[test]
fn round_trips_and_serde_validates() {
let id = Ident::parse("damocles").unwrap();