feat(apply): §6 M2 — structural-divergence detection + declarative reparent

With the declared parent on the wire (M1), `apply_tree` now compares each
EXISTING group node's server parent to the manifest's (directory-nesting)
parent and resolves a divergence per a request `StructureMode` (default
`Refuse` — a pre-M2 CLI never reshapes the tree):

- `Refuse` → `ApplyError::StructuralDivergence` (422), naming the flags.
- `ForceLocal` → reparent to the declared parent IN the apply tx via the
  extracted `group_repo::reparent_group_tx` (cycle guard + structure_version
  bump), §5.6-gated (`GroupAdmin` on node + source + destination) and
  attach-ceilinged.
- `AdoptServer` → keep the server shape, reconcile content in place.

M1's `create_missing_groups_tx` generalized to `reconcile_group_structure_tx`
(create + reparent share the coarse-lock / attach-ceiling / RBAC path; both are
recorded `structurally_changed` so the pool-based attach re-check skips their
uncommitted rows). `pic plan` previews the drift (`divergence_preview` → a
`structure` row naming server + manifest parents). CLI:
`--force-local-structure` / `--adopt-server-structure` (mutually exclusive) →
the `structure_mode` wire field; the 422 surfaces verbatim. A concurrent
`pic groups reparent` between plan and apply still trips `StateMoved` (the tree
token already folds structure_version).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-07 20:23:10 +02:00
parent dcc387c345
commit cb3d458cfd
7 changed files with 402 additions and 148 deletions

View File

@@ -19,7 +19,7 @@ use crate::app_repo::AppRepository;
use crate::apply_service::{ use crate::apply_service::{
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo, ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
ExtensionPointInfo, NodeKind, OwnershipClaim, PlanResult, ProjectDecl, RouteTemplateInfo, ExtensionPointInfo, NodeKind, OwnershipClaim, PlanResult, ProjectDecl, RouteTemplateInfo,
SuppressionInfo, TreeBundle, TreePlanResult, TriggerTemplateInfo, StructureMode, SuppressionInfo, TreeBundle, TreePlanResult, TriggerTemplateInfo,
}; };
use crate::authz::{require, AuthzDenied, Capability}; use crate::authz::{require, AuthzDenied, Capability};
use crate::group_repo::GroupRepository; use crate::group_repo::GroupRepository;
@@ -331,6 +331,10 @@ struct TreeApplyRequest {
project: Option<ProjectDecl>, project: Option<ProjectDecl>,
#[serde(default)] #[serde(default)]
takeover: bool, takeover: bool,
/// §6 M2: how to resolve a group whose server parent diverges from the
/// manifest (default `Refuse`; a pre-M2 CLI omits it).
#[serde(default)]
structure_mode: StructureMode,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -370,6 +374,7 @@ async fn tree_apply_handler(
req.prune, req.prune,
&claim, &claim,
req.expected_token.as_deref(), req.expected_token.as_deref(),
req.structure_mode,
) )
.await?; .await?;
Ok(Json(report)) Ok(Json(report))
@@ -571,7 +576,7 @@ impl IntoResponse for ApplyError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let (status, body) = match &self { let (status, body) = match &self {
Self::AppNotFound(_) => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })), Self::AppNotFound(_) => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
Self::Invalid(_) | Self::OutsideAttachPoint(_) => ( Self::Invalid(_) | Self::OutsideAttachPoint(_) | Self::StructuralDivergence(_) => (
StatusCode::UNPROCESSABLE_ENTITY, StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }), json!({ "error": self.to_string() }),
), ),

View File

