fix(desktop): gate identity transition at both supersession boundaries (P26-C1)

The coordinator exposed a single pre-commit validity check reaching only the
late (fenced) boundary, but P26-C1 mandates TWO gates: an early gate under the
held transition lock — before Mesh preflight and drain — so a task superseded
while queued on the locks is rejected having done zero disruptive work, and the
existing late gate held across the durable commit. A single FnOnce cannot cover
both; without the early gate a cancelled phone-recovery would drain runtimes and
revoke owner-identity durable capabilities before the late check rejected it,
churning the fail-closed side uncompensated.

Take two explicit checks (early_validity_check + late_validity_check). Normal
import supplies always-Ok for both; pairing supplies two clones of the
idempotent task-currency read. Collapse the mesh/non-mesh fork onto one manual
workspace_transition acquisition and gate the mesh preflight call so the early
gate sits at the same point under both feature configs.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-18 18:37:54 -04:00
parent 998f1ef8b9
commit b91754044b
3 changed files with 72 additions and 49 deletions

View File

@@ -383,8 +383,9 @@ pub async fn import_identity(
password: Option<String>,
app_handle: tauri::AppHandle,
) -> Result<IdentityInfo, String> {
// Normal import: no supersession fence, always-valid pre-commit check.
run_identity_transition(app_handle, nsec, password, None, || Ok(())).await
// Normal import: no supersession fence, both validity gates always pass —
// there is no queued-task currency to check.
run_identity_transition(app_handle, nsec, password, None, || Ok(()), || Ok(())).await
}
/// Finish an imported identity commit whose durable persist already succeeded

View File

