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

@ -135,12 +135,7 @@ pub fn spawn(
// paths independent reconnect state, so one could be serving while
// the other was still down. `async_nats::Client` is a handle, so the
// clone is cheap.
tokio::spawn(drain_swarm_events(
client.clone(),
hive.clone(),
coord,
shutdown.clone(),
));
tokio::spawn(drain_swarm_events(client.clone(), coord, shutdown.clone()));
let mut health = SweepHealth::new("swarm_status_publish", "warn", FAILURES_BEFORE_BANNER);
loop {
@ -179,7 +174,7 @@ pub fn spawn(
});
}
/// Listen on this hive's swarm-event subject and act on what arrives.
/// Listen on the swarm's knowledge-event subject and act on what arrives.
///
/// The controller decides *what a forge delivery means* and addresses the
/// result here; this end does not know a forge exists. Today the one event is
@ -211,12 +206,14 @@ pub fn spawn(
/// changes, the server log is the thing that knows why — nothing here will say.
async fn drain_swarm_events(
client: async_nats::Client,
hive: String,
coord: std::sync::Arc<crate::coordinator::Coordinator>,
mut shutdown: tokio::sync::watch::Receiver<bool>,
) {
let subject = swarm_queue_client::events::knowledge(&hive);
let mut sub = match client.subscribe(subject.clone()).await {
// One subject for the whole swarm, so this hive's own name never enters
// it: the controller publishes once and core NATS fans out to whoever is
// subscribed.
let subject = swarm_queue_client::knowledge::SUBJECT;
let mut sub = match client.subscribe(subject).await {
Ok(sub) => sub,
Err(e) => {
// Warn rather than a boot banner: the hive is fully functional