@@ -614,6 +614,34 @@ fn preview_ownership(
} }
} }
/// §6 M2: how a tree apply resolves a group whose SERVER parent differs from the
/// manifest's declared (directory-nesting) parent — Terraform-style
/// detect-and-refuse. Request-only; `Refuse` is the default (a pre-M2 CLI omits
/// it), so a divergence never silently reshapes the tree.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum StructureMode {
/// Refuse the apply on any structural divergence (422).
#[default]
Refuse,
/// Reparent each diverged group to the manifest's declared parent.
ForceLocal,
/// Accept the server's shape; reconcile content in place, no reparent.
AdoptServer,
}
/// §6 M2: a group node's structural state under the manifest — `in-sync` when
/// the server parent matches the declared (directory-nesting) parent, else
/// `diverged` (naming both parents). Previewed by `pic plan`.
#[derive(Debug, Clone, Serialize)]
pub struct StructurePreview {
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_parent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manifest_parent: Option<String>,
}
/// One node's diff within a tree plan. /// One node's diff within a tree plan.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct NodePlan { pub struct NodePlan {
@@ -624,6 +652,9 @@ pub struct NodePlan {
/// §7 M3: this node's ownership outcome under the tree's `[project]`. /// §7 M3: this node's ownership outcome under the tree's `[project]`.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ownership: Option<OwnershipPreview>, pub ownership: Option<OwnershipPreview>,
/// §6 M2: for a GROUP node, its structural state (in-sync / diverged).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub structure: Option<StructurePreview>,
} }
/// What `plan_tree` returns: a per-node diff plus ONE combined bound-plan token /// What `plan_tree` returns: a per-node diff plus ONE combined bound-plan token
@@ -746,6 +777,11 @@ pub enum ApplyError {
/// attach point — you can only apply within its subtree. Maps to 422. /// attach point — you can only apply within its subtree. Maps to 422.
#[error("outside attach point: {0}")] #[error("outside attach point: {0}")]
OutsideAttachPoint(String), OutsideAttachPoint(String),
/// §6 M2: a group's SERVER parent differs from the manifest's declared
/// (directory-nesting) parent, and no `--force-local-structure` /
/// `--adopt-server-structure` was given. Maps to 422 — actionable.
#[error("structural divergence: {0}")]
StructuralDivergence(String),
#[error("authorization repo error: {0}")] #[error("authorization repo error: {0}")]
AuthzRepo(String), AuthzRepo(String),
#[error("backend: {0}")] #[error("backend: {0}")]
@@ -1656,11 +1692,15 @@ impl ApplyService {
let mut nodes = Vec::with_capacity(prepared.len() + to_create.len()); let mut nodes = Vec::with_capacity(prepared.len() + to_create.len());
for p in prepared { for p in prepared {
let ownership = self.ownership_preview(p.owner, declared).await?; let ownership = self.ownership_preview(p.owner, declared).await?;
// §6 M2: for an existing group node, preview a structural divergence
// (server parent ≠ manifest parent) before apply.
let structure = self.divergence_preview(p.node).await?;
nodes.push(NodePlan { nodes.push(NodePlan {
kind: p.node.kind, kind: p.node.kind,
slug: p.node.slug.clone(), slug: p.node.slug.clone(),
plan: p.plan, plan: p.plan,
ownership, ownership,
structure,
}); });
} }
// §6: a to-create group is previewed with a full-create plan; if the // §6: a to-create group is previewed with a full-create plan; if the
@@ -1672,6 +1712,7 @@ impl ApplyService {
slug: c.node.slug.clone(), slug: c.node.slug.clone(),
plan: c.plan, plan: c.plan,
ownership, ownership,
structure: None,
}); });
} }
Ok(TreePlanResult { Ok(TreePlanResult {
@@ -1697,6 +1738,7 @@ impl ApplyService {
prune: bool, prune: bool,
claim: &OwnershipClaim<'_>, claim: &OwnershipClaim<'_>,
expected_token: Option<&str>, expected_token: Option<&str>,
structure_mode: StructureMode,
) -> Result<ApplyReport, ApplyError> { ) -> Result<ApplyReport, ApplyError> {
let actor = claim.principal.user_id; let actor = claim.principal.user_id;
let mut tx = self let mut tx = self
@@ -1704,15 +1746,23 @@ impl ApplyService {
.begin() .begin()
.await .await
.map_err(|e| ApplyError::Backend(e.to_string()))?; .map_err(|e| ApplyError::Backend(e.to_string()))?;
// §6 Phase 0: create every group the manifest declares (by directory // §6 Phase 0: reconcile the group tree SHAPE — create every group the
// nesting) but the server lacks, IN this tx (parents-first), returning // manifest declares (by directory nesting) but the server lacks, and
// the complete slug → id map. Enforces the attach-point ceiling + RBAC // (M2) reparent a diverged group per `structure_mode` — IN this tx
// per create and takes the coarse structural lock only when it creates. // (parents-first), returning the complete slug → id map and the set of
// structurally-changed groups. Enforces the attach ceiling + RBAC per
// mutation and takes the coarse structural lock only when it mutates.
// With all groups now resolvable, `prepare_tree` sees the whole tree // With all groups now resolvable, `prepare_tree` sees the whole tree
// (created groups load an empty current → a full-create plan), so its // (created groups load an empty current → a full-create plan), so its
// to-create list is empty. // to-create list is empty.
let (resolved, created) = self let (resolved, structurally_changed) = self
.create_missing_groups_tx(&mut tx, bundle, claim.project.as_ref(), claim.principal) .reconcile_group_structure_tx(
&mut tx,
bundle,
claim.project.as_ref(),
claim.principal,
structure_mode,
)
.await?; .await?;
let (prepared, to_create, token) = self.prepare_tree(bundle, &resolved).await?; let (prepared, to_create, token) = self.prepare_tree(bundle, &resolved).await?;
if !to_create.is_empty() { if !to_create.is_empty() {
@@ -1737,7 +1787,7 @@ impl ApplyService {
let pg = self.resolve_group(pg_slug).await?; let pg = self.resolve_group(pg_slug).await?;
for p in &prepared { for p in &prepared {
if let ApplyOwner::Group(gid) = p.owner { if let ApplyOwner::Group(gid) = p.owner {
if created.contains(&gid) { if structurally_changed.contains(&gid) {
continue; continue;
} }
} }
@@ -1883,26 +1933,122 @@ impl ApplyService {
Ok(map) Ok(map)
} }
/// §6: create every GROUP node the manifest declares but the server lacks, /// §6 M2: preview an EXISTING group node's structural divergence — `Some`
/// INSIDE the apply tx (parents-first, from the CLI's directory ordering). /// only when the server's parent differs from the manifest's declared
/// Returns the complete slug → id map (existing + created) so `prepare_tree` /// (directory-nesting) parent. `None` for an app node, a to-create group, or
/// resolves the whole tree. Enforces the attach-point ceiling + RBAC on each /// an in-sync group.
/// create; claiming happens in Phase A alongside existing groups. The coarse async fn divergence_preview(
/// structural lock is taken only once, on the first create. &self,
node: &TreeNode,
) -> Result<Option<StructurePreview>, ApplyError> {
if node.kind != NodeKind::Group {
return Ok(None);
}
let Some(group) = self
.groups
.get_by_slug(&node.slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
else {
return Ok(None); // to-create — not a divergence
};
let server_parent = match group.parent_id {
Some(pid) => self
.groups
.get_by_id(pid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.map(|g| g.slug),
None => None,
};
if server_parent.as_deref() == node.parent.as_deref() {
return Ok(None); // in-sync
}
Ok(Some(StructurePreview {
status: "diverged".to_string(),
server_parent,
manifest_parent: node.parent.clone(),
}))
}
/// §6: resolve a group node's DECLARED (directory-nesting) parent slug to a
/// `GroupId` — an in-tree group resolved earlier this tree wins, else a
/// committed server group; `None` = instance root. Errors if a declared
/// parent slug resolves to nothing.
async fn resolve_declared_parent(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
map: &HashMap<String, GroupId>,
n: &TreeNode,
) -> Result<Option<GroupId>, ApplyError> {
let Some(pslug) = &n.parent else {
return Ok(None);
};
if let Some(&g) = map.get(&pslug.to_lowercase()) {
return Ok(Some(g));
}
crate::group_repo::read_group_id_by_slug_tx(tx, pslug)
.await
.map_err(map_group_repo_err)?
.map(Some)
.ok_or_else(|| {
ApplyError::Invalid(format!(
"group `{}` declares parent `{pslug}`, which does not exist",
n.slug
))
})
}
/// §6: is `parent` within the attach-point subtree? `attach = None` (no
/// ceiling) → always true. A just-structurally-changed group in `ok` (its
/// own placement already validated within) short-circuits the ancestor walk.
async fn parent_within_attach(
&self,
parent: Option<GroupId>,
attach: Option<GroupId>,
ok: &HashSet<GroupId>,
) -> Result<bool, ApplyError> {
let Some(attach) = attach else {
return Ok(true);
};
Ok(match parent {
Some(p) if p == attach || ok.contains(&p) => true,
Some(p) => self
.groups
.ancestors(p)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.iter()
.any(|g| g.id == attach),
None => false,
})
}
/// §6 M1+M2: reconcile the group tree SHAPE inside the apply tx (parents-
/// first). A group the server lacks is CREATED; a group whose server parent
/// differs from the manifest's declared parent is a structural divergence,
/// resolved per `mode` (refuse / reparent / adopt-server). Returns the
/// complete slug → id map (so `prepare_tree` resolves the whole tree) and the
/// set of groups STRUCTURALLY changed here (created or reparented) — the
/// caller skips them in the pool-based attach re-check (their uncommitted row
/// isn't visible). Enforces the attach ceiling + RBAC per mutation; the
/// coarse structural lock is taken only once, on the first mutation.
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
async fn create_missing_groups_tx( async fn reconcile_group_structure_tx(
&self, &self,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
bundle: &TreeBundle, bundle: &TreeBundle,
project: Option<&ProjectDecl>, project: Option<&ProjectDecl>,
principal: &Principal, principal: &Principal,
mode: StructureMode,
) -> Result<(HashMap<String, GroupId>, HashSet<GroupId>), ApplyError> { ) -> Result<(HashMap<String, GroupId>, HashSet<GroupId>), ApplyError> {
use crate::group_repo::{ use crate::group_repo::{
create_group_tx, read_group_id_by_slug_tx, GROUP_STRUCTURAL_LOCK_KEY, create_group_tx, read_group_id_by_slug_tx, read_group_node_tx, reparent_group_tx,
GROUP_STRUCTURAL_LOCK_KEY,
}; };
// The attach-point ceiling group (if the repo declares one). let attach_slug = project.and_then(|p| p.parent_group.as_deref());
let attach_gid = match project.and_then(|p| p.parent_group.as_deref()) { let attach_gid = match attach_slug {
Some(slug) => Some( Some(slug) => Some(
read_group_id_by_slug_tx(tx, slug) read_group_id_by_slug_tx(tx, slug)
.await .await
@@ -1915,101 +2061,125 @@ impl ApplyService {
}; };
let mut map: HashMap<String, GroupId> = HashMap::new(); let mut map: HashMap<String, GroupId> = HashMap::new();
let mut created: HashSet<GroupId> = HashSet::new(); let mut changed: HashSet<GroupId> = HashSet::new();
let mut created_within_attach: HashSet<GroupId> = HashSet::new(); let mut within_attach: HashSet<GroupId> = HashSet::new();
let mut locked = false; let mut locked = false;
for n in &bundle.nodes { for n in &bundle.nodes {
if n.kind != NodeKind::Group { if n.kind != NodeKind::Group {
continue; continue;
} }
let key = n.slug.to_lowercase(); let key = n.slug.to_lowercase();
if let Some(gid) = read_group_id_by_slug_tx(tx, &n.slug) let mut existing = read_group_node_tx(tx, &n.slug)
.await .await
.map_err(map_group_repo_err)? .map_err(map_group_repo_err)?;
{ let declared_parent = self.resolve_declared_parent(tx, &map, n).await?;
map.insert(key, gid);
continue; // A structural change (create or reparent) is needed if the group is
} // missing, or exists with a divergent parent under ForceLocal.
// The group must be CREATED. Serialize creation with other structural let diverged = matches!(&existing, Some((_, sp)) if *sp != declared_parent);
// mutations under the coarse lock (acquired once, on first create), let needs_mutation =
// re-reading after the lock in case a racer created it first. existing.is_none() || (diverged && mode == StructureMode::ForceLocal);
if !locked { if needs_mutation && !locked {
sqlx::query("SELECT pg_advisory_xact_lock($1)") sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(GROUP_STRUCTURAL_LOCK_KEY) .bind(GROUP_STRUCTURAL_LOCK_KEY)
.execute(&mut **tx) .execute(&mut **tx)
.await .await
.map_err(|e| ApplyError::Backend(e.to_string()))?; .map_err(|e| ApplyError::Backend(e.to_string()))?;
locked = true; locked = true;
if let Some(gid) = read_group_id_by_slug_tx(tx, &n.slug) // Re-read under the lock — a racer may have created/moved it.
existing = read_group_node_tx(tx, &n.slug)
.await .await
.map_err(map_group_repo_err)? .map_err(map_group_repo_err)?;
{
map.insert(key, gid);
continue;
}
} }
crate::groups_api::validate_slug_format(&n.slug).map_err(ApplyError::Invalid)?;
// Resolve the declared parent: an in-tree group (created/existing let Some((gid, server_parent)) = existing else {
// earlier this tree) wins, else a committed server group; `None` = // Create the missing group under its declared parent.
// instance root. crate::groups_api::validate_slug_format(&n.slug).map_err(ApplyError::Invalid)?;
let parent_id = match &n.parent { if !self
Some(pslug) => Some(match map.get(&pslug.to_lowercase()) { .parent_within_attach(declared_parent, attach_gid, &within_attach)
Some(&g) => g, .await?
None => read_group_id_by_slug_tx(tx, pslug) {
.await
.map_err(map_group_repo_err)?
.ok_or_else(|| {
ApplyError::Invalid(format!(
"group `{}` declares parent `{pslug}`, which does not exist",
n.slug
))
})?,
}),
None => None,
};
// Attach-point ceiling: the new group must land within the subtree.
if let Some(attach) = attach_gid {
let within = match parent_id {
Some(p) if p == attach || created_within_attach.contains(&p) => true,
Some(p) => self
.groups
.ancestors(p)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.iter()
.any(|g| g.id == attach),
None => false,
};
if !within {
return Err(ApplyError::OutsideAttachPoint(format!( return Err(ApplyError::OutsideAttachPoint(format!(
"group `{}` would be created outside this project's attach point `{}`", "group `{}` would be created outside this project's attach point `{}`",
n.slug, n.slug,
project attach_slug.unwrap_or_default()
.and_then(|p| p.parent_group.as_deref())
.unwrap_or_default()
))); )));
} }
} // RBAC: a root group needs InstanceCreateGroup; a subgroup needs
// RBAC: a root group needs InstanceCreateGroup; a subgroup needs // GroupAdmin on the parent (mirrors create_group).
// GroupAdmin on the parent (mirrors groups_api::create_group). let cap = match declared_parent {
let cap = match parent_id { Some(p) => Capability::GroupAdmin(p),
Some(p) => Capability::GroupAdmin(p), None => Capability::InstanceCreateGroup,
None => Capability::InstanceCreateGroup, };
}; require(self.authz.as_ref(), principal, cap)
require(self.authz.as_ref(), principal, cap) .await
.await .map_err(map_authz)?;
.map_err(map_authz)?; let name = n.name.clone().unwrap_or_else(|| n.slug.clone());
let name = n.name.clone().unwrap_or_else(|| n.slug.clone()); let gid = create_group_tx(
let gid = create_group_tx(tx, &n.slug, &name, n.description.as_deref(), parent_id) tx,
&n.slug,
&name,
n.description.as_deref(),
declared_parent,
)
.await .await
.map_err(map_group_repo_err)?; .map_err(map_group_repo_err)?;
created.insert(gid); changed.insert(gid);
if attach_gid.is_some() { if attach_gid.is_some() {
created_within_attach.insert(gid); within_attach.insert(gid);
} }
map.insert(key, gid);
continue;
};
// The group exists. If its server parent matches the manifest, it's
// in-sync; otherwise resolve the divergence per `mode`.
map.insert(key, gid); map.insert(key, gid);
if server_parent == declared_parent {
continue;
}
match mode {
StructureMode::AdoptServer => {} // keep the server shape
StructureMode::Refuse => {
return Err(ApplyError::StructuralDivergence(format!(
"group `{}` is under a different parent on the server than the manifest \
declares — pass --force-local-structure to reparent it, or \
--adopt-server-structure to keep the server's placement",
n.slug
)));
}
StructureMode::ForceLocal => {
// §5.6 RBAC: admin on the node + source + destination.
require(self.authz.as_ref(), principal, Capability::GroupAdmin(gid))
.await
.map_err(map_authz)?;
for side in [server_parent, declared_parent].into_iter().flatten() {
require(self.authz.as_ref(), principal, Capability::GroupAdmin(side))
.await
.map_err(map_authz)?;
}
if !self
.parent_within_attach(declared_parent, attach_gid, &within_attach)
.await?
{
return Err(ApplyError::OutsideAttachPoint(format!(
"reparenting group `{}` would move it outside this project's attach \
point `{}`",
n.slug,
attach_slug.unwrap_or_default()
)));
}
reparent_group_tx(tx, gid, declared_parent)
.await
.map_err(map_group_repo_err)?;
changed.insert(gid);
if attach_gid.is_some() {
within_attach.insert(gid);
}
}
}
} }
Ok((map, created)) Ok((map, changed))
} }
/// Resolve nodes to owners, validate each (apps' route/trigger targets may /// Resolve nodes to owners, validate each (apps' route/trigger targets may

View File

@@ -87,6 +87,69 @@ pub(crate) async fn create_group_tx(
} }
} }
/// Reparent a group INSIDE an existing tx (the caller holds
/// [`GROUP_STRUCTURAL_LOCK_KEY`]): the ancestor-walk cycle guard + the
/// `parent_id` write + `structure_version` bump. Used by the imperative
/// `reparent` (its own tx) and §6 M2's declarative structural reconcile (the
/// apply tx). A same-tx just-created ancestor is visible to the cycle walk.
pub(crate) async fn reparent_group_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: GroupId,
new_parent: Option<GroupId>,
) -> Result<Group, GroupRepositoryError> {
if let Some(parent) = new_parent {
if parent == id {
return Err(GroupRepositoryError::Conflict(
"a group cannot be its own parent".into(),
));
}
// Cycle guard: walk from the destination up to the root; if we reach
// `id`, the move would place `id` beneath itself.
let mut cursor = Some(parent);
let mut hops = 0u32;
while let Some(node) = cursor {
if node == id {
return Err(GroupRepositoryError::Conflict(
"cannot reparent a group beneath one of its own descendants".into(),
));
}
hops += 1;
if hops > 64 {
return Err(GroupRepositoryError::Conflict(
"group ancestry exceeds the maximum depth".into(),
));
}
let parent_of: Option<(Option<Uuid>,)> =
sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
.bind(node.into_inner())
.fetch_optional(&mut **tx)
.await?;
match parent_of {
Some((p,)) => cursor = p.map(GroupId::from),
None => {
return Err(GroupRepositoryError::Conflict(
"destination parent group does not exist".into(),
));
}
}
}
}
let row = sqlx::query_as::<_, GroupRow>(&format!(
"UPDATE groups SET \
parent_id = $2, \
structure_version = structure_version + 1, \
updated_at = NOW() \
WHERE id = $1 \
RETURNING {GROUP_COLS}"
))
.bind(id.into_inner())
.bind(new_parent.map(GroupId::into_inner))
.fetch_optional(&mut **tx)
.await?;
row.map(Into::into)
.ok_or(GroupRepositoryError::NotFound(id))
}
/// §6: resolve a group slug → id inside the apply tx, so a group CREATED earlier /// §6: resolve a group slug → id inside the apply tx, so a group CREATED earlier
/// in the same transaction is visible (a pool read would miss the uncommitted /// in the same transaction is visible (a pool read would miss the uncommitted
/// row). `None` if absent. /// row). `None` if absent.
@@ -101,6 +164,21 @@ pub(crate) async fn read_group_id_by_slug_tx(
Ok(row.map(|(id,)| id.into())) Ok(row.map(|(id,)| id.into()))
} }
/// §6 M2: read a group's `(id, parent_id)` by slug inside the tx — the input to
/// the structural-divergence check (server parent vs manifest parent). `None`
/// if the group doesn't exist (it is to-create).
pub(crate) async fn read_group_node_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
slug: &str,
) -> Result<Option<(GroupId, Option<GroupId>)>, GroupRepositoryError> {
let row: Option<(Uuid, Option<Uuid>)> =
sqlx::query_as("SELECT id, parent_id FROM groups WHERE slug = $1")
.bind(slug)
.fetch_optional(&mut **tx)
.await?;
Ok(row.map(|(id, parent)| (id.into(), parent.map(GroupId::from))))
}
#[async_trait] #[async_trait]
pub trait GroupRepository: Send + Sync { pub trait GroupRepository: Send + Sync {
/// Every group on the instance, ordered by name. The tree is small /// Every group on the instance, ordered by name. The tree is small
@@ -324,63 +402,9 @@ impl GroupRepository for PostgresGroupRepository {
.bind(GROUP_STRUCTURAL_LOCK_KEY) .bind(GROUP_STRUCTURAL_LOCK_KEY)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
let group = reparent_group_tx(&mut tx, id, new_parent).await?;
if let Some(parent) = new_parent {
if parent == id {
return Err(GroupRepositoryError::Conflict(
"a group cannot be its own parent".into(),
));
}
// Cycle guard: walk from the destination up to the root; if we
// reach `id`, the move would place `id` beneath itself.
let mut cursor = Some(parent);
let mut hops = 0u32;
while let Some(node) = cursor {
if node == id {
return Err(GroupRepositoryError::Conflict(
"cannot reparent a group beneath one of its own descendants".into(),
));
}
hops += 1;
if hops > 64 {
return Err(GroupRepositoryError::Conflict(
"group ancestry exceeds the maximum depth".into(),
));
}
let parent_of: Option<(Option<Uuid>,)> =
sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
.bind(node.into_inner())
.fetch_optional(&mut *tx)
.await?;
match parent_of {
Some((p,)) => cursor = p.map(GroupId::from),
// Destination parent doesn't exist.
None => {
return Err(GroupRepositoryError::Conflict(
"destination parent group does not exist".into(),
));
}
}
}
}
let row = sqlx::query_as::<_, GroupRow>(&format!(
"UPDATE groups SET \
parent_id = $2, \
structure_version = structure_version + 1, \
updated_at = NOW() \
WHERE id = $1 \
RETURNING {GROUP_COLS}"
))
.bind(id.into_inner())
.bind(new_parent.map(GroupId::into_inner))
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
return Err(GroupRepositoryError::NotFound(id));
};
tx.commit().await?; tx.commit().await?;
Ok(row.into()) Ok(group)
} }
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError> { async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError> {

View File

@@ -1321,6 +1321,7 @@ impl Client {
/// `POST /api/v1/admin/tree/apply` — reconcile a whole project tree in one /// `POST /api/v1/admin/tree/apply` — reconcile a whole project tree in one
/// transaction (Phase 5). /// transaction (Phase 5).
#[allow(clippy::too_many_arguments)]
pub async fn apply_tree( pub async fn apply_tree(
&self, &self,
bundle: &serde_json::Value, bundle: &serde_json::Value,
@@ -1328,6 +1329,7 @@ impl Client {
project: Option<&crate::manifest::ManifestProject>, project: Option<&crate::manifest::ManifestProject>,
takeover: bool, takeover: bool,
expected_token: Option<&str>, expected_token: Option<&str>,
structure_mode: &str,
) -> Result<ApplyReportDto> { ) -> Result<ApplyReportDto> {
let body = serde_json::json!({ let body = serde_json::json!({
"bundle": bundle, "bundle": bundle,
@@ -1335,14 +1337,20 @@ impl Client {
"expected_token": expected_token, "expected_token": expected_token,
"project": project, "project": project,
"takeover": takeover, "takeover": takeover,
"structure_mode": structure_mode,
}); });
let resp = self let resp = self
.request(Method::POST, "/api/v1/admin/tree/apply") .request(Method::POST, "/api/v1/admin/tree/apply")
.json(&body) .json(&body)
.send() .send()
.await?; .await?;
// 409 = stale bound plan OR a §7 ownership conflict; surface verbatim. // 409 = stale bound plan OR a §7 ownership conflict; 422 = a §6 M2
if resp.status() == reqwest::StatusCode::CONFLICT { // structural divergence (or an invalid bundle). Surface the server
// message verbatim — it names the resolution flags.
let status = resp.status();
if status == reqwest::StatusCode::CONFLICT
|| status == reqwest::StatusCode::UNPROCESSABLE_ENTITY
{
let body = resp.text().await.unwrap_or_default(); let body = resp.text().await.unwrap_or_default();
let msg = parse_error_body(&body).unwrap_or(body); let msg = parse_error_body(&body).unwrap_or(body);
return Err(anyhow!("{msg}")); return Err(anyhow!("{msg}"));
@@ -1627,6 +1635,19 @@ pub struct NodePlanDto {
/// §7 M3: this node's ownership outcome under the tree's `[project]`. /// §7 M3: this node's ownership outcome under the tree's `[project]`.
#[serde(default)] #[serde(default)]
pub ownership: Option<OwnershipPreviewDto>, pub ownership: Option<OwnershipPreviewDto>,
/// §6 M2: for a group node, its structural state (in-sync omitted; diverged
/// names the server + manifest parents).
#[serde(default)]
pub structure: Option<StructurePreviewDto>,
}
#[derive(Debug, Deserialize)]
pub struct StructurePreviewDto {
pub status: String,
#[serde(default)]
pub server_parent: Option<String>,
#[serde(default)]
pub manifest_parent: Option<String>,
} }
/// Response of `POST .../apply`: counts of what changed. /// Response of `POST .../apply`: counts of what changed.

View File

@@ -124,12 +124,14 @@ pub async fn run(
/// `pic apply --dir <root>` (Phase 5): reconcile a whole project tree — every /// `pic apply --dir <root>` (Phase 5): reconcile a whole project tree — every
/// `picloud.toml` under `root` — in ONE atomic server transaction. /// `picloud.toml` under `root` — in ONE atomic server transaction.
#[allow(clippy::too_many_arguments)]
pub async fn run_tree( pub async fn run_tree(
dir: &Path, dir: &Path,
prune: bool, prune: bool,
yes: bool, yes: bool,
force: bool, force: bool,
takeover: bool, takeover: bool,
structure_mode: &str,
env: Option<&str>, env: Option<&str>,
mode: OutputMode, mode: OutputMode,
) -> Result<()> { ) -> Result<()> {
@@ -157,6 +159,7 @@ pub async fn run_tree(
project.as_ref(), project.as_ref(),
takeover, takeover,
expected_token.as_deref(), expected_token.as_deref(),
structure_mode,
) )
.await?; .await?;
crate::linkstate::clear_plan(dir, token_key); crate::linkstate::clear_plan(dir, token_key);

View File

@@ -66,6 +66,21 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]); let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
for n in &plan.nodes { for n in &plan.nodes {
let node = format!("{}:{}", n.kind, n.slug); let node = format!("{}:{}", n.kind, n.slug);
if let Some(s) = &n.structure {
table.row([
node.clone(),
"structure".to_string(),
s.status.clone(),
format!(
"server={}",
s.server_parent.clone().unwrap_or_else(|| "<root>".into())
),
format!(
"manifest={}",
s.manifest_parent.clone().unwrap_or_else(|| "<root>".into())
),
]);
}
if let Some(o) = &n.ownership { if let Some(o) = &n.ownership {
table.row([ table.row([
node.clone(), node.clone(),

View File

@@ -261,6 +261,14 @@ struct ApplyArgs {
/// top of the base manifest before applying. /// top of the base manifest before applying.
#[arg(long)] #[arg(long)]
env: Option<String>, env: Option<String>,
/// §6 M2: reparent a group whose server parent diverges from the
/// manifest's directory nesting (default: refuse on divergence).
#[arg(long, conflicts_with = "adopt_server_structure")]
force_local_structure: bool,
/// §6 M2: accept the server's group placement on a structural
/// divergence and reconcile content in place (no reparent).
#[arg(long)]
adopt_server_structure: bool,
} }
#[derive(Args)] #[derive(Args)]
@@ -1433,12 +1441,20 @@ async fn main() -> ExitCode {
Cmd::Whoami => cmds::whoami::run(mode).await, Cmd::Whoami => cmds::whoami::run(mode).await,
Cmd::Apply(args) => match &args.dir { Cmd::Apply(args) => match &args.dir {
Some(dir) => { Some(dir) => {
let structure_mode = if args.force_local_structure {
"force-local"
} else if args.adopt_server_structure {
"adopt-server"
} else {
"refuse"
};
cmds::apply::run_tree( cmds::apply::run_tree(
dir, dir,
args.prune, args.prune,
args.yes, args.yes,
args.force, args.force,
args.takeover, args.takeover,
structure_mode,
args.env.as_deref(), args.env.as_deref(),
mode, mode,
) )