swarm: name the deploy event and grant the controller its publish

The subject and its payload live in `swarm-queue-client` for the reason
the knowledge event's already does: three crates have to agree on the
string, and the one that agrees hardest — the auth-callout responder,
which decides whether the publish is permitted at all — speaks neither
`jetstream` nor `kv`.

Swarm-wide rather than a `$SWARM.deploy.<hive>` family. That family
would look like isolation and provide none: this responder scopes
publish only, leaving `sub` unrestricted, so a hive could subscribe to
another's subject as easily as to its own. Until `sub` is scoped the
split costs a wider grant and buys nothing, so the addressing goes in
the payload and each hive filters on its own name.

Unlike the knowledge event the message is addressed, so it carries a
payload — a trigger, never the config. The hive already tracks the
agent's config repo; desired state on the wire would make this a second
source of truth for something git owns, and a hive that missed a
message would be wrong rather than late.

Both test arms mirrored from the knowledge event. The negative one
matters more here: a forged knowledge event makes a hive re-read a
repo, a forged deploy event makes it rebuild and restart a named agent.
This commit is contained in:
atlas 2026-08-30 22:36:19 +02:00 committed by mara
commit 93c7454bf5
2 changed files with 73 additions and 0 deletions

View file

@ -278,6 +278,12 @@ impl Policy {
// about a change, with nothing in the controller's log to say a // about a change, with nothing in the controller's log to say a
// permission was the reason. // permission was the reason.
swarm_queue_client::KNOWLEDGE_SUBJECT.to_owned(), swarm_queue_client::KNOWLEDGE_SUBJECT.to_owned(),
// The deploy event, same shape and the same failure mode as the
// knowledge event above — one literal subject, one writer. Also
// deliberately not a per-hive family: this responder scopes
// publish only, so a per-hive subject would not stop a hive
// reading another's. See the const's own doc.
swarm_queue_client::DEPLOY_SUBJECT.to_owned(),
]); ]);
subjects subjects
} }
@ -373,6 +379,39 @@ mod tests {
); );
} }
#[test]
fn a_reader_may_publish_the_deploy_event() {
let p = policy().permissions("swarm-controller").expect("a reader");
assert!(
p.publish
.contains(&swarm_queue_client::DEPLOY_SUBJECT.to_owned()),
"the controller is the only publisher of this event; without the \
grant its publish is refused, and a refusal arrives as a timeout"
);
}
#[test]
fn a_hive_may_not_publish_the_deploy_event_to_anyone_including_itself() {
// Same arm as the knowledge event's, and it matters more here: a forged
// knowledge event makes a hive re-read a repo, while a forged deploy
// event makes it rebuild and restart a named agent. One shared subject
// means a single forged message reaches every hive in the swarm.
//
// Note this is the half `sub` scoping would not fix even once it lands:
// reading another hive's deploy message is a confidentiality question,
// *sending* one is this.
let p = policy()
.permissions("hive-alpha")
.expect("a hive is admitted");
assert!(
!p.publish
.iter()
.any(|s| s == swarm_queue_client::DEPLOY_SUBJECT),
"a hive must not publish the deploy event: {:?}",
p.publish
);
}
#[test] #[test]
fn a_hive_grant_never_includes_the_jetstream_wildcard() { fn a_hive_grant_never_includes_the_jetstream_wildcard() {
// `$JS.API.>` also covers `$JS.API.STREAM.DELETE.KV_hive-status`, with // `$JS.API.>` also covers `$JS.API.STREAM.DELETE.KV_hive-status`, with

View file

@ -173,6 +173,40 @@ pub mod status;
/// permitted at all — speaks neither `jetstream` nor `kv`. /// permitted at all — speaks neither `jetstream` nor `kv`.
pub const KNOWLEDGE_SUBJECT: &str = "$SWARM.knowledge"; pub const KNOWLEDGE_SUBJECT: &str = "$SWARM.knowledge";
/// The subject the swarm controller publishes on to ask a hive to rebuild one
/// of its agents. Same shape as [`KNOWLEDGE_SUBJECT`]: one writer, every hive
/// subscribes, and it lives here for the same three-crate reason.
///
/// # Swarm-wide, not per-hive, and that is deliberate
///
/// A `$SWARM.deploy.<hive>` family would *look* like isolation and provide
/// none: the auth-callout responder scopes **publish** only, leaving `sub`
/// unrestricted, so any hive could subscribe to another's subject just as
/// easily as to its own. Until `sub` is scoped, the per-hive split costs a
/// wider grant and buys nothing — so the addressing lives in the payload and
/// each hive filters on its own name, exactly as the knowledge event fans out.
pub const DEPLOY_SUBJECT: &str = "$SWARM.deploy";
/// What a [`DEPLOY_SUBJECT`] message carries.
///
/// The knowledge event has no payload — every hive does the same thing on
/// receipt. This one is addressed, so both ends have to agree on the fields,
/// which is why the type lives beside the subject rather than in whichever
/// crate happened to need it first.
///
/// ⚠️ **A trigger, not the config.** The hive already tracks the agent's
/// config repo; putting desired state on the wire would make this message a
/// second source of truth for something git already owns, and a hive that
/// missed a message would then be wrong rather than merely late.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct DeployRequest {
/// Which hive should act. Every hive receives the message; the one whose
/// own name this matches is the one that rebuilds.
pub hive: String,
/// The agent to rebuild, as the swarm knows it.
pub agent: String,
}
/// The hive-notices stream, shared by the hive that publishes and /// The hive-notices stream, shared by the hive that publishes and
/// whatever eventually consumes it. Behind the `notices` feature, same /// whatever eventually consumes it. Behind the `notices` feature, same
/// reason `status` is behind `kv` — see the module doc. /// reason `status` is behind `kv` — see the module doc.