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:
@@ -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 1–63 characters"));
|
||||
return Err("must be 1–63 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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user