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

@ -682,6 +682,20 @@ struct CreateAgentRequest {
#[derive(Clone, Debug, Serialize, ToSchema)]
struct CreateAgentResponse {
node_id: u64,
/// Name collisions that were **allowed through**, phrased for the
/// operator who just chose the name.
///
/// The creation still happened — this is the "loud warning now, refuse
/// after a grace period" step, so a caller that ignores this field gets
/// exactly today's behaviour. It rides on the response rather than
/// living only in the daemon's log because the person who can still fix
/// the name in one keystroke is the one holding this response, and they
/// are not reading the journal.
///
/// Empty (and omitted from the JSON) in the normal case, so a client
/// that never looks at it is not handed an empty array to reason about.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
warnings: Vec<String>,
}
/// Queue the whole agent-creation job graph for `name` — two independent
@ -736,9 +750,42 @@ async fn create_agent(
// `state.hives` is the swarm directory loaded from `SWARM_CONTROLLER_HIVES`
// at startup and never mutated, so this is a scan of a handful of
// entries against a value an operator just chose.
// The name is shaped like an identifier; these two ask whether it is
// *available*. Both WARN rather than refuse: an operator with agents
// already created under a colliding name would otherwise be unable to
// re-run creation at all, so the refusal comes after a grace period,
// once the warning has had time to be seen.
//
// Collected rather than logged-and-dropped — see `CreateAgentResponse`.
let mut warnings = Vec::new();
if hive_types::is_reserved_name(&agent) {
// A protocol literal: the message layer already produces this name
// as a sender or recipient, so wakes from the component and
// messages from the agent become the same broker row.
let detail = format!(
"agent name {agent:?} is a reserved protocol name — messages from this agent will be \
indistinguishable from hyperhive's own; this will become an error"
);
tracing::warn!(agent = %agent, "create_agent: reserved name");
warnings.push(detail);
}
let hive = hive_types::Ident::parse(&req.hive)
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.into_string();
// The roster is the same source the `hive` field is validated against
// immediately below — one scan, asked of the other field. A hive and an
// agent sharing a name collide in the swarm's own directory keys, which
// is what makes a pipeline and a label vanish with no failed assertion.
if state.hives.iter().any(|h| h.name == agent) {
let detail = format!(
"agent name {agent:?} is also a hive in this swarm — they share the swarm's directory \
keys; this will become an error"
);
tracing::warn!(agent = %agent, "create_agent: name collides with a hive");
warnings.push(detail);
}
if !state.hives.iter().any(|h| h.name == hive) {
let known: Vec<&str> = state.hives.iter().map(|h| h.name.as_str()).collect();
// Name the hives that *would* work: the caller is an operator who
@ -798,7 +845,10 @@ async fn create_agent(
let [id] = ids[..] else {
unreachable!("exactly one handle was asked for");
};
Ok(Json(CreateAgentResponse { node_id: id.get() }))
Ok(Json(CreateAgentResponse {
node_id: id.get(),
warnings,
}))
}
/// Query params for `GET /api/jobq/graph` — `?states=` narrows to root
@ -1597,3 +1647,33 @@ mod tests {
}
}
}
#[cfg(test)]
mod create_agent_warning_tests {
use super::CreateAgentResponse;
/// The normal case must not grow a field. A client that has never
/// heard of warnings should see the response it always saw — that is
/// what makes "warn now, refuse later" a non-breaking first step.
#[test]
fn no_warnings_are_omitted_from_the_json_entirely() {
let json = serde_json::to_string(&CreateAgentResponse {
node_id: 7,
warnings: vec![],
})
.unwrap();
assert_eq!(json, r#"{"node_id":7}"#);
}
/// Presence arm — without it, a `skip_serializing_if` that dropped the
/// field unconditionally would pass the test above.
#[test]
fn warnings_are_serialized_when_present() {
let json = serde_json::to_string(&CreateAgentResponse {
node_id: 7,
warnings: vec!["name is reserved".to_owned()],
})
.unwrap();
assert_eq!(json, r#"{"node_id":7,"warnings":["name is reserved"]}"#);
}
}