feat(apply): [project] parent_group attach-point ceiling

§6/§7 M2: a project's attach point is its ceiling — applies are refused for
any node not strictly within the declared subtree, so a repo can't reach (or
claim) above its local root even with the RBAC to do so.

- `[project] parent_group = "<slug>"` (ManifestProject + ProjectDecl); absent
  = instance root = no ceiling (the default, backward-compatible).
- `check_within_attach`: the attach group must be a PROPER ancestor of a group
  node (you can't apply the attach point itself) or an ancestor (inclusive) of
  an app node's group; resolved via `groups.ancestors`. Enforced read-only in
  apply_owner (prologue) + apply_tree (per node), before the claim.
- `ApplyError::OutsideAttachPoint` → 422 (a scope error, like Invalid).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-06 20:57:03 +02:00
parent 5bd72956b1
commit 5bb1ccad5e
3 changed files with 88 additions and 1 deletions

View File

@@ -278,6 +278,11 @@ pub struct ManifestProject {
pub slug: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// `parent_group` (§7/§6) — the pre-existing group this repo binds UNDER
/// (its attach point). Applies are refused for any node not strictly within
/// that subtree. Absent = instance root = no ceiling (the default).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_group: Option<String>,
}
/// The store kind of a shared collection (§11.6).
@@ -973,6 +978,14 @@ mod tests {
// Unknown key inside [project] is rejected (deny_unknown_fields).
Manifest::parse("[project]\nslug=\"p\"\nbogus=1\n[app]\nslug=\"w\"\nname=\"W\"\n")
.expect_err("unknown [project] key rejected");
// §6/§7 M2: the optional attach-point `parent_group`.
let m = Manifest::parse(
"[project]\nslug = \"p\"\nparent_group = \"acme\"\n\n\
[group]\nslug = \"team\"\nname = \"T\"\n",
)
.expect("parent_group parses");
assert_eq!(m.project.unwrap().parent_group.as_deref(), Some("acme"));
}
#[test]