feat(apply): cross-repo blast-radius in the plan preview

§7 M3 (part 2): planning a change to a GROUP node now reports the descendant
apps owned by OTHER projects that the change fans out to — the cross-repo
signal an operator needs before touching shared config.

- OwnershipPreview gains `blast_radius: Vec<ProjectImpact>`; `group_blast_radius`
  walks the group's subtree (recursive CTE) and groups descendant apps by their
  nearest-claimed-ancestor project, excluding the planning project. Ancestor
  resolution is memoized per group (one walk per distinct descendant group, not
  per app).
- `pic plan` renders `blast_radius` rows (mode-agnostic).
- the apply_ownership journey gains a plan-preview case pinning both the
  ownership annotation (claim / conflict) and the blast radius (two other
  projects' apps surfaced) end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-06 21:17:15 +02:00
parent b4eb226d20
commit 30fe160f16
4 changed files with 209 additions and 7 deletions

View File

@@ -545,10 +545,24 @@ pub struct OwnershipPreview {
pub action: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
/// §7 M3.2 cross-repo blast radius: for a GROUP node, the descendant apps
/// owned by OTHER projects that a config change here fans out to, grouped by
/// project. Empty for an app node or when nothing else is affected.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blast_radius: Vec<ProjectImpact>,
}
/// One entry of a group plan's blast radius: another project and how many of
/// its apps inherit from the group being changed.
#[derive(Debug, Clone, Serialize)]
pub struct ProjectImpact {
pub project: String,
pub apps: u32,
}
/// Pure preview policy (read-only, slug-only — no registration): compares the
/// node's current owner slug against this apply's declared project slug.
/// node's current owner slug against this apply's declared project slug. The
/// blast radius (a DB fan-out) is filled in by the caller for group nodes.
fn preview_ownership(
is_group: bool,
current: Option<&str>,
@@ -557,6 +571,7 @@ fn preview_ownership(
let mk = |action: &str, owner: Option<&str>| OwnershipPreview {
action: action.to_string(),
owner: owner.map(str::to_string),
blast_radius: Vec::new(),
};
match (current, declared) {
// A group node claims when unclaimed + a project is declared.
@@ -2142,11 +2157,68 @@ impl ApplyService {
(false, current)
}
};
Ok(Some(preview_ownership(
is_group,
current.as_deref(),
declared,
)))
let mut preview = preview_ownership(is_group, current.as_deref(), declared);
// §7 M3.2: for a group node, surface the cross-repo blast radius — the
// descendant apps owned by OTHER projects a config change here reaches.
if let ApplyOwner::Group(g) = owner {
preview.blast_radius = self.group_blast_radius(g, declared).await?;
}
Ok(Some(preview))
}
/// §7 M3.2: descendant apps of `gid` grouped by their nearest-claimed-
/// ancestor project, EXCLUDING `exclude` (the planning project) — the apps
/// a change to `gid`'s inherited config fans out to across other repos.
/// Ancestor resolution is memoized per group, so the cost is one walk per
/// distinct descendant group, not per app.
async fn group_blast_radius(
&self,
gid: GroupId,
exclude: Option<&str>,
) -> Result<Vec<ProjectImpact>, ApplyError> {
// All apps in gid's subtree (gid + descendant groups).
let rows: Vec<(uuid::Uuid, uuid::Uuid)> = sqlx::query_as(
"WITH RECURSIVE subtree AS (
SELECT id FROM groups WHERE id = $1
UNION ALL
SELECT g.id FROM groups g JOIN subtree s ON g.parent_id = s.id
)
SELECT a.id, a.group_id FROM apps a WHERE a.group_id IN (SELECT id FROM subtree)",
)
.bind(gid.into_inner())
.fetch_all(&self.pool)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let mut owner_by_group: HashMap<GroupId, Option<String>> = HashMap::new();
let mut counts: std::collections::BTreeMap<String, u32> = std::collections::BTreeMap::new();
for (_app, group_id) in rows {
let ag = GroupId::from(group_id);
let owner = if let Some(o) = owner_by_group.get(&ag) {
o.clone()
} else {
let chain = self
.groups
.ancestors(ag)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let o = self
.nearest_claimed_from_chain(&chain)
.await?
.map(|(_, s)| s);
owner_by_group.insert(ag, o.clone());
o
};
if let Some(slug) = owner {
if Some(slug.as_str()) != exclude {
*counts.entry(slug).or_default() += 1;
}
}
}
Ok(counts
.into_iter()
.map(|(project, apps)| ProjectImpact { project, apps })
.collect())
}
async fn resolve_app(&self, ident: &str) -> Result<picloud_shared::App, ApplyError> {