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

@ -367,14 +367,14 @@ pub(super) async fn post_webhook_forge(
"webhook: verified delivery"
);
match kind {
DeliveryKind::Knowledge => announce_knowledge_change(&state).await,
// Deploy coordination is a separate concern with its own issue: a
// hive does not want to hear that a config PR was opened, it wants
// to be told when to rebuild from main, and that is a decision the
// controller makes after a merge rather than a relay of this
// delivery. Logged above and deliberately not forwarded.
DeliveryKind::ConfigPr => {}
// Only the knowledge delivery is acted on. Deploy coordination is a
// separate concern with its own issue — a hive does not want to hear that a
// config PR was opened, it wants to be told when to rebuild from main, and
// that is a decision the controller makes after a merge rather than a relay
// of this delivery. Written as a condition rather than a match arm holding
// an empty body, which would claim this is where that path is handled.
if kind == DeliveryKind::Knowledge {
announce_knowledge_change(&state).await;
}
(StatusCode::OK, "ok").into_response()
@ -416,22 +416,23 @@ async fn announce_knowledge_change(state: &AppState) {
return;
};
let client = status.queue_client();
let subject = swarm_queue_client::knowledge::SUBJECT;
for hive in state.hives.iter() {
let subject = swarm_queue_client::events::knowledge(&hive.name);
if let Err(e) = client.publish(subject.clone(), Vec::new().into()).await {
tracing::warn!(
hive = %hive.name, %subject, error = %e,
"webhook: publishing the knowledge event failed"
);
} else {
tracing::info!(hive = %hive.name, %subject, "webhook: knowledge event published");
}
// One publish, not one per hive: every subscriber gets the same empty
// event, so the roster is not consulted at all. The controller does not
// need to know who the hives are in order to say the repository moved.
if let Err(e) = client.publish(subject, Vec::new().into()).await {
tracing::warn!(%subject, error = %e, "webhook: publishing the knowledge event failed");
return;
}
// Logged after the flush rather than after the publish: `publish` only
// hands the message to the client's write buffer, so a line printed there
// would claim delivery this end cannot yet know about.
if let Err(e) = client.flush().await {
tracing::warn!(error = %e, "webhook: flushing knowledge events failed");
tracing::warn!(%subject, error = %e, "webhook: flushing the knowledge event failed");
return;
}
tracing::info!(%subject, "webhook: knowledge event published");
}
#[cfg(test)]