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