feat(apply): §7 ownership claim state machine + project/takeover wire + 409
The heart of multi-repo ownership: an apply now claims/verifies node ownership before it reconciles, so two repos can't clobber each other's subtree. - Pure policy (DB-free, unit-tested): `decide_group_claim` (unclaimed→claim, owner→noop, foreign→conflict unless --takeover, no-project-into-claimed→ conflict) and `decide_app_owner` (an app must match its nearest claimed ancestor; an unclaimed subtree stays open — backward-compatible). - Execution under the per-node advisory lock: `upsert_project_tx` (first apply registers the project), `read/write_group_owner_tx`, `claim_group_owner` (takeover additionally requires `GroupAdmin` — ownership ⟂ RBAC), `check_app_owner_single` + tree `nearest_claimed_in_tx` (in-tree Phase-A claim overlays committed ancestors). No structure_version bump — a claim isn't a diff change, so it must not churn a pending bound plan. - `apply`/`apply_owner`/`apply_tree` take an `OwnershipClaim` (project + takeover + principal); the claim slots after the lock, before load_current, so a conflict short-circuits before any diff work. - New `ApplyError::OwnershipConflict` → HTTP 409 (actionable); wired through `ApplyRequest`/`TreeApplyRequest` (both `#[serde(default)]`, so the existing CLI stays compatible until it learns [project]). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,8 +18,8 @@ use serde_json::json;
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::apply_service::{
|
||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
|
||||
ExtensionPointInfo, NodeKind, PlanResult, RouteTemplateInfo, SuppressionInfo, TreeBundle,
|
||||
TreePlanResult, TriggerTemplateInfo,
|
||||
ExtensionPointInfo, NodeKind, OwnershipClaim, PlanResult, ProjectDecl, RouteTemplateInfo,
|
||||
SuppressionInfo, TreeBundle, TreePlanResult, TriggerTemplateInfo,
|
||||
};
|
||||
use crate::authz::{require, AuthzDenied, Capability};
|
||||
use crate::group_repo::GroupRepository;
|
||||
@@ -188,6 +188,12 @@ pub struct ApplyRequest {
|
||||
/// refuses (409) if the app's live state has changed since.
|
||||
#[serde(default)]
|
||||
pub expected_token: Option<String>,
|
||||
/// §7 ownership: the declaring repo's `[project]` (absent = no claim).
|
||||
#[serde(default)]
|
||||
pub project: Option<ProjectDecl>,
|
||||
/// §7 ownership: reassign a node owned by another project (group-admin).
|
||||
#[serde(default)]
|
||||
pub takeover: bool,
|
||||
}
|
||||
|
||||
async fn apply_handler(
|
||||
@@ -198,12 +204,17 @@ async fn apply_handler(
|
||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
require_app_node_writes(&svc, &principal, app_id, &req.bundle, req.prune).await?;
|
||||
let claim = OwnershipClaim {
|
||||
project: req.project,
|
||||
takeover: req.takeover,
|
||||
principal: &principal,
|
||||
};
|
||||
let report = svc
|
||||
.apply(
|
||||
app_id,
|
||||
&req.bundle,
|
||||
req.prune,
|
||||
principal.user_id,
|
||||
&claim,
|
||||
req.expected_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
@@ -263,12 +274,17 @@ async fn group_apply_handler(
|
||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
require_group_node_writes(&svc, &principal, group_id, &req.bundle, req.prune).await?;
|
||||
let claim = OwnershipClaim {
|
||||
project: req.project,
|
||||
takeover: req.takeover,
|
||||
principal: &principal,
|
||||
};
|
||||
let report = svc
|
||||
.apply_owner(
|
||||
ApplyOwner::Group(group_id),
|
||||
&req.bundle,
|
||||
req.prune,
|
||||
principal.user_id,
|
||||
&claim,
|
||||
req.expected_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
@@ -287,6 +303,11 @@ struct TreeApplyRequest {
|
||||
prune: bool,
|
||||
#[serde(default)]
|
||||
expected_token: Option<String>,
|
||||
/// §7 ownership: one `[project]` for the whole tree (the root manifest's).
|
||||
#[serde(default)]
|
||||
project: Option<ProjectDecl>,
|
||||
#[serde(default)]
|
||||
takeover: bool,
|
||||
}
|
||||
|
||||
async fn tree_plan_handler(
|
||||
@@ -304,11 +325,16 @@ async fn tree_apply_handler(
|
||||
Json(req): Json<TreeApplyRequest>,
|
||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||
authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
|
||||
let claim = OwnershipClaim {
|
||||
project: req.project,
|
||||
takeover: req.takeover,
|
||||
principal: &principal,
|
||||
};
|
||||
let report = svc
|
||||
.apply_tree(
|
||||
&req.bundle,
|
||||
req.prune,
|
||||
principal.user_id,
|
||||
&claim,
|
||||
req.expected_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
@@ -504,6 +530,10 @@ impl IntoResponse for ApplyError {
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
||||
// §7 ownership conflict — actionable (declare [project], --takeover).
|
||||
Self::OwnershipConflict(_) => {
|
||||
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "apply authz repo error");
|
||||
|
||||
Reference in New Issue
Block a user