refactor(apply): address ownership review follow-ups (#3–#6)

Four cleanups from the code review of the §7 ownership track, no behavior
change to any passing test (416 manager-core units + 133 CLI journeys green):

- #3 app-ownership read parity: `check_app_owner_single` now resolves the
  nearest-claimed ancestor WITHIN the apply tx (`nearest_claimed_in_tx`, the
  same mechanism the tree path uses) instead of on a separate pool connection,
  so the check and this apply's writes see one consistent snapshot. (Fully
  serializing against a concurrent claim on an out-of-tree ancestor stays part
  of the documented pool→tx follow-up.)

- #4 blast-radius N+1 → one query: `group_blast_radius` replaced the
  per-descendant-group `ancestors()` round-trips with a single recursive
  `subtree → app_chain → nearest` CTE that groups + counts server-side. Cost is
  now one query regardless of subtree size.

- #5 one ownership policy, two projections: `decide_group_claim` /
  `decide_app_owner` are generic over the identity key, and `preview_ownership`
  (plan side) now DERIVES its label from them (keyed on slugs, takeover-agnostic)
  rather than re-encoding the arms — plan and apply can no longer drift.

- #6 shared slug validator: `validate_project_slug` delegates to a new
  `groups_api::validate_slug_format`, which `groups_api::validate_slug` also now
  uses — the format rule lives once. Projects intentionally skip the
  reserved-word check (a project slug is never routed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-07 07:49:52 +02:00
parent ffa8b02506
commit 86b51df50d
2 changed files with 116 additions and 92 deletions

View File

@@ -568,18 +568,34 @@ fn preview_ownership(
current: Option<&str>, current: Option<&str>,
declared: Option<&str>, declared: Option<&str>,
) -> OwnershipPreview { ) -> OwnershipPreview {
let mk = |action: &str, owner: Option<&str>| OwnershipPreview { // Project the SAME pure policy the apply path runs onto a display label,
action: action.to_string(), // keyed on slugs (plan has no assigned project id yet) and takeover-agnostic
owner: owner.map(str::to_string), // (`takeover = false`; plan never mutates ownership). Deriving the preview
blast_radius: Vec::new(), // here — rather than re-encoding the arms — keeps plan and apply from
// drifting: one decision, two projections.
let cur = current.map(|s| (s.to_string(), s.to_string()));
let decl = declared.map(str::to_string);
let action = if is_group {
match decide_group_claim(cur, decl, false) {
GroupClaimAction::Claim(_) => "claim",
// Noop covers both "already ours" and "unclaimed + no project"; the
// current owner disambiguates.
GroupClaimAction::Noop if current.is_none() => "unclaimed",
GroupClaimAction::Noop => "owned",
// Takeover can't arise with `takeover = false`; treat defensively.
GroupClaimAction::Conflict(_) | GroupClaimAction::Takeover(_) => "conflict",
}
} else {
match decide_app_owner(cur, decl) {
Ok(()) if current.is_none() => "unclaimed",
Ok(()) => "owned",
Err(_) => "conflict",
}
}; };
match (current, declared) { OwnershipPreview {
// A group node claims when unclaimed + a project is declared. action: action.to_string(),
(None, Some(_)) if is_group => mk("claim", None), owner: current.map(str::to_string),
// Otherwise unclaimed (an app never claims; no project → nothing). blast_radius: Vec::new(),
(None, _) => mk("unclaimed", None),
(Some(c), Some(d)) if c == d => mk("owned", Some(c)),
(Some(c), _) => mk("conflict", Some(c)),
} }
} }
@@ -1505,7 +1521,7 @@ impl ApplyService {
.await?; .await?;
} }
ApplyOwner::App(app_id) => { ApplyOwner::App(app_id) => {
self.check_app_owner_single(app_id, declared_project) self.check_app_owner_single(&mut tx, app_id, declared_project)
.await?; .await?;
} }
} }
@@ -2014,12 +2030,18 @@ impl ApplyService {
} }
/// Verify an APP node's ownership (single-node path). The app claims /// Verify an APP node's ownership (single-node path). The app claims
/// nothing — it must match its nearest claimed ancestor group. Resolves the /// nothing — it must match its nearest claimed ancestor group. The ancestor
/// chain via `ancestors()` (which now carries `owner_project`); an unclaimed /// STRUCTURE is read on the pool (`ancestors()`), but the ownership VALUE is
/// subtree is open. The narrow read-then-commit window is accepted (an app /// then read WITHIN the tx via `nearest_claimed_in_tx` — the same mechanism
/// writes no ownership; hazard c). /// the tree path uses — so the check and this apply's writes see one
/// consistent snapshot instead of a separate pool connection (`in_tree` is
/// empty; a single app node claims no groups itself). An unclaimed subtree
/// stays open. (Fully serializing against a concurrent claim on an
/// out-of-tree ancestor is part of the tracked pool→tx follow-up — see the
/// `apply_owner` advisory-lock note.)
async fn check_app_owner_single( async fn check_app_owner_single(
&self, &self,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
app_id: AppId, app_id: AppId,
declared: Option<ProjectId>, declared: Option<ProjectId>,
) -> Result<(), ApplyError> { ) -> Result<(), ApplyError> {
@@ -2036,7 +2058,8 @@ impl ApplyService {
.ancestors(app.group_id) .ancestors(app.group_id)
.await .await
.map_err(|e| ApplyError::Backend(e.to_string()))?; .map_err(|e| ApplyError::Backend(e.to_string()))?;
let nearest = self.nearest_claimed_from_chain(&chain).await?; let chain_ids: Vec<GroupId> = chain.iter().map(|g| g.id).collect();
let nearest = nearest_claimed_in_tx(tx, &chain_ids, &HashMap::new()).await?;
decide_app_owner(nearest, declared).map_err(ApplyError::OwnershipConflict) decide_app_owner(nearest, declared).map_err(ApplyError::OwnershipConflict)
} }
@@ -2176,48 +2199,51 @@ impl ApplyService {
gid: GroupId, gid: GroupId,
exclude: Option<&str>, exclude: Option<&str>,
) -> Result<Vec<ProjectImpact>, ApplyError> { ) -> Result<Vec<ProjectImpact>, ApplyError> {
// All apps in gid's subtree (gid + descendant groups). // One set-based query: every app in gid's subtree, resolved to its
let rows: Vec<(uuid::Uuid, uuid::Uuid)> = sqlx::query_as( // nearest claimed ancestor project, grouped and counted — replacing the
// per-descendant-group `ancestors()` round-trips (N+1) with a single
// recursive walk. `subtree` = gid + descendant groups; `app_chain` =
// (app, ancestor group, depth) for every subtree app walked to the root;
// `nearest` picks the shallowest claimed ancestor per app. Apps owned by
// `exclude` (the planning project) or by nothing are dropped.
let rows: Vec<(String, i64)> = sqlx::query_as(
"WITH RECURSIVE subtree AS ( "WITH RECURSIVE subtree AS (
SELECT id FROM groups WHERE id = $1 SELECT id FROM groups WHERE id = $1
UNION ALL UNION ALL
SELECT g.id FROM groups g JOIN subtree s ON g.parent_id = s.id SELECT g.id FROM groups g JOIN subtree s ON g.parent_id = s.id
),
app_chain AS (
SELECT a.id AS app_id, a.group_id AS gid, 0 AS depth
FROM apps a WHERE a.group_id IN (SELECT id FROM subtree)
UNION ALL
SELECT ac.app_id, g.parent_id, ac.depth + 1
FROM app_chain ac JOIN groups g ON g.id = ac.gid
WHERE g.parent_id IS NOT NULL
),
nearest AS (
SELECT DISTINCT ON (ac.app_id) ac.app_id, g.owner_project
FROM app_chain ac JOIN groups g ON g.id = ac.gid
WHERE g.owner_project IS NOT NULL
ORDER BY ac.app_id, ac.depth ASC
) )
SELECT a.id, a.group_id FROM apps a WHERE a.group_id IN (SELECT id FROM subtree)", SELECT p.slug, COUNT(*) AS n
FROM nearest n JOIN projects p ON p.id = n.owner_project
WHERE p.slug IS DISTINCT FROM $2
GROUP BY p.slug
ORDER BY p.slug",
) )
.bind(gid.into_inner()) .bind(gid.into_inner())
.bind(exclude)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| ApplyError::Backend(e.to_string()))?; .map_err(|e| ApplyError::Backend(e.to_string()))?;
let mut owner_by_group: HashMap<GroupId, Option<String>> = HashMap::new(); Ok(rows
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() .into_iter()
.map(|(project, apps)| ProjectImpact { project, apps }) .map(|(project, n)| ProjectImpact {
project,
apps: u32::try_from(n).unwrap_or(u32::MAX),
})
.collect()) .collect())
} }
@@ -3342,26 +3368,29 @@ async fn delete_group_var_tx(
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
/// What a group-node apply should do about ownership, given the current owner, /// What a group-node apply should do about ownership, given the current owner,
/// the declared project, and whether `--takeover` was passed. /// the declared project, and whether `--takeover` was passed. Generic over the
/// identity key `K` so the same policy serves both the apply path (`K =
/// ProjectId`, comparing the assigned ids) and the plan preview (`K = String`,
/// comparing slugs before any id is assigned).
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
enum GroupClaimAction { enum GroupClaimAction<K> {
/// Nothing to write (unclaimed + no project, or already owned by us). /// Nothing to write (unclaimed + no project, or already owned by us).
Noop, Noop,
/// First-apply-claims: set `owner_project`. /// First-apply-claims: set `owner_project`.
Claim(ProjectId), Claim(K),
/// Reassign to us — capability-gated (`GroupAdmin`) at the call site. /// Reassign to us — capability-gated (`GroupAdmin`) at the call site.
Takeover(ProjectId), Takeover(K),
/// Refuse (409); the string is the actionable message. /// Refuse (409); the string is the actionable message.
Conflict(String), Conflict(String),
} }
/// Pure ownership policy for a GROUP node. `current` is the live owner as /// Pure ownership policy for a GROUP node. `current` is the live owner as
/// `(id, slug)`; `declared` is this apply's project id. See §7.3. /// `(key, slug)`; `declared` is this apply's project key. See §7.3.
fn decide_group_claim( fn decide_group_claim<K: Eq>(
current: Option<(ProjectId, String)>, current: Option<(K, String)>,
declared: Option<ProjectId>, declared: Option<K>,
takeover: bool, takeover: bool,
) -> GroupClaimAction { ) -> GroupClaimAction<K> {
match (current, declared) { match (current, declared) {
// Unclaimed: a declared project claims it; no project is a no-op (the // Unclaimed: a declared project claims it; no project is a no-op (the
// node stays UI/API-owned — the backward-compatible path). // node stays UI/API-owned — the backward-compatible path).
@@ -3388,11 +3417,12 @@ fn decide_group_claim(
} }
/// Pure ownership policy for an APP node. Apps never claim — they must match /// Pure ownership policy for an APP node. Apps never claim — they must match
/// their nearest claimed ancestor group (`nearest`, as `(id, slug)`); an /// their nearest claimed ancestor group (`nearest`, as `(key, slug)`); an
/// unclaimed subtree is open (backward-compatible). See §7. /// unclaimed subtree is open (backward-compatible). Generic over the identity
fn decide_app_owner( /// key `K` for the same reason as [`decide_group_claim`]. See §7.
nearest: Option<(ProjectId, String)>, fn decide_app_owner<K: Eq>(
declared: Option<ProjectId>, nearest: Option<(K, String)>,
declared: Option<K>,
) -> Result<(), String> { ) -> Result<(), String> {
match (nearest, declared) { match (nearest, declared) {
(None, _) => Ok(()), (None, _) => Ok(()),
@@ -3407,26 +3437,13 @@ fn decide_app_owner(
} }
} }
/// §7: validate a `[project]` slug — the same rule as app/group slugs /// §7: validate a `[project]` slug — the same FORMAT rule as app/group slugs
/// (`^[a-z0-9][a-z0-9-]{0,62}$`). /// (`^[a-z0-9][a-z0-9-]{0,62}$`), reusing the shared validator. A project slug
/// is intentionally NOT reserved-word-checked: unlike a group/app slug it is
/// never used in a route or host, so no path can collide with it.
fn validate_project_slug(slug: &str) -> Result<(), ApplyError> { fn validate_project_slug(slug: &str) -> Result<(), ApplyError> {
let bad = |why: &str| ApplyError::Invalid(format!("project slug {slug:?}: {why}")); crate::groups_api::validate_slug_format(slug)
if slug.is_empty() || slug.len() > 63 { .map_err(|why| ApplyError::Invalid(format!("project slug {slug:?}: {why}")))
return Err(bad("must be 163 characters"));
}
let first = slug.chars().next().expect("non-empty checked above");
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err(bad("must start with a lowercase letter or digit"));
}
if !slug
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(bad(
"may contain only lowercase letters, digits, and hyphens",
));
}
Ok(())
} }
/// Register (or fetch) a project by slug inside the apply tx, returning its id. /// Register (or fetch) a project by slug inside the apply tx, returning its id.
@@ -5378,7 +5395,7 @@ mod tests {
GroupClaimAction::Claim(p) GroupClaimAction::Claim(p)
); );
assert_eq!( assert_eq!(
decide_group_claim(None, None, false), decide_group_claim::<ProjectId>(None, None, false),
GroupClaimAction::Noop GroupClaimAction::Noop
); );
} }
@@ -5419,7 +5436,7 @@ mod tests {
let me = ProjectId::new(); let me = ProjectId::new();
// An unclaimed subtree is open (backward-compatible). // An unclaimed subtree is open (backward-compatible).
assert!(decide_app_owner(None, Some(me)).is_ok()); assert!(decide_app_owner(None, Some(me)).is_ok());
assert!(decide_app_owner(None, None).is_ok()); assert!(decide_app_owner::<ProjectId>(None, None).is_ok());
// Same project as the nearest claimed ancestor → ok. // Same project as the nearest claimed ancestor → ok.
assert!(decide_app_owner(Some((owner, "x".into())), Some(owner)).is_ok()); assert!(decide_app_owner(Some((owner, "x".into())), Some(owner)).is_ok());
// A different project, or none, under a claimed subtree → conflict. // A different project, or none, under a claimed subtree → conflict.

View File

@@ -451,25 +451,32 @@ async fn resolve_group(groups: &dyn GroupRepository, ident: &str) -> Result<Grou
found.ok_or_else(|| GroupsApiError::GroupNotFound(ident.to_string())) found.ok_or_else(|| GroupsApiError::GroupNotFound(ident.to_string()))
} }
/// Same rule as app slugs: `^[a-z0-9][a-z0-9-]{0,62}$`, no reserved words. /// The shared slug FORMAT rule (`^[a-z0-9][a-z0-9-]{0,62}$`), returning the
fn validate_slug(slug: &str) -> Result<(), GroupsApiError> { /// human reason on failure so each caller can wrap it in its own error type.
let invalid = |reason: &str| GroupsApiError::InvalidSlug(format!("{slug:?}: {reason}")); /// Shared by group slugs and §7 project slugs; the reserved-word check is
/// layered on separately by callers that route slugs (groups), not by projects
/// (never routed). See [`crate::apply_service`]'s `validate_project_slug`.
pub(crate) fn validate_slug_format(slug: &str) -> Result<(), String> {
if slug.is_empty() || slug.len() > SLUG_MAX { if slug.is_empty() || slug.len() > SLUG_MAX {
return Err(invalid("must be 163 characters")); return Err("must be 163 characters".to_string());
} }
let mut chars = slug.chars(); let first = slug.chars().next().expect("non-empty checked above");
let first = chars.next().unwrap();
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) { if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err(invalid("must start with a lowercase letter or digit")); return Err("must start with a lowercase letter or digit".to_string());
} }
if !slug if !slug
.chars() .chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{ {
return Err(invalid( return Err("may contain only lowercase letters, digits, and hyphens".to_string());
"may contain only lowercase letters, digits, and hyphens",
));
} }
Ok(())
}
/// Same rule as app slugs: `^[a-z0-9][a-z0-9-]{0,62}$`, no reserved words.
fn validate_slug(slug: &str) -> Result<(), GroupsApiError> {
let invalid = |reason: &str| GroupsApiError::InvalidSlug(format!("{slug:?}: {reason}"));
validate_slug_format(slug).map_err(|reason| invalid(&reason))?;
if RESERVED_SLUGS.contains(&slug) { if RESERVED_SLUGS.contains(&slug) {
return Err(invalid("is a reserved word")); return Err(invalid("is a reserved word"));
} }