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

@ -62,6 +62,14 @@ in
version = "0.1.0";
cargoTestExtraArgs = "--workspace";
HIVE_ASSETS_DIR = "${self.packages.${system}.assets}/share/hyperhive";
# The reserved-name blacklist moved out of the Rust tree and into
# `reserved-names.nix`, so the test that pins the message layer's
# sentinels against it can only run if nix hands it the same list the
# daemons get. Exported here and in `devshell.nix`, and the test PANICS
# rather than skipping when the variable is missing: a drift test that
# quietly does nothing is worse than no drift test, because it still
# shows up green.
HIVE_RESERVED_NAMES = pkgs.lib.concatStringsSep " " (import ./reserved-names.nix);
};
# Rustdoc gate. Builds the workspace's docs and turns rustdoc's own

View file

@ -5,6 +5,12 @@
{ pkgs, rust }:
{
default = pkgs.mkShell {
# Same list the daemons are handed and the same one `checks.nix` gives
# the test derivation — `nix/reserved-names.nix`. Without it here, a
# plain `cargo test` in the shell would panic on the drift test, so a
# developer's local run and CI would disagree about a test that exists
# precisely to stop two spellings of one fact from drifting.
HIVE_RESERVED_NAMES = pkgs.lib.concatStringsSep " " (import ./reserved-names.nix);
packages =
rust.nativeBuildInputs
++ (with pkgs; [

View file

@ -143,6 +143,21 @@ in
# mandatory, so this is unconditional (the whole env block is already
# gated on hyperhive being enabled). See `docs/gateway.md::HIVE_FORGE_URL`.
HIVE_FORGE_URL = "http://${config.services.hyperhive.swarm.forge.domain}";
# The one blacklist of names an agent may not take — see
# `nix/reserved-names.nix`, which is also read by the swarm controller, by
# the swarm collector's owner assertion, and by the test suite. Nix owns it
# so that keeping it current is a config change, not a rebuild of a binary.
#
# Whitespace-separated rather than JSON: every entry is an `Ident`
# (`[a-z0-9-]`), so a space can never occur inside a name and the encoding
# cannot be lossy. Spelling it as JSON would put a parser in the crate whose
# whole point is to have no dependencies.
#
# Unconditional on purpose. The consumer treats an ABSENT variable as "I was
# never told" and says so out loud, which is the correct reading — but it is
# a reading no correctly-built hive should ever have to make.
HIVE_RESERVED_NAMES = lib.concatStringsSep " " (import ../../reserved-names.nix);
}
//
lib.optionalAttrs

View file

@ -648,6 +648,13 @@ in
# `swarm.peerHives`, `swarm.hives` minus this hive) rather than
# peers-minus-self. Consumed by `GET /api/hives`
# (swarm-controller/src/main.rs::load_hives).
# The one blacklist of names an agent may not take, shared verbatim
# with hive-c0re and with the collector's owner assertion — see
# `nix/reserved-names.nix`. Whitespace-separated rather than JSON
# like its neighbour below, because every entry is an `Ident` and so
# cannot contain a space; the neighbour carries objects and has no
# such option.
HIVE_RESERVED_NAMES = lib.concatStringsSep " " (import ../reserved-names.nix);
SWARM_CONTROLLER_HIVES = builtins.toJSON (
lib.mapAttrsToList (name: h: {
inherit name;

View file

@ -55,9 +55,16 @@ let
# repeated at each site would let the guard and the config drift apart, which
# is the failure this guard exists to prevent.
swarmTierName = "swarm";
# Every `<owner>` this module claims for itself. One entry today; a second
# swarm-tier pipeline would be added here and inherit the check for free.
reservedOwners = [ swarmTierName ];
# Every `<owner>` no hive may take. Read from `nix/reserved-names.nix`, the
# same file the daemons are handed as `HIVE_RESERVED_NAMES`, because agent
# names and hive names are ONE namespace going forward — a locally-owned
# list here would be a second copy to keep in step, which is the failure a
# single blacklist exists to prevent.
#
# The assertion below still checks the string this module emits: the file
# is asserted to CONTAIN `swarmTierName`, so a rename that dropped it from
# the file would be an eval error rather than a silently missing guard.
reservedOwners = import ../reserved-names.nix;
# A published target is declared as ONE url, because that url is also the
# audience its token is minted for — but prometheus wants the same fact in
@ -581,6 +588,23 @@ in
List the swarm's hives.
'';
}
{
# The blacklist now lives in a shared file, so this module no longer
# controls its contents — and a guard whose subject can be edited
# elsewhere has to assert that its own case is still in there. Without
# this, deleting one line from `reserved-names.nix` would silently
# retire the check below rather than fail anything.
assertion = lib.elem swarmTierName reservedOwners;
message = ''
nix/reserved-names.nix no longer contains '${swarmTierName}', which
the swarm collector needs reserved: it names components
`<kind>/<owner>` and uses the hive name as the owner, so a hive
called '${swarmTierName}' would replace the swarm tier's own
pipelines and lose its own.
Put it back, or give this module a different swarmTierName.
'';
}
{
# A hive whose name is one this module claims for itself collides in
# the collector's component namespace, and `//` resolves it silently:

67
nix/reserved-names.nix Normal file
View file

@ -0,0 +1,67 @@
# The one blacklist: names no agent and no hive may take.
#
# Nix owns this list and hands it to the Rust side as an environment variable
# (`HIVE_RESERVED_NAMES`), so keeping it current is a config change rather than
# a rebuild of a binary. Read by:
#
# - `host-modules/hive-c0re/environment.nix` -> the env var, for the
# `request_init_config` path every hive uses
# - `host-modules/swarm-controller.nix` -> the same var, for the
# swarm-level `create_agent` path
# - `host-modules/swarm-otel.nix` -> its `<owner>` assertion, so
# a hive name and an agent name are checked against ONE list
# - `nix/checks.nix` -> exported into `cargo test`,
# which is what keeps the message layer's sentinels from drifting away
# from this file
#
# A plain nix file rather than a module option because two of those readers are
# flake-level (`checks.nix`) and cannot see a NixOS option.
#
# ⚠️ Agent names and hive names are ONE namespace going forward (mara, 2026-08-27:
# "agent and hive names live in the swarm level going forward"). Adding a name
# here forbids it for both. That is the point: two lists is how they drift.
#
# ⚠️ Every entry must be a value some component actually PRODUCES as a message
# `from`/`to`, or a component name the collector builds pipelines from — not a
# word that merely looked risky. A name in here that nothing emits is a refusal
# with no failure behind it.
[
# ---- message-layer senders -------------------------------------------
# The human at the dashboard: a broker recipient (the T4LK box sends
# `{from: "operator", ...}`) 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.
# `hive_sh4re::manager::SYSTEM_SENDER`.
"system"
# A due self-scheduled reminder arrives as its own sender, so 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"
# ---- swarm-tier component owners -------------------------------------
# The swarm collector names its components `<kind>/<owner>` and uses the hive
# name as the owner, so a hive called `swarm` would silently replace the
# swarm tier's own pipelines and lose its own — it would keep accepting
# pushes into a pipeline that routes nowhere. Previously enforced only
# against hive names, in `swarm-otel.nix`'s own `reservedOwners`.
"swarm"
]
# Deliberately absent, and both are load-bearing omissions:
#
# `<parent>` / `<children>` — routing recipients the ident charset already
# rejects, so no name can ever equal them; listing them would imply a guard
# that never fires.
#
# `ruth` — a real agent, not a literal. A second agent wanting that name is a
# name that is TAKEN, which the roster answers, not this list.