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:
MechaCat02
2026-07-06 20:27:51 +02:00
parent 2cce051dc4
commit 47072c481d
2 changed files with 450 additions and 12 deletions

View File

@@ -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");