swarm: split the deploy subject per hive

Per mara on the PR: *"split by hive. its not a security thing, just so
hives dont get messages they dont care about."*

She agreed with the finding and still wanted the split, which is the
part worth recording. I measured that a per-hive subject gives no
confidentiality — `sub` is unrestricted, so a hive that wanted another's
messages could subscribe to them — and concluded it bought nothing.
"Nothing" is a claim over every axis and I had checked one. The axis I
never priced: every hive in the swarm being woken by every other hive's
deploys.

So `deploy_subject(hive)` replaces the single literal, and the payload
drops `hive` to carry only the agent — the subject names the hive, and
two places stating one fact are free to disagree. The hive subscribes to
its own subject and no longer filters.

The grant is a wildcard rather than a subject per hive because the
responder has no roster: it cannot enumerate hives, and a grant that had
to track one would be a second place to get the list wrong — the same
argument `hive_name`'s doc makes about admission.

The negative test gets stronger rather than merely adapted. Splitting
the family makes "another hive's subject" and "its own" separate strings
for the first time, so it now asserts a hive reaches neither, nor the
wildcard.
This commit is contained in:
atlas 2026-08-30 23:07:30 +02:00 committed by mara
commit b004ba3dc5
4 changed files with 73 additions and 71 deletions

View file

@ -140,9 +140,9 @@ pub fn spawn(
/// already runs at boot, and **deploy this agent**, answered by the same
/// rebuild insert the operator's own verb makes.
///
/// Only the deploy event carries a payload, because only it is *addressed*:
/// a swarm-wide subject leaves the addressing nowhere to live but the body.
/// Even then it is a trigger — which hive, which agent — never the config,
/// Only the deploy event carries a payload, and only the agent name: its
/// subject already names the hive, so this one listens on its own rather than
/// filtering a swarm-wide feed. Even then it is a trigger, never the config,
/// which git owns.
///
/// # A missed message costs the two events very differently
@ -185,8 +185,10 @@ async fn drain_swarm_events(
return;
}
};
let deploy_subject = swarm_queue_client::DEPLOY_SUBJECT;
let mut deploy_sub = match client.subscribe(deploy_subject).await {
// This hive's own deploy subject, not a swarm-wide one: the controller
// addresses each hive, so nothing arrives here that is not for us.
let deploy_subject = swarm_queue_client::deploy_subject(&hive);
let mut deploy_sub = match client.subscribe(deploy_subject.clone()).await {
Ok(sub) => sub,
Err(e) => {
tracing::warn!(
@ -219,7 +221,7 @@ async fn drain_swarm_events(
tracing::warn!(subject = %deploy_subject, "swarm events: deploy subscription closed");
return;
};
handle_deploy_request(&coord, &hive, &msg.payload);
handle_deploy_request(&coord, &msg.payload);
}
_ = shutdown.changed() => {
tracing::info!("swarm events: shutdown signal received");
@ -229,18 +231,12 @@ async fn drain_swarm_events(
}
}
/// Act on one deploy request, if it is addressed to this hive.
/// Act on one deploy request. Everything that arrives on this hive's own
/// deploy subject is for this hive, so there is nothing to filter.
///
/// Every hive receives every message — that is what the swarm-wide subject
/// buys — so *not* being the addressee is the ordinary case and logs at
/// `debug`, not `warn`. A malformed payload is different: the controller and
/// this end share one type, so a decode failure means they disagree about it,
/// and that is worth saying out loud.
fn handle_deploy_request(
coord: &std::sync::Arc<crate::coordinator::Coordinator>,
hive: &str,
payload: &[u8],
) {
/// A payload that will not decode is worth a `warn`: the controller and this
/// end share one type, so a decode failure means they disagree about it.
fn handle_deploy_request(coord: &std::sync::Arc<crate::coordinator::Coordinator>, payload: &[u8]) {
let request: swarm_queue_client::DeployRequest = match serde_json::from_slice(payload) {
Ok(request) => request,
Err(e) => {
@ -248,10 +244,6 @@ fn handle_deploy_request(
return;
}
};
if request.hive != hive {
tracing::debug!(target_hive = %request.hive, agent = %request.agent, "swarm events: deploy for another hive");
return;
}
// The same insert the operator's own `rebuild` verb makes, relock and
// all: "deploy this agent" means here exactly what it already meant, and
// a swarm-triggered rebuild that quietly did something narrower would be