feat(apply): §6 M1 — declarative group create (directory-nesting parentage)

Lift "groups must pre-exist" for the tree apply: a `[group]` node now carries a
declared parent (inferred from directory nesting) and `pic apply --dir` creates
the ones the server lacks, in the same transaction.

Wire: `TreeNode` gains `parent`/`name`/`description` (`#[serde(default)]` — a
pre-§6 CLI still reconciles existing groups). `discover::build_tree` computes a
group's parent as the nearest ancestor directory holding a `[group]` (topmost →
the repo's `[project] parent_group` attach point, else instance root) and emits
nodes parents-first by directory depth.

Server: `apply_tree` runs a Phase 0 (`create_missing_groups_tx`) that
resolves-or-creates every group node IN the tx (a same-tree parent resolved
before its child via the new `group_repo::{create_group_tx,
read_group_id_by_slug_tx}`), under the coarse `GROUP_STRUCTURAL_LOCK_KEY` (taken
only when creating), enforcing the attach-point ceiling + RBAC
(`InstanceCreateGroup` / `GroupAdmin(parent)`) per create. A created group is
CLAIMED in Phase A alongside existing ones (`decide_group_claim` → `Claim`).
`prepare_tree` now takes a caller-supplied `resolved_ids` map and returns a
`to_create` list the plan path previews (empty current → full-create plan,
ownership `claim`); a to-create group's token part equals its post-create part
so plan/apply tokens match across a create. `validate_bundle_for` takes
`is_group: bool` (a to-create group has no id yet); `authz_tree` skips a
to-create group (its create authz is the service gate, not an existing-group
capability).

Reparent of a diverged existing group + the detect-and-refuse flags are M2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-07 20:07:03 +02:00
parent 76926de369
commit 0078ae8b26
4 changed files with 422 additions and 44 deletions

View File

@@ -401,7 +401,19 @@ async fn authz_tree(
}
}
NodeKind::Group => {
let group_id = resolve_group_id(svc.groups.as_ref(), &node.slug).await?;
// §6: a group node that doesn't exist yet is to-CREATE — its
// authorization (InstanceCreateGroup / GroupAdmin(parent)) is
// enforced in the service's create path, so skip the
// existing-group capability check here rather than 404.
let Some(group) = svc
.groups
.get_by_slug(&node.slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
else {
continue;
};
let group_id = group.id;
match apply_prune {
Some(prune) => {
require_group_node_writes(svc, principal, group_id, &node.bundle, prune)