test/docs(ownership): attach-ceiling journey + M2 status
The apply_ownership journey gains an attach-ceiling case: a group node below the attach point applies; the attach point itself and a sibling subtree are both refused (422, message names the attach point). Design doc §7 + CLAUDE.md record M2 shipped and re-point 'Next' at M3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -214,3 +214,80 @@ fn claim_conflict_takeover_and_app_inheritance() {
|
|||||||
"a refused takeover must not change ownership"
|
"a refused takeover must not change ownership"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §6/§7 M2 — `[project] parent_group` is the ceiling: applies are refused for
|
||||||
|
/// any node not strictly within the attach point's subtree.
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn attach_point_ceiling_bounds_the_subtree() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
// acme (root) → team (child); plus a sibling `outsider` root.
|
||||||
|
let acme = common::unique_slug("acme");
|
||||||
|
let team = common::unique_slug("team");
|
||||||
|
let outsider = common::unique_slug("outsider");
|
||||||
|
let _a = GroupGuard::new(&env.url, &env.token, &acme);
|
||||||
|
let _t = GroupGuard::new(&env.url, &env.token, &team);
|
||||||
|
let _o = GroupGuard::new(&env.url, &env.token, &outsider);
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["groups", "create", &acme])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["groups", "create", &team, "--parent", &acme])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["groups", "create", &outsider])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let proj = common::unique_slug("attach-p");
|
||||||
|
let dir = manifest_dir();
|
||||||
|
let apply = |m: &str| -> std::process::Output {
|
||||||
|
fs::write(dir.path().join("picloud.toml"), m).unwrap();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apply", "--file"])
|
||||||
|
.arg(dir.path().join("picloud.toml"))
|
||||||
|
.output()
|
||||||
|
.expect("apply")
|
||||||
|
};
|
||||||
|
|
||||||
|
// A group node strictly BELOW the attach point → ok.
|
||||||
|
let out = apply(&format!(
|
||||||
|
"[project]\nslug = \"{proj}\"\nparent_group = \"{acme}\"\n\n\
|
||||||
|
[group]\nslug = \"{team}\"\nname = \"Team\"\n"
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"a node below the attach point applies: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
// The attach point ITSELF → refused (you can't apply above your local root).
|
||||||
|
let out = apply(&format!(
|
||||||
|
"[project]\nslug = \"{proj}\"\nparent_group = \"{acme}\"\n\n\
|
||||||
|
[group]\nslug = \"{acme}\"\nname = \"Acme\"\n"
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
!out.status.success(),
|
||||||
|
"applying the attach point itself must be refused"
|
||||||
|
);
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||||||
|
assert!(
|
||||||
|
err.contains("attach point"),
|
||||||
|
"the refusal must mention the attach point:\n{err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A SIBLING subtree (not under acme) → refused.
|
||||||
|
let out = apply(&format!(
|
||||||
|
"[project]\nslug = \"{proj}\"\nparent_group = \"{acme}\"\n\n\
|
||||||
|
[group]\nslug = \"{outsider}\"\nname = \"Out\"\n"
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
!out.status.success(),
|
||||||
|
"a sibling subtree is outside the attach point"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -775,10 +775,16 @@ requires `GroupAdmin` — ownership ⟂ RBAC), no-project-into-a-claimed-subtree
|
|||||||
`owner_project`; an app inherits ownership from its **nearest claimed ancestor group** (the ancestor walk
|
`owner_project`; an app inherits ownership from its **nearest claimed ancestor group** (the ancestor walk
|
||||||
is the boundary), and an unclaimed subtree stays open (backward-compatible — nothing changes until a repo
|
is the boundary), and an unclaimed subtree stays open (backward-compatible — nothing changes until a repo
|
||||||
first declares `[project]`). Visibility: `pic groups ls` shows an `owner` column; `--takeover` on `pic
|
first declares `[project]`). Visibility: `pic groups ls` shows an `owner` column; `--takeover` on `pic
|
||||||
apply`. Pinned by `apply_service` unit tests + the `apply_ownership` journey. **Deferred:** point 2's
|
apply`. Pinned by `apply_service` unit tests + the `apply_ownership` journey.
|
||||||
attach-point *ceiling* (M2 — `[project] parent_group`), and the plan-time cross-repo blast-radius preview +
|
|
||||||
`pic projects ls` (M3). The **structural-divergence** detection of §6 and declarative group create/reparent
|
**Status — M2 shipped (the attach-point ceiling, point 2).** A `[project] parent_group = "<slug>"` binds the
|
||||||
(lifting "groups pre-exist") remain later work.
|
repo UNDER a pre-existing group; applies are refused (422 `OutsideAttachPoint`) for any node not strictly
|
||||||
|
within that subtree. `check_within_attach` requires the attach group be a **proper** ancestor of a group node
|
||||||
|
(so you can't apply the attach point itself, only its descendants) or an ancestor (inclusive) of an app node's
|
||||||
|
group — resolved via `groups.ancestors`, enforced read-only before the claim in both `apply_owner` and
|
||||||
|
`apply_tree`. Absent = instance root = no ceiling (default). Pinned by the `apply_ownership` journey's
|
||||||
|
attach-ceiling case. **Deferred:** the plan-time cross-repo blast-radius preview + `pic projects ls` (M3); the
|
||||||
|
**structural-divergence** detection of §6; declarative group create/reparent (lifting "groups pre-exist").
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user