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

@@ -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.