//! 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, Route, Script, ScriptId, ScriptKind, ScriptOwner, ScriptSandbox, ScriptValidator, TriggerId, }; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; 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, } /// 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, }, } 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, .. } => 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 { .. } => 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, .. } => *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}"), } } #[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", } } } /// 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, } 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) .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, } // ---------------------------------------------------------------------------- // 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 (must pre-exist on the server), /// 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, pub bundle: Bundle, } /// A whole project subtree submitted to `apply_tree`. #[derive(Debug, Clone, Default, Deserialize)] pub struct TreeBundle { #[serde(default)] pub nodes: Vec, /// Project-level policy from the repo's root manifest `[project]` block /// (§4.2/§6, M5). The server re-derives env approval policy from here — the /// CLI's enforcement is convenience, this is authoritative. #[serde(default)] pub project: Option, } /// Project-level policy (the root manifest's `[project]` block, M5). #[derive(Debug, Clone, Default, Deserialize)] pub struct ProjectPolicy { #[serde(default)] pub environments: Vec, } /// One environment's apply policy. `confirm = true` makes applying to this env a /// gated, default-off step: a per-env `--approve ` is required (a blanket /// `--yes` does NOT cover it) and the act is admin-gated + audited (§4.2). #[derive(Debug, Clone, Deserialize)] pub struct EnvPolicy { pub name: String, #[serde(default)] pub confirm: bool, } impl ProjectPolicy { /// Does environment `env` require explicit approval to apply? #[must_use] pub fn confirm_required(&self, env: &str) -> bool { self.environments.iter().any(|e| e.name == env && e.confirm) } /// Names of every confirm-required environment (for `plan` to surface). #[must_use] pub fn gated_envs(&self) -> Vec { self.environments .iter() .filter(|e| e.confirm) .map(|e| e.name.clone()) .collect() } } /// 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, } /// 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, /// Environments the project marks confirm-required (§4.2, M5): applying to /// one needs an explicit `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, /// Group nodes this manifest declares that another project already owns /// (§7, M3). A read-only `plan` surfaces them so CI sees the conflict before /// an `apply` refuses; `apply` proceeds only with `--takeover`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub conflicts: Vec, /// Slugs of groups this project owns that are absent from the manifest — /// what `apply --prune` would delete (leaf-first, RESTRICT-guarded). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub structural_prunes: Vec, } /// One ownership conflict: a declared group already claimed by another project. #[derive(Debug, Clone, Serialize)] pub struct OwnershipConflict { pub slug: String, /// The owning project's key (so the operator knows whose node it is). pub owner_key: String, } // ---------------------------------------------------------------------------- // Current state snapshot // ---------------------------------------------------------------------------- /// The app's live state, loaded once for a diff. #[derive(Debug, Default)] pub struct CurrentState { pub scripts: Vec