topology: drop the parent field and the hierarchy it fed

`topology.json` was a map of `name -> parent | null`, and that value fed
the whole agent hierarchy: `<parent>` / `<children>` recipient sentinels,
the reparenting API (CLI verb, wire verb, dashboard endpoints, DAG node),
the dashboard tree, the rebuild depth sort, and an unconditional
bind-mount grant giving every agent RW on its direct children's state.

Per the operator's ruling the field goes, and with it all of the above.
The file survives as what remains once the value is gone: the roster of
agent names, which is the set `ManageRootAgent` grants mounts over. It is
now a JSON array; `read` still accepts the old map shape and keeps its
keys, so a hive that upgrades across this does not blank its roster (and
so no capability holder loses its mounts for the length of that window).

Two sites kept their behaviour under a different recipient rather than
losing it. Both addressed `<parent>`, which the broker already resolved to
`operator` for a root agent, and every agent is now what that fallback
called a root:

- the harness's turn-failure / plugin-failure notification
  (`Surface::send_to_parent` -> `send_to_operator`), and
- the send allow-list's always-permitted escape hatch, so an agent with a
  restrictive allow-list still has a way to say it is stuck.

What is NOT preserved, deliberately: an agent with no capability no longer
sees any other agent's dirs. `ManageRootAgent`'s own grant is unchanged --
still every agent in the roster, still state RW + config RO, still no
`harness`.

The dashboard's reparenting control (the M0V3 picker) is deleted with its
CSS. The tree rendering that reads `ContainerView.parent` is left for the
frontend owner -- it degrades to a flat list with the field gone.
This commit is contained in:
atlas 2026-09-21 21:04:22 +02:00 committed by atlas
commit d94bc2188d
28 changed files with 236 additions and 1513 deletions

View file

