//! Declarative reconcile engine for a single app. //! //! Takes a desired-state [`Bundle`] (an app's scripts, routes, triggers, //! and the names of its secrets) and either diffs it against the live //! database ([`ApplyService::plan`], read-only) or reconciles it in one //! transaction ([`ApplyService::apply`]). //! //! Identity keys match the DB UNIQUE constraints so the diff is stable: //! scripts by `lower(name)`, routes by the `(method, host, path)` tuple, //! triggers by their per-kind semantic tuple, secrets by name. //! //! **Trigger-identity limitation:** a trigger's identity *is* its semantic //! definition, so the diff only ever Creates or Deletes (a change is //! delete-old + create-new). For queue the identity is `queue|{queue_name}` //! (script-independent — it encodes the one-consumer-per-queue invariant) //! and for email it is `email|{script}`. Consequence: rebinding a queue to //! a different script, changing its visibility timeout, or rotating an email //! trigger's secret/reference diffs as a **NoOp and is not applied**. To //! change those, recreate the trigger (drop it from the manifest, apply //! `--prune`, then re-add it). //! //! Other known limitations vs. the interactive API: a script **rename** //! diffs as delete+create, so under `--prune` it loses the old script's id, //! version counter, and execution logs; an `endpoint`→`module` kind flip is //! not guarded against *live* (un-pruned) routes/triggers still bound to the //! script; and cross-pattern route conflict-vs-live is not checked (only //! identical tuples collide). See `validate_bundle`. use std::collections::{BTreeSet, HashMap, HashSet}; use std::sync::Arc; use picloud_orchestrator_core::routing::{pattern, RouteTable}; use picloud_shared::{ AdminUserId, AppId, DispatchMode, DocsEventOp, FilesEventOp, GroupId, HostKind, KvEventOp, MasterKey, PathKind, Principal, ProjectId, Route, Script, ScriptId, ScriptKind, ScriptOwner, ScriptSandbox, ScriptValidator, TriggerId, }; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use crate::app_domain_repo::AppDomainRepository; use crate::app_repo::AppRepository; use crate::authz::{require, AuthzDenied, AuthzRepo, Capability}; use crate::config_resolver::CHAIN_LEVELS_CTE; use crate::repo::{ delete_script_tx, insert_script_tx, update_script_tx, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError, }; use crate::route_repo::{delete_route_tx, insert_route_tx, NewRoute, RouteRepository}; use crate::sandbox::SandboxCeiling; use crate::secrets_repo::SecretsRepo; use crate::trigger_config::TriggerConfig; use crate::trigger_repo::{ delete_trigger_tx, insert_email_trigger_tx, insert_trigger_tx, Trigger, TriggerDetails, TriggerDispatchMode, TriggerRepo, TriggerRepoError, }; use crate::vars_repo::{VarOwner, VarsRepo}; /// Reserved path prefixes that user routes may not claim (mirrors the /// route-create rejection — see CLAUDE.md "Reserved path prefixes"). const RESERVED_PATH_PREFIXES: &[&str] = &["/api/", "/admin/"]; const RESERVED_PATH_EXACT: &[&str] = &["/healthz", "/version"]; // ---------------------------------------------------------------------------- // Wire types — desired state (request) and the computed plan (response) // ---------------------------------------------------------------------------- /// Desired state for one app, sent by `pic plan` / `pic apply`. #[derive(Debug, Clone, Deserialize)] pub struct Bundle { #[serde(default)] pub scripts: Vec, #[serde(default)] pub routes: Vec, #[serde(default)] pub triggers: Vec, /// Declared secret *names* only; values are pushed out-of-band. #[serde(default)] pub secrets: Vec, /// Declared app-owned config `vars` — key → JSON value. Unlike secrets, /// var values are non-secret and live inline in the manifest. Written at /// app scope `*` (an app *is* an environment, §2 — per-env scoping is a /// group concern, deferred to the nested-manifest model). Tombstones are /// not expressible here; use `pic vars set --tombstone`. #[serde(default)] pub vars: std::collections::BTreeMap, /// Declared extension-point *names* (§5.5) — module names this node offers /// for descendants to provide/override. Name-only, like `secrets`; the /// optional default body is a co-located `kind = module` script. #[serde(default)] pub extension_points: Vec, /// Declared shared group collections (§11.6) — `(name, kind)` markers this /// node offers as cross-app-shared (`kind` ∈ `kv`/`docs`). The data lives in /// the per-kind store (`group_kv_entries` / `group_docs`). Authored on /// `[group]` nodes only (the CLI rejects it on `[app]`). #[serde(default)] pub collections: Vec, /// §11 tail per-app opt-out: handler SCRIPT names whose inherited group /// triggers this app declines. App-only (rejected on a group node). #[serde(default)] pub suppress_triggers: Vec, /// §11 tail per-app opt-out: PATHS whose inherited group route this app /// declines (the binding 404s instead of serving). App-only. #[serde(default)] pub suppress_routes: Vec, /// v1.2 Workflows: declared DAG workflow definitions. App-owned (M1); a /// `[group]` carrying workflows is rejected in `validate_bundle_for`. #[serde(default)] pub workflows: Vec, /// §9.4 service interceptors: before-op allow/deny hooks. One entry per /// `(service, op)` guarded, naming the interceptor script. App- OR /// group-owned; resolved nearest-owner-wins on a descendant app's chain. #[serde(default)] pub interceptors: Vec, } /// One §9.4 interceptor marker on the wire: which `(service, op)` it guards and /// the interceptor script name. The CLI expands a manifest `[[interceptors]]` /// entry's `ops = [...]` into one of these per op. #[derive(Debug, Clone, Deserialize)] pub struct BundleInterceptor { pub service: String, pub op: String, /// `before` (allow/deny + transform) or `after` (observe; §9.4 M3). /// Defaults to `before` so a pre-M3 CLI (which omits it) still applies. #[serde(default = "default_before_phase")] pub phase: String, pub script: String, /// §9.4 M5: per-hook timeout in ms (`None` = instance default). `default` /// so a pre-M5 CLI (which omits it) still deserializes. #[serde(default)] pub timeout_ms: Option, } fn default_before_phase() -> String { "before".to_string() } /// One declared workflow on the wire: a name + its (already-parsed) DAG. #[derive(Debug, Clone, Deserialize)] pub struct BundleWorkflow { pub name: String, pub definition: picloud_shared::workflow::WorkflowDefinition, /// Three-state lifecycle (§4.3); omitted ⇒ active. #[serde(default = "picloud_shared::default_true")] pub enabled: bool, } /// One declared shared-collection marker on the wire: a name + its store kind. #[derive(Debug, Clone, Deserialize)] pub struct CollectionSpec { pub name: String, /// Storage kind; defaults to `kv` for back-compat with the name-only form. #[serde(default = "default_collection_kind")] pub kind: String, } fn default_collection_kind() -> String { "kv".to_string() } /// The shared-collection kinds the apply engine accepts. const COLLECTION_KINDS: &[&str] = &["kv", "docs", "files", "topic", "queue"]; #[derive(Debug, Clone, Deserialize)] pub struct BundleScript { pub name: String, pub source: String, #[serde(default)] pub kind: ScriptKind, #[serde(default)] pub description: Option, #[serde(default)] pub timeout_seconds: Option, #[serde(default)] pub memory_limit_mb: Option, #[serde(default)] pub sandbox: Option, /// Three-state lifecycle (§4.3); omitted ⇒ active. Declarative (not /// leave-as-is): a script absent the key is `enabled = true`. #[serde(default = "picloud_shared::default_true")] pub enabled: bool, } #[derive(Debug, Clone, Deserialize)] pub struct BundleRoute { /// Name of the script this route binds to. pub script: String, #[serde(default)] pub method: Option, pub host_kind: HostKind, #[serde(default)] pub host: String, #[serde(default)] pub host_param_name: Option, pub path_kind: PathKind, pub path: String, #[serde(default)] pub dispatch_mode: DispatchMode, /// Three-state lifecycle (§4.3); omitted ⇒ active. #[serde(default = "picloud_shared::default_true")] pub enabled: bool, /// §11 tail: `sealed = true` on a group route template makes it /// non-suppressible — a descendant's `[suppress]` cannot decline it. /// Rejected (in `validate_bundle_for`) on an app owner. Omitted ⇒ unsealed. #[serde(default)] pub sealed: bool, } /// Desired trigger, tagged by kind on the wire (`{ "kind": "cron", … }`). #[derive(Debug, Clone, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum BundleTrigger { Kv { script: String, collection_glob: String, #[serde(default)] ops: Vec, #[serde(default)] dispatch_mode: Option, #[serde(default)] retry_max_attempts: Option, /// §11 tail: sealed group template (event kinds only). See /// [`BundleTrigger::sealed`]. #[serde(default)] sealed: bool, /// §11.6: shared-collection group trigger. See [`BundleTrigger::shared`]. #[serde(default)] shared: bool, }, Docs { script: String, collection_glob: String, #[serde(default)] ops: Vec, #[serde(default)] dispatch_mode: Option, #[serde(default)] retry_max_attempts: Option, #[serde(default)] sealed: bool, #[serde(default)] shared: bool, }, Files { script: String, collection_glob: String, #[serde(default)] ops: Vec, #[serde(default)] dispatch_mode: Option, #[serde(default)] retry_max_attempts: Option, #[serde(default)] sealed: bool, #[serde(default)] shared: bool, }, Cron { script: String, schedule: String, #[serde(default = "default_timezone")] timezone: String, #[serde(default)] dispatch_mode: Option, #[serde(default)] retry_max_attempts: Option, }, Pubsub { script: String, topic_pattern: String, #[serde(default)] dispatch_mode: Option, #[serde(default)] retry_max_attempts: Option, #[serde(default)] sealed: bool, /// §11.6 D2: `true` for a shared-TOPIC group trigger — fires when a /// subtree app publishes to the group's shared topic namespace, not on /// per-app publishes. #[serde(default)] shared: bool, }, Email { script: String, /// Name of the secret (set via `pic secret set`) holding the /// inbound HMAC value; resolved + sealed at apply time. Never the /// value itself. inbound_secret_ref: String, #[serde(default)] dispatch_mode: Option, #[serde(default)] retry_max_attempts: Option, }, Queue { script: String, queue_name: String, #[serde(default)] visibility_timeout_secs: Option, #[serde(default)] dispatch_mode: Option, #[serde(default)] retry_max_attempts: Option, /// §11.6 D3: `true` for a shared-QUEUE group consumer — materializes a /// competing consumer per descendant app, all claiming the group store. #[serde(default)] shared: bool, }, /// §11.6 B2: a dead-letter handler. Declarative authoring is restricted to /// GROUP-owned + `shared = true` — it fires when a message in the group's /// SHARED queue is exhausted (dead-lettered). An app-owned or non-shared /// `dead_letter` bundle trigger is rejected in `validate_bundle_for`. DeadLetter { script: String, /// Match only dead-letters filed under this source (`"queue"` for a /// shared-queue exhaustion). `None` matches any source. #[serde(default)] source_filter: Option, #[serde(default)] dispatch_mode: Option, #[serde(default)] retry_max_attempts: Option, #[serde(default)] shared: bool, }, } fn default_timezone() -> String { "UTC".to_string() } impl BundleTrigger { /// The script name this trigger fires. #[must_use] pub fn script(&self) -> &str { match self { Self::Kv { script, .. } | Self::Docs { script, .. } | Self::Files { script, .. } | Self::Cron { script, .. } | Self::Pubsub { script, .. } | Self::Email { script, .. } | Self::Queue { script, .. } | Self::DeadLetter { script, .. } => script, } } /// Whether this trigger is an email trigger, which resolves and decrypts /// a stored secret by reference at apply time (gating an extra capability). #[must_use] pub fn is_email(&self) -> bool { matches!(self, Self::Email { .. }) } /// §11 tail: whether this template is `sealed` (non-suppressible). Only the /// event kinds (which can be group templates) carry the flag; the app-only /// kinds (cron/email/queue) are never sealed. #[must_use] pub fn sealed(&self) -> bool { match self { Self::Kv { sealed, .. } | Self::Docs { sealed, .. } | Self::Files { sealed, .. } | Self::Pubsub { sealed, .. } => *sealed, Self::Cron { .. } | Self::Email { .. } | Self::Queue { .. } | Self::DeadLetter { .. } => false, } } /// §11.6: whether this template watches a SHARED collection. The collection- /// scoped event kinds (kv/docs/files) and pubsub (D2: a shared TOPIC) can be /// shared; cron/email/queue are never shared here (a shared queue consumer /// is authored as a `[[triggers.queue]]` and handled via materialization). #[must_use] pub fn shared(&self) -> bool { match self { Self::Kv { shared, .. } | Self::Docs { shared, .. } | Self::Files { shared, .. } | Self::Pubsub { shared, .. } | Self::Queue { shared, .. } | Self::DeadLetter { shared, .. } => *shared, Self::Cron { .. } | Self::Email { .. } => false, } } /// The watched collection glob for the collection-scoped kinds (kv/docs/ /// files); `None` for the others. #[must_use] pub fn collection_glob(&self) -> Option<&str> { match self { Self::Kv { collection_glob, .. } | Self::Docs { collection_glob, .. } | Self::Files { collection_glob, .. } => Some(collection_glob), _ => None, } } /// Stable semantic identity (the per-kind tuple), used for the diff. #[must_use] pub fn identity(&self) -> String { match self { // §11 tail: `sealed` is part of the identity for the event kinds, so // toggling it re-diffs (a Create + a Delete-on-prune of the stale // row) like any other definitional change on a template. Self::Kv { script, collection_glob, ops, sealed, shared, .. } => format!( "kv|{script}|{collection_glob}|{}|{sealed}|{shared}", sorted_csv(ops.iter().copied().map(KvEventOp::as_str)) ), Self::Docs { script, collection_glob, ops, sealed, shared, .. } => format!( "docs|{script}|{collection_glob}|{}|{sealed}|{shared}", sorted_csv(ops.iter().copied().map(DocsEventOp::as_str)) ), Self::Files { script, collection_glob, ops, sealed, shared, .. } => format!( "files|{script}|{collection_glob}|{}|{sealed}|{shared}", sorted_csv(ops.iter().copied().map(FilesEventOp::as_str)) ), Self::Cron { script, schedule, timezone, .. } => format!("cron|{script}|{schedule}|{timezone}"), Self::Pubsub { script, topic_pattern, sealed, shared, .. } => format!("pubsub|{script}|{topic_pattern}|{sealed}|{shared}"), Self::Email { script, .. } => format!("email|{script}"), Self::Queue { queue_name, shared, .. } => format!("queue|{queue_name}|{shared}"), // §11.6 B2: mirrored by `current_trigger_identity` for a group-owned // shared dead_letter, so a re-apply diffs as NoOp. Self::DeadLetter { script, source_filter, shared, .. } => format!( "dead_letter|{script}|{}|{shared}", source_filter.as_deref().unwrap_or("") ), } } #[must_use] pub fn kind_str(&self) -> &'static str { match self { Self::Kv { .. } => "kv", Self::Docs { .. } => "docs", Self::Files { .. } => "files", Self::Cron { .. } => "cron", Self::Pubsub { .. } => "pubsub", Self::Email { .. } => "email", Self::Queue { .. } => "queue", Self::DeadLetter { .. } => "dead_letter", } } } /// One change in the plan. `key` is the human-renderable identity. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ResourceChange { pub op: Op, pub key: String, /// Optional one-line note (e.g. why an update, or "value must be set"). #[serde(skip_serializing_if = "Option::is_none")] pub detail: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum Op { Create, Update, NoOp, Delete, } /// The computed diff, grouped by resource kind. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct Plan { pub scripts: Vec, pub routes: Vec, pub triggers: Vec, pub secrets: Vec, #[serde(default)] pub vars: Vec, #[serde(default)] pub extension_points: Vec, #[serde(default)] pub collections: Vec, /// §11 tail per-app suppression markers, keyed `"{target_kind}:{reference}"`. #[serde(default)] pub suppressions: Vec, /// v1.2 Workflows: workflow definitions, keyed by name. #[serde(default)] pub workflows: Vec, /// §9.4 interceptor markers, keyed `"{service}/{op}"` with the script as the /// value (create/update/delete like `vars`). #[serde(default)] pub interceptors: Vec, } impl Plan { /// True when every resource is a no-op (nothing to apply). #[must_use] pub fn is_noop(&self) -> bool { self.scripts .iter() .chain(&self.routes) .chain(&self.triggers) .chain(&self.secrets) .chain(&self.vars) .chain(&self.extension_points) .chain(&self.collections) .chain(&self.suppressions) .chain(&self.workflows) .chain(&self.interceptors) .all(|c| c.op == Op::NoOp) } } /// What `plan` returns: the diff plus a fingerprint of the live state it was /// computed against. The token is flattened onto the plan JSON, so the wire /// shape stays `{ scripts, routes, triggers, secrets, state_token }`. #[derive(Debug, Clone, Serialize)] pub struct PlanResult { #[serde(flatten)] pub plan: Plan, pub state_token: String, /// §7 M3: the ownership outcome this apply would produce (claim / owned / /// conflict / unclaimed). Present only when a `[project]` was declared. #[serde(default, skip_serializing_if = "Option::is_none")] pub ownership: Option, /// §3 M3: environments the governing project marks confirm-required — applying /// to one needs `pic apply --approve `. Surfaced so CI sees the gate at /// `plan` time, before an `apply` refuses. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub approvals_required: Vec, /// The desired-state warnings `apply` would emit, computed at plan so `pic /// plan` is a faithful preview for CI: an enabled binding on a disabled /// script (deployed-but-unreachable), an endpoint with no route/trigger, a /// `[suppress]` that matches no inherited template. Post-commit side-effect /// warnings (route-table refresh, materialization skips) are apply-only. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub warnings: Vec, } // ---------------------------------------------------------------------------- // Tree apply (Phase 5): a whole project subtree applied in one transaction. // ---------------------------------------------------------------------------- /// Whether a tree node is an app or a group. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum NodeKind { App, Group, } /// One node of a project tree: its kind, slug, and its desired-state bundle (a /// group's bundle has no routes/triggers). Request-only (deserialized from the /// CLI's tree bundle). #[derive(Debug, Clone, Deserialize)] pub struct TreeNode { pub kind: NodeKind, pub slug: String, /// §6: for a GROUP node, the declared parent group slug inferred from the /// repo's directory nesting (`None` = the repo's attach point / instance /// root). Absent for an app node, and absent on the wire from a pre-§6 CLI /// (`#[serde(default)]`) — in which case a group is resolved as before and /// must pre-exist. Used to CREATE a missing group and (M2) to detect a /// server/manifest structural divergence. #[serde(default)] pub parent: Option, /// §6: a GROUP node's display name / description, used only when the group /// is CREATED (a missing group defaults its name to the slug). Content /// reconcile never touches them. #[serde(default)] pub name: Option, #[serde(default)] pub description: Option, pub bundle: Bundle, } /// A whole project subtree submitted to `apply_tree`. #[derive(Debug, Clone, Default, Deserialize)] pub struct TreeBundle { #[serde(default)] pub nodes: Vec, } /// §7 multi-repo ownership: the `[project]` identity an apply declares. The /// slug is committed in the repo's root manifest (stable across clones); the /// first apply with a new slug registers the project (server-assigned UUID). #[derive(Debug, Clone, Deserialize)] pub struct ProjectDecl { pub slug: String, #[serde(default)] pub name: Option, /// §7/§6 attach point: the pre-existing group this repo binds under. Every /// applied node must be strictly within its subtree; `None` = instance root /// (no ceiling). #[serde(default)] pub parent_group: Option, /// §3 M3 per-env approval policy (the root manifest's `[project.environments]`). /// A `confirm = true` env makes applying to it a gated, admin-gated + audited /// step (`--approve ` required; a blanket `--yes` does NOT cover it). The /// server re-derives the gate from THIS field — the CLI's client-side check is /// convenience, this is the authoritative source. `#[serde(default)]` keeps a /// pre-M3 client (which omits it) backward-compatible (no gate). #[serde(default)] pub environments: std::collections::BTreeMap, } /// §3 M3: one environment's server-side apply policy (mirrors the manifest's /// `EnvPolicy`). `confirm = true` gates applying to this env. #[derive(Debug, Clone, Deserialize)] pub struct EnvPolicyDecl { #[serde(default)] pub confirm: bool, } impl ProjectDecl { /// Does environment `env` require explicit approval to apply (§3 M3)? #[must_use] pub fn confirm_required(&self, env: &str) -> bool { self.environments.get(env).is_some_and(|p| p.confirm) } /// Names of every confirm-required environment, sorted (for `plan` to surface /// so CI sees the gate before an `apply` refuses). #[must_use] pub fn gated_envs(&self) -> Vec { self.environments .iter() .filter(|(_, p)| p.confirm) .map(|(name, _)| name.clone()) .collect() } } /// The ownership context threaded through an apply: the declared project (if /// any), whether the caller passed `--takeover`, and the principal (its /// `user_id` is the actor; a takeover additionally requires `GroupAdmin`). pub struct OwnershipClaim<'a> { pub project: Option, pub takeover: bool, pub principal: &'a Principal, } /// §7 M3 plan preview: what applying this node WOULD do about ownership, so /// `pic plan` surfaces a conflict before `pic apply`. `action` ∈ `claim` /// (unclaimed → this project claims it), `owned` (already this project), /// `conflict` (owned by `owner`), `unclaimed` (no project, or an app in open /// territory). `owner` is the current owner's slug for `owned`/`conflict`. #[derive(Debug, Clone, Serialize)] pub struct OwnershipPreview { pub action: String, #[serde(skip_serializing_if = "Option::is_none")] pub owner: Option, /// §7 M3.2 cross-repo blast radius: for a GROUP node, the descendant apps /// owned by OTHER projects that a config change here fans out to, grouped by /// project. Empty for an app node or when nothing else is affected. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub blast_radius: Vec, } /// One entry of a group plan's blast radius: another project and how many of /// its apps inherit from the group being changed. #[derive(Debug, Clone, Serialize)] pub struct ProjectImpact { pub project: String, pub apps: u32, } /// Pure preview policy (read-only, slug-only — no registration): compares the /// node's current owner slug against this apply's declared project slug. The /// blast radius (a DB fan-out) is filled in by the caller for group nodes. fn preview_ownership( is_group: bool, current: Option<&str>, declared: Option<&str>, ) -> OwnershipPreview { // Project the SAME pure policy the apply path runs onto a display label, // keyed on slugs (plan has no assigned project id yet) and takeover-agnostic // (`takeover = false`; plan never mutates ownership). Deriving the preview // here — rather than re-encoding the arms — keeps plan and apply from // drifting: one decision, two projections. let cur = current.map(|s| (s.to_string(), s.to_string())); let decl = declared.map(str::to_string); let action = if is_group { match decide_group_claim(cur, decl, false) { GroupClaimAction::Claim(_) => "claim", // Noop covers both "already ours" and "unclaimed + no project"; the // current owner disambiguates. GroupClaimAction::Noop if current.is_none() => "unclaimed", GroupClaimAction::Noop => "owned", // Takeover can't arise with `takeover = false`; treat defensively. GroupClaimAction::Conflict(_) | GroupClaimAction::Takeover(_) => "conflict", } } else { match decide_app_owner(cur, decl) { Ok(()) if current.is_none() => "unclaimed", Ok(()) => "owned", Err(_) => "conflict", } }; OwnershipPreview { action: action.to_string(), owner: current.map(str::to_string), blast_radius: Vec::new(), } } /// §6 M2: how a tree apply resolves a group whose SERVER parent differs from the /// manifest's declared (directory-nesting) parent — Terraform-style /// detect-and-refuse. Request-only; `Refuse` is the default (a pre-M2 CLI omits /// it), so a divergence never silently reshapes the tree. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum StructureMode { /// Refuse the apply on any structural divergence (422). #[default] Refuse, /// Reparent each diverged group to the manifest's declared parent. ForceLocal, /// Accept the server's shape; reconcile content in place, no reparent. AdoptServer, } /// §6 M2: a group node's structural state under the manifest — `in-sync` when /// the server parent matches the declared (directory-nesting) parent, else /// `diverged` (naming both parents). Previewed by `pic plan`. #[derive(Debug, Clone, Serialize)] pub struct StructurePreview { pub status: String, #[serde(skip_serializing_if = "Option::is_none")] pub server_parent: Option, #[serde(skip_serializing_if = "Option::is_none")] pub manifest_parent: Option, } /// One node's diff within a tree plan. #[derive(Debug, Clone, Serialize)] pub struct NodePlan { pub kind: NodeKind, pub slug: String, #[serde(flatten)] pub plan: Plan, /// §7 M3: this node's ownership outcome under the tree's `[project]`. #[serde(default, skip_serializing_if = "Option::is_none")] pub ownership: Option, /// §6 M2: for a GROUP node, its structural state (in-sync / diverged). #[serde(default, skip_serializing_if = "Option::is_none")] pub structure: Option, /// The desired-state warnings this node's apply would emit (same set as /// [`PlanResult::warnings`]), so `pic plan --dir` previews them per node. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub warnings: Vec, } /// What `plan_tree` returns: a per-node diff plus ONE combined bound-plan token /// (fingerprint of every node's content + every in-scope group's structure /// version, so a reparent or content edit between plan and apply is caught). #[derive(Debug, Clone, Serialize)] pub struct TreePlanResult { pub nodes: Vec, pub state_token: String, /// §3 M3: environments the project marks confirm-required (see /// [`PlanResult::approvals_required`]). Surfaced so CI sees the gate at plan. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub approvals_required: Vec, } // ---------------------------------------------------------------------------- // Current state snapshot // ---------------------------------------------------------------------------- /// The app's live state, loaded once for a diff. #[derive(Debug, Default)] pub struct CurrentState { pub scripts: Vec