diff --git a/crates/omnigraph-cluster/src/lib.rs b/crates/omnigraph-cluster/src/lib.rs index 8b41fdf..dc66408 100644 --- a/crates/omnigraph-cluster/src/lib.rs +++ b/crates/omnigraph-cluster/src/lib.rs @@ -20,11 +20,14 @@ use ulid::Ulid; pub mod failpoints; mod config; +mod types; mod diff; mod serve; mod sweep; mod store; use store::{LocalStateBackend, StateLockGuard, StateSnapshot}; +pub use types::*; +use types::*; pub use serve::{ServingGraph, ServingPolicy, ServingQuery, ServingSnapshot, read_serving_snapshot}; use serve::read_verified_payload; use config::{QueriesDecl, observe_declared_graphs, validate_cluster_header, future_field_diagnostics, initial_import_state, observe_live_graph, preview_schema_migration, state_resource_digests, graph_address, policy_address, query_address, schema_address, load_desired, normalize_policy_target, parse_cluster_config, resolve_config_path, resolve_query_decls, validate_id, validate_query_source}; @@ -40,513 +43,6 @@ pub const CLUSTER_RESOURCES_DIR: &str = "__cluster/resources"; pub const CLUSTER_RECOVERIES_DIR: &str = "__cluster/recoveries"; pub const CLUSTER_APPROVALS_DIR: &str = "__cluster/approvals"; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[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, path: impl Into, message: impl Into) -> Self { - Self { - code: code.into(), - severity: DiagnosticSeverity::Error, - path: path.into(), - message: message.into(), - } - } - - fn warning( - code: impl Into, - path: impl Into, - message: impl Into, - ) -> 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, -} - -#[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, - pub resources: Vec, - pub dependencies: Vec, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub struct DesiredRevision { - #[serde(skip_serializing_if = "Option::is_none")] - pub config_digest: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct StateObservations { - pub state_path: String, - pub lock_path: String, - pub state_found: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub applied_config_digest: Option, - pub state_revision: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub state_cas: Option, - pub resource_count: usize, - pub locked: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub lock_id: Option, - pub lock_acquired: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub acquired_lock_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub lock_operation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub lock_created_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub lock_pid: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub lock_age_seconds: Option, -} - -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); - } -} - -#[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub message: Option, -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum PlanOperation { - Create, - Update, - Delete, -} - -/// How `cluster apply` treats a planned change in the current stage. -/// -/// `Applied` changes execute (config-only query/policy catalog writes). -/// `Derived` marks a `graph.` 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, -} - -#[derive(Debug, Clone, Serialize, PartialEq)] -pub struct PlanChange { - pub resource: String, - pub operation: PlanOperation, - #[serde(skip_serializing_if = "Option::is_none")] - pub before_digest: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub after_digest: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub disposition: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option, - /// 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, - /// 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, -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct BlastRadius { - pub resource: String, - pub affected: Vec, -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct ApprovalRequirement { - pub resource: String, - pub reason: String, - /// True when a valid (digest-matching, unconsumed) approval artifact is - /// pending for this change. - pub satisfied: bool, -} - -#[derive(Debug, Clone, Serialize)] -pub struct PlanOutput { - pub ok: bool, - pub config_dir: String, - pub desired_revision: DesiredRevision, - pub resource_digests: BTreeMap, - pub dependencies: Vec, - pub state_observations: StateObservations, - pub changes: Vec, - pub blast_radius: Vec, - pub approvals_required: Vec, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub struct StatusOutput { - pub ok: bool, - pub config_dir: String, - pub state_observations: StateObservations, - pub resource_digests: BTreeMap, - pub resource_statuses: BTreeMap, - pub observations: BTreeMap, - pub diagnostics: Vec, -} - -#[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, - pub resource_statuses: BTreeMap, - pub observations: BTreeMap, - pub diagnostics: Vec, -} - -#[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, -} - -/// 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, - #[serde(skip_serializing_if = "Option::is_none")] - pub actor: Option, - pub desired_revision: DesiredRevision, - pub state_observations: StateObservations, - /// Every planned change, with `disposition`/`reason` always populated. - pub changes: Vec, - 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, - /// The statuses as persisted: post-apply on success, the pre-apply on-disk - /// snapshot when the state write fails (never unpersisted in-memory state). - pub resource_statuses: BTreeMap, - pub diagnostics: Vec, -} - -/// 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, - #[serde(default)] - bound_after_digest: Option, - approved_by: String, - created_at: String, - #[serde(default)] - consumed_at: Option, - #[serde(default)] - consumed_by_operation: Option, -} - -#[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, - #[serde(skip_serializing_if = "Option::is_none")] - pub resource: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub operation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub approved_by: Option, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone)] -struct DesiredCluster { - config_dir: PathBuf, - config_digest: String, - state_lock: bool, - graphs: Vec, - resource_digests: BTreeMap, - resources: Vec, - dependencies: Vec, - /// `policy.` address -> normalized applies_to refs. - policy_bindings: BTreeMap>, -} - -#[derive(Debug, Clone)] -struct DesiredGraph { - id: String, - schema_digest: String, -} - -#[derive(Debug)] -struct ParsedConfig { - raw: Option, - diagnostics: Vec, - config_dir: PathBuf, - config_file: PathBuf, -} - -#[derive(Debug, Clone, Copy)] -struct ClusterSettings { - state_lock: bool, -} - -#[derive(Debug)] -struct LoadOutcome { - desired: Option, - diagnostics: Vec, - config_dir: PathBuf, - config_file: PathBuf, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct RawClusterConfig { - version: u32, - #[serde(default)] - metadata: Metadata, - #[serde(default)] - state: StateConfig, - #[serde(default)] - graphs: BTreeMap, - #[serde(default)] - policies: BTreeMap, -} - -#[derive(Debug, Default, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct Metadata { - name: Option, -} - -#[derive(Debug, Default, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct StateConfig { - backend: Option, - lock: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct GraphConfig { - schema: PathBuf, - #[serde(default)] - 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 -#[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct QueryConfig { - file: PathBuf, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct PolicyConfig { - file: PathBuf, - applies_to: Vec, -} - -// Stage 2A/2B accept these forward-compatible state sections so existing -// ledgers won't churn while approval/recovery semantics are staged later. -#[allow(dead_code)] -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct ClusterState { - version: u32, - #[serde(default)] - state_revision: u64, - applied_revision: AppliedRevisionState, - #[serde(default)] - resource_statuses: BTreeMap, - #[serde(default)] - approval_records: BTreeMap, - #[serde(default)] - recovery_records: BTreeMap, - #[serde(default)] - observations: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct AppliedRevisionState { - #[serde(default)] - config_digest: Option, - #[serde(default)] - resources: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct StateResource { - digest: String, - /// Policy resources only: the applied `applies_to` bindings, normalized - /// to typed refs (`cluster` | `graph.`). 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>, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct StateLockFile { - version: u32, - lock_id: String, - operation: String, - created_at: String, - pid: u32, -} - -/// 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, - kind: RecoverySidecarKind, - graph_id: String, - graph_uri: String, - #[serde(default)] - observed_manifest_version: Option, - #[serde(default)] - expected_manifest_version: Option, - desired_schema_digest: String, - #[serde(default)] - state_cas_base: Option, - /// For graph_delete: the approval this operation consumes; lets a sweep - /// roll-forward consume it too. - #[serde(default)] - approval_id: Option, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -enum RecoverySidecarKind { - GraphCreate, - SchemaApply, - GraphDelete, -} - -#[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, - /// 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, - /// 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, -} - - pub fn validate_config_dir(config_dir: impl AsRef) -> ValidateOutput { let outcome = load_desired(config_dir.as_ref()); let (resource_digests, resources, dependencies) = match outcome.desired { diff --git a/crates/omnigraph-cluster/src/types.rs b/crates/omnigraph-cluster/src/types.rs new file mode 100644 index 0000000..c366f04 --- /dev/null +++ b/crates/omnigraph-cluster/src/types.rs @@ -0,0 +1,510 @@ +//! Public output/diagnostic types and internal state/sidecar/approval +//! models (moved verbatim from lib.rs in the modularization). + +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[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 { + pub(crate) fn error(code: impl Into, path: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + severity: DiagnosticSeverity::Error, + path: path.into(), + message: message.into(), + } + } + + pub(crate) fn warning( + code: impl Into, + path: impl Into, + message: impl Into, + ) -> 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, +} + +#[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, + pub resources: Vec, + pub dependencies: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DesiredRevision { + #[serde(skip_serializing_if = "Option::is_none")] + pub config_digest: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct StateObservations { + pub state_path: String, + pub lock_path: String, + pub state_found: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub applied_config_digest: Option, + pub state_revision: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub state_cas: Option, + pub resource_count: usize, + pub locked: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub lock_id: Option, + pub lock_acquired: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub acquired_lock_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lock_operation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lock_created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lock_pid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lock_age_seconds: Option, +} + +impl StateObservations { + pub(crate) 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); + } +} + +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PlanOperation { + Create, + Update, + Delete, +} + +/// How `cluster apply` treats a planned change in the current stage. +/// +/// `Applied` changes execute (config-only query/policy catalog writes). +/// `Derived` marks a `graph.` 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, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct PlanChange { + pub resource: String, + pub operation: PlanOperation, + #[serde(skip_serializing_if = "Option::is_none")] + pub before_digest: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub after_digest: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub disposition: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// 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, + /// 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, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct BlastRadius { + pub resource: String, + pub affected: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ApprovalRequirement { + pub resource: String, + pub reason: String, + /// True when a valid (digest-matching, unconsumed) approval artifact is + /// pending for this change. + pub satisfied: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PlanOutput { + pub ok: bool, + pub config_dir: String, + pub desired_revision: DesiredRevision, + pub resource_digests: BTreeMap, + pub dependencies: Vec, + pub state_observations: StateObservations, + pub changes: Vec, + pub blast_radius: Vec, + pub approvals_required: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct StatusOutput { + pub ok: bool, + pub config_dir: String, + pub state_observations: StateObservations, + pub resource_digests: BTreeMap, + pub resource_statuses: BTreeMap, + pub observations: BTreeMap, + pub diagnostics: Vec, +} + +#[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, + pub resource_statuses: BTreeMap, + pub observations: BTreeMap, + pub diagnostics: Vec, +} + +#[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, +} + +/// 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub actor: Option, + pub desired_revision: DesiredRevision, + pub state_observations: StateObservations, + /// Every planned change, with `disposition`/`reason` always populated. + pub changes: Vec, + 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, + /// The statuses as persisted: post-apply on success, the pre-apply on-disk + /// snapshot when the state write fails (never unpersisted in-memory state). + pub resource_statuses: BTreeMap, + pub diagnostics: Vec, +} + +/// 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)] +pub(crate) struct ApprovalArtifact { + pub(crate) schema_version: u32, + pub(crate) approval_id: String, + pub(crate) resource: String, + pub(crate) operation: String, + pub(crate) reason: String, + pub(crate) bound_config_digest: String, + #[serde(default)] + pub(crate) bound_before_digest: Option, + #[serde(default)] + pub(crate) bound_after_digest: Option, + pub(crate) approved_by: String, + pub(crate) created_at: String, + #[serde(default)] + pub(crate) consumed_at: Option, + #[serde(default)] + pub(crate) consumed_by_operation: Option, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub resource: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub operation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub approved_by: Option, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct DesiredCluster { + pub(crate) config_dir: PathBuf, + pub(crate) config_digest: String, + pub(crate) state_lock: bool, + pub(crate) graphs: Vec, + pub(crate) resource_digests: BTreeMap, + pub(crate) resources: Vec, + pub(crate) dependencies: Vec, + /// `policy.` address -> normalized applies_to refs. + pub(crate) policy_bindings: BTreeMap>, +} + +#[derive(Debug, Clone)] +pub(crate) struct DesiredGraph { + pub(crate) id: String, + pub(crate) schema_digest: String, +} + +#[derive(Debug)] +pub(crate) struct ParsedConfig { + pub(crate) raw: Option, + pub(crate) diagnostics: Vec, + pub(crate) config_dir: PathBuf, + pub(crate) config_file: PathBuf, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct ClusterSettings { + pub(crate) state_lock: bool, +} + +#[derive(Debug)] +pub(crate) struct LoadOutcome { + pub(crate) desired: Option, + pub(crate) diagnostics: Vec, + pub(crate) config_dir: PathBuf, + pub(crate) config_file: PathBuf, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RawClusterConfig { + pub(crate) version: u32, + #[serde(default)] + pub(crate) metadata: Metadata, + #[serde(default)] + pub(crate) state: StateConfig, + #[serde(default)] + pub(crate) graphs: BTreeMap, + #[serde(default)] + pub(crate) policies: BTreeMap, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Metadata { + pub(crate) name: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct StateConfig { + pub(crate) backend: Option, + pub(crate) lock: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct GraphConfig { + pub(crate) schema: PathBuf, + #[serde(default)] + pub(crate) 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 +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct QueryConfig { + pub(crate) file: PathBuf, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct PolicyConfig { + pub(crate) file: PathBuf, + pub(crate) applies_to: Vec, +} + +// Stage 2A/2B accept these forward-compatible state sections so existing +// ledgers won't churn while approval/recovery semantics are staged later. +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ClusterState { + pub(crate) version: u32, + #[serde(default)] + pub(crate) state_revision: u64, + pub(crate) applied_revision: AppliedRevisionState, + #[serde(default)] + pub(crate) resource_statuses: BTreeMap, + #[serde(default)] + pub(crate) approval_records: BTreeMap, + #[serde(default)] + pub(crate) recovery_records: BTreeMap, + #[serde(default)] + pub(crate) observations: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct AppliedRevisionState { + #[serde(default)] + pub(crate) config_digest: Option, + #[serde(default)] + pub(crate) resources: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct StateResource { + pub(crate) digest: String, + /// Policy resources only: the applied `applies_to` bindings, normalized + /// to typed refs (`cluster` | `graph.`). 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")] + pub(crate) applies_to: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct StateLockFile { + pub(crate) version: u32, + pub(crate) lock_id: String, + pub(crate) operation: String, + pub(crate) created_at: String, + pub(crate) pid: u32, +} + +/// 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)] +pub(crate) struct RecoverySidecar { + pub(crate) schema_version: u32, + pub(crate) operation_id: String, + pub(crate) started_at: String, + #[serde(default)] + pub(crate) actor: Option, + pub(crate) kind: RecoverySidecarKind, + pub(crate) graph_id: String, + pub(crate) graph_uri: String, + #[serde(default)] + pub(crate) observed_manifest_version: Option, + #[serde(default)] + pub(crate) expected_manifest_version: Option, + pub(crate) desired_schema_digest: String, + #[serde(default)] + pub(crate) state_cas_base: Option, + /// For graph_delete: the approval this operation consumes; lets a sweep + /// roll-forward consume it too. + #[serde(default)] + pub(crate) approval_id: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum RecoverySidecarKind { + GraphCreate, + SchemaApply, + GraphDelete, +} + +#[derive(Debug, Default)] +pub(crate) struct SweepOutcome { + /// Graphs whose sidecar was kept (rows 5/6): graph-moving work for them + /// is blocked until the operator repairs and re-observes. + pub(crate) pending_graphs: BTreeSet, + /// Sidecars whose outcome is recorded (rows 2/4): deleted only after the + /// command's state write lands, so a CAS failure re-sweeps them. + pub(crate) completed_sidecars: Vec, + /// Approval artifacts consumed by a roll-forward (delete row 7b): their + /// files are rewritten with consumed_at only after the state write lands. + pub(crate) consumed_approvals: Vec, +}