@ -77,7 +77,8 @@ pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied";
/// clone is where a config change is staged, so it can hold a proposal
/// that is still under review or was rejected outright — mounting it shows
/// an agent a config which does not govern it. Both mounts (an agent's own
/// and a parent's view of a child's) go through here so they cannot drift.
/// and a `ManageRootAgent` holder's view of another agent's) go through
/// here so they cannot drift.
///
/// Never empty under a live container: `provision_container` runs
/// `setup_applied` before `create_only` makes the container at all.
@ -86,26 +87,30 @@ fn config_bind_source(name: &str) -> PathBuf {
}
/// Append bind flags for `child`'s state and config dirs into `binds`.
/// See docs/agent-lifecycle/persistence.md ("Parent access to child state") for what a
/// parent may touch and why. Creates missing host-side directories so
/// nspawn doesn't refuse to start; missing dirs are non-fatal.
/// See docs/agent-lifecycle/persistence.md ("Cross-agent access to state")
/// for what the holder may touch and why. Creates missing host-side
/// directories so nspawn doesn't refuse to start; missing dirs are
/// non-fatal.
///
/// The only caller left is the `ManageRootAgent` grant — #4472 removed the
/// parent/child tree that used to hand every agent its own children here.
///
/// **Three dirs, three different answers** — the uniformity of the
/// original loop is what hid that:
///
/// - `state` is **read-write**: a parent reads and writes a child's notes
/// to recover it, which is the one case that needs to work while the
/// child is down.
/// - `config` is **read-only**, and read-only for the *parent* is the
/// point: a config change is a PR against the child's repo on the
/// - `state` is **read-write**: the holder reads and writes another
/// agent's notes to recover it, which is the one case that needs to
/// work while that agent is down.
/// - `config` is **read-only**, and read-only for the *holder* is the
/// point: a config change is a PR against the other agent's repo on the
/// forge, reviewed and merged, never an edit in place. A writable mount
/// here is a second path to the same file that skips the review — the
/// boundary would then be a convention rather than a permission.
/// - `harness` is **absent entirely**. It holds the child's own runtime
/// - `harness` is **absent entirely**. It holds that agent's own runtime
/// material — `bash-tasks/`, turn-stats and event sqlite dbs — none of
/// which a parent has a stated reason to read, let alone write.
/// which the holder has a stated reason to read, let alone write.
/// hive-c0re still reads it directly on the host (`stats::hive_stats`),
/// which needs no bind mount into the parent.
/// which needs no bind mount into anyone else's container.
///
/// ⚠️ The config-repo seeding hive-c0re does at spawn is **not** affected by
/// the `config` flag and must not be read as a reason to widen it: that runs
@ -316,21 +321,12 @@ async fn set_nspawn_flags(
read_only: true,
});
// Topology-driven child mounts: every direct child of this agent gets
// its state dir RW and its config dir RO. See `bind_child_agent_dirs`
// for why each is what it is.
let direct_children = crate::topology::children_of(agent_name);
for child in &direct_children {
bind_child_agent_dirs(child, &mut binds);
}
// `ManageRootAgent` capability: additionally mount *every* agent in
// the hive as a virtual child. Enables recovery — the holder can
// update another agent's config even when that agent is down. Also
// grants RO access to /applied and /meta.
// `ManageRootAgent` capability: mount *every* agent in the hive as a
// virtual child. Enables recovery — the holder can update another
// agent's config even when that agent is down. Also grants RO access
// to /applied and /meta.
//
// ⚠️ "every agent" reads as a widening next to the `children_of`
// mounts above, so: it is the definition of this capability, not an
// ⚠️ "every agent" is the definition of this capability, not an
// accident of how the set is computed. The grant used to hang off a
// `can_manage_top_level_agents` role and cover `top_level_agents()`
// — i.e. `parent.is_none()` — which was "everything outside the
@ -338,13 +334,18 @@ async fn set_nspawn_flags(
// parentless, so that set *was* every agent anyway; the capability
// now says so out loud instead of deriving it from a field that no
// longer discriminates.
//
// This is the *only* cross-agent mount left. #4472 dropped the
// topology parent field, and with it the unconditional grant every
// agent used to get over its own direct children — so an agent with
// no capability now sees its own dirs and nothing else.
if crate::capabilities::has_cap(agent_name, Capability::ManageRootAgent) {
// Skipping self is a no-op, not a narrowing: `agent_notes_dir` is
// `agent_state_dir/state` and `config_bind_source` is shared, so
// binding the holder as its own virtual child reproduced the two
// own-dir mounts pushed above, byte for byte.
for other in crate::topology::all_agents() {
if other != agent_name && !direct_children.contains(&other) {
if other != agent_name {
bind_child_agent_dirs(&other, &mut binds);
}
}
@ -416,10 +417,11 @@ mod tests {
binds
}
/// The boundary, asserted as a whole rather than per-dir: a parent
/// sees a child's `state` and `config`, and nothing else.
/// The boundary, asserted as a whole rather than per-dir: a
/// `ManageRootAgent` holder sees another agent's `state` and
/// `config`, and nothing else.
#[test]
fn parent_sees_only_child_state_and_config() {
fn holder_sees_only_other_agents_state_and_config() {
let paths: Vec<String> = child_binds()
.into_iter()
.map(|b| b.container_path)
@ -448,21 +450,21 @@ mod tests {
);
}
/// The regression this exists for. `harness` holds the child's own
/// runtime material and was only ever mounted because one loop
/// The regression this exists for. `harness` holds the other agent's
/// own runtime material and was only ever mounted because one loop
/// treated all three dirs alike — re-adding it to that loop is a
/// one-word change that nothing else would catch.
#[test]
fn parent_never_sees_a_child_harness_dir() {
fn a_holder_never_sees_another_agents_harness_dir() {
for bind in child_binds() {
assert!(
!bind.container_path.contains("harness"),
"child harness must not be bound into a parent: {}",
"another agent's harness must not be bound in: {}",
bind.container_path
);
assert!(
!bind.host_path.contains("harness"),
"child harness must not be bound into a parent: {}",
"another agent's harness must not be bound in: {}",
bind.host_path
);
}