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::{
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
ExtensionPointInfo, NodeKind, OwnershipClaim, PlanResult, ProjectDecl, RouteTemplateInfo,
SuppressionInfo, TreeBundle, TreePlanResult, TriggerTemplateInfo,
StructureMode, SuppressionInfo, TreeBundle, TreePlanResult, TriggerTemplateInfo,
};
use crate::authz::{require, AuthzDenied, Capability};
use crate::group_repo::GroupRepository;
@@ -331,6 +331,10 @@ struct TreeApplyRequest {
project: Option<ProjectDecl>,
#[serde(default)]
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)]
@@ -370,6 +374,7 @@ async fn tree_apply_handler(
req.prune,
&claim,
req.expected_token.as_deref(),
req.structure_mode,
)
.await?;
Ok(Json(report))
@@ -571,7 +576,7 @@ impl IntoResponse for ApplyError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound(_) => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
Self::Invalid(_) | Self::OutsideAttachPoint(_) => (
Self::Invalid(_) | Self::OutsideAttachPoint(_) | Self::StructuralDivergence(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
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.
#[derive(Debug, Clone, Serialize)]
pub struct NodePlan {
@@ -624,6 +652,9 @@ pub struct NodePlan {
/// §7 M3: this node's ownership outcome under the tree's `[project]`.
#[serde(default, skip_serializing_if = "Option::is_none")]
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
@@ -746,6 +777,11 @@ pub enum ApplyError {
/// attach point — you can only apply within its subtree. Maps to 422.
#[error("outside attach point: {0}")]
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}")]
AuthzRepo(String),
#[error("backend: {0}")]
@@ -1656,11 +1692,15 @@ impl ApplyService {
let mut nodes = Vec::with_capacity(prepared.len() + to_create.len());
for p in prepared {
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 {
kind: p.node.kind,
slug: p.node.slug.clone(),
plan: p.plan,
ownership,
structure,
});
}
// §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(),
plan: c.plan,
ownership,
structure: None,
});
}
Ok(TreePlanResult {
@@ -1697,6 +1738,7 @@ impl ApplyService {
prune: bool,
claim: &OwnershipClaim<'_>,
expected_token: Option<&str>,
structure_mode: StructureMode,
) -> Result<ApplyReport, ApplyError> {
let actor = claim.principal.user_id;
let mut tx = self
@@ -1704,15 +1746,23 @@ impl ApplyService {
.begin()
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
// §6 Phase 0: create every group the manifest declares (by directory
// nesting) but the server lacks, IN this tx (parents-first), returning
// the complete slug → id map. Enforces the attach-point ceiling + RBAC
// per create and takes the coarse structural lock only when it creates.
// §6 Phase 0: reconcile the group tree SHAPE — create every group the
// manifest declares (by directory nesting) but the server lacks, and
// (M2) reparent a diverged group per `structure_mode` — IN this tx
// (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
// (created groups load an empty current → a full-create plan), so its
// to-create list is empty.
let (resolved, created) = self
.create_missing_groups_tx(&mut tx, bundle, claim.project.as_ref(), claim.principal)
let (resolved, structurally_changed) = self
.reconcile_group_structure_tx(
&mut tx,
bundle,
claim.project.as_ref(),
claim.principal,
structure_mode,
)
.await?;
let (prepared, to_create, token) = self.prepare_tree(bundle, &resolved).await?;
if !to_create.is_empty() {
@@ -1737,7 +1787,7 @@ impl ApplyService {
let pg = self.resolve_group(pg_slug).await?;
for p in &prepared {
if let ApplyOwner::Group(gid) = p.owner {
if created.contains(&gid) {
if structurally_changed.contains(&gid) {
continue;
}
}
@@ -1883,26 +1933,122 @@ impl ApplyService {
Ok(map)
}
/// §6: create every GROUP node the manifest declares but the server lacks,
/// INSIDE the apply tx (parents-first, from the CLI's directory ordering).
/// Returns the complete slug → id map (existing + created) so `prepare_tree`
/// resolves the whole tree. Enforces the attach-point ceiling + RBAC on each
/// create; claiming happens in Phase A alongside existing groups. The coarse
/// structural lock is taken only once, on the first create.
/// §6 M2: preview an EXISTING group node's structural divergence — `Some`
/// only when the server's parent differs from the manifest's declared
/// (directory-nesting) parent. `None` for an app node, a to-create group, or
/// an in-sync group.
async fn divergence_preview(
&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)]
async fn create_missing_groups_tx(
async fn reconcile_group_structure_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
bundle: &TreeBundle,
project: Option<&ProjectDecl>,
principal: &Principal,
mode: StructureMode,
) -> Result<(HashMap<String, GroupId>, HashSet<GroupId>), ApplyError> {
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_gid = match project.and_then(|p| p.parent_group.as_deref()) {
let attach_slug = project.and_then(|p| p.parent_group.as_deref());
let attach_gid = match attach_slug {
Some(slug) => Some(
read_group_id_by_slug_tx(tx, slug)
.await
@@ -1915,101 +2061,125 @@ impl ApplyService {
};
let mut map: HashMap<String, GroupId> = HashMap::new();
let mut created: HashSet<GroupId> = HashSet::new();
let mut created_within_attach: HashSet<GroupId> = HashSet::new();
let mut changed: HashSet<GroupId> = HashSet::new();
let mut within_attach: HashSet<GroupId> = HashSet::new();
let mut locked = false;
for n in &bundle.nodes {
if n.kind != NodeKind::Group {
continue;
}
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
.map_err(map_group_repo_err)?
{
map.insert(key, gid);
continue;
}
// The group must be CREATED. Serialize creation with other structural
// mutations under the coarse lock (acquired once, on first create),
// re-reading after the lock in case a racer created it first.
if !locked {
.map_err(map_group_repo_err)?;
let declared_parent = self.resolve_declared_parent(tx, &map, n).await?;
// A structural change (create or reparent) is needed if the group is
// missing, or exists with a divergent parent under ForceLocal.
let diverged = matches!(&existing, Some((_, sp)) if *sp != declared_parent);
let needs_mutation =
existing.is_none() || (diverged && mode == StructureMode::ForceLocal);
if needs_mutation && !locked {
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(GROUP_STRUCTURAL_LOCK_KEY)
.execute(&mut **tx)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
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
.map_err(map_group_repo_err)?
{
map.insert(key, gid);
continue;
}
.map_err(map_group_repo_err)?;
}
crate::groups_api::validate_slug_format(&n.slug).map_err(ApplyError::Invalid)?;
// Resolve the declared parent: an in-tree group (created/existing
// earlier this tree) wins, else a committed server group; `None` =
// instance root.
let parent_id = match &n.parent {
Some(pslug) => Some(match map.get(&pslug.to_lowercase()) {
Some(&g) => g,
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 {
let Some((gid, server_parent)) = existing else {
// Create the missing group under its declared parent.
crate::groups_api::validate_slug_format(&n.slug).map_err(ApplyError::Invalid)?;
if !self
.parent_within_attach(declared_parent, attach_gid, &within_attach)
.await?
{
return Err(ApplyError::OutsideAttachPoint(format!(
"group `{}` would be created outside this project's attach point `{}`",
n.slug,
project
.and_then(|p| p.parent_group.as_deref())
.unwrap_or_default()
attach_slug.unwrap_or_default()
)));
}
}
// RBAC: a root group needs InstanceCreateGroup; a subgroup needs
// GroupAdmin on the parent (mirrors groups_api::create_group).
let cap = match parent_id {
Some(p) => Capability::GroupAdmin(p),
None => Capability::InstanceCreateGroup,
};
require(self.authz.as_ref(), principal, cap)
.await
.map_err(map_authz)?;
let name = n.name.clone().unwrap_or_else(|| n.slug.clone());
let gid = create_group_tx(tx, &n.slug, &name, n.description.as_deref(), parent_id)
// RBAC: a root group needs InstanceCreateGroup; a subgroup needs
// GroupAdmin on the parent (mirrors create_group).
let cap = match declared_parent {
Some(p) => Capability::GroupAdmin(p),
None => Capability::InstanceCreateGroup,
};
require(self.authz.as_ref(), principal, cap)
.await
.map_err(map_authz)?;
let name = n.name.clone().unwrap_or_else(|| n.slug.clone());
let gid = create_group_tx(
tx,
&n.slug,
&name,
n.description.as_deref(),
declared_parent,
)
.await
.map_err(map_group_repo_err)?;
created.insert(gid);
if attach_gid.is_some() {
created_within_attach.insert(gid);
}
changed.insert(gid);
if attach_gid.is_some() {
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);
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

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
/// in the same transaction is visible (a pool read would miss the uncommitted
/// row). `None` if absent.
@@ -101,6 +164,21 @@ pub(crate) async fn read_group_id_by_slug_tx(
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]
pub trait GroupRepository: Send + Sync {
/// 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)
.execute(&mut *tx)
.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));
};
let group = reparent_group_tx(&mut tx, id, new_parent).await?;
tx.commit().await?;
Ok(row.into())
Ok(group)
}
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
/// transaction (Phase 5).
#[allow(clippy::too_many_arguments)]
pub async fn apply_tree(
&self,
bundle: &serde_json::Value,
@@ -1328,6 +1329,7 @@ impl Client {
project: Option<&crate::manifest::ManifestProject>,
takeover: bool,
expected_token: Option<&str>,
structure_mode: &str,
) -> Result<ApplyReportDto> {
let body = serde_json::json!({
"bundle": bundle,
@@ -1335,14 +1337,20 @@ impl Client {
"expected_token": expected_token,
"project": project,
"takeover": takeover,
"structure_mode": structure_mode,
});
let resp = self
.request(Method::POST, "/api/v1/admin/tree/apply")
.json(&body)
.send()
.await?;
// 409 = stale bound plan OR a §7 ownership conflict; surface verbatim.
if resp.status() == reqwest::StatusCode::CONFLICT {
// 409 = stale bound plan OR a §7 ownership conflict; 422 = a §6 M2
// 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 msg = parse_error_body(&body).unwrap_or(body);
return Err(anyhow!("{msg}"));
@@ -1627,6 +1635,19 @@ pub struct NodePlanDto {
/// §7 M3: this node's ownership outcome under the tree's `[project]`.
#[serde(default)]
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.

View File

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

View File

@@ -261,6 +261,14 @@ struct ApplyArgs {
/// top of the base manifest before applying.
#[arg(long)]
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)]
@@ -1433,12 +1441,20 @@ async fn main() -> ExitCode {
Cmd::Whoami => cmds::whoami::run(mode).await,
Cmd::Apply(args) => match &args.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(
dir,
args.prune,
args.yes,
args.force,
args.takeover,
structure_mode,
args.env.as_deref(),
mode,
)