2026-06-08 20:07:39 +03:00
use std ::collections ::{ BTreeMap , BTreeSet } ;
2026-06-08 21:09:23 +03:00
use std ::fs ::{ self , OpenOptions } ;
use std ::io ::{ ErrorKind , Write } ;
2026-06-08 20:07:39 +03:00
use std ::path ::{ Path , PathBuf } ;
2026-06-08 21:09:23 +03:00
use std ::process ;
2026-06-08 20:07:39 +03:00
2026-06-10 13:04:19 +03:00
use omnigraph ::db ::{ Omnigraph , ReadTarget , SchemaApplyOptions } ;
use omnigraph_compiler ::SchemaMigrationPlan ;
2026-06-08 20:07:39 +03:00
use omnigraph_compiler ::build_catalog ;
use omnigraph_compiler ::query ::parser ::parse_query ;
use omnigraph_compiler ::query ::typecheck ::typecheck_query_decl ;
use omnigraph_compiler ::schema ::parser ::parse_schema ;
use serde ::{ Deserialize , Serialize } ;
2026-06-08 23:18:44 +03:00
use serde_json ::json ;
2026-06-08 20:07:39 +03:00
use sha2 ::{ Digest , Sha256 } ;
2026-06-08 21:09:23 +03:00
use time ::OffsetDateTime ;
use time ::format_description ::well_known ::Rfc3339 ;
use ulid ::Ulid ;
2026-06-08 20:07:39 +03:00
2026-06-10 02:12:59 +03:00
pub mod failpoints ;
2026-06-11 05:28:04 +03:00
mod store ;
use store ::{ LocalStateBackend , StateLockGuard , StateSnapshot } ;
2026-06-08 20:07:39 +03:00
pub const CLUSTER_CONFIG_FILE : & str = " cluster.yaml " ;
2026-06-08 23:18:44 +03:00
pub const CLUSTER_GRAPHS_DIR : & str = " graphs " ;
2026-06-08 21:09:23 +03:00
pub const CLUSTER_STATE_DIR : & str = " __cluster " ;
2026-06-08 20:07:39 +03:00
pub const CLUSTER_STATE_FILE : & str = " __cluster/state.json " ;
2026-06-08 21:09:23 +03:00
pub const CLUSTER_LOCK_FILE : & str = " __cluster/lock.json " ;
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
pub const CLUSTER_RESOURCES_DIR : & str = " __cluster/resources " ;
2026-06-10 04:50:42 +03:00
pub const CLUSTER_RECOVERIES_DIR : & str = " __cluster/recoveries " ;
2026-06-10 14:29:00 +03:00
pub const CLUSTER_APPROVALS_DIR : & str = " __cluster/approvals " ;
2026-06-08 20:07:39 +03:00
2026-06-08 21:09:23 +03:00
#[ derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq) ]
2026-06-08 20:07:39 +03:00
#[ serde(rename_all = " snake_case " ) ]
pub enum DiagnosticSeverity {
Error ,
Warning ,
}
#[ derive(Debug, Clone, Serialize, PartialEq, Eq) ]
pub struct Diagnostic {
pub code : String ,
pub severity : DiagnosticSeverity ,
pub path : String ,
pub message : String ,
}
impl Diagnostic {
fn error ( code : impl Into < String > , path : impl Into < String > , message : impl Into < String > ) -> Self {
Self {
code : code . into ( ) ,
severity : DiagnosticSeverity ::Error ,
path : path . into ( ) ,
message : message . into ( ) ,
}
}
fn warning (
code : impl Into < String > ,
path : impl Into < String > ,
message : impl Into < String > ,
) -> Self {
Self {
code : code . into ( ) ,
severity : DiagnosticSeverity ::Warning ,
path : path . into ( ) ,
message : message . into ( ) ,
}
}
}
#[ derive(Debug, Clone, Serialize, PartialEq, Eq) ]
pub struct ResourceSummary {
pub address : String ,
pub kind : String ,
pub digest : String ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub path : Option < String > ,
}
#[ derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord) ]
pub struct Dependency {
pub from : String ,
pub to : String ,
}
#[ derive(Debug, Clone, Serialize) ]
pub struct ValidateOutput {
pub ok : bool ,
pub config_dir : String ,
pub config_file : String ,
pub resource_digests : BTreeMap < String , String > ,
pub resources : Vec < ResourceSummary > ,
pub dependencies : Vec < Dependency > ,
pub diagnostics : Vec < Diagnostic > ,
}
#[ derive(Debug, Clone, Serialize) ]
pub struct DesiredRevision {
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub config_digest : Option < String > ,
}
#[ derive(Debug, Clone, Serialize) ]
pub struct StateObservations {
pub state_path : String ,
2026-06-08 21:09:23 +03:00
pub lock_path : String ,
2026-06-08 20:07:39 +03:00
pub state_found : bool ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub applied_config_digest : Option < String > ,
2026-06-08 21:09:23 +03:00
pub state_revision : u64 ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub state_cas : Option < String > ,
2026-06-08 20:07:39 +03:00
pub resource_count : usize ,
2026-06-08 21:09:23 +03:00
pub locked : bool ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub lock_id : Option < String > ,
2026-06-09 18:30:33 +03:00
pub lock_acquired : bool ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub acquired_lock_id : Option < String > ,
2026-06-09 02:12:00 +03:00
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub lock_operation : Option < String > ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub lock_created_at : Option < String > ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub lock_pid : Option < u32 > ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub lock_age_seconds : Option < u64 > ,
}
impl StateObservations {
fn observe_lock_metadata ( & mut self , lock : & StateLockFile ) {
self . locked = true ;
self . lock_id = Some ( lock . lock_id . clone ( ) ) ;
self . lock_operation = Some ( lock . operation . clone ( ) ) ;
self . lock_created_at = Some ( lock . created_at . clone ( ) ) ;
self . lock_pid = Some ( lock . pid ) ;
self . lock_age_seconds = lock_age_seconds ( & lock . created_at ) ;
}
2026-06-08 21:09:23 +03:00
}
#[ derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq) ]
#[ serde(rename_all = " snake_case " ) ]
pub enum ResourceLifecycleStatus {
Pending ,
Planned ,
Applying ,
Applied ,
Drifted ,
Blocked ,
Error ,
}
#[ derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq) ]
#[ serde(deny_unknown_fields) ]
pub struct ResourceStatusRecord {
pub status : ResourceLifecycleStatus ,
#[ serde(default, skip_serializing_if = " Vec::is_empty " ) ]
pub conditions : Vec < String > ,
#[ serde(default, skip_serializing_if = " Option::is_none " ) ]
pub message : Option < String > ,
2026-06-08 20:07:39 +03:00
}
#[ derive(Debug, Clone, Serialize, PartialEq, Eq) ]
#[ serde(rename_all = " snake_case " ) ]
pub enum PlanOperation {
Create ,
Update ,
Delete ,
}
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
/// How `cluster apply` treats a planned change in the current stage.
///
/// `Applied` changes execute (config-only query/policy catalog writes).
/// `Derived` marks a `graph.<id>` composite-digest update that converges
/// automatically once its applied query digests land in state. `Deferred`
/// changes need a later phase (graph/schema lifecycle or schema content).
/// `Blocked` query/policy changes are gated by an unapplied or missing
/// dependency.
#[ derive(Debug, Clone, Copy, Serialize, PartialEq, Eq) ]
#[ serde(rename_all = " snake_case " ) ]
pub enum ApplyDisposition {
Applied ,
Derived ,
Deferred ,
Blocked ,
}
2026-06-10 13:04:19 +03:00
#[ derive(Debug, Clone, Serialize, PartialEq) ]
2026-06-08 20:07:39 +03:00
pub struct PlanChange {
pub resource : String ,
pub operation : PlanOperation ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub before_digest : Option < String > ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub after_digest : Option < String > ,
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub disposition : Option < ApplyDisposition > ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub reason : Option < String > ,
2026-06-10 15:30:33 +03:00
/// True for a policy change whose file digest is unchanged but whose
/// `applies_to` bindings differ from the applied revision (including the
/// pre-5A backfill case).
#[ serde(default, skip_serializing_if = " std::ops::Not::not " ) ]
pub binding_change : bool ,
2026-06-10 13:04:19 +03:00
/// For schema updates: the engine's migration plan against the live
/// graph (RFC-004 §D7's data-aware preview). Absent when the preview is
/// unavailable (warning `schema_preview_unavailable`).
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub migration : Option < SchemaMigrationPlan > ,
2026-06-08 20:07:39 +03:00
}
#[ derive(Debug, Clone, Serialize, PartialEq, Eq) ]
pub struct BlastRadius {
pub resource : String ,
pub affected : Vec < String > ,
}
#[ derive(Debug, Clone, Serialize, PartialEq, Eq) ]
pub struct ApprovalRequirement {
pub resource : String ,
pub reason : String ,
2026-06-10 14:29:00 +03:00
/// True when a valid (digest-matching, unconsumed) approval artifact is
/// pending for this change.
pub satisfied : bool ,
2026-06-08 20:07:39 +03:00
}
#[ derive(Debug, Clone, Serialize) ]
pub struct PlanOutput {
pub ok : bool ,
pub config_dir : String ,
pub desired_revision : DesiredRevision ,
pub resource_digests : BTreeMap < String , String > ,
pub dependencies : Vec < Dependency > ,
pub state_observations : StateObservations ,
pub changes : Vec < PlanChange > ,
pub blast_radius : Vec < BlastRadius > ,
pub approvals_required : Vec < ApprovalRequirement > ,
pub diagnostics : Vec < Diagnostic > ,
}
2026-06-08 21:09:23 +03:00
#[ derive(Debug, Clone, Serialize) ]
pub struct StatusOutput {
pub ok : bool ,
pub config_dir : String ,
pub state_observations : StateObservations ,
pub resource_digests : BTreeMap < String , String > ,
pub resource_statuses : BTreeMap < String , ResourceStatusRecord > ,
2026-06-08 23:18:44 +03:00
pub observations : BTreeMap < String , serde_json ::Value > ,
pub diagnostics : Vec < Diagnostic > ,
}
#[ derive(Debug, Clone, Copy, Serialize, PartialEq, Eq) ]
#[ serde(rename_all = " snake_case " ) ]
pub enum StateSyncOperation {
Refresh ,
Import ,
}
#[ derive(Debug, Clone, Serialize) ]
pub struct StateSyncOutput {
pub ok : bool ,
pub operation : StateSyncOperation ,
pub config_dir : String ,
pub state_observations : StateObservations ,
pub resource_digests : BTreeMap < String , String > ,
pub resource_statuses : BTreeMap < String , ResourceStatusRecord > ,
pub observations : BTreeMap < String , serde_json ::Value > ,
2026-06-08 21:09:23 +03:00
pub diagnostics : Vec < Diagnostic > ,
}
2026-06-09 02:12:00 +03:00
#[ derive(Debug, Clone, Serialize) ]
pub struct ForceUnlockOutput {
pub ok : bool ,
pub config_dir : String ,
pub state_observations : StateObservations ,
pub lock_removed : bool ,
pub diagnostics : Vec < Diagnostic > ,
}
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
/// Output of config-only `cluster apply`. "Applied" means recorded in the
/// local cluster catalog (`__cluster/`); nothing applied here serves traffic —
/// the server still boots from `omnigraph.yaml` until the server-boot stage.
#[ derive(Debug, Clone, Serialize) ]
pub struct ApplyOutput {
pub ok : bool ,
pub config_dir : String ,
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub actor : Option < String > ,
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
pub desired_revision : DesiredRevision ,
pub state_observations : StateObservations ,
/// Every planned change, with `disposition`/`reason` always populated.
pub changes : Vec < PlanChange > ,
pub applied_count : usize ,
/// Deferred + Blocked changes (Derived composite updates count as neither).
pub deferred_count : usize ,
/// True when state matches the desired revision after this apply.
pub converged : bool ,
/// False for a no-op re-apply: state bytes (and revision) were left untouched.
pub state_written : bool ,
2026-06-10 00:35:03 +03:00
/// The statuses as persisted: post-apply on success, the pre-apply on-disk
/// snapshot when the state write fails (never unpersisted in-memory state).
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
pub resource_statuses : BTreeMap < String , ResourceStatusRecord > ,
pub diagnostics : Vec < Diagnostic > ,
}
2026-06-10 14:29:00 +03:00
/// A digest-bound human approval for an irreversible operation (RFC-004
/// §D4). Written by `cluster approve`, consumed by apply. The file is never
/// deleted on consumption — it is rewritten with `consumed_at` and also
/// summarized into the state ledger's `approval_records`, so the audit fact
/// survives the loss of either store (axiom 11).
#[ derive(Debug, Clone, Serialize, Deserialize) ]
#[ serde(deny_unknown_fields) ]
struct ApprovalArtifact {
schema_version : u32 ,
approval_id : String ,
resource : String ,
operation : String ,
reason : String ,
bound_config_digest : String ,
#[ serde(default) ]
bound_before_digest : Option < String > ,
#[ serde(default) ]
bound_after_digest : Option < String > ,
approved_by : String ,
created_at : String ,
#[ serde(default) ]
consumed_at : Option < String > ,
#[ serde(default) ]
consumed_by_operation : Option < String > ,
}
#[ derive(Debug, Clone, Serialize) ]
pub struct ApproveOutput {
pub ok : bool ,
pub config_dir : String ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub approval_id : Option < String > ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub resource : Option < String > ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub operation : Option < PlanOperation > ,
#[ serde(skip_serializing_if = " Option::is_none " ) ]
pub approved_by : Option < String > ,
pub diagnostics : Vec < Diagnostic > ,
}
2026-06-08 20:07:39 +03:00
#[ derive(Debug, Clone) ]
struct DesiredCluster {
config_dir : PathBuf ,
config_digest : String ,
2026-06-08 21:09:23 +03:00
state_lock : bool ,
2026-06-08 23:18:44 +03:00
graphs : Vec < DesiredGraph > ,
2026-06-08 20:07:39 +03:00
resource_digests : BTreeMap < String , String > ,
resources : Vec < ResourceSummary > ,
dependencies : Vec < Dependency > ,
2026-06-10 15:30:33 +03:00
/// `policy.<name>` address -> normalized applies_to refs.
policy_bindings : BTreeMap < String , Vec < String > > ,
2026-06-08 20:07:39 +03:00
}
2026-06-08 23:18:44 +03:00
#[ derive(Debug, Clone) ]
struct DesiredGraph {
id : String ,
schema_digest : String ,
}
2026-06-08 21:09:23 +03:00
#[ derive(Debug) ]
struct ParsedConfig {
raw : Option < RawClusterConfig > ,
diagnostics : Vec < Diagnostic > ,
config_dir : PathBuf ,
config_file : PathBuf ,
}
#[ derive(Debug, Clone, Copy) ]
struct ClusterSettings {
state_lock : bool ,
}
2026-06-08 20:07:39 +03:00
#[ derive(Debug) ]
struct LoadOutcome {
desired : Option < DesiredCluster > ,
diagnostics : Vec < Diagnostic > ,
config_dir : PathBuf ,
config_file : PathBuf ,
}
2026-06-09 18:30:33 +03:00
#[ derive(Debug, Serialize, Deserialize) ]
2026-06-08 20:07:39 +03:00
#[ serde(deny_unknown_fields) ]
struct RawClusterConfig {
version : u32 ,
#[ serde(default) ]
metadata : Metadata ,
#[ serde(default) ]
state : StateConfig ,
#[ serde(default) ]
graphs : BTreeMap < String , GraphConfig > ,
#[ serde(default) ]
policies : BTreeMap < String , PolicyConfig > ,
}
2026-06-09 18:30:33 +03:00
#[ derive(Debug, Default, Serialize, Deserialize) ]
2026-06-08 20:07:39 +03:00
#[ serde(deny_unknown_fields) ]
struct Metadata {
name : Option < String > ,
}
2026-06-09 18:30:33 +03:00
#[ derive(Debug, Default, Serialize, Deserialize) ]
2026-06-08 20:07:39 +03:00
#[ serde(deny_unknown_fields) ]
struct StateConfig {
backend : Option < String > ,
lock : Option < bool > ,
}
2026-06-09 18:30:33 +03:00
#[ derive(Debug, Serialize, Deserialize) ]
2026-06-08 20:07:39 +03:00
#[ serde(deny_unknown_fields) ]
struct GraphConfig {
schema : PathBuf ,
#[ serde(default) ]
2026-06-11 00:46:21 +03:00
queries : QueriesDecl ,
}
/// How a graph declares its stored queries. Terraform-style: the `.gq`
/// files ARE the declaration — point at them (or a directory) and every
/// `query <name>` they contain is discovered. The explicit name->file map
/// remains for fine-grained control.
#[ derive(Debug, Serialize, Deserialize) ]
#[ serde(untagged) ]
enum QueriesDecl {
/// `queries: ./queries/` — a directory (top-level `*.gq`, sorted) or a
/// single `.gq` file; every declaration inside is registered.
Discover ( PathBuf ) ,
/// `queries: [./queries/, ./extra.gq]` — several directories/files.
DiscoverMany ( Vec < PathBuf > ) ,
/// `queries: { name: { file: ... } }` — explicit registry.
Explicit ( BTreeMap < String , QueryConfig > ) ,
}
impl Default for QueriesDecl {
fn default ( ) -> Self {
QueriesDecl ::Explicit ( BTreeMap ::new ( ) )
}
}
/// Expand a graph's query declaration into the canonical name->file map.
/// Discovery reads and parses each `.gq`; unreadable or unparseable files
/// and duplicate query names are loud validation errors — a declaration the
/// tool cannot enumerate is broken, not partially usable.
fn resolve_query_decls (
config_dir : & Path ,
graph_id : & str ,
decl : & QueriesDecl ,
diagnostics : & mut Vec < Diagnostic > ,
2026-06-11 01:35:47 +03:00
) -> ( BTreeMap < String , QueryConfig > , BTreeMap < PathBuf , String > ) {
2026-06-11 00:46:21 +03:00
let paths : Vec < PathBuf > = match decl {
QueriesDecl ::Explicit ( map ) = > {
2026-06-11 01:35:47 +03:00
return (
map . iter ( )
. map ( | ( name , config ) | {
( name . clone ( ) , QueryConfig { file : config . file . clone ( ) } )
} )
. collect ( ) ,
BTreeMap ::new ( ) ,
) ;
2026-06-11 00:46:21 +03:00
}
QueriesDecl ::Discover ( path ) = > vec! [ path . clone ( ) ] ,
QueriesDecl ::DiscoverMany ( paths ) = > paths . clone ( ) ,
} ;
let mut files : Vec < ( PathBuf , PathBuf ) > = Vec ::new ( ) ; // (declared-relative, resolved)
for declared in & paths {
let resolved = resolve_config_path ( config_dir , declared ) ;
if resolved . is_dir ( ) {
let mut entries : Vec < PathBuf > = match fs ::read_dir ( & resolved ) {
Ok ( read ) = > read
. flatten ( )
. map ( | entry | entry . path ( ) )
. filter ( | path | path . extension ( ) . is_some_and ( | ext | ext = = " gq " ) )
. collect ( ) ,
Err ( err ) = > {
diagnostics . push ( Diagnostic ::error (
" query_dir_unreadable " ,
format! ( " graphs. {graph_id} .queries " ) ,
format! ( " could not list query directory ' {} ': {err} " , resolved . display ( ) ) ,
) ) ;
continue ;
}
} ;
entries . sort ( ) ;
if entries . is_empty ( ) {
diagnostics . push ( Diagnostic ::warning (
" query_dir_empty " ,
format! ( " graphs. {graph_id} .queries " ) ,
format! ( " query directory ' {} ' contains no .gq files " , resolved . display ( ) ) ,
) ) ;
}
for path in entries {
let relative = declared . join ( path . file_name ( ) . expect ( " dir entries have names " ) ) ;
files . push ( ( relative , path ) ) ;
}
} else {
files . push ( ( declared . clone ( ) , resolved ) ) ;
}
}
let mut registry : BTreeMap < String , QueryConfig > = BTreeMap ::new ( ) ;
let mut origin : BTreeMap < String , PathBuf > = BTreeMap ::new ( ) ;
2026-06-11 01:35:47 +03:00
// Content read once at discovery and handed to the caller — the per-query
// digest/typecheck pass reuses it instead of re-reading (no N+1 reads, no
// window for the file to change between enumeration and validation).
let mut contents : BTreeMap < PathBuf , String > = BTreeMap ::new ( ) ;
2026-06-11 00:46:21 +03:00
for ( declared , resolved ) in files {
let source = match fs ::read_to_string ( & resolved ) {
Ok ( source ) = > source ,
Err ( err ) = > {
diagnostics . push ( Diagnostic ::error (
" query_file_missing " ,
format! ( " graphs. {graph_id} .queries " ) ,
format! ( " could not read query file ' {} ': {err} " , resolved . display ( ) ) ,
) ) ;
continue ;
}
} ;
let parsed = match parse_query ( & source ) {
Ok ( parsed ) = > parsed ,
Err ( err ) = > {
diagnostics . push ( Diagnostic ::error (
" query_parse_error " ,
format! ( " graphs. {graph_id} .queries " ) ,
format! ( " ' {} ' does not parse: {err} " , resolved . display ( ) ) ,
) ) ;
continue ;
}
} ;
for query_decl in & parsed . queries {
let name = query_decl . name . clone ( ) ;
if let Some ( previous ) = origin . get ( & name ) {
diagnostics . push ( Diagnostic ::error (
" duplicate_query_name " ,
format! ( " graphs. {graph_id} .queries. {name} " ) ,
format! (
" query '{name}' is declared in both '{}' and '{}' " ,
previous . display ( ) ,
declared . display ( )
) ,
) ) ;
continue ;
}
origin . insert ( name . clone ( ) , declared . clone ( ) ) ;
registry . insert ( name , QueryConfig { file : declared . clone ( ) } ) ;
}
2026-06-11 01:35:47 +03:00
contents . insert ( declared , source ) ;
2026-06-11 00:46:21 +03:00
}
2026-06-11 01:35:47 +03:00
( registry , contents )
2026-06-08 20:07:39 +03:00
}
2026-06-09 18:30:33 +03:00
#[ derive(Debug, Serialize, Deserialize) ]
2026-06-08 20:07:39 +03:00
#[ serde(deny_unknown_fields) ]
struct QueryConfig {
file : PathBuf ,
}
2026-06-09 18:30:33 +03:00
#[ derive(Debug, Serialize, Deserialize) ]
2026-06-08 20:07:39 +03:00
#[ serde(deny_unknown_fields) ]
struct PolicyConfig {
file : PathBuf ,
applies_to : Vec < String > ,
}
2026-06-08 23:18:44 +03:00
// Stage 2A/2B accept these forward-compatible state sections so existing
// ledgers won't churn while approval/recovery semantics are staged later.
2026-06-08 21:09:23 +03:00
#[ allow(dead_code) ]
2026-06-08 23:18:44 +03:00
#[ derive(Debug, Clone, Serialize, Deserialize) ]
2026-06-08 20:07:39 +03:00
#[ serde(deny_unknown_fields) ]
struct ClusterState {
version : u32 ,
2026-06-08 21:09:23 +03:00
#[ serde(default) ]
state_revision : u64 ,
2026-06-08 20:07:39 +03:00
applied_revision : AppliedRevisionState ,
2026-06-08 21:09:23 +03:00
#[ serde(default) ]
resource_statuses : BTreeMap < String , ResourceStatusRecord > ,
#[ serde(default) ]
approval_records : BTreeMap < String , serde_json ::Value > ,
#[ serde(default) ]
recovery_records : BTreeMap < String , serde_json ::Value > ,
#[ serde(default) ]
observations : BTreeMap < String , serde_json ::Value > ,
2026-06-08 20:07:39 +03:00
}
2026-06-08 23:18:44 +03:00
#[ derive(Debug, Clone, Serialize, Deserialize) ]
2026-06-08 20:07:39 +03:00
#[ serde(deny_unknown_fields) ]
struct AppliedRevisionState {
#[ serde(default) ]
config_digest : Option < String > ,
#[ serde(default) ]
resources : BTreeMap < String , StateResource > ,
}
2026-06-08 23:18:44 +03:00
#[ derive(Debug, Clone, Serialize, Deserialize) ]
2026-06-08 20:07:39 +03:00
#[ serde(deny_unknown_fields) ]
struct StateResource {
digest : String ,
2026-06-10 15:30:33 +03:00
/// Policy resources only: the applied `applies_to` bindings, normalized
/// to typed refs (`cluster` | `graph.<id>`). Recorded so the state
/// ledger is serving-sufficient for the Phase-5 server boot (RFC-005
/// §D3). Absent on pre-5A entries (backfilled by the next apply) and on
/// non-policy resources.
#[ serde(default, skip_serializing_if = " Option::is_none " ) ]
applies_to : Option < Vec < String > > ,
2026-06-08 20:07:39 +03:00
}
2026-06-08 21:09:23 +03:00
#[ derive(Debug, Serialize, Deserialize) ]
#[ serde(deny_unknown_fields) ]
struct StateLockFile {
version : u32 ,
lock_id : String ,
operation : String ,
created_at : String ,
pid : u32 ,
}
2026-06-10 04:50:42 +03:00
/// Recovery-intent record for a graph-moving apply operation (RFC-004 §D2).
/// Written under the state lock before the engine call that can create or
/// move a graph manifest; deleted only after the cluster state CAS that
/// records the outcome lands. The sweep (§D3) classifies survivors.
#[ derive(Debug, Clone, Serialize, Deserialize) ]
#[ serde(deny_unknown_fields) ]
struct RecoverySidecar {
schema_version : u32 ,
operation_id : String ,
started_at : String ,
#[ serde(default) ]
actor : Option < String > ,
kind : RecoverySidecarKind ,
graph_id : String ,
graph_uri : String ,
#[ serde(default) ]
observed_manifest_version : Option < u64 > ,
#[ serde(default) ]
expected_manifest_version : Option < u64 > ,
desired_schema_digest : String ,
#[ serde(default) ]
state_cas_base : Option < String > ,
feat(cluster): execute approved graph deletes in cluster apply
Stage 4C execution half (RFC-004 §D5/§D6 + sweep rows 7/7b/8): an approved
graph.<id> delete — and its riding schema/query deletes — classifies Applied
and executes LAST in the run, sidecar-fenced: pre-op manifest pin (best
effort; partial roots still delete), approval_id carried in the sidecar,
recursive root removal (NotFound tolerated), subtree tombstoned out of the
ledger with a tombstone observation, the approval consumed in the same state
CAS (ledger summary) and its artifact file rewritten with consumed_at only
after the CAS lands — a failed run consumes nothing and the approval stays
valid for the retry.
Sweep rows: already-tombstoned intents retire (7); a completed delete with a
stale ledger rolls forward — tombstone + approval consumption + audit entry
(7b, idempotent); a still-present root retires the stale intent with a
graph_delete_incomplete warning and the still-approved delete re-executes in
the same run (8) — prefix removal is idempotent, so retry IS the repair.
The multi-graph mixed e2e gets its conclusion: blocked without approval,
cluster approve graph.engineering --as andrew, converge, tombstone visible
in status. Phase 4's disposition matrix is now fully executable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:34:02 +03:00
/// For graph_delete: the approval this operation consumes; lets a sweep
/// roll-forward consume it too.
#[ serde(default) ]
approval_id : Option < String > ,
2026-06-10 04:50:42 +03:00
}
#[ derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq) ]
#[ serde(rename_all = " snake_case " ) ]
enum RecoverySidecarKind {
GraphCreate ,
2026-06-10 13:05:42 +03:00
SchemaApply ,
feat(cluster): execute approved graph deletes in cluster apply
Stage 4C execution half (RFC-004 §D5/§D6 + sweep rows 7/7b/8): an approved
graph.<id> delete — and its riding schema/query deletes — classifies Applied
and executes LAST in the run, sidecar-fenced: pre-op manifest pin (best
effort; partial roots still delete), approval_id carried in the sidecar,
recursive root removal (NotFound tolerated), subtree tombstoned out of the
ledger with a tombstone observation, the approval consumed in the same state
CAS (ledger summary) and its artifact file rewritten with consumed_at only
after the CAS lands — a failed run consumes nothing and the approval stays
valid for the retry.
Sweep rows: already-tombstoned intents retire (7); a completed delete with a
stale ledger rolls forward — tombstone + approval consumption + audit entry
(7b, idempotent); a still-present root retires the stale intent with a
graph_delete_incomplete warning and the still-approved delete re-executes in
the same run (8) — prefix removal is idempotent, so retry IS the repair.
The multi-graph mixed e2e gets its conclusion: blocked without approval,
cluster approve graph.engineering --as andrew, converge, tombstone visible
in status. Phase 4's disposition matrix is now fully executable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:34:02 +03:00
GraphDelete ,
2026-06-10 04:50:42 +03:00
}
#[ derive(Debug, Default) ]
struct SweepOutcome {
/// Graphs whose sidecar was kept (rows 5/6): graph-moving work for them
/// is blocked until the operator repairs and re-observes.
pending_graphs : BTreeSet < String > ,
/// Sidecars whose outcome is recorded (rows 2/4): deleted only after the
/// command's state write lands, so a CAS failure re-sweeps them.
completed_sidecars : Vec < PathBuf > ,
feat(cluster): execute approved graph deletes in cluster apply
Stage 4C execution half (RFC-004 §D5/§D6 + sweep rows 7/7b/8): an approved
graph.<id> delete — and its riding schema/query deletes — classifies Applied
and executes LAST in the run, sidecar-fenced: pre-op manifest pin (best
effort; partial roots still delete), approval_id carried in the sidecar,
recursive root removal (NotFound tolerated), subtree tombstoned out of the
ledger with a tombstone observation, the approval consumed in the same state
CAS (ledger summary) and its artifact file rewritten with consumed_at only
after the CAS lands — a failed run consumes nothing and the approval stays
valid for the retry.
Sweep rows: already-tombstoned intents retire (7); a completed delete with a
stale ledger rolls forward — tombstone + approval consumption + audit entry
(7b, idempotent); a still-present root retires the stale intent with a
graph_delete_incomplete warning and the still-approved delete re-executes in
the same run (8) — prefix removal is idempotent, so retry IS the repair.
The multi-graph mixed e2e gets its conclusion: blocked without approval,
cluster approve graph.engineering --as andrew, converge, tombstone visible
in status. Phase 4's disposition matrix is now fully executable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:34:02 +03:00
/// Approval artifacts consumed by a roll-forward (delete row 7b): their
/// files are rewritten with consumed_at only after the state write lands.
consumed_approvals : Vec < String > ,
2026-06-10 04:50:42 +03:00
}
2026-06-08 21:09:23 +03:00
2026-06-08 20:07:39 +03:00
pub fn validate_config_dir ( config_dir : impl AsRef < Path > ) -> ValidateOutput {
let outcome = load_desired ( config_dir . as_ref ( ) ) ;
let ( resource_digests , resources , dependencies ) = match outcome . desired {
Some ( desired ) = > (
desired . resource_digests ,
desired . resources ,
desired . dependencies ,
) ,
None = > ( BTreeMap ::new ( ) , Vec ::new ( ) , Vec ::new ( ) ) ,
} ;
let ok = ! has_errors ( & outcome . diagnostics ) ;
ValidateOutput {
ok ,
config_dir : display_path ( & outcome . config_dir ) ,
config_file : display_path ( & outcome . config_file ) ,
resource_digests ,
resources ,
dependencies ,
diagnostics : outcome . diagnostics ,
}
}
2026-06-10 13:02:12 +03:00
pub async fn plan_config_dir ( config_dir : impl AsRef < Path > ) -> PlanOutput {
2026-06-08 20:07:39 +03:00
let outcome = load_desired ( config_dir . as_ref ( ) ) ;
let mut diagnostics = outcome . diagnostics ;
2026-06-08 21:09:23 +03:00
let backend = LocalStateBackend ::new ( & outcome . config_dir ) ;
let mut observations = backend . observations ( ) ;
2026-06-08 20:07:39 +03:00
let Some ( desired ) = outcome . desired else {
return PlanOutput {
ok : false ,
config_dir : display_path ( & outcome . config_dir ) ,
desired_revision : DesiredRevision {
config_digest : None ,
} ,
resource_digests : BTreeMap ::new ( ) ,
dependencies : Vec ::new ( ) ,
state_observations : observations ,
changes : Vec ::new ( ) ,
blast_radius : Vec ::new ( ) ,
approvals_required : Vec ::new ( ) ,
diagnostics ,
} ;
} ;
2026-06-08 21:09:23 +03:00
if has_errors ( & diagnostics ) {
return PlanOutput {
ok : false ,
config_dir : display_path ( & desired . config_dir ) ,
desired_revision : DesiredRevision {
config_digest : Some ( desired . config_digest ) ,
} ,
resource_digests : desired . resource_digests ,
dependencies : desired . dependencies ,
state_observations : observations ,
changes : Vec ::new ( ) ,
blast_radius : Vec ::new ( ) ,
approvals_required : Vec ::new ( ) ,
diagnostics ,
} ;
}
let _lock_guard = if desired . state_lock {
match backend . acquire_lock ( " plan " , & mut observations ) {
Ok ( guard ) = > Some ( guard ) ,
Err ( diagnostic ) = > {
diagnostics . push ( diagnostic ) ;
None
}
}
} else {
diagnostics . push ( Diagnostic ::warning (
" state_lock_disabled " ,
" state.lock " ,
" state.lock is false; plan read state without acquiring the cluster state lock " ,
) ) ;
None
} ;
2026-06-10 04:50:42 +03:00
// Plan is read-only: pending sidecars are reported, never acted on
// (RFC-004 open question 3 keeps read-only commands warn-only).
warn_pending_recovery_sidecars ( & desired . config_dir , & mut diagnostics ) ;
2026-06-08 20:07:39 +03:00
let mut prior_resources = BTreeMap ::new ( ) ;
2026-06-10 15:30:33 +03:00
let mut prior_state : Option < ClusterState > = None ;
2026-06-08 21:09:23 +03:00
if ! has_errors ( & diagnostics ) {
match backend . read_state ( & mut observations ) {
Ok ( snapshot ) = > {
if let Some ( state ) = snapshot . state {
prior_resources = state_resource_digests ( & state ) ;
2026-06-10 15:30:33 +03:00
prior_state = Some ( state ) ;
2026-06-08 20:07:39 +03:00
}
2026-06-08 21:09:23 +03:00
}
Err ( diagnostic ) = > diagnostics . push ( diagnostic ) ,
2026-06-08 20:07:39 +03:00
}
}
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
let mut changes = if has_errors ( & diagnostics ) {
2026-06-08 20:07:39 +03:00
Vec ::new ( )
} else {
diff_resources ( & prior_resources , & desired . resource_digests )
} ;
2026-06-10 15:30:33 +03:00
if ! has_errors ( & diagnostics ) {
append_policy_binding_changes ( & mut changes , prior_state . as_ref ( ) , & desired ) ;
}
2026-06-10 04:58:56 +03:00
// Plan previews dispositions without sweeping; a pending recovery is
// surfaced as the cluster_recovery_pending warning above instead.
2026-06-10 14:29:00 +03:00
let artifacts = backend . list_approval_artifacts ( & mut diagnostics ) ;
let approved = approved_resources (
& artifacts ,
& changes ,
& desired . config_digest ,
& mut diagnostics ,
) ;
classify_changes ( & mut changes , & desired . dependencies , & BTreeSet ::new ( ) , & approved ) ;
2026-06-10 13:04:19 +03:00
// Embed real migration steps for schema updates so plan is a data-aware
// preview; failures degrade to the digest diff with a warning.
for change in & mut changes {
if change . operation ! = PlanOperation ::Update {
continue ;
}
let ResourceKind ::Schema ( graph_id ) = resource_kind ( & change . resource ) else {
continue ;
} ;
let graph_uri = display_path (
& desired
. config_dir
. join ( CLUSTER_GRAPHS_DIR )
. join ( format! ( " {graph_id} .omni " ) ) ,
) ;
let source_path = desired
. resources
. iter ( )
. find ( | resource | resource . address = = change . resource )
. and_then ( | resource | resource . path . clone ( ) ) ;
let preview = match source_path {
Some ( path ) = > preview_schema_migration ( & graph_uri , & path ) . await ,
None = > Err ( " no schema source recorded " . to_string ( ) ) ,
} ;
match preview {
Ok ( migration ) = > change . migration = Some ( migration ) ,
Err ( err ) = > diagnostics . push ( Diagnostic ::warning (
" schema_preview_unavailable " ,
change . resource . clone ( ) ,
format! ( " could not preview the schema migration: {err} " ) ,
) ) ,
}
}
2026-06-08 20:07:39 +03:00
let blast_radius = compute_blast_radius ( & changes , & desired . dependencies ) ;
2026-06-10 14:29:00 +03:00
let approvals_required = compute_approvals ( & changes , & approved ) ;
2026-06-08 20:07:39 +03:00
let ok = ! has_errors ( & diagnostics ) ;
PlanOutput {
ok ,
config_dir : display_path ( & desired . config_dir ) ,
desired_revision : DesiredRevision {
config_digest : Some ( desired . config_digest ) ,
} ,
resource_digests : desired . resource_digests ,
dependencies : desired . dependencies ,
state_observations : observations ,
changes ,
blast_radius ,
approvals_required ,
diagnostics ,
}
}
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
/// Config-only `cluster apply` (Stage 3A): execute the query/policy subset of
/// the plan against the local cluster catalog. The plan is recomputed under
/// the state lock, so freshness is structural; the state CAS inside
/// `write_state` is the second fence. Graph/schema changes are never executed
/// here — they are deferred to the graph-lifecycle phase and reported loudly.
///
/// Payloads are content-addressed and written BEFORE the state CAS because
/// state is the publish point: a failure after payload writes leaves inert
/// digest-named blobs and no success acknowledgement; re-running apply is the
/// repair.
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
/// Options for `cluster apply`. `actor` attributes graph-moving operations
/// (recorded in sidecars and audit entries, threaded to the engine's
/// `apply_schema_as` so Cedar enforcement fires wherever a policy checker is
/// installed).
#[ derive(Debug, Clone, Default) ]
pub struct ApplyOptions {
pub actor : Option < String > ,
}
2026-06-10 04:43:38 +03:00
pub async fn apply_config_dir ( config_dir : impl AsRef < Path > ) -> ApplyOutput {
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
apply_config_dir_with_options ( config_dir , ApplyOptions ::default ( ) ) . await
}
pub async fn apply_config_dir_with_options (
config_dir : impl AsRef < Path > ,
options : ApplyOptions ,
) -> ApplyOutput {
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
let outcome = load_desired ( config_dir . as_ref ( ) ) ;
let mut diagnostics = outcome . diagnostics ;
let backend = LocalStateBackend ::new ( & outcome . config_dir ) ;
let mut observations = backend . observations ( ) ;
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
let actor_for_output = options . actor . clone ( ) ;
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
let early_return = | config_dir : String ,
config_digest : Option < String > ,
observations : StateObservations ,
changes : Vec < PlanChange > ,
resource_statuses : BTreeMap < String , ResourceStatusRecord > ,
diagnostics : Vec < Diagnostic > | {
ApplyOutput {
ok : ! has_errors ( & diagnostics ) ,
config_dir ,
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
actor : actor_for_output . clone ( ) ,
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
desired_revision : DesiredRevision {
config_digest ,
} ,
state_observations : observations ,
changes ,
applied_count : 0 ,
deferred_count : 0 ,
converged : false ,
state_written : false ,
resource_statuses ,
diagnostics ,
}
} ;
let Some ( desired ) = outcome . desired else {
return early_return (
display_path ( & outcome . config_dir ) ,
None ,
observations ,
Vec ::new ( ) ,
BTreeMap ::new ( ) ,
diagnostics ,
) ;
} ;
if has_errors ( & diagnostics ) {
return early_return (
display_path ( & desired . config_dir ) ,
Some ( desired . config_digest ) ,
observations ,
Vec ::new ( ) ,
BTreeMap ::new ( ) ,
diagnostics ,
) ;
}
// Named guard: the lock must be held until the state outcome is recorded.
let _lock_guard = if desired . state_lock {
match backend . acquire_lock ( " apply " , & mut observations ) {
Ok ( guard ) = > Some ( guard ) ,
Err ( diagnostic ) = > {
diagnostics . push ( diagnostic ) ;
None
}
}
} else {
diagnostics . push ( Diagnostic ::warning (
" state_lock_disabled " ,
" state.lock " ,
" state.lock is false; apply wrote state without acquiring the cluster state lock " ,
) ) ;
None
} ;
if has_errors ( & diagnostics ) {
return early_return (
display_path ( & desired . config_dir ) ,
Some ( desired . config_digest ) ,
observations ,
Vec ::new ( ) ,
BTreeMap ::new ( ) ,
diagnostics ,
) ;
}
let snapshot = match backend . read_state ( & mut observations ) {
Ok ( snapshot ) = > snapshot ,
Err ( diagnostic ) = > {
diagnostics . push ( diagnostic ) ;
return early_return (
display_path ( & desired . config_dir ) ,
Some ( desired . config_digest ) ,
observations ,
Vec ::new ( ) ,
BTreeMap ::new ( ) ,
diagnostics ,
) ;
}
} ;
let expected_cas = snapshot . state_cas ;
2026-06-10 04:50:42 +03:00
let Some ( mut state ) = snapshot . state else {
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
diagnostics . push ( Diagnostic ::error (
" state_missing " ,
CLUSTER_STATE_FILE ,
" apply requires an existing state.json; run `cluster import` to bootstrap state " ,
) ) ;
return early_return (
display_path ( & desired . config_dir ) ,
Some ( desired . config_digest ) ,
observations ,
Vec ::new ( ) ,
BTreeMap ::new ( ) ,
diagnostics ,
) ;
} ;
2026-06-10 04:50:42 +03:00
// Snapshot the as-read state BEFORE the sweep so sweep mutations count as
// changes for the final dirty check and get persisted by the state CAS.
let before_value =
serde_json ::to_value ( & state ) . expect ( " cluster state must serialize deterministically " ) ;
let sweep = sweep_recovery_sidecars ( & backend , & mut state , & mut diagnostics ) . await ;
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
let prior_resources = state_resource_digests ( & state ) ;
let mut changes = diff_resources ( & prior_resources , & desired . resource_digests ) ;
2026-06-10 15:30:33 +03:00
append_policy_binding_changes ( & mut changes , Some ( & state ) , & desired ) ;
2026-06-10 14:29:00 +03:00
let approval_artifacts = backend . list_approval_artifacts ( & mut diagnostics ) ;
let approved = approved_resources (
& approval_artifacts ,
& changes ,
& desired . config_digest ,
& mut diagnostics ,
) ;
classify_changes (
& mut changes ,
& desired . dependencies ,
& sweep . pending_graphs ,
& approved ,
) ;
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
2026-06-10 14:29:00 +03:00
// Defensive invariant: nothing the approval gate covers may be executable
// WITHOUT a matching approval. Gated changes with a valid artifact are the
// sanctioned exception (stage 4C).
let approvals = compute_approvals ( & changes , & approved ) ;
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
let approval_violation = changes . iter ( ) . any ( | change | {
change . disposition = = Some ( ApplyDisposition ::Applied )
& & approvals
. iter ( )
2026-06-10 14:29:00 +03:00
. any ( | approval | approval . resource = = change . resource & & ! approval . satisfied )
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
} ) ;
if approval_violation {
diagnostics . push ( Diagnostic ::error (
" apply_approval_invariant_violation " ,
" changes " ,
" an executable change requires approval; refusing to apply " ,
) ) ;
return early_return (
display_path ( & desired . config_dir ) ,
Some ( desired . config_digest ) ,
observations ,
changes ,
state . resource_statuses ,
diagnostics ,
) ;
}
2026-06-10 04:58:56 +03:00
// Graph creates execute first (RFC-004 §D5), sequentially, sidecar-fenced:
// sidecar written before the init, rewritten with the post-init manifest
// version, deleted only after the final state CAS lands. A failure stops
// further graph-moving work and demotes that graph's dependents.
let source_paths : BTreeMap < & str , & str > = desired
. resources
. iter ( )
. filter_map ( | resource | {
resource
. path
. as_deref ( )
. map ( | path | ( resource . address . as_str ( ) , path ) )
} )
. collect ( ) ;
let graph_creates_to_run : Vec < String > = changes
. iter ( )
. filter ( | change | {
change . disposition = = Some ( ApplyDisposition ::Applied )
& & change . operation = = PlanOperation ::Create
& & matches! ( resource_kind ( & change . resource ) , ResourceKind ::Graph ( _ ) )
} )
. filter_map ( | change | change . resource . strip_prefix ( " graph. " ) . map ( str ::to_string ) )
. collect ( ) ;
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
let mut completed_op_sidecars : Vec < PathBuf > = Vec ::new ( ) ;
let mut failed_graphs : BTreeMap < String , FailedGraphOrigin > = BTreeMap ::new ( ) ;
let mut graph_moving_aborted = false ;
2026-06-10 04:58:56 +03:00
for graph_id in & graph_creates_to_run {
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
if graph_moving_aborted {
2026-06-10 04:58:56 +03:00
// A prior create failed: stop graph-moving work (loud partials).
diagnostics . push ( Diagnostic ::warning (
" graph_create_skipped " ,
graph_address ( graph_id ) ,
" skipped after an earlier graph create failed in this run " ,
) ) ;
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
failed_graphs . insert ( graph_id . clone ( ) , FailedGraphOrigin ::GraphCreate ) ;
2026-06-10 04:58:56 +03:00
continue ;
}
let Some ( desired_graph ) = desired . graphs . iter ( ) . find ( | graph | & graph . id = = graph_id )
else {
continue ;
} ;
let graph_uri = display_path (
& desired
. config_dir
. join ( CLUSTER_GRAPHS_DIR )
. join ( format! ( " {graph_id} .omni " ) ) ,
) ;
let mut sidecar = RecoverySidecar {
schema_version : 1 ,
operation_id : Ulid ::new ( ) . to_string ( ) ,
started_at : now_rfc3339 ( ) ,
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
actor : options . actor . clone ( ) ,
2026-06-10 04:58:56 +03:00
kind : RecoverySidecarKind ::GraphCreate ,
graph_id : graph_id . clone ( ) ,
graph_uri : graph_uri . clone ( ) ,
observed_manifest_version : None ,
expected_manifest_version : None ,
desired_schema_digest : desired_graph . schema_digest . clone ( ) ,
state_cas_base : expected_cas . clone ( ) ,
feat(cluster): execute approved graph deletes in cluster apply
Stage 4C execution half (RFC-004 §D5/§D6 + sweep rows 7/7b/8): an approved
graph.<id> delete — and its riding schema/query deletes — classifies Applied
and executes LAST in the run, sidecar-fenced: pre-op manifest pin (best
effort; partial roots still delete), approval_id carried in the sidecar,
recursive root removal (NotFound tolerated), subtree tombstoned out of the
ledger with a tombstone observation, the approval consumed in the same state
CAS (ledger summary) and its artifact file rewritten with consumed_at only
after the CAS lands — a failed run consumes nothing and the approval stays
valid for the retry.
Sweep rows: already-tombstoned intents retire (7); a completed delete with a
stale ledger rolls forward — tombstone + approval consumption + audit entry
(7b, idempotent); a still-present root retires the stale intent with a
graph_delete_incomplete warning and the still-approved delete re-executes in
the same run (8) — prefix removal is idempotent, so retry IS the repair.
The multi-graph mixed e2e gets its conclusion: blocked without approval,
cluster approve graph.engineering --as andrew, converge, tombstone visible
in status. Phase 4's disposition matrix is now fully executable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:34:02 +03:00
approval_id : None ,
2026-06-10 04:58:56 +03:00
} ;
let sidecar_path = match backend . write_recovery_sidecar ( & sidecar ) {
Ok ( path ) = > path ,
Err ( diagnostic ) = > {
diagnostics . push ( diagnostic ) ;
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
failed_graphs . insert ( graph_id . clone ( ) , FailedGraphOrigin ::GraphCreate ) ;
graph_moving_aborted = true ;
2026-06-10 04:58:56 +03:00
continue ;
}
} ;
if let Err ( diagnostic ) = failpoints ::maybe_fail ( " cluster_apply.before_graph_create " ) {
// Simulated crash before the init: the sidecar stays for the
// sweep (row 1: root absent -> intent removed next run).
diagnostics . push ( diagnostic ) ;
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
failed_graphs . insert ( graph_id . clone ( ) , FailedGraphOrigin ::GraphCreate ) ;
graph_moving_aborted = true ;
2026-06-10 04:58:56 +03:00
continue ;
}
// Re-read + re-verify the schema source under the lock — the same
// TOCTOU posture as write_resource_payload.
let schema_source = source_paths
. get ( schema_address ( graph_id ) . as_str ( ) )
. ok_or_else ( | | {
Diagnostic ::error (
" graph_create_failed " ,
graph_address ( graph_id ) ,
" no schema source recorded for graph " ,
)
} )
. and_then ( | path | {
fs ::read_to_string ( Path ::new ( path ) ) . map_err ( | err | {
Diagnostic ::error (
" graph_create_failed " ,
graph_address ( graph_id ) ,
format! ( " could not read schema source ' {path} ': {err} " ) ,
)
} )
} )
. and_then ( | source | {
if sha256_hex ( source . as_bytes ( ) ) = = desired_graph . schema_digest {
Ok ( source )
} else {
Err ( Diagnostic ::error (
" resource_content_changed " ,
schema_address ( graph_id ) ,
" schema source changed while apply was running; re-run `cluster apply` " ,
) )
}
} ) ;
let schema_source = match schema_source {
Ok ( source ) = > source ,
Err ( diagnostic ) = > {
diagnostics . push ( diagnostic ) ;
let _ = fs ::remove_file ( & sidecar_path ) ; // nothing moved
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
failed_graphs . insert ( graph_id . clone ( ) , FailedGraphOrigin ::GraphCreate ) ;
graph_moving_aborted = true ;
2026-06-10 04:58:56 +03:00
continue ;
}
} ;
match Omnigraph ::init ( & graph_uri , & schema_source ) . await {
Ok ( _ ) = > { }
Err ( err ) = > {
diagnostics . push ( Diagnostic ::error (
" graph_create_failed " ,
graph_address ( graph_id ) ,
format! ( " could not initialize graph at ' {graph_uri} ': {err} " ) ,
) ) ;
// The sidecar stays: the sweep classifies whether the failed
// init left a partial root (row 5) or nothing (row 1).
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
failed_graphs . insert ( graph_id . clone ( ) , FailedGraphOrigin ::GraphCreate ) ;
graph_moving_aborted = true ;
2026-06-10 04:58:56 +03:00
continue ;
}
}
// Record the post-init pin in the sidecar (best effort — a failure
// here leaves expected = null and the sweep classifies by digest).
if let Ok ( db ) = Omnigraph ::open_read_only ( & graph_uri ) . await {
if let Ok ( snapshot ) = db . snapshot_of ( ReadTarget ::branch ( " main " ) ) . await {
sidecar . expected_manifest_version = Some ( snapshot . version ( ) ) ;
if let Err ( diagnostic ) = backend . write_recovery_sidecar ( & sidecar ) {
diagnostics . push ( diagnostic ) ;
}
}
}
// Crash point: the graph exists, the cluster state does not record it
// yet. A failure here must acknowledge nothing; the next run's sweep
// rolls the ledger forward (row 4).
if let Err ( diagnostic ) = failpoints ::maybe_fail ( " cluster_apply.after_graph_create " ) {
diagnostics . push ( diagnostic ) ;
return early_return (
display_path ( & desired . config_dir ) ,
Some ( desired . config_digest ) ,
observations ,
changes ,
state . resource_statuses ,
diagnostics ,
) ;
}
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
completed_op_sidecars . push ( sidecar_path ) ;
2026-06-10 04:58:56 +03:00
}
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
// Schema applies execute next (RFC-004 §D5): the first cluster operation
// that moves an EXISTING graph manifest, sidecar-fenced the same way.
let schema_updates_to_run : Vec < String > = changes
. iter ( )
. filter ( | change | {
change . disposition = = Some ( ApplyDisposition ::Applied )
& & change . operation = = PlanOperation ::Update
& & matches! ( resource_kind ( & change . resource ) , ResourceKind ::Schema ( _ ) )
} )
. filter_map ( | change | change . resource . strip_prefix ( " schema. " ) . map ( str ::to_string ) )
. collect ( ) ;
for graph_id in & schema_updates_to_run {
if graph_moving_aborted {
diagnostics . push ( Diagnostic ::warning (
" schema_apply_skipped " ,
schema_address ( graph_id ) ,
" skipped after an earlier graph-moving operation failed in this run " ,
) ) ;
failed_graphs . insert ( graph_id . clone ( ) , FailedGraphOrigin ::SchemaApply ) ;
continue ;
}
let Some ( desired_graph ) = desired . graphs . iter ( ) . find ( | graph | & graph . id = = graph_id )
else {
continue ;
} ;
let graph_uri = display_path (
& desired
. config_dir
. join ( CLUSTER_GRAPHS_DIR )
. join ( format! ( " {graph_id} .omni " ) ) ,
) ;
// Read-write open: the engine's own recovery sweep runs here, which
// is exactly what we want before moving its manifest.
let db = match Omnigraph ::open ( & graph_uri ) . await {
Ok ( db ) = > db ,
Err ( err ) = > {
diagnostics . push ( Diagnostic ::error (
" schema_apply_failed " ,
schema_address ( graph_id ) ,
format! ( " could not open graph at ' {graph_uri} ': {err} " ) ,
) ) ;
failed_graphs . insert ( graph_id . clone ( ) , FailedGraphOrigin ::SchemaApply ) ;
graph_moving_aborted = true ;
continue ;
}
} ;
let observed_manifest_version = match db . snapshot_of ( ReadTarget ::branch ( " main " ) ) . await {
Ok ( snapshot ) = > Some ( snapshot . version ( ) ) ,
Err ( _ ) = > None ,
} ;
let mut sidecar = RecoverySidecar {
schema_version : 1 ,
operation_id : Ulid ::new ( ) . to_string ( ) ,
started_at : now_rfc3339 ( ) ,
actor : options . actor . clone ( ) ,
kind : RecoverySidecarKind ::SchemaApply ,
graph_id : graph_id . clone ( ) ,
graph_uri : graph_uri . clone ( ) ,
observed_manifest_version ,
expected_manifest_version : None ,
desired_schema_digest : desired_graph . schema_digest . clone ( ) ,
state_cas_base : expected_cas . clone ( ) ,
feat(cluster): execute approved graph deletes in cluster apply
Stage 4C execution half (RFC-004 §D5/§D6 + sweep rows 7/7b/8): an approved
graph.<id> delete — and its riding schema/query deletes — classifies Applied
and executes LAST in the run, sidecar-fenced: pre-op manifest pin (best
effort; partial roots still delete), approval_id carried in the sidecar,
recursive root removal (NotFound tolerated), subtree tombstoned out of the
ledger with a tombstone observation, the approval consumed in the same state
CAS (ledger summary) and its artifact file rewritten with consumed_at only
after the CAS lands — a failed run consumes nothing and the approval stays
valid for the retry.
Sweep rows: already-tombstoned intents retire (7); a completed delete with a
stale ledger rolls forward — tombstone + approval consumption + audit entry
(7b, idempotent); a still-present root retires the stale intent with a
graph_delete_incomplete warning and the still-approved delete re-executes in
the same run (8) — prefix removal is idempotent, so retry IS the repair.
The multi-graph mixed e2e gets its conclusion: blocked without approval,
cluster approve graph.engineering --as andrew, converge, tombstone visible
in status. Phase 4's disposition matrix is now fully executable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:34:02 +03:00
approval_id : None ,
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
} ;
let sidecar_path = match backend . write_recovery_sidecar ( & sidecar ) {
Ok ( path ) = > path ,
Err ( diagnostic ) = > {
diagnostics . push ( diagnostic ) ;
failed_graphs . insert ( graph_id . clone ( ) , FailedGraphOrigin ::SchemaApply ) ;
graph_moving_aborted = true ;
continue ;
}
} ;
if let Err ( diagnostic ) = failpoints ::maybe_fail ( " cluster_apply.before_schema_apply " ) {
// Simulated crash before the engine call: the sidecar stays; the
// sweep retires it next run (ledger still consistent with live).
diagnostics . push ( diagnostic ) ;
failed_graphs . insert ( graph_id . clone ( ) , FailedGraphOrigin ::SchemaApply ) ;
graph_moving_aborted = true ;
continue ;
}
// Re-read + digest-verify the desired schema source under the lock.
let schema_source = source_paths
. get ( schema_address ( graph_id ) . as_str ( ) )
. ok_or_else ( | | {
Diagnostic ::error (
" schema_apply_failed " ,
schema_address ( graph_id ) ,
" no schema source recorded for graph " ,
)
} )
. and_then ( | path | {
fs ::read_to_string ( Path ::new ( path ) ) . map_err ( | err | {
Diagnostic ::error (
" schema_apply_failed " ,
schema_address ( graph_id ) ,
format! ( " could not read schema source ' {path} ': {err} " ) ,
)
} )
} )
. and_then ( | source | {
if sha256_hex ( source . as_bytes ( ) ) = = desired_graph . schema_digest {
Ok ( source )
} else {
Err ( Diagnostic ::error (
" resource_content_changed " ,
schema_address ( graph_id ) ,
" schema source changed while apply was running; re-run `cluster apply` " ,
) )
}
} ) ;
let schema_source = match schema_source {
Ok ( source ) = > source ,
Err ( diagnostic ) = > {
diagnostics . push ( diagnostic ) ;
let _ = fs ::remove_file ( & sidecar_path ) ; // nothing moved
failed_graphs . insert ( graph_id . clone ( ) , FailedGraphOrigin ::SchemaApply ) ;
graph_moving_aborted = true ;
continue ;
}
} ;
// Soft drops only: allow_data_loss stays false until the approval
// artifacts of stage 4C exist (RFC-004 §D4).
match db
. apply_schema_as (
& schema_source ,
SchemaApplyOptions ::default ( ) ,
options . actor . as_deref ( ) ,
)
. await
{
Ok ( result ) = > {
sidecar . expected_manifest_version = Some ( result . manifest_version ) ;
if let Err ( diagnostic ) = backend . write_recovery_sidecar ( & sidecar ) {
diagnostics . push ( diagnostic ) ;
}
}
Err ( err ) = > {
diagnostics . push ( Diagnostic ::error (
" schema_apply_failed " ,
schema_address ( graph_id ) ,
format! ( " schema apply failed on ' {graph_uri} ': {err} " ) ,
) ) ;
// Sidecar stays; the sweep retires it (live digest unchanged
// == ledger consistent) or flags real movement.
failed_graphs . insert ( graph_id . clone ( ) , FailedGraphOrigin ::SchemaApply ) ;
graph_moving_aborted = true ;
continue ;
}
}
// Crash point: the manifest moved, the ledger does not record it yet.
// A failure here acknowledges nothing; the sweep rolls forward.
if let Err ( diagnostic ) = failpoints ::maybe_fail ( " cluster_apply.after_schema_apply " ) {
diagnostics . push ( diagnostic ) ;
return early_return (
display_path ( & desired . config_dir ) ,
Some ( desired . config_digest ) ,
observations ,
changes ,
state . resource_statuses ,
diagnostics ,
) ;
}
completed_op_sidecars . push ( sidecar_path ) ;
}
2026-06-10 04:58:56 +03:00
if ! failed_graphs . is_empty ( ) {
demote_dependents_of_failed_graphs ( & mut changes , & failed_graphs , & desired . dependencies ) ;
}
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
for change in & changes {
match change . disposition {
Some ( ApplyDisposition ::Deferred ) = > diagnostics . push ( Diagnostic ::warning (
" apply_unsupported_change " ,
change . resource . clone ( ) ,
" graph/schema changes are not applied in this stage; they are deferred to the graph-lifecycle phase " ,
) ) ,
Some ( ApplyDisposition ::Blocked ) = > diagnostics . push ( Diagnostic ::warning (
" apply_dependency_blocked " ,
change . resource . clone ( ) ,
format! (
" blocked by an unapplied or missing dependency ({}) " ,
change . reason . as_deref ( ) . unwrap_or ( " dependency " )
) ,
) ) ,
_ = > { }
}
}
// Payload phase: content-addressed writes before the state CAS. Any
// failure aborts before state moves; blobs already written are inert.
2026-06-10 04:50:42 +03:00
// Gate on payload-phase errors only — sweep errors (e.g. a kept row-5
// sidecar) must not abort the run, or their statuses would never persist.
let errors_before_payloads = count_errors ( & diagnostics ) ;
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
for change in & changes {
if change . disposition ! = Some ( ApplyDisposition ::Applied )
| | change . operation = = PlanOperation ::Delete
{
continue ;
}
let kind = resource_kind ( & change . resource ) ;
let digest = change
. after_digest
. as_deref ( )
. expect ( " create/update always carries an after digest " ) ;
let Some ( target ) = payload_path ( & desired . config_dir , & kind , digest ) else {
continue ;
} ;
let Some ( source ) = source_paths . get ( change . resource . as_str ( ) ) else {
diagnostics . push ( Diagnostic ::error (
" resource_payload_write_error " ,
change . resource . clone ( ) ,
" no source file recorded for resource " ,
) ) ;
continue ;
} ;
if let Err ( diagnostic ) =
write_resource_payload ( & target , Path ::new ( source ) , digest , & change . resource )
{
diagnostics . push ( diagnostic ) ;
}
}
2026-06-10 04:50:42 +03:00
if count_errors ( & diagnostics ) > errors_before_payloads {
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
return early_return (
display_path ( & desired . config_dir ) ,
Some ( desired . config_digest ) ,
observations ,
changes ,
state . resource_statuses ,
diagnostics ,
) ;
}
2026-06-10 02:12:59 +03:00
// Crash point: payloads are on disk, state has not moved. A failure here
// must leave state.json byte-identical and acknowledge nothing; re-running
// apply repairs via the skip-if-exists blob reuse.
if let Err ( diagnostic ) = failpoints ::maybe_fail ( " cluster_apply.after_payload_phase " ) {
diagnostics . push ( diagnostic ) ;
return early_return (
display_path ( & desired . config_dir ) ,
Some ( desired . config_digest ) ,
observations ,
changes ,
state . resource_statuses ,
diagnostics ,
) ;
}
feat(cluster): execute approved graph deletes in cluster apply
Stage 4C execution half (RFC-004 §D5/§D6 + sweep rows 7/7b/8): an approved
graph.<id> delete — and its riding schema/query deletes — classifies Applied
and executes LAST in the run, sidecar-fenced: pre-op manifest pin (best
effort; partial roots still delete), approval_id carried in the sidecar,
recursive root removal (NotFound tolerated), subtree tombstoned out of the
ledger with a tombstone observation, the approval consumed in the same state
CAS (ledger summary) and its artifact file rewritten with consumed_at only
after the CAS lands — a failed run consumes nothing and the approval stays
valid for the retry.
Sweep rows: already-tombstoned intents retire (7); a completed delete with a
stale ledger rolls forward — tombstone + approval consumption + audit entry
(7b, idempotent); a still-present root retires the stale intent with a
graph_delete_incomplete warning and the still-approved delete re-executes in
the same run (8) — prefix removal is idempotent, so retry IS the repair.
The multi-graph mixed e2e gets its conclusion: blocked without approval,
cluster approve graph.engineering --as andrew, converge, tombstone visible
in status. Phase 4's disposition matrix is now fully executable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:34:02 +03:00
// Approved graph deletes execute LAST (RFC-004 §D5): catalog writes for
// surviving resources land first, then the irreversible work.
let graph_deletes_to_run : Vec < String > = changes
. iter ( )
. filter ( | change | {
change . disposition = = Some ( ApplyDisposition ::Applied )
& & change . operation = = PlanOperation ::Delete
& & matches! ( resource_kind ( & change . resource ) , ResourceKind ::Graph ( _ ) )
} )
. filter_map ( | change | change . resource . strip_prefix ( " graph. " ) . map ( str ::to_string ) )
. collect ( ) ;
let mut executed_deletes : Vec < ( String , Option < String > ) > = Vec ::new ( ) ; // (graph_id, approval_id)
let mut consumed_approval_ids : Vec < String > = Vec ::new ( ) ;
for graph_id in & graph_deletes_to_run {
if graph_moving_aborted {
diagnostics . push ( Diagnostic ::warning (
" graph_delete_skipped " ,
graph_address ( graph_id ) ,
" skipped after an earlier graph-moving operation failed in this run " ,
) ) ;
failed_graphs . insert ( graph_id . clone ( ) , FailedGraphOrigin ::GraphDelete ) ;
continue ;
}
let graph_addr = graph_address ( graph_id ) ;
// Re-locate the consumable approval (classification verified one exists).
let approval_id = approval_artifacts
. iter ( )
. map ( | ( _ , artifact ) | artifact )
. find ( | artifact | {
artifact . consumed_at . is_none ( )
& & artifact . resource = = graph_addr
& & artifact . bound_config_digest = = desired . config_digest
} )
. map ( | artifact | artifact . approval_id . clone ( ) ) ;
let graph_uri = display_path (
& desired
. config_dir
. join ( CLUSTER_GRAPHS_DIR )
. join ( format! ( " {graph_id} .omni " ) ) ,
) ;
let observed_manifest_version = match Omnigraph ::open_read_only ( & graph_uri ) . await {
Ok ( db ) = > match db . snapshot_of ( ReadTarget ::branch ( " main " ) ) . await {
Ok ( snapshot ) = > Some ( snapshot . version ( ) ) ,
Err ( _ ) = > None ,
} ,
Err ( _ ) = > None , // partial/unopenable roots still get deleted
} ;
let sidecar = RecoverySidecar {
schema_version : 1 ,
operation_id : Ulid ::new ( ) . to_string ( ) ,
started_at : now_rfc3339 ( ) ,
actor : options . actor . clone ( ) ,
kind : RecoverySidecarKind ::GraphDelete ,
graph_id : graph_id . clone ( ) ,
graph_uri : graph_uri . clone ( ) ,
observed_manifest_version ,
expected_manifest_version : None , // no post-op manifest exists
desired_schema_digest : String ::new ( ) ,
state_cas_base : expected_cas . clone ( ) ,
approval_id : approval_id . clone ( ) ,
} ;
let sidecar_path = match backend . write_recovery_sidecar ( & sidecar ) {
Ok ( path ) = > path ,
Err ( diagnostic ) = > {
diagnostics . push ( diagnostic ) ;
failed_graphs . insert ( graph_id . clone ( ) , FailedGraphOrigin ::GraphDelete ) ;
graph_moving_aborted = true ;
continue ;
}
} ;
if let Err ( diagnostic ) = failpoints ::maybe_fail ( " cluster_apply.before_graph_delete " ) {
// Simulated crash before removal: row 8 retires the intent and
// the still-valid approval lets a later run retry.
diagnostics . push ( diagnostic ) ;
failed_graphs . insert ( graph_id . clone ( ) , FailedGraphOrigin ::GraphDelete ) ;
graph_moving_aborted = true ;
continue ;
}
match fs ::remove_dir_all ( PathBuf ::from ( & graph_uri ) ) {
Ok ( ( ) ) = > { }
Err ( err ) if err . kind ( ) = = ErrorKind ::NotFound = > { } // already gone
Err ( err ) = > {
diagnostics . push ( Diagnostic ::error (
" graph_delete_failed " ,
graph_addr . clone ( ) ,
format! ( " could not remove graph root ' {graph_uri} ': {err} " ) ,
) ) ;
failed_graphs . insert ( graph_id . clone ( ) , FailedGraphOrigin ::GraphDelete ) ;
graph_moving_aborted = true ;
continue ;
}
}
// Crash point: the root is gone, the ledger does not record it yet.
// The sweep rolls forward (row 7b) and consumes the approval.
if let Err ( diagnostic ) = failpoints ::maybe_fail ( " cluster_apply.after_graph_delete " ) {
diagnostics . push ( diagnostic ) ;
return early_return (
display_path ( & desired . config_dir ) ,
Some ( desired . config_digest ) ,
observations ,
changes ,
state . resource_statuses ,
diagnostics ,
) ;
}
executed_deletes . push ( ( graph_id . clone ( ) , approval_id . clone ( ) ) ) ;
if let Some ( approval_id ) = approval_id {
consumed_approval_ids . push ( approval_id ) ;
}
completed_op_sidecars . push ( sidecar_path ) ;
}
if ! failed_graphs . is_empty ( ) {
demote_dependents_of_failed_graphs ( & mut changes , & failed_graphs , & desired . dependencies ) ;
}
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
// State mutation. Apply owns query/policy statuses only; graph/schema
2026-06-10 04:50:42 +03:00
// statuses belong to refresh/import observation and must not be clobbered
// (the sweep above is the one exception: it owns recovery statuses).
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
let mut new_state = state . clone ( ) ;
for change in & changes {
match change . disposition {
Some ( ApplyDisposition ::Applied ) = > match change . operation {
PlanOperation ::Create | PlanOperation ::Update = > {
new_state . applied_revision . resources . insert (
change . resource . clone ( ) ,
StateResource {
digest : change
. after_digest
. clone ( )
. expect ( " create/update always carries an after digest " ) ,
2026-06-10 15:30:33 +03:00
// Policies record their applied bindings so the
// ledger is serving-sufficient (RFC-005 §D3).
applies_to : desired
. policy_bindings
. get ( & change . resource )
. cloned ( ) ,
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
} ,
) ;
set_resource_status_applied ( & mut new_state , & change . resource ) ;
}
PlanOperation ::Delete = > {
new_state . applied_revision . resources . remove ( & change . resource ) ;
new_state . resource_statuses . remove ( & change . resource ) ;
}
} ,
Some ( ApplyDisposition ::Blocked ) = > {
2026-06-10 04:58:56 +03:00
// The sweep owns recovery statuses (Drifted/Error with their
// conditions); a generic Blocked must not clobber them.
if change . reason . as_deref ( ) ! = Some ( " cluster_recovery_pending " ) {
set_resource_status (
& mut new_state ,
& change . resource ,
ResourceLifecycleStatus ::Blocked ,
change . reason . as_deref ( ) . unwrap_or ( " dependency_not_applied " ) ,
" waiting on an unapplied or missing dependency " ,
) ;
}
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
}
_ = > { }
}
}
feat(cluster): execute approved graph deletes in cluster apply
Stage 4C execution half (RFC-004 §D5/§D6 + sweep rows 7/7b/8): an approved
graph.<id> delete — and its riding schema/query deletes — classifies Applied
and executes LAST in the run, sidecar-fenced: pre-op manifest pin (best
effort; partial roots still delete), approval_id carried in the sidecar,
recursive root removal (NotFound tolerated), subtree tombstoned out of the
ledger with a tombstone observation, the approval consumed in the same state
CAS (ledger summary) and its artifact file rewritten with consumed_at only
after the CAS lands — a failed run consumes nothing and the approval stays
valid for the retry.
Sweep rows: already-tombstoned intents retire (7); a completed delete with a
stale ledger rolls forward — tombstone + approval consumption + audit entry
(7b, idempotent); a still-present root retires the stale intent with a
graph_delete_incomplete warning and the still-approved delete re-executes in
the same run (8) — prefix removal is idempotent, so retry IS the repair.
The multi-graph mixed e2e gets its conclusion: blocked without approval,
cluster approve graph.engineering --as andrew, converge, tombstone visible
in status. Phase 4's disposition matrix is now fully executable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:34:02 +03:00
for ( graph_id , approval_id ) in & executed_deletes {
tombstone_graph_subtree (
& mut new_state ,
graph_id ,
approval_id . as_deref ( ) ,
options . actor . as_deref ( ) ,
) ;
if let Some ( approval_id ) = approval_id {
record_approval_consumed ( & mut new_state , approval_id , " apply " ) ;
}
}
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
recompute_state_graph_digests ( & mut new_state , & desired ) ;
2026-06-10 15:30:33 +03:00
let mut residual = diff_resources (
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
& state_resource_digests ( & new_state ) ,
& desired . resource_digests ,
) ;
2026-06-10 15:30:33 +03:00
append_policy_binding_changes ( & mut residual , Some ( & new_state ) , & desired ) ;
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
let converged = residual . is_empty ( ) ;
if converged {
new_state . applied_revision . config_digest = Some ( desired . config_digest . clone ( ) ) ;
}
let after_value =
serde_json ::to_value ( & new_state ) . expect ( " cluster state must serialize deterministically " ) ;
let mut state_written = false ;
2026-06-10 00:35:03 +03:00
let mut state_write_failed = false ;
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
if after_value ! = before_value {
new_state . state_revision = new_state . state_revision . saturating_add ( 1 ) ;
2026-06-10 02:12:59 +03:00
// The failpoint error routes through state_write_failed so the
// persisted-statuses revert contract below is exercised; a cfg_callback
// on this point can mutate state.json to simulate a concurrent writer,
// making write_state's CAS check fail organically.
let write_result = failpoints ::maybe_fail ( " cluster_apply.before_state_write " )
. and_then ( | ( ) | backend . write_state ( & new_state , expected_cas . as_deref ( ) , & mut observations ) ) ;
match write_result {
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
Ok ( ( ) ) = > state_written = true ,
2026-06-10 00:35:03 +03:00
Err ( diagnostic ) = > {
diagnostics . push ( diagnostic ) ;
state_write_failed = true ;
}
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
}
}
2026-06-10 04:50:42 +03:00
// Completed (rows 2/4) sweep sidecars are deleted only once their outcome
// is durably recorded; on a failed write they stay and re-sweep next run.
if ! state_write_failed {
2026-06-10 04:58:56 +03:00
for sidecar_path in sweep
. completed_sidecars
. iter ( )
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
. chain ( completed_op_sidecars . iter ( ) )
2026-06-10 04:58:56 +03:00
{
2026-06-10 04:50:42 +03:00
let _ = fs ::remove_file ( sidecar_path ) ;
}
feat(cluster): execute approved graph deletes in cluster apply
Stage 4C execution half (RFC-004 §D5/§D6 + sweep rows 7/7b/8): an approved
graph.<id> delete — and its riding schema/query deletes — classifies Applied
and executes LAST in the run, sidecar-fenced: pre-op manifest pin (best
effort; partial roots still delete), approval_id carried in the sidecar,
recursive root removal (NotFound tolerated), subtree tombstoned out of the
ledger with a tombstone observation, the approval consumed in the same state
CAS (ledger summary) and its artifact file rewritten with consumed_at only
after the CAS lands — a failed run consumes nothing and the approval stays
valid for the retry.
Sweep rows: already-tombstoned intents retire (7); a completed delete with a
stale ledger rolls forward — tombstone + approval consumption + audit entry
(7b, idempotent); a still-present root retires the stale intent with a
graph_delete_incomplete warning and the still-approved delete re-executes in
the same run (8) — prefix removal is idempotent, so retry IS the repair.
The multi-graph mixed e2e gets its conclusion: blocked without approval,
cluster approve graph.engineering --as andrew, converge, tombstone visible
in status. Phase 4's disposition matrix is now fully executable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:34:02 +03:00
let mut all_consumed = sweep . consumed_approvals . clone ( ) ;
all_consumed . extend ( consumed_approval_ids . iter ( ) . cloned ( ) ) ;
mark_approvals_consumed ( & backend , & all_consumed ) ;
2026-06-10 04:50:42 +03:00
}
2026-06-10 00:35:03 +03:00
// On a failed state write, report the statuses that are actually on disk
// (the pre-apply snapshot), not the in-memory mutations that were never
// persisted — automation reading `resource_statuses` independently of `ok`
// must not see phantom status updates.
let resource_statuses = if state_write_failed {
state . resource_statuses
} else {
new_state . resource_statuses
} ;
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
let applied_count = changes
. iter ( )
. filter ( | change | change . disposition = = Some ( ApplyDisposition ::Applied ) )
. count ( ) ;
let deferred_count = changes
. iter ( )
. filter ( | change | {
matches! (
change . disposition ,
Some ( ApplyDisposition ::Deferred ) | Some ( ApplyDisposition ::Blocked )
)
} )
. count ( ) ;
ApplyOutput {
ok : ! has_errors ( & diagnostics ) ,
config_dir : display_path ( & desired . config_dir ) ,
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
actor : options . actor . clone ( ) ,
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
desired_revision : DesiredRevision {
config_digest : Some ( desired . config_digest ) ,
} ,
state_observations : observations ,
changes ,
applied_count ,
deferred_count ,
converged ,
state_written ,
2026-06-10 00:35:03 +03:00
resource_statuses ,
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
diagnostics ,
}
}
2026-06-10 14:29:00 +03:00
/// Record a digest-bound human approval for a gated (irreversible) change —
/// today: graph deletes. The artifact binds to the exact desired config
/// digest and the change's before/after digests, so config or state drift
/// invalidates it automatically (a stale approval can never authorize a
/// different change).
pub async fn approve_config_dir (
config_dir : impl AsRef < Path > ,
resource : & str ,
approved_by : & str ,
) -> ApproveOutput {
let outcome = load_desired ( config_dir . as_ref ( ) ) ;
let mut diagnostics = outcome . diagnostics ;
let backend = LocalStateBackend ::new ( & outcome . config_dir ) ;
let mut observations = backend . observations ( ) ;
let fail = | config_dir : String , diagnostics : Vec < Diagnostic > | ApproveOutput {
ok : false ,
config_dir ,
approval_id : None ,
resource : None ,
operation : None ,
approved_by : None ,
diagnostics ,
} ;
let Some ( desired ) = outcome . desired else {
return fail ( display_path ( & outcome . config_dir ) , diagnostics ) ;
} ;
if has_errors ( & diagnostics ) {
return fail ( display_path ( & desired . config_dir ) , diagnostics ) ;
}
let _lock_guard = if desired . state_lock {
match backend . acquire_lock ( " approve " , & mut observations ) {
Ok ( guard ) = > Some ( guard ) ,
Err ( diagnostic ) = > {
diagnostics . push ( diagnostic ) ;
return fail ( display_path ( & desired . config_dir ) , diagnostics ) ;
}
}
} else {
diagnostics . push ( Diagnostic ::warning (
" state_lock_disabled " ,
" state.lock " ,
" state.lock is false; approve ran without acquiring the cluster state lock " ,
) ) ;
None
} ;
let state = match backend . read_state ( & mut observations ) {
Ok ( snapshot ) = > match snapshot . state {
Some ( state ) = > state ,
None = > {
diagnostics . push ( Diagnostic ::error (
" state_missing " ,
CLUSTER_STATE_FILE ,
" approve requires an existing state.json; run `cluster import` first " ,
) ) ;
return fail ( display_path ( & desired . config_dir ) , diagnostics ) ;
}
} ,
Err ( diagnostic ) = > {
diagnostics . push ( diagnostic ) ;
return fail ( display_path ( & desired . config_dir ) , diagnostics ) ;
}
} ;
let prior_resources = state_resource_digests ( & state ) ;
let changes = diff_resources ( & prior_resources , & desired . resource_digests ) ;
let gates = compute_approvals ( & changes , & BTreeSet ::new ( ) ) ;
let Some ( change ) = changes . iter ( ) . find ( | change | {
change . resource = = resource & & gates . iter ( ) . any ( | gate | gate . resource = = resource )
} ) else {
diagnostics . push ( Diagnostic ::error (
" approval_not_required " ,
resource ,
" no pending change for this resource requires approval (check `cluster plan`) " ,
) ) ;
return fail ( display_path ( & desired . config_dir ) , diagnostics ) ;
} ;
let artifact = ApprovalArtifact {
schema_version : 1 ,
approval_id : Ulid ::new ( ) . to_string ( ) ,
resource : change . resource . clone ( ) ,
operation : match change . operation {
PlanOperation ::Create = > " create " ,
PlanOperation ::Update = > " update " ,
PlanOperation ::Delete = > " delete " ,
}
. to_string ( ) ,
reason : gates
. iter ( )
. find ( | gate | gate . resource = = resource )
. map ( | gate | gate . reason . clone ( ) )
. unwrap_or_default ( ) ,
bound_config_digest : desired . config_digest . clone ( ) ,
bound_before_digest : change . before_digest . clone ( ) ,
bound_after_digest : change . after_digest . clone ( ) ,
approved_by : approved_by . to_string ( ) ,
created_at : now_rfc3339 ( ) ,
consumed_at : None ,
consumed_by_operation : None ,
} ;
if let Err ( diagnostic ) = backend . write_approval_artifact ( & artifact ) {
diagnostics . push ( diagnostic ) ;
return fail ( display_path ( & desired . config_dir ) , diagnostics ) ;
}
ApproveOutput {
ok : ! has_errors ( & diagnostics ) ,
config_dir : display_path ( & desired . config_dir ) ,
approval_id : Some ( artifact . approval_id ) ,
resource : Some ( artifact . resource ) ,
operation : Some ( change . operation . clone ( ) ) ,
approved_by : Some ( artifact . approved_by ) ,
diagnostics ,
}
}
2026-06-10 17:39:26 +03:00
/// One graph in a serving snapshot: its id and on-disk root.
#[ derive(Debug, Clone) ]
pub struct ServingGraph {
pub graph_id : String ,
pub root : PathBuf ,
}
/// One stored query: its graph binding, registry name, and verified source.
#[ derive(Debug, Clone) ]
pub struct ServingQuery {
pub graph_id : String ,
pub name : String ,
pub source : String ,
}
/// One policy bundle: its verified catalog blob path and applied bindings
/// (normalized typed refs: `cluster` | `graph.<id>`).
#[ derive(Debug, Clone) ]
pub struct ServingPolicy {
pub name : String ,
pub blob_path : PathBuf ,
pub applies_to : Vec < String > ,
}
/// Everything a server needs to boot from the cluster catalog (RFC-005 §D2).
#[ derive(Debug, Clone) ]
pub struct ServingSnapshot {
pub graphs : Vec < ServingGraph > ,
pub queries : Vec < ServingQuery > ,
pub policies : Vec < ServingPolicy > ,
}
/// Read the applied revision as a serving snapshot — the read-only loader for
/// the Phase-5 server boot. All-or-nothing per RFC-005 §D4: every readiness
/// failure is collected and the whole snapshot refused; no partial serving.
/// Takes no lock: the state file is replaced atomically, so this reads a
/// consistent point-in-time ledger.
pub fn read_serving_snapshot ( config_dir : impl AsRef < Path > ) -> Result < ServingSnapshot , Vec < Diagnostic > > {
let config_dir = config_dir . as_ref ( ) . to_path_buf ( ) ;
let backend = LocalStateBackend ::new ( & config_dir ) ;
let mut diagnostics : Vec < Diagnostic > = Vec ::new ( ) ;
// A ledger a sweep is about to rewrite must not start serving.
let sidecars = backend . list_recovery_sidecars ( & mut diagnostics ) ;
if ! sidecars . is_empty ( ) {
diagnostics . push ( Diagnostic ::error (
" cluster_recovery_pending " ,
CLUSTER_RECOVERIES_DIR ,
format! (
" {} interrupted operation(s) await recovery; run any state-mutating cluster command (e.g. `cluster apply`) to sweep, then retry " ,
sidecars . len ( )
) ,
) ) ;
}
let mut observations = backend . observations ( ) ;
let state = match backend . read_state ( & mut observations ) {
Ok ( snapshot ) = > match snapshot . state {
Some ( state ) = > Some ( state ) ,
None = > {
diagnostics . push ( Diagnostic ::error (
" cluster_state_missing " ,
CLUSTER_STATE_FILE ,
" no cluster state ledger; run `cluster import` and `cluster apply` first " ,
) ) ;
None
}
} ,
Err ( diagnostic ) = > {
diagnostics . push ( diagnostic ) ;
None
}
} ;
let Some ( state ) = state else {
return Err ( diagnostics ) ;
} ;
let mut graphs = Vec ::new ( ) ;
let mut queries = Vec ::new ( ) ;
let mut policies = Vec ::new ( ) ;
for ( address , entry ) in & state . applied_revision . resources {
match resource_kind ( address ) {
ResourceKind ::Graph ( graph_id ) = > {
graphs . push ( ServingGraph {
root : config_dir
. join ( CLUSTER_GRAPHS_DIR )
. join ( format! ( " {graph_id} .omni " ) ) ,
graph_id ,
} ) ;
}
ResourceKind ::Schema ( _ ) = > { }
kind @ ResourceKind ::Query { .. } = > {
let ResourceKind ::Query { graph , name } = & kind else {
unreachable! ( )
} ;
match read_verified_payload ( & config_dir , & kind , & entry . digest , address ) {
Ok ( source ) = > queries . push ( ServingQuery {
graph_id : graph . clone ( ) ,
name : name . clone ( ) ,
source ,
} ) ,
Err ( diagnostic ) = > diagnostics . push ( diagnostic ) ,
}
}
kind @ ResourceKind ::Policy ( _ ) = > {
let ResourceKind ::Policy ( name ) = & kind else {
unreachable! ( )
} ;
let Some ( applies_to ) = entry . applies_to . clone ( ) else {
diagnostics . push ( Diagnostic ::error (
" policy_bindings_missing " ,
address . clone ( ) ,
" no applied applies_to bindings recorded (ledger predates binding metadata); re-run `cluster apply` to backfill " ,
) ) ;
continue ;
} ;
match read_verified_payload ( & config_dir , & kind , & entry . digest , address ) {
Ok ( _ ) = > policies . push ( ServingPolicy {
name : name . clone ( ) ,
blob_path : payload_path ( & config_dir , & kind , & entry . digest )
. expect ( " policy kind always has a payload path " ) ,
applies_to ,
} ) ,
Err ( diagnostic ) = > diagnostics . push ( diagnostic ) ,
}
}
ResourceKind ::Unknown = > { }
}
}
if graphs . is_empty ( ) {
diagnostics . push ( Diagnostic ::error (
" cluster_empty " ,
CLUSTER_STATE_FILE ,
" the applied revision records no graphs; apply a cluster with at least one graph before serving from it " ,
) ) ;
}
if has_errors ( & diagnostics ) {
return Err ( diagnostics ) ;
}
Ok ( ServingSnapshot {
graphs ,
queries ,
policies ,
} )
}
/// Read a catalog blob and verify it against the recorded digest.
fn read_verified_payload (
config_dir : & Path ,
kind : & ResourceKind ,
digest : & str ,
address : & str ,
) -> Result < String , Diagnostic > {
let path = payload_path ( config_dir , kind , digest )
. expect ( " query/policy kinds always have a payload path " ) ;
let bytes = fs ::read ( & path ) . map_err ( | err | {
Diagnostic ::error (
" catalog_payload_missing " ,
address ,
format! (
" catalog blob '{}' unreadable ({err}); run `cluster refresh` then `cluster apply`, and restart " ,
display_path ( & path )
) ,
)
} ) ? ;
if sha256_hex ( & bytes ) ! = digest {
return Err ( Diagnostic ::error (
" catalog_payload_digest_mismatch " ,
address ,
format! (
" catalog blob '{}' does not match its recorded digest; run `cluster refresh` then `cluster apply`, and restart " ,
display_path ( & path )
) ,
) ) ;
}
String ::from_utf8 ( bytes ) . map_err ( | err | {
Diagnostic ::error (
" catalog_payload_invalid " ,
address ,
format! ( " catalog blob is not valid UTF-8: {err} " ) ,
)
} )
}
2026-06-08 21:09:23 +03:00
pub fn status_config_dir ( config_dir : impl AsRef < Path > ) -> StatusOutput {
let parsed = parse_cluster_config ( config_dir . as_ref ( ) ) ;
let mut diagnostics = parsed . diagnostics ;
let backend = LocalStateBackend ::new ( & parsed . config_dir ) ;
let mut observations = backend . observations ( ) ;
backend . observe_lock ( & mut observations , & mut diagnostics ) ;
2026-06-10 04:50:42 +03:00
warn_pending_recovery_sidecars ( & parsed . config_dir , & mut diagnostics ) ;
2026-06-08 21:09:23 +03:00
let mut resource_digests = BTreeMap ::new ( ) ;
let mut resource_statuses = BTreeMap ::new ( ) ;
2026-06-08 23:18:44 +03:00
let mut state_observation_records = BTreeMap ::new ( ) ;
2026-06-08 21:09:23 +03:00
if let Some ( raw ) = parsed . raw . as_ref ( ) {
let _settings = validate_cluster_header ( raw , & mut diagnostics ) ;
if ! has_errors ( & diagnostics ) {
match backend . read_state ( & mut observations ) {
Ok ( snapshot ) = > {
if let Some ( state ) = snapshot . state {
2026-06-10 02:07:08 +03:00
// Read-only point-in-time catalog check: report the
// findings as diagnostics; persisting Drifted statuses
// is refresh's job. Status never writes state.
for ( address , finding ) in
verify_catalog_payloads ( & parsed . config_dir , & state )
{
diagnostics . push ( payload_finding_diagnostic ( & address , & finding ) ) ;
}
2026-06-08 21:09:23 +03:00
resource_digests = state_resource_digests ( & state ) ;
resource_statuses = state . resource_statuses ;
2026-06-08 23:18:44 +03:00
state_observation_records = state . observations ;
2026-06-08 21:09:23 +03:00
} else {
diagnostics . push ( Diagnostic ::warning (
" state_missing " ,
CLUSTER_STATE_FILE ,
" state.json is missing; no applied cluster revision has been recorded " ,
) ) ;
}
}
Err ( diagnostic ) = > diagnostics . push ( diagnostic ) ,
}
}
}
StatusOutput {
ok : ! has_errors ( & diagnostics ) ,
config_dir : display_path ( & parsed . config_dir ) ,
state_observations : observations ,
resource_digests ,
resource_statuses ,
2026-06-08 23:18:44 +03:00
observations : state_observation_records ,
diagnostics ,
}
}
2026-06-09 02:12:00 +03:00
pub fn force_unlock_config_dir (
config_dir : impl AsRef < Path > ,
lock_id : impl AsRef < str > ,
) -> ForceUnlockOutput {
let parsed = parse_cluster_config ( config_dir . as_ref ( ) ) ;
let mut diagnostics = parsed . diagnostics ;
let backend = LocalStateBackend ::new ( & parsed . config_dir ) ;
let mut observations = backend . observations ( ) ;
let mut lock_removed = false ;
if let Some ( raw ) = parsed . raw . as_ref ( ) {
let _settings = validate_cluster_header ( raw , & mut diagnostics ) ;
if ! has_errors ( & diagnostics ) {
match backend . force_unlock ( lock_id . as_ref ( ) , & mut observations ) {
Ok ( ( ) ) = > lock_removed = true ,
Err ( diagnostic ) = > diagnostics . push ( diagnostic ) ,
}
}
}
ForceUnlockOutput {
ok : ! has_errors ( & diagnostics ) ,
config_dir : display_path ( & parsed . config_dir ) ,
state_observations : observations ,
lock_removed ,
diagnostics ,
}
}
2026-06-08 23:18:44 +03:00
pub async fn refresh_config_dir ( config_dir : impl AsRef < Path > ) -> StateSyncOutput {
sync_config_dir ( config_dir . as_ref ( ) , StateSyncOperation ::Refresh ) . await
}
pub async fn import_config_dir ( config_dir : impl AsRef < Path > ) -> StateSyncOutput {
sync_config_dir ( config_dir . as_ref ( ) , StateSyncOperation ::Import ) . await
}
async fn sync_config_dir ( config_dir : & Path , operation : StateSyncOperation ) -> StateSyncOutput {
let outcome = load_desired ( config_dir ) ;
let mut diagnostics = outcome . diagnostics ;
let backend = LocalStateBackend ::new ( & outcome . config_dir ) ;
let mut observations = backend . observations ( ) ;
let Some ( desired ) = outcome . desired else {
return StateSyncOutput {
ok : false ,
operation ,
config_dir : display_path ( & outcome . config_dir ) ,
state_observations : observations ,
resource_digests : BTreeMap ::new ( ) ,
resource_statuses : BTreeMap ::new ( ) ,
observations : BTreeMap ::new ( ) ,
diagnostics ,
} ;
} ;
if has_errors ( & diagnostics ) {
return StateSyncOutput {
ok : false ,
operation ,
config_dir : display_path ( & desired . config_dir ) ,
state_observations : observations ,
resource_digests : desired . resource_digests ,
resource_statuses : BTreeMap ::new ( ) ,
observations : BTreeMap ::new ( ) ,
diagnostics ,
} ;
}
let operation_label = state_sync_operation_label ( operation ) ;
let _lock_guard = if desired . state_lock {
match backend . acquire_lock ( operation_label , & mut observations ) {
Ok ( guard ) = > Some ( guard ) ,
Err ( diagnostic ) = > {
diagnostics . push ( diagnostic ) ;
None
}
}
} else {
diagnostics . push ( Diagnostic ::warning (
" state_lock_disabled " ,
" state.lock " ,
format! (
" state.lock is false; {operation_label} wrote state without acquiring the cluster state lock "
) ,
) ) ;
None
} ;
if has_errors ( & diagnostics ) {
return StateSyncOutput {
ok : false ,
operation ,
config_dir : display_path ( & desired . config_dir ) ,
state_observations : observations ,
resource_digests : desired . resource_digests ,
resource_statuses : BTreeMap ::new ( ) ,
observations : BTreeMap ::new ( ) ,
diagnostics ,
} ;
}
let snapshot = match backend . read_state ( & mut observations ) {
Ok ( snapshot ) = > snapshot ,
Err ( diagnostic ) = > {
diagnostics . push ( diagnostic ) ;
return StateSyncOutput {
ok : false ,
operation ,
config_dir : display_path ( & desired . config_dir ) ,
state_observations : observations ,
resource_digests : desired . resource_digests ,
resource_statuses : BTreeMap ::new ( ) ,
observations : BTreeMap ::new ( ) ,
diagnostics ,
} ;
}
} ;
let expected_cas = snapshot . state_cas ;
let mut state = match ( operation , snapshot . state ) {
( StateSyncOperation ::Refresh , Some ( state ) ) = > state ,
( StateSyncOperation ::Refresh , None ) = > {
diagnostics . push ( Diagnostic ::error (
" state_missing " ,
CLUSTER_STATE_FILE ,
" refresh requires an existing state.json; run `cluster import` to bootstrap state " ,
) ) ;
return StateSyncOutput {
ok : false ,
operation ,
config_dir : display_path ( & desired . config_dir ) ,
state_observations : observations ,
resource_digests : BTreeMap ::new ( ) ,
resource_statuses : BTreeMap ::new ( ) ,
observations : BTreeMap ::new ( ) ,
diagnostics ,
} ;
}
( StateSyncOperation ::Import , Some ( state ) ) = > {
diagnostics . push ( Diagnostic ::error (
" state_already_exists " ,
CLUSTER_STATE_FILE ,
" import creates initial state only when state.json is missing; use `cluster refresh` for an existing state ledger " ,
) ) ;
return StateSyncOutput {
ok : false ,
operation ,
config_dir : display_path ( & desired . config_dir ) ,
state_observations : observations ,
resource_digests : state_resource_digests ( & state ) ,
resource_statuses : state . resource_statuses ,
observations : state . observations ,
diagnostics ,
} ;
}
( StateSyncOperation ::Import , None ) = > initial_import_state ( & desired ) ,
} ;
2026-06-10 04:50:42 +03:00
// Recovery sweep first (RFC-004 §D3): classify any interrupted graph
// operation before observation/verification so a rolled-forward outcome
// is what those passes see.
let sweep = sweep_recovery_sidecars ( & backend , & mut state , & mut diagnostics ) . await ;
2026-06-10 02:07:08 +03:00
// Catalog payload verification must run BEFORE graph observation: removing
// a drifted query digest first means the live-graph composite recompute
// below already excludes it, so the persisted graph.<id> composite stays
// consistent and the next plan shows exactly the create + derived update.
for ( address , finding ) in verify_catalog_payloads ( & desired . config_dir , & state ) {
diagnostics . push ( payload_finding_diagnostic ( & address , & finding ) ) ;
match finding {
PayloadFinding ::Missing = > {
state . applied_revision . resources . remove ( & address ) ;
set_resource_status (
& mut state ,
& address ,
ResourceLifecycleStatus ::Drifted ,
" payload_missing " ,
" catalog payload blob is missing; re-run `cluster apply` to republish " ,
) ;
}
PayloadFinding ::Mismatch { .. } = > {
state . applied_revision . resources . remove ( & address ) ;
set_resource_status (
& mut state ,
& address ,
ResourceLifecycleStatus ::Drifted ,
" payload_mismatch " ,
" catalog payload blob does not match the recorded digest; re-run `cluster apply` to republish " ,
) ;
}
// Transient IO must not trigger a spurious republish: keep the
// digest, surface the error, let a later clean refresh converge.
PayloadFinding ::ReadError ( error ) = > {
set_resource_status (
& mut state ,
& address ,
ResourceLifecycleStatus ::Error ,
" payload_read_error " ,
& error ,
) ;
}
}
}
2026-06-08 23:18:44 +03:00
let graph_error_count = observe_declared_graphs ( & desired , & mut state ) . await ;
if graph_error_count > 0 {
diagnostics . push ( Diagnostic ::error (
" graph_observation_error " ,
CLUSTER_GRAPHS_DIR ,
format! ( " {graph_error_count} graph observation(s) failed " ) ,
) ) ;
}
if operation = = StateSyncOperation ::Import & & has_errors ( & diagnostics ) {
return StateSyncOutput {
ok : false ,
operation ,
config_dir : display_path ( & desired . config_dir ) ,
state_observations : observations ,
resource_digests : state_resource_digests ( & state ) ,
resource_statuses : state . resource_statuses ,
observations : state . observations ,
diagnostics ,
} ;
}
if operation = = StateSyncOperation ::Import {
state . state_revision = 1 ;
} else {
state . state_revision = state . state_revision . saturating_add ( 1 ) ;
}
match backend . write_state ( & state , expected_cas . as_deref ( ) , & mut observations ) {
2026-06-10 04:50:42 +03:00
Ok ( ( ) ) = > {
// Completed sweep sidecars are deleted only after their outcome
// is durably recorded; on failure they stay and re-sweep.
for sidecar_path in & sweep . completed_sidecars {
let _ = fs ::remove_file ( sidecar_path ) ;
}
feat(cluster): execute approved graph deletes in cluster apply
Stage 4C execution half (RFC-004 §D5/§D6 + sweep rows 7/7b/8): an approved
graph.<id> delete — and its riding schema/query deletes — classifies Applied
and executes LAST in the run, sidecar-fenced: pre-op manifest pin (best
effort; partial roots still delete), approval_id carried in the sidecar,
recursive root removal (NotFound tolerated), subtree tombstoned out of the
ledger with a tombstone observation, the approval consumed in the same state
CAS (ledger summary) and its artifact file rewritten with consumed_at only
after the CAS lands — a failed run consumes nothing and the approval stays
valid for the retry.
Sweep rows: already-tombstoned intents retire (7); a completed delete with a
stale ledger rolls forward — tombstone + approval consumption + audit entry
(7b, idempotent); a still-present root retires the stale intent with a
graph_delete_incomplete warning and the still-approved delete re-executes in
the same run (8) — prefix removal is idempotent, so retry IS the repair.
The multi-graph mixed e2e gets its conclusion: blocked without approval,
cluster approve graph.engineering --as andrew, converge, tombstone visible
in status. Phase 4's disposition matrix is now fully executable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:34:02 +03:00
mark_approvals_consumed ( & backend , & sweep . consumed_approvals ) ;
2026-06-10 04:50:42 +03:00
}
2026-06-08 23:18:44 +03:00
Err ( diagnostic ) = > diagnostics . push ( diagnostic ) ,
}
let resource_digests = state_resource_digests ( & state ) ;
let ok = ! has_errors ( & diagnostics ) ;
StateSyncOutput {
ok ,
operation ,
config_dir : display_path ( & desired . config_dir ) ,
state_observations : observations ,
resource_digests ,
resource_statuses : state . resource_statuses ,
observations : state . observations ,
2026-06-08 21:09:23 +03:00
diagnostics ,
}
}
fn parse_cluster_config ( config_dir : & Path ) -> ParsedConfig {
2026-06-08 20:07:39 +03:00
let config_dir = config_dir . to_path_buf ( ) ;
let config_file = config_dir . join ( CLUSTER_CONFIG_FILE ) ;
let mut diagnostics = Vec ::new ( ) ;
if ! config_dir . is_dir ( ) {
diagnostics . push ( Diagnostic ::error (
" config_dir_not_found " ,
display_path ( & config_dir ) ,
" `--config` must point at a directory containing cluster.yaml " ,
) ) ;
2026-06-08 21:09:23 +03:00
return ParsedConfig {
raw : None ,
2026-06-08 20:07:39 +03:00
diagnostics ,
config_dir ,
config_file ,
} ;
}
let text = match fs ::read_to_string ( & config_file ) {
Ok ( text ) = > text ,
Err ( err ) = > {
diagnostics . push ( Diagnostic ::error (
" cluster_config_read_error " ,
CLUSTER_CONFIG_FILE ,
format! ( " could not read cluster.yaml: {err} " ) ,
) ) ;
2026-06-08 21:09:23 +03:00
return ParsedConfig {
raw : None ,
2026-06-08 20:07:39 +03:00
diagnostics ,
config_dir ,
config_file ,
} ;
}
} ;
diagnostics . extend ( duplicate_key_diagnostics ( & text ) ) ;
diagnostics . extend ( future_field_diagnostics ( & text ) ) ;
if has_errors ( & diagnostics ) {
2026-06-08 21:09:23 +03:00
return ParsedConfig {
raw : None ,
2026-06-08 20:07:39 +03:00
diagnostics ,
config_dir ,
config_file ,
} ;
}
let raw = match serde_yaml ::from_str ::< RawClusterConfig > ( & text ) {
2026-06-08 21:09:23 +03:00
Ok ( raw ) = > Some ( raw ) ,
2026-06-08 20:07:39 +03:00
Err ( err ) = > {
diagnostics . push ( Diagnostic ::error (
" invalid_cluster_yaml " ,
CLUSTER_CONFIG_FILE ,
format! ( " could not parse cluster.yaml: {err} " ) ,
) ) ;
2026-06-08 21:09:23 +03:00
None
2026-06-08 20:07:39 +03:00
}
} ;
2026-06-08 21:09:23 +03:00
ParsedConfig {
raw ,
diagnostics ,
config_dir ,
config_file ,
}
}
fn validate_cluster_header (
raw : & RawClusterConfig ,
diagnostics : & mut Vec < Diagnostic > ,
) -> ClusterSettings {
2026-06-08 20:07:39 +03:00
if raw . version ! = 1 {
diagnostics . push ( Diagnostic ::error (
" unsupported_cluster_config_version " ,
" version " ,
format! (
" unsupported cluster config version {}; this build supports version 1 " ,
raw . version
) ,
) ) ;
}
if let Some ( name ) = raw . metadata . name . as_deref ( ) {
if name . trim ( ) . is_empty ( ) {
diagnostics . push ( Diagnostic ::error (
" empty_metadata_name " ,
" metadata.name " ,
" metadata.name must not be empty when provided " ,
) ) ;
}
}
if let Some ( backend ) = raw . state . backend . as_deref ( ) {
if backend ! = " cluster " {
diagnostics . push ( Diagnostic ::error (
" unsupported_state_backend " ,
" state.backend " ,
2026-06-09 02:12:00 +03:00
" Stage 2C supports only omitted state.backend or `cluster` " ,
2026-06-08 21:09:23 +03:00
) ) ;
}
}
ClusterSettings {
state_lock : raw . state . lock . unwrap_or ( true ) ,
}
}
2026-06-09 02:12:00 +03:00
fn parse_lock_file_for_unlock ( text : & str ) -> Result < StateLockFile , Diagnostic > {
let lock = serde_json ::from_str ::< StateLockFile > ( text ) . map_err ( | err | {
Diagnostic ::error (
" invalid_state_lock " ,
CLUSTER_LOCK_FILE ,
format! ( " could not parse state lock: {err} " ) ,
)
} ) ? ;
if lock . version ! = 1 {
return Err ( Diagnostic ::error (
" unsupported_state_lock_version " ,
CLUSTER_LOCK_FILE ,
format! ( " unsupported cluster state lock version {} " , lock . version ) ,
) ) ;
}
Ok ( lock )
}
fn state_lock_held_message ( observations : & StateObservations ) -> String {
match observations . lock_id . as_deref ( ) {
Some ( lock_id ) = > format! (
" cluster state lock already exists (lock id {lock_id}); run `omnigraph cluster force-unlock {lock_id}` only after confirming no cluster operation is active "
) ,
None = > " cluster state lock already exists; remove it only after confirming no cluster operation is active " . to_string ( ) ,
}
}
2026-06-08 21:09:23 +03:00
fn state_resource_digests ( state : & ClusterState ) -> BTreeMap < String , String > {
state
. applied_revision
. resources
. iter ( )
. map ( | ( address , resource ) | ( address . clone ( ) , resource . digest . clone ( ) ) )
. collect ( )
}
2026-06-08 23:18:44 +03:00
fn initial_import_state ( desired : & DesiredCluster ) -> ClusterState {
ClusterState {
version : 1 ,
state_revision : 0 ,
applied_revision : AppliedRevisionState {
config_digest : Some ( desired . config_digest . clone ( ) ) ,
resources : BTreeMap ::new ( ) ,
} ,
resource_statuses : BTreeMap ::new ( ) ,
approval_records : BTreeMap ::new ( ) ,
recovery_records : BTreeMap ::new ( ) ,
observations : BTreeMap ::new ( ) ,
}
}
2026-06-10 04:50:42 +03:00
/// Recovery sweep (RFC-004 §D3): runs at the start of every state-mutating
/// cluster command, under the state lock, before the command's own work.
/// Roll-forward-only — the engine's own sidecars make each graph-level
/// operation atomic within the graph, so the cluster never rolls a graph
/// back; it converges the ledger to observable reality or refuses loudly.
/// Mutations ride the calling command's CAS-checked state write; completed
/// sidecars are deleted only after that write lands.
async fn sweep_recovery_sidecars (
backend : & LocalStateBackend ,
state : & mut ClusterState ,
diagnostics : & mut Vec < Diagnostic > ,
) -> SweepOutcome {
let mut outcome = SweepOutcome ::default ( ) ;
for ( path , sidecar ) in backend . list_recovery_sidecars ( diagnostics ) {
match sidecar . kind {
RecoverySidecarKind ::GraphCreate = > {
sweep_graph_create_sidecar ( path , sidecar , state , diagnostics , & mut outcome ) . await ;
}
2026-06-10 13:05:42 +03:00
RecoverySidecarKind ::SchemaApply = > {
sweep_schema_apply_sidecar ( path , sidecar , state , diagnostics , & mut outcome ) . await ;
}
feat(cluster): execute approved graph deletes in cluster apply
Stage 4C execution half (RFC-004 §D5/§D6 + sweep rows 7/7b/8): an approved
graph.<id> delete — and its riding schema/query deletes — classifies Applied
and executes LAST in the run, sidecar-fenced: pre-op manifest pin (best
effort; partial roots still delete), approval_id carried in the sidecar,
recursive root removal (NotFound tolerated), subtree tombstoned out of the
ledger with a tombstone observation, the approval consumed in the same state
CAS (ledger summary) and its artifact file rewritten with consumed_at only
after the CAS lands — a failed run consumes nothing and the approval stays
valid for the retry.
Sweep rows: already-tombstoned intents retire (7); a completed delete with a
stale ledger rolls forward — tombstone + approval consumption + audit entry
(7b, idempotent); a still-present root retires the stale intent with a
graph_delete_incomplete warning and the still-approved delete re-executes in
the same run (8) — prefix removal is idempotent, so retry IS the repair.
The multi-graph mixed e2e gets its conclusion: blocked without approval,
cluster approve graph.engineering --as andrew, converge, tombstone visible
in status. Phase 4's disposition matrix is now fully executable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:34:02 +03:00
RecoverySidecarKind ::GraphDelete = > {
sweep_graph_delete_sidecar ( path , sidecar , state , diagnostics , & mut outcome ) ;
}
2026-06-10 04:50:42 +03:00
}
}
outcome
}
async fn sweep_graph_create_sidecar (
path : PathBuf ,
sidecar : RecoverySidecar ,
state : & mut ClusterState ,
diagnostics : & mut Vec < Diagnostic > ,
outcome : & mut SweepOutcome ,
) {
let graph_address = graph_address ( & sidecar . graph_id ) ;
let schema_addr = schema_address ( & sidecar . graph_id ) ;
let graph_path = PathBuf ::from ( & sidecar . graph_uri ) ;
// Row 1: nothing moved — the init never landed. The sidecar is pure
// intent; remove it and let the command's own plan re-propose the create.
if ! graph_path . exists ( ) {
let _ = fs ::remove_file ( & path ) ;
return ;
}
match Omnigraph ::open_read_only ( & sidecar . graph_uri ) . await {
Ok ( db ) = > {
let live_digest = sha256_hex ( db . schema_source ( ) . as_bytes ( ) ) ;
let recorded = state
. applied_revision
. resources
. get ( & schema_addr )
. map ( | resource | resource . digest . clone ( ) ) ;
if recorded . as_deref ( ) = = Some ( live_digest . as_str ( ) ) {
// Row 2: crash fell between the state CAS and sidecar delete.
outcome . completed_sidecars . push ( path ) ;
} else if live_digest = = sidecar . desired_schema_digest {
// Row 4: the create completed on the graph; roll the cluster
// state forward to observable reality.
state . applied_revision . resources . insert (
schema_addr . clone ( ) ,
StateResource {
digest : live_digest . clone ( ) ,
2026-06-10 15:30:33 +03:00
applies_to : None ,
2026-06-10 04:50:42 +03:00
} ,
) ;
let query_digests = state_query_digests_for_graph ( state , & sidecar . graph_id ) ;
let composite =
graph_digest ( & sidecar . graph_id , Some ( & live_digest ) , Some ( & query_digests ) ) ;
state
. applied_revision
. resources
2026-06-10 15:30:33 +03:00
. insert ( graph_address . clone ( ) , StateResource { digest : composite , applies_to : None } ) ;
2026-06-10 04:50:42 +03:00
set_resource_status_applied ( state , & graph_address ) ;
set_resource_status_applied ( state , & schema_addr ) ;
state . recovery_records . insert (
sidecar . operation_id . clone ( ) ,
json! ( {
" kind " : " graph_create " ,
" graph_id " : sidecar . graph_id ,
" outcome " : " rolled_forward " ,
" recovered_at " : now_rfc3339 ( ) ,
" actor " : sidecar . actor ,
} ) ,
) ;
diagnostics . push ( Diagnostic ::warning (
" cluster_recovery_rolled_forward " ,
graph_address . clone ( ) ,
" an interrupted graph create had completed on the graph; cluster state was rolled forward to match " ,
) ) ;
outcome . completed_sidecars . push ( path ) ;
} else {
// Row 6: the graph moved to something the sidecar did not
// intend. Refuse to guess; require refresh + operator re-plan.
set_resource_status (
state ,
& graph_address ,
ResourceLifecycleStatus ::Drifted ,
" actual_applied_state_pending " ,
" graph state does not match the interrupted operation; run `cluster refresh` and re-plan " ,
) ;
set_resource_status (
state ,
& schema_addr ,
ResourceLifecycleStatus ::Drifted ,
" actual_applied_state_pending " ,
" graph state does not match the interrupted operation; run `cluster refresh` and re-plan " ,
) ;
diagnostics . push ( Diagnostic ::warning (
" cluster_recovery_pending " ,
graph_address . clone ( ) ,
" an interrupted graph create left unexpected graph state; graph-moving work is blocked until repaired " ,
) ) ;
outcome . pending_graphs . insert ( sidecar . graph_id . clone ( ) ) ;
}
}
Err ( err ) = > {
// Row 5: partial root (the engine's documented init gap). Never
// auto-delete — reconciler deletes are the same data-loss class
// as human deletes; the operator removes the root explicitly.
set_resource_status (
state ,
& graph_address ,
ResourceLifecycleStatus ::Error ,
" graph_create_incomplete " ,
" graph root exists but cannot be opened; remove the graph root and re-run `cluster apply` " ,
) ;
set_resource_status (
state ,
& schema_addr ,
ResourceLifecycleStatus ::Error ,
" graph_create_incomplete " ,
" graph root exists but cannot be opened; remove the graph root and re-run `cluster apply` " ,
) ;
diagnostics . push ( Diagnostic ::error (
" graph_create_incomplete " ,
graph_address . clone ( ) ,
format! (
" graph root '{}' exists but cannot be opened ({err}); remove the graph root and re-run `cluster apply` " ,
sidecar . graph_uri
) ,
) ) ;
outcome . pending_graphs . insert ( sidecar . graph_id . clone ( ) ) ;
}
}
}
2026-06-10 13:05:42 +03:00
async fn sweep_schema_apply_sidecar (
path : PathBuf ,
sidecar : RecoverySidecar ,
state : & mut ClusterState ,
diagnostics : & mut Vec < Diagnostic > ,
outcome : & mut SweepOutcome ,
) {
let graph_address = graph_address ( & sidecar . graph_id ) ;
let schema_addr = schema_address ( & sidecar . graph_id ) ;
// Digest-based classification: robust to unrelated manifest movement;
// the sidecar's version pins stay forensic.
let live_digest = match Omnigraph ::open_read_only ( & sidecar . graph_uri ) . await {
Ok ( db ) = > sha256_hex ( db . schema_source ( ) . as_bytes ( ) ) ,
Err ( err ) = > {
// Cannot verify the interrupted operation — refuse to guess.
diagnostics . push ( Diagnostic ::warning (
" cluster_recovery_pending " ,
graph_address . clone ( ) ,
format! (
" an interrupted schema apply cannot be verified (graph '{}' did not open: {err}); graph-moving work is blocked until repaired " ,
sidecar . graph_uri
) ,
) ) ;
outcome . pending_graphs . insert ( sidecar . graph_id . clone ( ) ) ;
return ;
}
} ;
let recorded = state
. applied_revision
. resources
. get ( & schema_addr )
. map ( | resource | resource . digest . clone ( ) ) ;
if recorded . as_deref ( ) = = Some ( live_digest . as_str ( ) ) {
// Ledger consistent with the live graph (the apply never landed, or
// landed and was recorded): the sidecar is stale intent — retire it.
outcome . completed_sidecars . push ( path ) ;
} else if live_digest = = sidecar . desired_schema_digest {
// RFC-004 §D3 row 3: the schema apply completed on the graph; roll
// the cluster state forward to observable reality.
state . applied_revision . resources . insert (
schema_addr . clone ( ) ,
StateResource {
digest : live_digest . clone ( ) ,
2026-06-10 15:30:33 +03:00
applies_to : None ,
2026-06-10 13:05:42 +03:00
} ,
) ;
let query_digests = state_query_digests_for_graph ( state , & sidecar . graph_id ) ;
let composite = graph_digest ( & sidecar . graph_id , Some ( & live_digest ) , Some ( & query_digests ) ) ;
state
. applied_revision
. resources
2026-06-10 15:30:33 +03:00
. insert ( graph_address . clone ( ) , StateResource { digest : composite , applies_to : None } ) ;
2026-06-10 13:05:42 +03:00
set_resource_status_applied ( state , & graph_address ) ;
set_resource_status_applied ( state , & schema_addr ) ;
state . recovery_records . insert (
sidecar . operation_id . clone ( ) ,
json! ( {
" kind " : " schema_apply " ,
" graph_id " : sidecar . graph_id ,
" outcome " : " rolled_forward " ,
" recovered_at " : now_rfc3339 ( ) ,
" actor " : sidecar . actor ,
} ) ,
) ;
diagnostics . push ( Diagnostic ::warning (
" cluster_recovery_rolled_forward " ,
graph_address . clone ( ) ,
" an interrupted schema apply had completed on the graph; cluster state was rolled forward to match " ,
) ) ;
outcome . completed_sidecars . push ( path ) ;
} else {
// Row 6: live schema is neither the recorded nor the desired digest.
set_resource_status (
state ,
& graph_address ,
ResourceLifecycleStatus ::Drifted ,
" actual_applied_state_pending " ,
" graph state does not match the interrupted operation; run `cluster refresh` and re-plan " ,
) ;
set_resource_status (
state ,
& schema_addr ,
ResourceLifecycleStatus ::Drifted ,
" actual_applied_state_pending " ,
" graph state does not match the interrupted operation; run `cluster refresh` and re-plan " ,
) ;
diagnostics . push ( Diagnostic ::warning (
" cluster_recovery_pending " ,
graph_address . clone ( ) ,
" an interrupted schema apply left unexpected graph state; graph-moving work is blocked until repaired " ,
) ) ;
outcome . pending_graphs . insert ( sidecar . graph_id . clone ( ) ) ;
}
}
feat(cluster): execute approved graph deletes in cluster apply
Stage 4C execution half (RFC-004 §D5/§D6 + sweep rows 7/7b/8): an approved
graph.<id> delete — and its riding schema/query deletes — classifies Applied
and executes LAST in the run, sidecar-fenced: pre-op manifest pin (best
effort; partial roots still delete), approval_id carried in the sidecar,
recursive root removal (NotFound tolerated), subtree tombstoned out of the
ledger with a tombstone observation, the approval consumed in the same state
CAS (ledger summary) and its artifact file rewritten with consumed_at only
after the CAS lands — a failed run consumes nothing and the approval stays
valid for the retry.
Sweep rows: already-tombstoned intents retire (7); a completed delete with a
stale ledger rolls forward — tombstone + approval consumption + audit entry
(7b, idempotent); a still-present root retires the stale intent with a
graph_delete_incomplete warning and the still-approved delete re-executes in
the same run (8) — prefix removal is idempotent, so retry IS the repair.
The multi-graph mixed e2e gets its conclusion: blocked without approval,
cluster approve graph.engineering --as andrew, converge, tombstone visible
in status. Phase 4's disposition matrix is now fully executable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:34:02 +03:00
fn sweep_graph_delete_sidecar (
path : PathBuf ,
sidecar : RecoverySidecar ,
state : & mut ClusterState ,
diagnostics : & mut Vec < Diagnostic > ,
outcome : & mut SweepOutcome ,
) {
let graph_address = graph_address ( & sidecar . graph_id ) ;
let root = PathBuf ::from ( & sidecar . graph_uri ) ;
if root . exists ( ) {
// Row 8: the delete never completed. Prefix removal is idempotent and
// works on partial roots, so the repair is simply the re-proposed,
// still-approved delete on a later run — retire the stale intent.
diagnostics . push ( Diagnostic ::warning (
" graph_delete_incomplete " ,
graph_address ,
" a previous graph delete did not complete; it will be re-proposed by plan and can be retried under its approval " ,
) ) ;
outcome . completed_sidecars . push ( path ) ;
return ;
}
if ! state . applied_revision . resources . contains_key ( & graph_address ) {
// Row 7: already tombstoned (or never recorded); crash fell between
// the state CAS and sidecar delete.
outcome . completed_sidecars . push ( path ) ;
return ;
}
// Row 7b: the root is gone, the ledger is stale — roll forward the
// tombstone, consume the approval the sidecar carries, audit.
tombstone_graph_subtree ( state , & sidecar . graph_id , sidecar . approval_id . as_deref ( ) , sidecar . actor . as_deref ( ) ) ;
state . recovery_records . insert (
sidecar . operation_id . clone ( ) ,
json! ( {
" kind " : " graph_delete " ,
" graph_id " : sidecar . graph_id ,
" outcome " : " rolled_forward " ,
" recovered_at " : now_rfc3339 ( ) ,
" actor " : sidecar . actor ,
} ) ,
) ;
if let Some ( approval_id ) = & sidecar . approval_id {
record_approval_consumed ( state , approval_id , & sidecar . operation_id ) ;
outcome . consumed_approvals . push ( approval_id . clone ( ) ) ;
}
diagnostics . push ( Diagnostic ::warning (
" cluster_recovery_rolled_forward " ,
graph_address ,
" an interrupted graph delete had completed on disk; cluster state was rolled forward to match " ,
) ) ;
outcome . completed_sidecars . push ( path ) ;
}
/// Remove a graph's subtree (graph, schema, queries) from the ledger and
/// leave a tombstone observation. Idempotent.
fn tombstone_graph_subtree (
state : & mut ClusterState ,
graph_id : & str ,
approval_id : Option < & str > ,
actor : Option < & str > ,
) {
let graph_addr = graph_address ( graph_id ) ;
let schema_addr = schema_address ( graph_id ) ;
let query_prefix = format! ( " query. {graph_id} . " ) ;
state . applied_revision . resources . remove ( & graph_addr ) ;
state . applied_revision . resources . remove ( & schema_addr ) ;
state
. applied_revision
. resources
. retain ( | address , _ | ! address . starts_with ( & query_prefix ) ) ;
state . resource_statuses . remove ( & graph_addr ) ;
state . resource_statuses . remove ( & schema_addr ) ;
state
. resource_statuses
. retain ( | address , _ | ! address . starts_with ( & query_prefix ) ) ;
state . observations . insert (
graph_addr ,
json! ( {
" kind " : " tombstone " ,
" deleted_at " : now_rfc3339 ( ) ,
" approval_id " : approval_id ,
" actor " : actor ,
} ) ,
) ;
}
/// Record approval consumption in the state ledger. The artifact FILE is
/// rewritten with consumed_at only after the state write lands, so a failed
/// CAS leaves the approval valid for the retry.
fn record_approval_consumed ( state : & mut ClusterState , approval_id : & str , operation_id : & str ) {
state . approval_records . insert (
approval_id . to_string ( ) ,
json! ( {
" consumed_at " : now_rfc3339 ( ) ,
" consumed_by_operation " : operation_id ,
} ) ,
) ;
}
/// Mark approval artifact files consumed on disk (post-CAS).
fn mark_approvals_consumed ( backend : & LocalStateBackend , approval_ids : & [ String ] ) {
if approval_ids . is_empty ( ) {
return ;
}
let mut sink = Vec ::new ( ) ;
for ( _ , mut artifact ) in backend . list_approval_artifacts ( & mut sink ) {
if approval_ids . contains ( & artifact . approval_id ) & & artifact . consumed_at . is_none ( ) {
artifact . consumed_at = Some ( now_rfc3339 ( ) ) ;
let _ = backend . write_approval_artifact ( & artifact ) ;
}
}
}
2026-06-10 04:50:42 +03:00
/// Read-only commands report pending sidecars without acting on them.
fn warn_pending_recovery_sidecars ( config_dir : & Path , diagnostics : & mut Vec < Diagnostic > ) {
let recoveries_dir = config_dir . join ( CLUSTER_RECOVERIES_DIR ) ;
let Ok ( entries ) = fs ::read_dir ( & recoveries_dir ) else {
return ;
} ;
let mut names : Vec < String > = entries
. flatten ( )
. filter ( | entry | entry . path ( ) . extension ( ) . is_some_and ( | ext | ext = = " json " ) )
. map ( | entry | entry . file_name ( ) . to_string_lossy ( ) . into_owned ( ) )
. collect ( ) ;
names . sort ( ) ;
for name in names {
diagnostics . push ( Diagnostic ::warning (
" cluster_recovery_pending " ,
format! ( " {CLUSTER_RECOVERIES_DIR} / {name} " ) ,
" a recovery sidecar from an interrupted apply is pending; the next state-mutating command will classify it " ,
) ) ;
}
}
2026-06-08 23:18:44 +03:00
async fn observe_declared_graphs ( desired : & DesiredCluster , state : & mut ClusterState ) -> usize {
let mut graph_error_count = 0 ;
for graph in & desired . graphs {
let graph_address = graph_address ( & graph . id ) ;
let schema_address = schema_address ( & graph . id ) ;
let graph_path = desired
. config_dir
. join ( CLUSTER_GRAPHS_DIR )
. join ( format! ( " {} .omni " , graph . id ) ) ;
let graph_uri = display_path ( & graph_path ) ;
let observed_at = now_rfc3339 ( ) ;
if ! graph_path . exists ( ) {
state . applied_revision . resources . remove ( & graph_address ) ;
state . applied_revision . resources . remove ( & schema_address ) ;
state . observations . insert (
graph_address . clone ( ) ,
graph_observation_json ( GraphObservationJson {
address : & graph_address ,
graph_uri : & graph_uri ,
observed_at : & observed_at ,
exists : false ,
manifest_version : None ,
schema_digest : None ,
desired_schema_digest : & graph . schema_digest ,
schema_matches_desired : Some ( false ) ,
error : Some ( " derived graph root is missing " ) ,
} ) ,
) ;
set_resource_status (
state ,
& graph_address ,
ResourceLifecycleStatus ::Drifted ,
" graph_missing " ,
" derived graph root is missing " ,
) ;
set_resource_status (
state ,
& schema_address ,
ResourceLifecycleStatus ::Drifted ,
" graph_missing " ,
" derived graph root is missing " ,
) ;
continue ;
}
match observe_live_graph ( & graph_uri ) . await {
Ok ( observation ) = > {
let schema_matches = observation . schema_digest = = graph . schema_digest ;
state . applied_revision . resources . insert (
schema_address . clone ( ) ,
StateResource {
digest : observation . schema_digest . clone ( ) ,
2026-06-10 15:30:33 +03:00
applies_to : None ,
2026-06-08 23:18:44 +03:00
} ,
) ;
let query_digests = state_query_digests_for_graph ( state , & graph . id ) ;
let graph_digest_value = graph_digest (
& graph . id ,
Some ( & observation . schema_digest ) ,
Some ( & query_digests ) ,
) ;
state . applied_revision . resources . insert (
graph_address . clone ( ) ,
StateResource {
digest : graph_digest_value ,
2026-06-10 15:30:33 +03:00
applies_to : None ,
2026-06-08 23:18:44 +03:00
} ,
) ;
state . observations . insert (
graph_address . clone ( ) ,
graph_observation_json ( GraphObservationJson {
address : & graph_address ,
graph_uri : & graph_uri ,
observed_at : & observed_at ,
exists : true ,
manifest_version : Some ( observation . manifest_version ) ,
schema_digest : Some ( observation . schema_digest . as_str ( ) ) ,
desired_schema_digest : & graph . schema_digest ,
schema_matches_desired : Some ( schema_matches ) ,
error : None ,
} ) ,
) ;
if schema_matches {
set_resource_status_applied ( state , & graph_address ) ;
set_resource_status_applied ( state , & schema_address ) ;
} else {
set_resource_status (
state ,
& graph_address ,
ResourceLifecycleStatus ::Drifted ,
" schema_mismatch " ,
" live schema digest differs from desired schema digest " ,
) ;
set_resource_status (
state ,
& schema_address ,
ResourceLifecycleStatus ::Drifted ,
" schema_mismatch " ,
" live schema digest differs from desired schema digest " ,
) ;
}
}
Err ( error ) = > {
graph_error_count + = 1 ;
state . observations . insert (
graph_address . clone ( ) ,
graph_observation_json ( GraphObservationJson {
address : & graph_address ,
graph_uri : & graph_uri ,
observed_at : & observed_at ,
exists : true ,
manifest_version : None ,
schema_digest : None ,
desired_schema_digest : & graph . schema_digest ,
schema_matches_desired : None ,
error : Some ( error . as_str ( ) ) ,
} ) ,
) ;
set_resource_status (
state ,
& graph_address ,
ResourceLifecycleStatus ::Error ,
" graph_observation_error " ,
error . as_str ( ) ,
) ;
set_resource_status (
state ,
& schema_address ,
ResourceLifecycleStatus ::Error ,
" graph_observation_error " ,
error . as_str ( ) ,
) ;
}
}
}
graph_error_count
}
2026-06-10 13:04:19 +03:00
/// RFC-004 §D7: the data-aware preview — the engine's migration plan for a
/// desired schema against the live graph, computed read-only (no lock).
async fn preview_schema_migration (
graph_uri : & str ,
schema_path : & str ,
) -> Result < SchemaMigrationPlan , String > {
let source = fs ::read_to_string ( schema_path ) . map_err ( | err | err . to_string ( ) ) ? ;
let db = Omnigraph ::open_read_only ( graph_uri )
. await
. map_err ( | err | err . to_string ( ) ) ? ;
let preview = db
. preview_schema_apply_with_options ( & source , SchemaApplyOptions ::default ( ) )
. await
. map_err ( | err | err . to_string ( ) ) ? ;
Ok ( preview . plan )
}
2026-06-08 23:18:44 +03:00
struct LiveGraphObservation {
manifest_version : u64 ,
schema_digest : String ,
}
async fn observe_live_graph ( graph_uri : & str ) -> Result < LiveGraphObservation , String > {
let db = Omnigraph ::open_read_only ( graph_uri )
. await
. map_err ( | err | err . to_string ( ) ) ? ;
let snapshot = db
. snapshot_of ( ReadTarget ::branch ( " main " ) )
. await
. map_err ( | err | err . to_string ( ) ) ? ;
let schema_source = db . schema_source ( ) ;
Ok ( LiveGraphObservation {
manifest_version : snapshot . version ( ) ,
schema_digest : sha256_hex ( schema_source . as_bytes ( ) ) ,
} )
}
struct GraphObservationJson < ' a > {
address : & ' a str ,
graph_uri : & ' a str ,
observed_at : & ' a str ,
exists : bool ,
manifest_version : Option < u64 > ,
schema_digest : Option < & ' a str > ,
desired_schema_digest : & ' a str ,
schema_matches_desired : Option < bool > ,
error : Option < & ' a str > ,
}
fn graph_observation_json ( observation : GraphObservationJson < '_ > ) -> serde_json ::Value {
json! ( {
" kind " : " graph " ,
" address " : observation . address ,
" graph_uri " : observation . graph_uri ,
" observed_at " : observation . observed_at ,
" exists " : observation . exists ,
" manifest_version " : observation . manifest_version ,
" schema_digest " : observation . schema_digest ,
" desired_schema_digest " : observation . desired_schema_digest ,
" schema_matches_desired " : observation . schema_matches_desired ,
" error " : observation . error ,
} )
}
fn state_query_digests_for_graph ( state : & ClusterState , graph_id : & str ) -> BTreeMap < String , String > {
let prefix = format! ( " query. {graph_id} . " ) ;
state
. applied_revision
. resources
. iter ( )
. filter_map ( | ( address , resource ) | {
address
. strip_prefix ( & prefix )
. map ( | name | ( name . to_string ( ) , resource . digest . clone ( ) ) )
} )
. collect ( )
}
fn set_resource_status_applied ( state : & mut ClusterState , address : & str ) {
state . resource_statuses . insert (
address . to_string ( ) ,
ResourceStatusRecord {
status : ResourceLifecycleStatus ::Applied ,
conditions : Vec ::new ( ) ,
message : None ,
} ,
) ;
}
fn set_resource_status (
state : & mut ClusterState ,
address : & str ,
status : ResourceLifecycleStatus ,
condition : & str ,
message : & str ,
) {
state . resource_statuses . insert (
address . to_string ( ) ,
ResourceStatusRecord {
status ,
conditions : vec ! [ condition . to_string ( ) ] ,
message : Some ( message . to_string ( ) ) ,
} ,
) ;
}
2026-06-08 21:09:23 +03:00
fn load_desired ( config_dir : & Path ) -> LoadOutcome {
let parsed = parse_cluster_config ( config_dir ) ;
let config_dir = parsed . config_dir ;
let config_file = parsed . config_file ;
let mut diagnostics = parsed . diagnostics ;
let Some ( raw ) = parsed . raw else {
return LoadOutcome {
desired : None ,
diagnostics ,
config_dir ,
config_file ,
} ;
} ;
let settings = validate_cluster_header ( & raw , & mut diagnostics ) ;
2026-06-08 20:07:39 +03:00
let mut resources = BTreeMap ::new ( ) ;
let mut dependencies = BTreeSet ::new ( ) ;
let mut graph_query_digests : BTreeMap < String , BTreeMap < String , String > > = BTreeMap ::new ( ) ;
let mut graph_schema_digests : BTreeMap < String , String > = BTreeMap ::new ( ) ;
for ( graph_id , graph ) in & raw . graphs {
validate_id (
" graph id " ,
& format! ( " graphs. {graph_id} " ) ,
graph_id ,
& mut diagnostics ,
) ;
let graph_address = graph_address ( graph_id ) ;
let schema_address = schema_address ( graph_id ) ;
dependencies . insert ( Dependency {
from : schema_address . clone ( ) ,
to : graph_address . clone ( ) ,
} ) ;
let schema_path = resolve_config_path ( & config_dir , & graph . schema ) ;
let schema_source = match fs ::read_to_string ( & schema_path ) {
Ok ( source ) = > {
let digest = sha256_hex ( source . as_bytes ( ) ) ;
graph_schema_digests . insert ( graph_id . clone ( ) , digest . clone ( ) ) ;
resources . insert (
schema_address . clone ( ) ,
ResourceSummary {
address : schema_address . clone ( ) ,
kind : " schema " . to_string ( ) ,
digest ,
path : Some ( display_path ( & schema_path ) ) ,
} ,
) ;
Some ( source )
}
Err ( err ) = > {
diagnostics . push ( Diagnostic ::error (
" schema_file_missing " ,
format! ( " graphs. {graph_id} .schema " ) ,
format! (
" could not read schema file '{}': {err} " ,
schema_path . display ( )
) ,
) ) ;
None
}
} ;
let catalog = schema_source . and_then ( | source | match parse_schema ( & source ) {
Ok ( schema ) = > match build_catalog ( & schema ) {
Ok ( catalog ) = > Some ( catalog ) ,
Err ( err ) = > {
diagnostics . push ( Diagnostic ::error (
" schema_catalog_error " ,
format! ( " graphs. {graph_id} .schema " ) ,
err . to_string ( ) ,
) ) ;
None
}
} ,
Err ( err ) = > {
diagnostics . push ( Diagnostic ::error (
" schema_parse_error " ,
format! ( " graphs. {graph_id} .schema " ) ,
err . to_string ( ) ,
) ) ;
None
}
} ) ;
2026-06-11 01:35:47 +03:00
let ( graph_queries , query_contents ) =
resolve_query_decls ( & config_dir , graph_id , & graph . queries , & mut diagnostics ) ;
2026-06-11 00:46:21 +03:00
for ( query_name , query ) in & graph_queries {
2026-06-08 20:07:39 +03:00
validate_id (
" query name " ,
& format! ( " graphs. {graph_id} .queries. {query_name} " ) ,
query_name ,
& mut diagnostics ,
) ;
let query_address = query_address ( graph_id , query_name ) ;
dependencies . insert ( Dependency {
from : query_address . clone ( ) ,
to : graph_address . clone ( ) ,
} ) ;
dependencies . insert ( Dependency {
from : query_address . clone ( ) ,
to : schema_address . clone ( ) ,
} ) ;
let query_path = resolve_config_path ( & config_dir , & query . file ) ;
2026-06-11 01:35:47 +03:00
let source = match query_contents . get ( & query . file ) {
Some ( cached ) = > Ok ( cached . clone ( ) ) ,
None = > fs ::read_to_string ( & query_path ) ,
} ;
match source {
2026-06-08 20:07:39 +03:00
Ok ( source ) = > {
let digest = sha256_hex ( source . as_bytes ( ) ) ;
graph_query_digests
. entry ( graph_id . clone ( ) )
. or_default ( )
. insert ( query_name . clone ( ) , digest . clone ( ) ) ;
resources . insert (
query_address . clone ( ) ,
ResourceSummary {
address : query_address ,
kind : " query " . to_string ( ) ,
digest ,
path : Some ( display_path ( & query_path ) ) ,
} ,
) ;
validate_query_source (
graph_id ,
query_name ,
& source ,
catalog . as_ref ( ) ,
& mut diagnostics ,
) ;
}
Err ( err ) = > diagnostics . push ( Diagnostic ::error (
" query_file_missing " ,
format! ( " graphs. {graph_id} .queries. {query_name} .file " ) ,
format! (
" could not read query file '{}': {err} " ,
query_path . display ( )
) ,
) ) ,
}
}
}
for graph_id in raw . graphs . keys ( ) {
let digest = graph_digest (
graph_id ,
graph_schema_digests . get ( graph_id ) ,
graph_query_digests . get ( graph_id ) ,
) ;
resources . insert (
graph_address ( graph_id ) ,
ResourceSummary {
address : graph_address ( graph_id ) ,
kind : " graph " . to_string ( ) ,
digest ,
path : None ,
} ,
) ;
}
2026-06-10 15:30:33 +03:00
let mut policy_bindings : BTreeMap < String , Vec < String > > = BTreeMap ::new ( ) ;
2026-06-08 20:07:39 +03:00
for ( policy_name , policy ) in & raw . policies {
validate_id (
" policy name " ,
& format! ( " policies. {policy_name} " ) ,
policy_name ,
& mut diagnostics ,
) ;
if policy . applies_to . is_empty ( ) {
diagnostics . push ( Diagnostic ::error (
" policy_missing_applies_to " ,
format! ( " policies. {policy_name} .applies_to " ) ,
" policy.applies_to must name `cluster` or at least one graph " ,
) ) ;
}
let policy_address = policy_address ( policy_name ) ;
2026-06-10 15:30:33 +03:00
let mut normalized_bindings : Vec < String > = Vec ::new ( ) ;
2026-06-08 20:07:39 +03:00
for ( idx , target ) in policy . applies_to . iter ( ) . enumerate ( ) {
match normalize_policy_target ( target ) {
2026-06-10 15:30:33 +03:00
PolicyTarget ::Cluster = > {
normalized_bindings . push ( " cluster " . to_string ( ) ) ;
}
2026-06-08 20:07:39 +03:00
PolicyTarget ::Graph ( graph_id ) = > {
2026-06-10 15:30:33 +03:00
normalized_bindings . push ( graph_address ( & graph_id ) ) ;
2026-06-08 20:07:39 +03:00
if raw . graphs . contains_key ( & graph_id ) {
dependencies . insert ( Dependency {
from : policy_address . clone ( ) ,
to : graph_address ( & graph_id ) ,
} ) ;
} else {
diagnostics . push ( Diagnostic ::error (
" dangling_graph_reference " ,
format! ( " policies. {policy_name} .applies_to[ {idx} ] " ) ,
format! (
" policy references graph `{graph_id}`, but no graph with that id is declared "
) ,
) ) ;
}
}
PolicyTarget ::WrongKind ( kind ) = > diagnostics . push ( Diagnostic ::error (
" wrong_kind_reference " ,
format! ( " policies. {policy_name} .applies_to[ {idx} ] " ) ,
format! ( " policy applies_to expects graph refs or `cluster`, got ` {kind} ` " ) ,
) ) ,
}
}
2026-06-10 15:30:33 +03:00
normalized_bindings . sort ( ) ;
normalized_bindings . dedup ( ) ;
policy_bindings . insert ( policy_address . clone ( ) , normalized_bindings ) ;
2026-06-08 20:07:39 +03:00
let policy_path = resolve_config_path ( & config_dir , & policy . file ) ;
match fs ::read ( & policy_path ) {
Ok ( bytes ) = > {
resources . insert (
policy_address . clone ( ) ,
ResourceSummary {
address : policy_address ,
kind : " policy " . to_string ( ) ,
digest : sha256_hex ( & bytes ) ,
path : Some ( display_path ( & policy_path ) ) ,
} ,
) ;
}
Err ( err ) = > diagnostics . push ( Diagnostic ::error (
" policy_file_missing " ,
format! ( " policies. {policy_name} .file " ) ,
format! (
" could not read policy file '{}': {err} " ,
policy_path . display ( )
) ,
) ) ,
}
}
let mut resource_digests = BTreeMap ::new ( ) ;
let mut resource_list = Vec ::new ( ) ;
for ( address , resource ) in resources {
resource_digests . insert ( address , resource . digest . clone ( ) ) ;
resource_list . push ( resource ) ;
}
let dependencies : Vec < _ > = dependencies . into_iter ( ) . collect ( ) ;
2026-06-08 23:18:44 +03:00
let graphs = raw
. graphs
. keys ( )
. map ( | graph_id | DesiredGraph {
id : graph_id . clone ( ) ,
schema_digest : graph_schema_digests
. get ( graph_id )
. cloned ( )
. unwrap_or_default ( ) ,
} )
. collect ( ) ;
2026-06-09 18:30:33 +03:00
let config_digest = desired_config_digest ( & raw , & resource_digests ) ;
2026-06-08 20:07:39 +03:00
LoadOutcome {
desired : Some ( DesiredCluster {
config_dir : config_dir . clone ( ) ,
config_digest ,
2026-06-08 21:09:23 +03:00
state_lock : settings . state_lock ,
2026-06-08 23:18:44 +03:00
graphs ,
2026-06-08 20:07:39 +03:00
resource_digests ,
resources : resource_list ,
dependencies ,
2026-06-10 15:30:33 +03:00
policy_bindings ,
2026-06-08 20:07:39 +03:00
} ) ,
diagnostics ,
config_dir ,
config_file ,
}
}
fn validate_query_source (
graph_id : & str ,
query_name : & str ,
source : & str ,
catalog : Option < & omnigraph_compiler ::catalog ::Catalog > ,
diagnostics : & mut Vec < Diagnostic > ,
) {
let path = format! ( " graphs. {graph_id} .queries. {query_name} " ) ;
match parse_query ( source ) {
Ok ( query_file ) = > {
let Some ( query_decl ) = query_file . queries . iter ( ) . find ( | q | q . name = = query_name ) else {
diagnostics . push ( Diagnostic ::error (
" query_key_mismatch " ,
path ,
format! ( " no `query {query_name} ` declaration found in the referenced .gq file " ) ,
) ) ;
return ;
} ;
if let Some ( catalog ) = catalog {
if let Err ( err ) = typecheck_query_decl ( catalog , query_decl ) {
diagnostics . push ( Diagnostic ::error (
" query_typecheck_error " ,
format! ( " graphs. {graph_id} .queries. {query_name} " ) ,
err . to_string ( ) ,
) ) ;
}
} else {
diagnostics . push ( Diagnostic ::warning (
" query_typecheck_skipped " ,
format! ( " graphs. {graph_id} .queries. {query_name} " ) ,
" query parsed, but type-check was skipped because the graph schema is invalid " ,
) ) ;
}
}
Err ( err ) = > diagnostics . push ( Diagnostic ::error (
" query_parse_error " ,
path ,
err . to_string ( ) ,
) ) ,
}
}
fn diff_resources (
prior : & BTreeMap < String , String > ,
desired : & BTreeMap < String , String > ,
) -> Vec < PlanChange > {
let mut changes = Vec ::new ( ) ;
for ( address , after ) in desired {
match prior . get ( address ) {
None = > changes . push ( PlanChange {
resource : address . clone ( ) ,
operation : PlanOperation ::Create ,
before_digest : None ,
after_digest : Some ( after . clone ( ) ) ,
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
disposition : None ,
reason : None ,
2026-06-10 15:30:33 +03:00
binding_change : false ,
2026-06-10 13:04:19 +03:00
migration : None ,
2026-06-08 20:07:39 +03:00
} ) ,
Some ( before ) if before ! = after = > changes . push ( PlanChange {
resource : address . clone ( ) ,
operation : PlanOperation ::Update ,
before_digest : Some ( before . clone ( ) ) ,
after_digest : Some ( after . clone ( ) ) ,
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
disposition : None ,
reason : None ,
2026-06-10 15:30:33 +03:00
binding_change : false ,
2026-06-10 13:04:19 +03:00
migration : None ,
2026-06-08 20:07:39 +03:00
} ) ,
Some ( _ ) = > { }
}
}
for ( address , before ) in prior {
if ! desired . contains_key ( address ) {
changes . push ( PlanChange {
resource : address . clone ( ) ,
operation : PlanOperation ::Delete ,
before_digest : Some ( before . clone ( ) ) ,
after_digest : None ,
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
disposition : None ,
reason : None ,
2026-06-10 15:30:33 +03:00
binding_change : false ,
2026-06-10 13:04:19 +03:00
migration : None ,
2026-06-08 20:07:39 +03:00
} ) ;
}
}
changes . sort_by ( | a , b | a . resource . cmp ( & b . resource ) ) ;
changes
}
2026-06-10 15:30:33 +03:00
/// Binding-only policy changes: the file digest is unchanged (so
/// `diff_resources` saw nothing) but the applied `applies_to` differs from
/// the desired bindings — including the pre-5A case where the state entry
/// has no bindings recorded yet. These are first-class plan changes: without
/// this pass a binding edit would silently rot or silently converge.
fn append_policy_binding_changes (
changes : & mut Vec < PlanChange > ,
prior_state : Option < & ClusterState > ,
desired : & DesiredCluster ,
) {
let Some ( state ) = prior_state else {
return ; // no state: everything is already a Create carrying bindings
} ;
for ( address , desired_bindings ) in & desired . policy_bindings {
if changes . iter ( ) . any ( | change | & change . resource = = address ) {
continue ; // content change already covers it
}
let Some ( entry ) = state . applied_revision . resources . get ( address ) else {
continue ; // not applied yet: the Create covers it
} ;
if entry . applies_to . as_ref ( ) = = Some ( desired_bindings ) {
continue ;
}
changes . push ( PlanChange {
resource : address . clone ( ) ,
operation : PlanOperation ::Update ,
before_digest : Some ( entry . digest . clone ( ) ) ,
after_digest : Some ( entry . digest . clone ( ) ) ,
disposition : None ,
reason : None ,
binding_change : true ,
migration : None ,
} ) ;
}
changes . sort_by ( | a , b | a . resource . cmp ( & b . resource ) ) ;
}
2026-06-08 20:07:39 +03:00
fn compute_blast_radius ( changes : & [ PlanChange ] , dependencies : & [ Dependency ] ) -> Vec < BlastRadius > {
changes
. iter ( )
. filter_map ( | change | {
let affected : Vec < _ > = dependencies
. iter ( )
. filter_map ( | dep | ( dep . to = = change . resource ) . then_some ( dep . from . clone ( ) ) )
. collect ( ) ;
( ! affected . is_empty ( ) ) . then ( | | BlastRadius {
resource : change . resource . clone ( ) ,
affected ,
} )
} )
. collect ( )
}
2026-06-10 14:29:00 +03:00
fn compute_approvals (
changes : & [ PlanChange ] ,
approved : & BTreeSet < String > ,
) -> Vec < ApprovalRequirement > {
// One gate per subtree: the graph.<id> delete carries its schema and
// queries, so a schema delete whose graph is also deleted is not listed.
let graph_deletes : BTreeSet < String > = changes
. iter ( )
. filter ( | change | change . operation = = PlanOperation ::Delete )
. filter_map ( | change | change . resource . strip_prefix ( " graph. " ) . map ( str ::to_string ) )
. collect ( ) ;
2026-06-08 20:07:39 +03:00
changes
. iter ( )
. filter_map ( | change | {
2026-06-10 14:29:00 +03:00
if change . operation ! = PlanOperation ::Delete {
return None ;
2026-06-08 20:07:39 +03:00
}
2026-06-10 14:29:00 +03:00
let gated = match resource_kind ( & change . resource ) {
ResourceKind ::Graph ( _ ) = > true ,
ResourceKind ::Schema ( graph ) = > ! graph_deletes . contains ( & graph ) ,
_ = > false ,
} ;
gated . then ( | | ApprovalRequirement {
resource : change . resource . clone ( ) ,
reason : " delete may remove deployed graph or schema definition " . to_string ( ) ,
satisfied : approved . contains ( & change . resource ) ,
} )
2026-06-08 20:07:39 +03:00
} )
. collect ( )
}
2026-06-10 14:29:00 +03:00
/// Resources with a valid (digest-matching, unconsumed) pending approval.
/// Near-misses — an artifact for the same resource whose bound digests no
/// longer match — warn as `approval_stale` and never authorize anything.
fn approved_resources (
artifacts : & [ ( PathBuf , ApprovalArtifact ) ] ,
changes : & [ PlanChange ] ,
config_digest : & str ,
diagnostics : & mut Vec < Diagnostic > ,
) -> BTreeSet < String > {
let mut approved = BTreeSet ::new ( ) ;
for change in changes {
let candidates : Vec < & ApprovalArtifact > = artifacts
. iter ( )
. map ( | ( _ , artifact ) | artifact )
. filter ( | artifact | artifact . consumed_at . is_none ( ) & & artifact . resource = = change . resource )
. collect ( ) ;
if candidates . is_empty ( ) {
continue ;
}
let matched = candidates . iter ( ) . any ( | artifact | {
artifact . bound_config_digest = = config_digest
& & artifact . bound_before_digest = = change . before_digest
& & artifact . bound_after_digest = = change . after_digest
} ) ;
if matched {
approved . insert ( change . resource . clone ( ) ) ;
} else {
diagnostics . push ( Diagnostic ::warning (
" approval_stale " ,
change . resource . clone ( ) ,
" an approval artifact exists but its bound digests no longer match the plan; re-run `cluster approve` " ,
) ) ;
}
}
approved
}
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
#[ derive(Debug, PartialEq, Eq) ]
enum ResourceKind {
Graph ( String ) ,
Schema ( String ) ,
Query { graph : String , name : String } ,
Policy ( String ) ,
Unknown ,
}
fn resource_kind ( address : & str ) -> ResourceKind {
if let Some ( graph ) = address . strip_prefix ( " graph. " ) {
ResourceKind ::Graph ( graph . to_string ( ) )
} else if let Some ( graph ) = address . strip_prefix ( " schema. " ) {
ResourceKind ::Schema ( graph . to_string ( ) )
} else if let Some ( rest ) = address . strip_prefix ( " query. " ) {
match rest . split_once ( '.' ) {
Some ( ( graph , name ) ) = > ResourceKind ::Query {
graph : graph . to_string ( ) ,
name : name . to_string ( ) ,
} ,
None = > ResourceKind ::Unknown ,
}
} else if let Some ( name ) = address . strip_prefix ( " policy. " ) {
ResourceKind ::Policy ( name . to_string ( ) )
} else {
ResourceKind ::Unknown
}
}
/// Classify every planned change with the disposition config-only apply gives
/// it. Stage 3A executes only query/policy catalog writes; graph/schema
/// movement is a later phase, and `graph.<id>` composite updates whose schema
/// component is unchanged converge automatically once query digests land.
2026-06-10 04:58:56 +03:00
fn classify_changes (
changes : & mut [ PlanChange ] ,
dependencies : & [ Dependency ] ,
pending_recovery : & BTreeSet < String > ,
2026-06-10 14:29:00 +03:00
approved : & BTreeSet < String > ,
2026-06-10 04:58:56 +03:00
) {
let mut schema_creates = BTreeSet ::new ( ) ;
let mut schema_pending = BTreeSet ::new ( ) ;
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
let mut graph_creates = BTreeSet ::new ( ) ;
let mut graph_deletes = BTreeSet ::new ( ) ;
for change in changes . iter ( ) {
match resource_kind ( & change . resource ) {
2026-06-10 04:58:56 +03:00
ResourceKind ::Schema ( graph ) = > match change . operation {
PlanOperation ::Create = > {
schema_creates . insert ( graph ) ;
}
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
// Schema updates execute in-run before catalog writes (4B)
// and no longer block dependents; deletes (4C) still do.
PlanOperation ::Update = > { }
PlanOperation ::Delete = > {
2026-06-10 04:58:56 +03:00
schema_pending . insert ( graph ) ;
}
} ,
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
ResourceKind ::Graph ( graph ) = > match change . operation {
PlanOperation ::Create = > {
graph_creates . insert ( graph ) ;
}
PlanOperation ::Delete = > {
graph_deletes . insert ( graph ) ;
}
PlanOperation ::Update = > { }
} ,
_ = > { }
}
}
2026-06-10 04:58:56 +03:00
// A schema Create is satisfied by its paired graph create (the init
// carries the schema); a standalone schema Create stays pending.
for graph in & schema_creates {
if ! graph_creates . contains ( graph ) {
schema_pending . insert ( graph . clone ( ) ) ;
}
}
feat(cluster): execute approved graph deletes in cluster apply
Stage 4C execution half (RFC-004 §D5/§D6 + sweep rows 7/7b/8): an approved
graph.<id> delete — and its riding schema/query deletes — classifies Applied
and executes LAST in the run, sidecar-fenced: pre-op manifest pin (best
effort; partial roots still delete), approval_id carried in the sidecar,
recursive root removal (NotFound tolerated), subtree tombstoned out of the
ledger with a tombstone observation, the approval consumed in the same state
CAS (ledger summary) and its artifact file rewritten with consumed_at only
after the CAS lands — a failed run consumes nothing and the approval stays
valid for the retry.
Sweep rows: already-tombstoned intents retire (7); a completed delete with a
stale ledger rolls forward — tombstone + approval consumption + audit entry
(7b, idempotent); a still-present root retires the stale intent with a
graph_delete_incomplete warning and the still-approved delete re-executes in
the same run (8) — prefix removal is idempotent, so retry IS the repair.
The multi-graph mixed e2e gets its conclusion: blocked without approval,
cluster approve graph.engineering --as andrew, converge, tombstone visible
in status. Phase 4's disposition matrix is now fully executable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:34:02 +03:00
// Subtree deletes ride the approved graph delete.
let rides_approved_delete = | graph : & str | {
graph_deletes . contains ( graph )
& & approved . contains ( & graph_address ( graph ) )
& & ! pending_recovery . contains ( graph )
} ;
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
for change in changes . iter_mut ( ) {
let ( disposition , reason ) = match resource_kind ( & change . resource ) {
2026-06-10 04:58:56 +03:00
ResourceKind ::Schema ( graph ) = > match change . operation {
PlanOperation ::Create
if graph_creates . contains ( & graph )
& & ! pending_recovery . contains ( & graph ) = >
{
// Applied with the graph create — the init carries it.
( ApplyDisposition ::Applied , None )
}
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
PlanOperation ::Update if ! pending_recovery . contains ( & graph ) = > {
// Stage 4B: schema updates execute via the engine's
// schema apply (soft drops only; allow_data_loss is 4C).
( ApplyDisposition ::Applied , None )
}
PlanOperation ::Create | PlanOperation ::Update = > {
2026-06-10 04:58:56 +03:00
( ApplyDisposition ::Blocked , Some ( " cluster_recovery_pending " ) )
}
2026-06-10 14:29:00 +03:00
PlanOperation ::Delete if graph_deletes . contains ( & graph ) = > {
if rides_approved_delete ( & graph ) {
( ApplyDisposition ::Applied , None )
} else if pending_recovery . contains ( & graph ) {
( ApplyDisposition ::Blocked , Some ( " cluster_recovery_pending " ) )
} else {
( ApplyDisposition ::Blocked , Some ( " approval_required " ) )
}
}
2026-06-10 04:58:56 +03:00
_ = > ( ApplyDisposition ::Deferred , Some ( " apply_unsupported_kind " ) ) ,
} ,
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
ResourceKind ::Graph ( graph ) = > match change . operation {
2026-06-10 04:58:56 +03:00
PlanOperation ::Create = > {
if pending_recovery . contains ( & graph ) {
( ApplyDisposition ::Blocked , Some ( " cluster_recovery_pending " ) )
} else {
( ApplyDisposition ::Applied , None )
}
}
PlanOperation ::Update if ! schema_pending . contains ( & graph ) = > {
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
( ApplyDisposition ::Derived , None )
}
2026-06-10 14:29:00 +03:00
// Stage 4C: an approved graph delete executes (the
// irreversible tier — gated by a digest-bound artifact).
PlanOperation ::Delete = > {
if pending_recovery . contains ( & graph ) {
( ApplyDisposition ::Blocked , Some ( " cluster_recovery_pending " ) )
} else if rides_approved_delete ( & graph ) {
( ApplyDisposition ::Applied , None )
} else {
( ApplyDisposition ::Blocked , Some ( " approval_required " ) )
}
}
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
_ = > ( ApplyDisposition ::Deferred , Some ( " apply_unsupported_kind " ) ) ,
} ,
ResourceKind ::Query { graph , .. } = > match change . operation {
PlanOperation ::Delete = > {
2026-06-10 14:29:00 +03:00
if rides_approved_delete ( & graph ) {
// Tombstoned with the approved graph delete.
( ApplyDisposition ::Applied , None )
} else if graph_deletes . contains ( & graph ) {
( ApplyDisposition ::Blocked , Some ( " approval_required " ) )
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
} else {
( ApplyDisposition ::Applied , None )
}
}
PlanOperation ::Create | PlanOperation ::Update = > {
2026-06-10 04:58:56 +03:00
if pending_recovery . contains ( & graph ) {
( ApplyDisposition ::Blocked , Some ( " cluster_recovery_pending " ) )
} else if schema_pending . contains ( & graph ) {
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
(
ApplyDisposition ::Blocked ,
Some ( " dependency_not_applied " ) ,
)
} else {
2026-06-10 04:58:56 +03:00
// A graph create in the same plan no longer blocks:
// creates execute first in the same apply run.
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
( ApplyDisposition ::Applied , None )
}
}
} ,
ResourceKind ::Policy ( _ ) = > match change . operation {
PlanOperation ::Delete = > ( ApplyDisposition ::Applied , None ) ,
PlanOperation ::Create | PlanOperation ::Update = > {
2026-06-10 04:58:56 +03:00
let blocked_pending = dependencies . iter ( ) . any ( | dep | {
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
dep . from = = change . resource
& & dep
. to
. strip_prefix ( " graph. " )
2026-06-10 04:58:56 +03:00
. is_some_and ( | graph | pending_recovery . contains ( graph ) )
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
} ) ;
2026-06-10 04:58:56 +03:00
if blocked_pending {
( ApplyDisposition ::Blocked , Some ( " cluster_recovery_pending " ) )
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
} else {
( ApplyDisposition ::Applied , None )
}
}
} ,
ResourceKind ::Unknown = > {
( ApplyDisposition ::Deferred , Some ( " apply_unsupported_kind " ) )
}
} ;
change . disposition = Some ( disposition ) ;
change . reason = reason . map ( str ::to_string ) ;
}
}
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
#[ derive(Debug, Clone, Copy, PartialEq, Eq) ]
enum FailedGraphOrigin {
GraphCreate ,
SchemaApply ,
feat(cluster): execute approved graph deletes in cluster apply
Stage 4C execution half (RFC-004 §D5/§D6 + sweep rows 7/7b/8): an approved
graph.<id> delete — and its riding schema/query deletes — classifies Applied
and executes LAST in the run, sidecar-fenced: pre-op manifest pin (best
effort; partial roots still delete), approval_id carried in the sidecar,
recursive root removal (NotFound tolerated), subtree tombstoned out of the
ledger with a tombstone observation, the approval consumed in the same state
CAS (ledger summary) and its artifact file rewritten with consumed_at only
after the CAS lands — a failed run consumes nothing and the approval stays
valid for the retry.
Sweep rows: already-tombstoned intents retire (7); a completed delete with a
stale ledger rolls forward — tombstone + approval consumption + audit entry
(7b, idempotent); a still-present root retires the stale intent with a
graph_delete_incomplete warning and the still-approved delete re-executes in
the same run (8) — prefix removal is idempotent, so retry IS the repair.
The multi-graph mixed e2e gets its conclusion: blocked without approval,
cluster approve graph.engineering --as andrew, converge, tombstone visible
in status. Phase 4's disposition matrix is now fully executable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:34:02 +03:00
GraphDelete ,
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
}
/// After a graph-moving operation fails mid-run, every change that depended
/// on that graph flips from Applied to Blocked so the output and the
/// persisted statuses tell the truth about what this run actually executed.
/// The originating change carries the failure code; dependents carry
/// `dependency_not_applied`.
2026-06-10 04:58:56 +03:00
fn demote_dependents_of_failed_graphs (
changes : & mut [ PlanChange ] ,
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
failed : & BTreeMap < String , FailedGraphOrigin > ,
2026-06-10 04:58:56 +03:00
dependencies : & [ Dependency ] ,
) {
for change in changes . iter_mut ( ) {
if change . disposition ! = Some ( ApplyDisposition ::Applied ) {
continue ;
}
let demote_reason = match resource_kind ( & change . resource ) {
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
ResourceKind ::Graph ( graph ) = > match failed . get ( & graph ) {
Some ( FailedGraphOrigin ::GraphCreate ) = > Some ( " graph_create_failed " ) ,
feat(cluster): execute approved graph deletes in cluster apply
Stage 4C execution half (RFC-004 §D5/§D6 + sweep rows 7/7b/8): an approved
graph.<id> delete — and its riding schema/query deletes — classifies Applied
and executes LAST in the run, sidecar-fenced: pre-op manifest pin (best
effort; partial roots still delete), approval_id carried in the sidecar,
recursive root removal (NotFound tolerated), subtree tombstoned out of the
ledger with a tombstone observation, the approval consumed in the same state
CAS (ledger summary) and its artifact file rewritten with consumed_at only
after the CAS lands — a failed run consumes nothing and the approval stays
valid for the retry.
Sweep rows: already-tombstoned intents retire (7); a completed delete with a
stale ledger rolls forward — tombstone + approval consumption + audit entry
(7b, idempotent); a still-present root retires the stale intent with a
graph_delete_incomplete warning and the still-approved delete re-executes in
the same run (8) — prefix removal is idempotent, so retry IS the repair.
The multi-graph mixed e2e gets its conclusion: blocked without approval,
cluster approve graph.engineering --as andrew, converge, tombstone visible
in status. Phase 4's disposition matrix is now fully executable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:34:02 +03:00
Some ( FailedGraphOrigin ::GraphDelete ) = > Some ( " graph_delete_failed " ) ,
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
Some ( FailedGraphOrigin ::SchemaApply ) = > Some ( " dependency_not_applied " ) ,
None = > None ,
} ,
ResourceKind ::Schema ( graph ) = > match failed . get ( & graph ) {
Some ( FailedGraphOrigin ::SchemaApply ) = > Some ( " schema_apply_failed " ) ,
feat(cluster): execute approved graph deletes in cluster apply
Stage 4C execution half (RFC-004 §D5/§D6 + sweep rows 7/7b/8): an approved
graph.<id> delete — and its riding schema/query deletes — classifies Applied
and executes LAST in the run, sidecar-fenced: pre-op manifest pin (best
effort; partial roots still delete), approval_id carried in the sidecar,
recursive root removal (NotFound tolerated), subtree tombstoned out of the
ledger with a tombstone observation, the approval consumed in the same state
CAS (ledger summary) and its artifact file rewritten with consumed_at only
after the CAS lands — a failed run consumes nothing and the approval stays
valid for the retry.
Sweep rows: already-tombstoned intents retire (7); a completed delete with a
stale ledger rolls forward — tombstone + approval consumption + audit entry
(7b, idempotent); a still-present root retires the stale intent with a
graph_delete_incomplete warning and the still-approved delete re-executes in
the same run (8) — prefix removal is idempotent, so retry IS the repair.
The multi-graph mixed e2e gets its conclusion: blocked without approval,
cluster approve graph.engineering --as andrew, converge, tombstone visible
in status. Phase 4's disposition matrix is now fully executable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:34:02 +03:00
Some ( FailedGraphOrigin ::GraphCreate ) | Some ( FailedGraphOrigin ::GraphDelete ) = > {
Some ( " dependency_not_applied " )
}
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
None = > None ,
} ,
ResourceKind ::Query { graph , .. } if failed . contains_key ( & graph ) = > {
2026-06-10 04:58:56 +03:00
Some ( " dependency_not_applied " )
}
ResourceKind ::Policy ( _ ) = > {
let blocked = dependencies . iter ( ) . any ( | dep | {
dep . from = = change . resource
& & dep
. to
. strip_prefix ( " graph. " )
feat(cluster): execute schema applies in cluster apply
Stage 4B (RFC-004 §D1/§D5): schema.<id> Update changes classify Applied and
execute after graph creates, sequentially and sidecar-fenced — read-write
open (the engine's own recovery runs first), pre-op manifest pin recorded,
apply_schema_as with allow_data_loss: false (soft drops only; hard drops wait
for 4C's approval artifacts), post-op pin rewritten into the sidecar, sidecar
retired only after the final state CAS. Queries gated on a same-plan schema
update unblock (the migration lands first in the same run); failures —
unsupported migrations, lock contention, user branches — surface as
schema_apply_failed with the engine's message, demote dependents via the
origin-aware demotion helper, and stop further graph-moving work.
Schema evolution is now fully cluster-driven (the defer -> manual schema
apply -> refresh loop is gone), and out-of-band schema drift is converged
back by apply as an ordinary soft migration (axiom 8: drift correction is
gated like any change; the recoverable tier needs no approval) — both pinned
by reworked e2es. The multi-graph mixed e2e's deferred row is now
delete-shaped, pre-staging the 4C surface.
Actor: cluster apply accepts the CLI's global --as via the new ApplyOptions /
apply_config_dir_with_options (apply_config_dir delegates unchanged); the
actor is echoed in ApplyOutput and recorded in sidecars and audit entries,
and threads to apply_schema_as so Cedar fires wherever a checker is
installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:12:15 +03:00
. is_some_and ( | graph | failed . contains_key ( graph ) )
2026-06-10 04:58:56 +03:00
} ) ;
blocked . then_some ( " dependency_not_applied " )
}
_ = > None ,
} ;
if let Some ( reason ) = demote_reason {
change . disposition = Some ( ApplyDisposition ::Blocked ) ;
change . reason = Some ( reason . to_string ( ) ) ;
}
}
}
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
/// Content-addressed catalog path for an applied resource payload. Extensions
/// are fixed per kind (`.gq` / `.yaml`) regardless of the source file's name,
/// so the catalog layout cannot drift with operator file conventions.
fn payload_path ( config_dir : & Path , kind : & ResourceKind , digest : & str ) -> Option < PathBuf > {
let resources_dir = config_dir . join ( CLUSTER_RESOURCES_DIR ) ;
match kind {
ResourceKind ::Query { graph , name } = > Some (
resources_dir
. join ( " query " )
. join ( graph )
. join ( name )
. join ( format! ( " {digest} .gq " ) ) ,
) ,
ResourceKind ::Policy ( name ) = > Some (
resources_dir
. join ( " policy " )
. join ( name )
. join ( format! ( " {digest} .yaml " ) ) ,
) ,
_ = > None ,
}
}
2026-06-10 02:07:08 +03:00
#[ derive(Debug, PartialEq, Eq) ]
enum PayloadFinding {
Missing ,
Mismatch { actual_digest : String } ,
ReadError ( String ) ,
}
/// Verify every catalog-backed resource digest in state against its
/// content-addressed blob under `__cluster/resources/`. Graph, schema, and
/// unknown addresses have no payloads and are skipped. Read-only; findings
/// are deterministic (BTreeMap order). Payloads are small (queries, policy
/// bundles), so a full digest re-hash is cheap.
fn verify_catalog_payloads (
config_dir : & Path ,
state : & ClusterState ,
) -> Vec < ( String , PayloadFinding ) > {
let mut findings = Vec ::new ( ) ;
for ( address , resource ) in & state . applied_revision . resources {
let kind = resource_kind ( address ) ;
let Some ( path ) = payload_path ( config_dir , & kind , & resource . digest ) else {
continue ;
} ;
match fs ::read ( & path ) {
Ok ( bytes ) = > {
let actual_digest = sha256_hex ( & bytes ) ;
if actual_digest ! = resource . digest {
findings . push ( ( address . clone ( ) , PayloadFinding ::Mismatch { actual_digest } ) ) ;
}
}
Err ( err ) if err . kind ( ) = = ErrorKind ::NotFound = > {
findings . push ( ( address . clone ( ) , PayloadFinding ::Missing ) ) ;
}
Err ( err ) = > {
findings . push ( (
address . clone ( ) ,
PayloadFinding ::ReadError ( format! (
" could not read catalog payload '{}': {err} " ,
path . display ( )
) ) ,
) ) ;
}
}
}
findings
}
fn payload_finding_diagnostic ( address : & str , finding : & PayloadFinding ) -> Diagnostic {
match finding {
PayloadFinding ::Missing = > Diagnostic ::warning (
" catalog_payload_missing " ,
address ,
" catalog payload blob is missing; re-run `cluster apply` to republish " ,
) ,
PayloadFinding ::Mismatch { actual_digest } = > Diagnostic ::warning (
" catalog_payload_mismatch " ,
address ,
format! (
" catalog payload blob does not match the recorded digest (actual sha256:{actual_digest}); re-run `cluster apply` to republish "
) ,
) ,
// An unverifiable blob must not report healthy.
PayloadFinding ::ReadError ( error ) = > {
Diagnostic ::error ( " catalog_payload_read_error " , address , error . clone ( ) )
}
}
}
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
/// Write one content-addressed payload blob. Idempotent: an existing
/// digest-named file is trusted as-is. The digest re-check is the apply-side
/// TOCTOU detector — the source file changing between `load_desired` and the
/// payload write must fail loudly, never publish mismatched content.
fn write_resource_payload (
target : & Path ,
source : & Path ,
expected_digest : & str ,
resource : & str ,
) -> Result < ( ) , Diagnostic > {
if target . exists ( ) {
return Ok ( ( ) ) ;
}
let bytes = fs ::read ( source ) . map_err ( | err | {
Diagnostic ::error (
" resource_payload_write_error " ,
resource ,
format! ( " could not read resource source ' {} ': {err} " , source . display ( ) ) ,
)
} ) ? ;
if sha256_hex ( & bytes ) ! = expected_digest {
return Err ( Diagnostic ::error (
" resource_content_changed " ,
resource ,
format! (
" resource source '{}' changed while apply was running; re-run `cluster apply` " ,
source . display ( )
) ,
) ) ;
}
let parent = target . parent ( ) . expect ( " payload path always has a parent " ) ;
fs ::create_dir_all ( parent ) . map_err ( | err | {
Diagnostic ::error (
" resource_payload_write_error " ,
resource ,
format! ( " could not create payload directory: {err} " ) ,
)
} ) ? ;
let file_name = target
. file_name ( )
. expect ( " payload path always has a file name " )
. to_string_lossy ( ) ;
let tmp_path = parent . join ( format! ( " {file_name} .tmp. {} " , Ulid ::new ( ) ) ) ;
let mut file = OpenOptions ::new ( )
. write ( true )
. create_new ( true )
. open ( & tmp_path )
. map_err ( | err | {
Diagnostic ::error (
" resource_payload_write_error " ,
resource ,
format! ( " could not create temporary payload file: {err} " ) ,
)
} ) ? ;
let write_result = file
. write_all ( & bytes )
. and_then ( | ( ) | file . sync_all ( ) )
. map_err ( | err | {
Diagnostic ::error (
" resource_payload_write_error " ,
resource ,
format! ( " could not write payload file: {err} " ) ,
)
} ) ;
drop ( file ) ;
if let Err ( diagnostic ) = write_result {
let _ = fs ::remove_file ( & tmp_path ) ;
return Err ( diagnostic ) ;
}
if let Err ( err ) = fs ::rename ( & tmp_path , target ) {
let _ = fs ::remove_file ( & tmp_path ) ;
return Err ( Diagnostic ::error (
" resource_payload_write_error " ,
resource ,
format! ( " could not move payload file into place: {err} " ) ,
) ) ;
}
Ok ( ( ) )
}
/// Recompute the composite `graph.<id>` digests for state-resident graphs from
/// state's own schema/query components. Without this, an applied query change
/// would leave the prior composite digest in state and `graph.<id>` would show
/// a phantom update in every later plan — apply could never converge.
fn recompute_state_graph_digests ( state : & mut ClusterState , desired : & DesiredCluster ) {
for graph in & desired . graphs {
let graph_address = graph_address ( & graph . id ) ;
if ! state . applied_revision . resources . contains_key ( & graph_address ) {
continue ;
}
let schema_digest = state
. applied_revision
. resources
. get ( & schema_address ( & graph . id ) )
. map ( | resource | resource . digest . clone ( ) ) ;
let query_digests = state_query_digests_for_graph ( state , & graph . id ) ;
let digest = graph_digest ( & graph . id , schema_digest . as_ref ( ) , Some ( & query_digests ) ) ;
state
. applied_revision
. resources
2026-06-10 15:30:33 +03:00
. insert ( graph_address , StateResource { digest , applies_to : None } ) ;
feat(cluster): config-only apply with content-addressed catalog publish
apply_config_dir executes the query/policy subset of the plan: payloads are
written content-addressed under __cluster/resources/{query,policy}/... before
the state CAS (state is the publish point; orphaned blobs from a failed CAS
are inert and re-apply is the repair), then state.json is CAS-updated with
applied digests, Applied/Blocked statuses, and a revision bump. Graph/schema
changes are never executed here: schema content and graph lifecycle defer to
a later phase with loud warnings, while graph.<id> composite-digest updates
whose schema component is unchanged converge automatically via recomputation
from state's own components (without which apply could never converge).
Idempotent re-apply leaves state bytes and revision untouched.
PlanChange gains optional disposition/reason fields, populated by the same
classifier in cluster plan, so plan is an honest preview of what apply will
execute, derive, defer, or block.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:32:13 +03:00
}
}
2026-06-08 20:07:39 +03:00
fn duplicate_key_diagnostics ( text : & str ) -> Vec < Diagnostic > {
#[ derive(Debug) ]
struct Frame {
indent : isize ,
path : String ,
keys : BTreeSet < String > ,
}
let mut diagnostics = Vec ::new ( ) ;
let mut stack = vec! [ Frame {
indent : - 1 ,
path : String ::new ( ) ,
keys : BTreeSet ::new ( ) ,
} ] ;
for ( line_idx , line ) in text . lines ( ) . enumerate ( ) {
let line_without_comment = strip_comment ( line ) ;
if line_without_comment . trim ( ) . is_empty ( ) {
continue ;
}
let indent = line_without_comment
. chars ( )
. take_while ( | ch | * ch = = ' ' )
. count ( ) as isize ;
let trimmed = line_without_comment . trim_start ( ) ;
if trimmed . starts_with ( '-' ) {
continue ;
}
let Some ( ( raw_key , raw_value ) ) = trimmed . split_once ( ':' ) else {
continue ;
} ;
let key = raw_key . trim ( ) ;
if key . is_empty ( ) | | key . starts_with ( '{' ) | | key . starts_with ( '[' ) {
continue ;
}
while stack . last ( ) . is_some_and ( | frame | indent < = frame . indent ) {
stack . pop ( ) ;
}
let parent = stack . last_mut ( ) . expect ( " root frame is always present " ) ;
let full_path = if parent . path . is_empty ( ) {
key . to_string ( )
} else {
format! ( " {} . {} " , parent . path , key )
} ;
if ! parent . keys . insert ( key . to_string ( ) ) {
diagnostics . push ( Diagnostic ::error (
" duplicate_yaml_key " ,
full_path . clone ( ) ,
format! ( " duplicate YAML key ` {key} ` on line {} " , line_idx + 1 ) ,
) ) ;
}
if raw_value . trim ( ) . is_empty ( ) {
stack . push ( Frame {
indent ,
path : full_path ,
keys : BTreeSet ::new ( ) ,
} ) ;
}
}
diagnostics
}
fn future_field_diagnostics ( text : & str ) -> Vec < Diagnostic > {
let Ok ( value ) = serde_yaml ::from_str ::< serde_yaml ::Value > ( text ) else {
return Vec ::new ( ) ;
} ;
let Some ( mapping ) = value . as_mapping ( ) else {
return Vec ::new ( ) ;
} ;
let future_fields = [
" apply " ,
" env_file " ,
" providers " ,
" pipelines " ,
" embeddings " ,
" ui " ,
" aliases " ,
" bindings " ,
] ;
mapping
. keys ( )
. filter_map ( | key | key . as_str ( ) )
. filter ( | key | future_fields . contains ( key ) )
. map ( | key | {
Diagnostic ::error (
" future_phase_field " ,
key ,
format! ( " ` {key} ` is reserved for a later cluster-control phase " ) ,
)
} )
. collect ( )
}
fn strip_comment ( line : & str ) -> String {
let mut in_single_quote = false ;
let mut in_double_quote = false ;
let mut escaped = false ;
for ( idx , ch ) in line . char_indices ( ) {
if escaped {
escaped = false ;
continue ;
}
match ch {
'\\' if in_double_quote = > escaped = true ,
'\'' if ! in_double_quote = > in_single_quote = ! in_single_quote ,
'"' if ! in_single_quote = > in_double_quote = ! in_double_quote ,
'#' if ! in_single_quote & & ! in_double_quote = > return line [ .. idx ] . to_string ( ) ,
_ = > { }
}
}
line . to_string ( )
}
fn validate_id ( kind : & str , path : & str , value : & str , diagnostics : & mut Vec < Diagnostic > ) {
let mut chars = value . chars ( ) ;
let valid = chars
. next ( )
. is_some_and ( | ch | ch . is_ascii_alphabetic ( ) | | ch = = '_' )
& & chars . all ( | ch | ch . is_ascii_alphanumeric ( ) | | ch = = '_' | | ch = = '-' ) ;
if ! valid {
diagnostics . push ( Diagnostic ::error (
" invalid_resource_id " ,
path ,
format! ( " {kind} ` {value} ` must start with a letter or `_` and contain only ASCII letters, digits, `_`, or `-` " ) ,
) ) ;
}
}
enum PolicyTarget {
Cluster ,
Graph ( String ) ,
WrongKind ( String ) ,
}
fn normalize_policy_target ( value : & str ) -> PolicyTarget {
if value = = " cluster " {
PolicyTarget ::Cluster
} else if let Some ( graph_id ) = value . strip_prefix ( " graph. " ) {
PolicyTarget ::Graph ( graph_id . to_string ( ) )
} else if value . contains ( '.' ) {
PolicyTarget ::WrongKind ( value . to_string ( ) )
} else {
PolicyTarget ::Graph ( value . to_string ( ) )
}
}
fn graph_address ( graph_id : & str ) -> String {
format! ( " graph. {graph_id} " )
}
fn schema_address ( graph_id : & str ) -> String {
format! ( " schema. {graph_id} " )
}
fn query_address ( graph_id : & str , query_name : & str ) -> String {
format! ( " query. {graph_id} . {query_name} " )
}
fn policy_address ( policy_name : & str ) -> String {
format! ( " policy. {policy_name} " )
}
fn resolve_config_path ( config_dir : & Path , path : & Path ) -> PathBuf {
if path . is_absolute ( ) {
path . to_path_buf ( )
} else {
config_dir . join ( path )
}
}
fn graph_digest (
graph_id : & str ,
schema_digest : Option < & String > ,
query_digests : Option < & BTreeMap < String , String > > ,
) -> String {
let mut input = format! (
" graph \0 {graph_id} \0 schema \0 {} \0 " ,
schema_digest . map_or ( " " , String ::as_str )
) ;
if let Some ( query_digests ) = query_digests {
for ( name , digest ) in query_digests {
input . push_str ( " query \0 " ) ;
input . push_str ( name ) ;
input . push ( '\0' ) ;
input . push_str ( digest ) ;
input . push ( '\0' ) ;
}
}
sha256_hex ( input . as_bytes ( ) )
}
fn desired_config_digest (
2026-06-09 18:30:33 +03:00
raw : & RawClusterConfig ,
2026-06-08 20:07:39 +03:00
resource_digests : & BTreeMap < String , String > ,
) -> String {
let mut input = String ::from ( " cluster-config \0 " ) ;
2026-06-09 18:30:33 +03:00
// Hash parsed semantics, not raw YAML bytes, so comments and formatting do
// not create a new desired revision and the digest cannot drift from parse.
let config_semantics =
serde_json ::to_string ( raw ) . expect ( " raw cluster config must serialize deterministically " ) ;
input . push_str ( & config_semantics ) ;
2026-06-08 20:07:39 +03:00
input . push ( '\0' ) ;
for ( address , digest ) in resource_digests {
input . push_str ( address ) ;
input . push ( '\0' ) ;
input . push_str ( digest ) ;
input . push ( '\0' ) ;
}
sha256_hex ( input . as_bytes ( ) )
}
fn sha256_hex ( bytes : & [ u8 ] ) -> String {
let digest = Sha256 ::digest ( bytes ) ;
2026-06-08 23:18:44 +03:00
const HEX : & [ u8 ; 16 ] = b " 0123456789abcdef " ;
2026-06-08 20:07:39 +03:00
let mut out = String ::with_capacity ( digest . len ( ) * 2 ) ;
for byte in digest {
2026-06-08 23:18:44 +03:00
out . push ( HEX [ ( byte > > 4 ) as usize ] as char ) ;
out . push ( HEX [ ( byte & 0x0f ) as usize ] as char ) ;
2026-06-08 20:07:39 +03:00
}
out
}
2026-06-08 23:18:44 +03:00
fn now_rfc3339 ( ) -> String {
OffsetDateTime ::now_utc ( )
. format ( & Rfc3339 )
. unwrap_or_else ( | _ | " 1970-01-01T00:00:00Z " . to_string ( ) )
}
2026-06-09 02:12:00 +03:00
fn lock_age_seconds ( created_at : & str ) -> Option < u64 > {
let created_at = OffsetDateTime ::parse ( created_at , & Rfc3339 ) . ok ( ) ? ;
Some (
( OffsetDateTime ::now_utc ( ) - created_at )
. whole_seconds ( )
. max ( 0 ) as u64 ,
)
}
2026-06-08 23:18:44 +03:00
fn state_sync_operation_label ( operation : StateSyncOperation ) -> & 'static str {
match operation {
StateSyncOperation ::Refresh = > " refresh " ,
StateSyncOperation ::Import = > " import " ,
}
}
2026-06-08 20:07:39 +03:00
fn has_errors ( diagnostics : & [ Diagnostic ] ) -> bool {
diagnostics
. iter ( )
. any ( | diagnostic | diagnostic . severity = = DiagnosticSeverity ::Error )
}
2026-06-10 04:50:42 +03:00
fn count_errors ( diagnostics : & [ Diagnostic ] ) -> usize {
diagnostics
. iter ( )
. filter ( | diagnostic | diagnostic . severity = = DiagnosticSeverity ::Error )
. count ( )
}
2026-06-08 20:07:39 +03:00
fn display_path ( path : & Path ) -> String {
path . display ( ) . to_string ( )
}
2026-06-10 14:29:00 +03:00
2026-06-11 05:25:53 +03:00
#[ cfg(test) ]
#[ path = " tests.rs " ]
mod tests ;