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

@@ -451,25 +451,32 @@ async fn resolve_group(groups: &dyn GroupRepository, ident: &str) -> Result<Grou
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.
fn validate_slug(slug: &str) -> Result<(), GroupsApiError> {
let invalid = |reason: &str| GroupsApiError::InvalidSlug(format!("{slug:?}: {reason}"));
/// The shared slug FORMAT rule (`^[a-z0-9][a-z0-9-]{0,62}$`), returning the
/// human reason on failure so each caller can wrap it in its own error type.
/// 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 {
return Err(invalid("must be 163 characters"));
return Err("must be 163 characters".to_string());
}
let mut chars = slug.chars();
let first = chars.next().unwrap();
let first = slug.chars().next().expect("non-empty checked above");
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
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(invalid(
"may contain only lowercase letters, digits, and hyphens",
));
return Err("may contain only lowercase letters, digits, and hyphens".to_string());
}
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) {
return Err(invalid("is a reserved word"));
}