@@ -72,17 +72,43 @@ fn compensate_import_drain(
/// `workspace_transition` (never `identity_mutation`), so the global order
/// `identity_mutation` → `workspace_transition` has no cycle.
///
/// `commit_fence` + `validity_check` are threaded to the pre-commit boundary:
/// normal import supplies `None` + always-`Ok`; the phone-recovery continuation
/// supplies the pairing `generation_fence` + its generation-current check, so a
/// superseded recovery compensates the drain and commits NO identity while a
/// recovery that wins the fence commits durably (P26-C1).
/// `commit_fence` + `late_validity_check` are threaded to the pre-commit
/// boundary: normal import supplies `None` + always-`Ok`; the phone-recovery
/// continuation supplies the pairing `generation_fence` + its generation-current
/// check, so a superseded recovery compensates the drain and commits NO identity
/// while a recovery that wins the fence commits durably (P26-C1).
///
/// # Two validity boundaries (P26-C1)
///
/// The transition is gated at TWO points, each closing a distinct supersession
/// window; a single check cannot cover both because they straddle the drain:
///
/// - **`early_validity_check`** runs under the held `workspace_transition`
/// guard, immediately after the locks are acquired and BEFORE the Mesh
/// preflight and drain. Its job is to reject a task that was already
/// superseded/cancelled while it queued on the locks, doing ZERO disruptive
/// work — no drain, no Mesh state change, no egress-barrier bump, nothing to
/// compensate. Normal import supplies always-`Ok`; pairing supplies the
/// task-currency check.
/// - **`late_validity_check`** runs inside [`import_identity_blocking`] at the
/// fence-held pre-commit boundary — after a successful drain, immediately
/// before the durable dispatch — so a supersession that lands during the
/// drain window is caught before any identity is committed (the drained
/// runtimes are then compensated). This is the boundary the `commit_fence`
/// makes indivisible against a racing invalidation.
///
/// A cancellation that lands AFTER the early gate but DURING the drain reaches
/// the runtime revoke before `late_validity_check` rejects it; the drained
/// runtimes compensate, but revoked owner-identity durable capabilities stay
/// revoked. This is design-conformant fail-closed churn, not a split state —
/// see `CROSS_WORKSPACE_AGENT_LIBRARY.md` §3.3a (barrier sequence is fixed).
pub(crate) async fn run_identity_transition(
app_handle: tauri::AppHandle,
nsec: String,
password: Option<String>,
commit_fence: Option<std::sync::Arc<std::sync::Mutex<()>>>,
validity_check: impl FnOnce() -> Result<(), String> + Send + 'static,
early_validity_check: impl FnOnce() -> Result<(), String>,
late_validity_check: impl FnOnce() -> Result<(), String> + Send + 'static,
) -> Result<IdentityInfo, String> {
// ── Layer 1: identity_mutation (async serialization lock) ────────────────
// Held for the full import to prevent a concurrent stale persist from
@@ -95,52 +121,43 @@ pub(crate) async fn run_identity_transition(
let lock_state = lock_handle.state::<AppState>();
let _mutation_guard = lock_state.identity_mutation.lock().await;
// ── Layer 1b: workspace_transition + Mesh preflight (UNCONDITIONAL) ──────
// ── Layer 1b: workspace_transition (UNCONDITIONAL) ───────────────────────
// Always route through the transition lock, whether or not a scope appears
// active — the active/no-scope decision is made INSIDE the blocking body
// from a snapshot taken under this guard, so a concurrent apply_workspace
// can neither slip a `None → Some` activation past the scope sample nor
// race the durable commit (P26-C1). When the `mesh-llm` feature is enabled,
// `with_workspace_transition_preflight` acquires `workspace_transition`,
// runs `fail_if_client_mesh_active`, then invokes the body while the lock
// remains held — the same orchestration path `apply_workspace` uses.
// Without `mesh-llm`, acquire `workspace_transition` manually (no mesh
// check needed) and invoke the blocking body under it.
let app_for_body = app_handle.clone();
// race the durable commit (P26-C1).
let _transition_guard = lock_state.workspace_transition.lock().await;
// ── Early validity gate (P26-C1) ─────────────────────────────────────────
// Under the held transition guard, BEFORE the Mesh preflight and drain: a
// task superseded while queued on the locks is rejected here having done
// ZERO disruptive work (no drain, no Mesh, no egress-barrier change).
early_validity_check()?;
// ── Mesh preflight (UNCONDITIONAL, no-op without `mesh-llm`) ──────────────
// Fail closed if a client-mode Mesh runtime is active. Runs under the held
// `workspace_transition` guard — the same orchestration invariant
// `apply_workspace` relies on.
#[cfg(feature = "mesh-llm")]
let result = crate::commands::mesh_llm::scope_impl::with_workspace_transition_preflight(
&app_handle,
move || {
Box::pin(async move {
tokio::task::spawn_blocking(move || {
import_identity_blocking(
app_for_body,
nsec,
password,
commit_fence,
validity_check,
)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
})
},
)
.await;
crate::commands::mesh_llm::scope_impl::run_mesh_transition_preflight(&app_handle).await?;
#[cfg(not(feature = "mesh-llm"))]
let result = {
let _transition_guard = lock_state.workspace_transition.lock().await;
tokio::task::spawn_blocking(move || {
import_identity_blocking(app_for_body, nsec, password, commit_fence, validity_check)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
};
let app_for_body = app_handle.clone();
let result = tokio::task::spawn_blocking(move || {
import_identity_blocking(
app_for_body,
nsec,
password,
commit_fence,
late_validity_check,
)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?;
// identity_mutation must outlive spawn_blocking — drop explicitly here so
// the compiler can see the guard's lifetime covers both branches.
// Both transition guards must outlive spawn_blocking — drop explicitly here
// so the compiler can see their lifetimes cover the blocking body.
drop(_transition_guard);
drop(_mutation_guard);
result

View File

@@ -472,14 +472,19 @@ async fn import_recovered_identity(
// runtimes, clears the active scope, and bumps the scope generation — none
// of which the old direct `commit_imported_identity` path performed. The
// pairing `generation_fence` is the coordinator's commit fence and the
// task-currency check is its pre-commit validity check, held across the
// durable commit (P26-C1): a superseded recovery compensates the drain and
// commits NO identity; a recovery that wins the fence commits durably.
// task-currency check gates BOTH validity boundaries (P26-C1): the early
// gate rejects a recovery already superseded while it queued on the locks
// (zero disruptive work); the late gate, held across the durable commit,
// rejects one superseded during the drain (compensates, commits NO
// identity). `ensure_pairing_task_is_current` is an idempotent currency
// read, so both gates get an independent clone of the same check.
let early_generation = Arc::clone(&generation);
crate::commands::identity::run_identity_transition(
app.clone(),
nsec.trim().to_string(),
None,
Some(Arc::clone(generation_fence)),
move || ensure_pairing_task_is_current(&early_generation, task_generation),
move || ensure_pairing_task_is_current(&generation, task_generation),
)
.await