refactor(#3255): one knowledge subject, single writer and many readers

Review call: the event was addressed per hive — `$SWARM.events.<hive>.knowledge`,
published in a loop over the roster, granted through a wildcard. It does not
need to be. The payload is empty and the event means the same thing to every
hive, so one publish to one subject delivers exactly what N publishes to N
subjects did, and core NATS already fans out to whoever is subscribed. A hive
that was down misses it either way and reconciles on its next periodic pull.

That deletes rather than reshuffles: the roster loop, the wildcard, and the
shared subject-building function whose entire purpose was keeping the grant and
the publish from drifting apart. With one literal there is nothing to disagree
about.

The per-hive shape was justified by the callout policy's rule that an extra
subject must contain the hive name. That rule governs `extra_hive_subjects` —
what a HIVE may publish. This subject lives in the controller's reader grant,
which the rule does not constrain, so a real rule was carried across into a
decision it had no authority over.

Knowledge becomes its own category rather than a leaf under a general event
namespace, since a namespace shaped for events that do not exist yet is a
decision made before there is anything to decide from. The empty config-PR match
arm goes with it: an arm with no body claims this is where the deploy path is
handled, and it is not.

The deny test stays and matters more, not less: with one shared subject a forged
event would reach the whole swarm where a per-hive one reached a single hive.
This commit is contained in:
atlas 2026-08-19 19:49:37 +02:00 committed by mara
commit 9b939f4626
6 changed files with 104 additions and 156 deletions

View file

@ -1,100 +0,0 @@
//! Swarm event subjects: the names the controller publishes on and hives
//! subscribe to.
//!
//! Same reason [`crate::status`] exists rather than a `const` on each side —
//! **the ends must agree, and a literal repeated across crates is an agreement
//! nothing checks.** Here there are three of them: the swarm controller
//! publishes, a hive subscribes, and the auth-callout responder derives the
//! subject the controller is *permitted* to publish to. A copied literal in the
//! third would produce the worst failure of the set: a grant that looks right,
//! a publish that is refused, and — because a NATS denial reaches the client as
//! a timeout rather than an error — no message saying so.
//!
//! Unconditional, with no feature gate and no NATS types, for the same reason
//! the bucket *name* in [`crate::status`] is not gated: the responder names
//! this subject without ever publishing to it, and speaks neither `jetstream`
//! nor `kv`. A gate here would make that consumer choose between a stack it
//! does not use and a copied literal.
//!
//! # Why a per-hive subject rather than one shared one
//!
//! The callout policy refuses any extra hive subject with no `{hive}` in it,
//! because such a template expands to the same subject for every hive and so
//! grants each of them the others'. A per-hive event subject satisfies that by
//! construction. It is also what makes the event addressable: the controller
//! decides *which* hives need to know, rather than every hive filtering a
//! shared firehose.
/// The root of the swarm event namespace.
///
/// `$SWARM` rather than a bare name: the `$` prefix is NATS' convention for
/// system-ish subjects and keeps these clear of anything an application might
/// choose for itself.
pub const SUBJECT_ROOT: &str = "$SWARM.events";
/// The leaf naming the *knowledge repository changed* event.
///
/// Semantic, not transport-shaped: it says what happened, not that a forge
/// webhook arrived. The controller interprets a delivery and decides this is
/// what it means; a hive that receives it does not need to know a forge exists.
const KNOWLEDGE_LEAF: &str = "knowledge";
/// The wildcard standing for "any hive", for a grant that must cover all of
/// them.
///
/// Exported so the one caller that needs it — the callout responder, building
/// the controller's publish grant — can pass it to [`knowledge`] instead of
/// assembling a wildcard subject itself. That is the whole point: the grant and
/// the published subject come out of **the same function**, so they cannot
/// drift into disagreement the way two literals would.
pub const ANY_HIVE: &str = "*";
/// The subject carrying *the knowledge repository changed* for `hive`.
///
/// Pass [`ANY_HIVE`] to get the wildcard form used by a grant.
#[must_use]
pub fn knowledge(hive: &str) -> String {
format!("{SUBJECT_ROOT}.{hive}.{KNOWLEDGE_LEAF}")
}
#[cfg(test)]
mod tests {
use super::{ANY_HIVE, SUBJECT_ROOT, knowledge};
/// The concrete and wildcard forms must differ in exactly the hive token.
///
/// Written as a structural comparison rather than by asserting two
/// literals, because two literals is the failure this module exists to
/// prevent: a test that spells the expected subject out by hand passes
/// happily when both it and the code are wrong in the same way.
#[test]
fn the_grant_form_and_the_published_form_differ_only_in_the_hive() {
let concrete = knowledge("alpha");
let wildcard = knowledge(ANY_HIVE);
assert_eq!(
concrete.replacen("alpha", ANY_HIVE, 1),
wildcard,
"substituting the hive token must turn one form into the other"
);
}
/// A NATS wildcard matches one token, so the hive must occupy exactly one.
/// A hive name with a dot in it would silently widen the grant.
#[test]
fn the_hive_occupies_exactly_one_subject_token() {
let root_tokens = SUBJECT_ROOT.split('.').count();
assert_eq!(
knowledge("alpha").split('.').count(),
root_tokens + 2,
"root + hive + leaf; anything else means the hive is not one token"
);
}
/// The event is addressed per hive — one hive's subject must never be
/// another's. Cheap, and it is the property the callout policy relies on.
#[test]
fn two_hives_get_different_subjects() {
assert_ne!(knowledge("alpha"), knowledge("beta"));
}
}

View file

@ -0,0 +1,46 @@
//! The subject carrying *the knowledge repository changed*.
//!
//! One writer, many readers: the swarm controller publishes, every hive
//! subscribes. The controller does not need to know who the hives are to tell
//! them the repository moved — core NATS fans one publish out to whoever is
//! listening.
//!
//! # Why one subject rather than one per hive
//!
//! The payload is empty and means the same thing to every hive, so per-hive
//! addressing delivers exactly what one subject does, having first made the
//! publisher enumerate the roster and the grant carry a wildcard. Delivery is
//! at-most-once either way, and a hive that was down reconciles on its next
//! periodic pull — a missed event costs latency, not correctness.
//!
//! ⚠️ The per-hive shape was justified by the callout policy's rule that an
//! extra subject must contain `{hive}`. That rule governs what a *hive* may
//! publish; this subject lives in the controller's reader grant, which it does
//! not constrain. The argument came from the wrong half of the permission model.
//!
//! # Why the constant lives here
//!
//! Three consumers name it: the controller publishing, the callout responder
//! granting, and hive-c0re subscribing. A copied literal in the third gives the
//! worst failure of the set — a grant that looks right, a publish that is
//! refused, and, because a NATS denial reaches the client as a timeout, nothing
//! saying so. Unconditional and NATS-type-free for the same reason
//! [`crate::status`]'s bucket name is: the responder speaks neither `jetstream`
//! nor `kv`.
/// The subject the swarm controller publishes on when the hive-wide knowledge
/// repository has changed.
///
/// `$SWARM` rather than a bare name: the `$` prefix is NATS' convention for
/// system-ish subjects, keeping it clear of anything an application might
/// choose for itself.
///
/// Semantic, not transport-shaped — it says what happened, not that a forge
/// webhook arrived. The controller interprets a delivery and decides this is
/// what it meant; a hive receiving it does not need to know a forge exists.
///
/// No unit test here: a single constant has no structure to assert, and a test
/// comparing it to a second spelling is the failure this module exists to
/// prevent. The property worth testing is *who may publish it*, which lives
/// with the policy that decides — see `swarm-nats-auth`.
pub const SUBJECT: &str = "$SWARM.knowledge";

View file

@ -156,14 +156,18 @@ pub fn chain(error: &dyn std::error::Error) -> String {
/// which is the disagreement this module exists to prevent.
pub mod status;
/// Swarm event subjects — the names the controller publishes on and hives
/// subscribe to.
/// The *knowledge repository changed* subject — one writer (the controller),
/// many readers (the hives).
///
/// Its own category rather than a leaf under a general event namespace: a
/// namespace built for events that do not exist yet is a shape decided before
/// there is anything to decide it from.
///
/// Unconditional and NATS-type-free for the same reason the bucket name above
/// is: three crates must agree on these strings, and the one that agrees
/// hardest — the auth-callout responder, which decides whether a publish is
/// even permitted — speaks neither `jetstream` nor `kv`.
pub mod events;
/// is: three crates must agree on this string, and the one that agrees hardest
/// — the auth-callout responder, which decides whether a publish is even
/// permitted — speaks neither `jetstream` nor `kv`.
pub mod knowledge;
/// Only the fields this needs; authelia returns several.
#[derive(serde::Deserialize)]