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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user