feat(apply): project on the plan wire + ownership preview annotation

§7 M3 (part 1): `pic plan` now surfaces the ownership outcome BEFORE apply.

- plan / plan_owner / plan_tree take the declaring `[project]`; each result
  carries an `ownership` preview (pure `preview_ownership`, unit-tested):
  claim (unclaimed + project), owned (already this project), conflict (owned by
  X — named), or unclaimed. A group node previews its OWN owner; an app node its
  nearest claimed ancestor (read-only, on the pool).
- the attach-point ceiling (M2) is now previewed at plan too, so `pic plan`
  outside the subtree fails early — consistent with apply.
- wire: PlanRequest / TreePlanRequest wrap { bundle, project } (project
  `#[serde(default)]` → a pre-M3 CLI still plans); the plan DTOs + `pic plan`
  gain an `ownership` row (mode-agnostic: shows in tsv + json).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-06 21:09:55 +02:00
parent 5a820d7262
commit b4eb226d20
4 changed files with 241 additions and 21 deletions

View File

@@ -179,6 +179,15 @@ async fn group_extension_points_handler(
Ok(Json(report))
}
/// A `plan` request (§7 M3): the desired bundle plus the optional declaring
/// `[project]`, so the plan can preview the ownership outcome + attach ceiling.
#[derive(Deserialize)]
pub struct PlanRequest {
pub bundle: Bundle,
#[serde(default)]
pub project: Option<ProjectDecl>,
}
#[derive(Deserialize)]
pub struct ApplyRequest {
pub bundle: Bundle,
@@ -225,7 +234,7 @@ async fn plan_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(bundle): Json<Bundle>,
Json(req): Json<PlanRequest>,
) -> Result<Json<PlanResult>, ApplyError> {
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
// NOTE: the returned `Plan` discloses live secret NAMES (not values). That
@@ -237,7 +246,7 @@ async fn plan_handler(
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
.await
.map_err(map_authz)?;
let plan = svc.plan(app_id, &bundle).await?;
let plan = svc.plan(app_id, &req.bundle, req.project.as_ref()).await?;
Ok(Json(plan))
}
@@ -251,7 +260,7 @@ async fn group_plan_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(bundle): Json<Bundle>,
Json(req): Json<PlanRequest>,
) -> Result<Json<PlanResult>, ApplyError> {
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
// Plan discloses the group's script + var + secret names; viewer-tier reads.
@@ -262,7 +271,13 @@ async fn group_plan_handler(
)
.await
.map_err(map_authz)?;
let plan = svc.plan_owner(ApplyOwner::Group(group_id), &bundle).await?;
let plan = svc
.plan_owner(
ApplyOwner::Group(group_id),
&req.bundle,
req.project.as_ref(),
)
.await?;
Ok(Json(plan))
}
@@ -310,13 +325,22 @@ struct TreeApplyRequest {
takeover: bool,
}
#[derive(Deserialize)]
struct TreePlanRequest {
bundle: TreeBundle,
#[serde(default)]
project: Option<ProjectDecl>,
}
async fn tree_plan_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Json(bundle): Json<TreeBundle>,
Json(req): Json<TreePlanRequest>,
) -> Result<Json<TreePlanResult>, ApplyError> {
authz_tree(&svc, &principal, &bundle, None).await?;
Ok(Json(svc.plan_tree(&bundle).await?))
authz_tree(&svc, &principal, &req.bundle, None).await?;
Ok(Json(
svc.plan_tree(&req.bundle, req.project.as_ref()).await?,
))
}
async fn tree_apply_handler(