Files
PiCloud/crates/manager-core/src/apply_service.rs
MechaCat02 eae2ee08f1 feat(triggers): shared dead-letter trigger fan-out (B2)
A group declares a declaratively-authored [[triggers.dead_letter]] shared=true
handler; when a message in the group's SHARED queue is exhausted, the
dispatcher's q_terminal group branch (after persisting to group_dead_letters)
fans out to it via list_matching_shared_dead_letter(owning_group, "queue", …),
each outbox row stamped the WRITER app_id (the consuming app — the M2 shared
-write model), so the handler runs under the consumer. The per-app
list_matching_dead_letter gained AND t.shared = FALSE (the shared flag is the
namespace boundary); the owning-group filter is the isolation boundary.

Adds BundleTrigger::DeadLetter + a DeadLetterTriggerSpec manifest kind (group
+shared only — validate_bundle_for rejects app-owned or non-shared, and exempts
it from the shared-requires-a-collection rule); insert_trigger_tx now accepts
dead_letter and writes dead_letter_trigger_details; current_trigger_identity
matches a group-shared dead_letter so re-apply is a NoOp (app-owned ones stay
diff-invisible). Pinned by tests/shared_dead_letter.rs (owning group matches,
per-app query does not, foreign group does not).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 22:20:24 +02:00

7106 lines
282 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<BundleScript>,
#[serde(default)]
pub routes: Vec<BundleRoute>,
#[serde(default)]
pub triggers: Vec<BundleTrigger>,
/// Declared secret *names* only; values are pushed out-of-band.
#[serde(default)]
pub secrets: Vec<String>,
/// 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<String, serde_json::Value>,
/// 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<String>,
/// 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<CollectionSpec>,
/// §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<String>,
/// §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<String>,
/// 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<BundleWorkflow>,
/// §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<BundleInterceptor>,
}
/// 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,
pub script: 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<String>,
#[serde(default)]
pub timeout_seconds: Option<i32>,
#[serde(default)]
pub memory_limit_mb: Option<i32>,
#[serde(default)]
pub sandbox: Option<ScriptSandbox>,
/// 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<String>,
pub host_kind: HostKind,
#[serde(default)]
pub host: String,
#[serde(default)]
pub host_param_name: Option<String>,
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<KvEventOp>,
#[serde(default)]
dispatch_mode: Option<TriggerDispatchMode>,
#[serde(default)]
retry_max_attempts: Option<u32>,
/// §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<DocsEventOp>,
#[serde(default)]
dispatch_mode: Option<TriggerDispatchMode>,
#[serde(default)]
retry_max_attempts: Option<u32>,
#[serde(default)]
sealed: bool,
#[serde(default)]
shared: bool,
},
Files {
script: String,
collection_glob: String,
#[serde(default)]
ops: Vec<FilesEventOp>,
#[serde(default)]
dispatch_mode: Option<TriggerDispatchMode>,
#[serde(default)]
retry_max_attempts: Option<u32>,
#[serde(default)]
sealed: bool,
#[serde(default)]
shared: bool,
},
Cron {
script: String,
schedule: String,
#[serde(default = "default_timezone")]
timezone: String,
#[serde(default)]
dispatch_mode: Option<TriggerDispatchMode>,
#[serde(default)]
retry_max_attempts: Option<u32>,
},
Pubsub {
script: String,
topic_pattern: String,
#[serde(default)]
dispatch_mode: Option<TriggerDispatchMode>,
#[serde(default)]
retry_max_attempts: Option<u32>,
#[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<TriggerDispatchMode>,
#[serde(default)]
retry_max_attempts: Option<u32>,
},
Queue {
script: String,
queue_name: String,
#[serde(default)]
visibility_timeout_secs: Option<u32>,
#[serde(default)]
dispatch_mode: Option<TriggerDispatchMode>,
#[serde(default)]
retry_max_attempts: Option<u32>,
/// §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<String>,
#[serde(default)]
dispatch_mode: Option<TriggerDispatchMode>,
#[serde(default)]
retry_max_attempts: Option<u32>,
#[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<String>,
}
#[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<ResourceChange>,
pub routes: Vec<ResourceChange>,
pub triggers: Vec<ResourceChange>,
pub secrets: Vec<ResourceChange>,
#[serde(default)]
pub vars: Vec<ResourceChange>,
#[serde(default)]
pub extension_points: Vec<ResourceChange>,
#[serde(default)]
pub collections: Vec<ResourceChange>,
/// §11 tail per-app suppression markers, keyed `"{target_kind}:{reference}"`.
#[serde(default)]
pub suppressions: Vec<ResourceChange>,
/// v1.2 Workflows: workflow definitions, keyed by name.
#[serde(default)]
pub workflows: Vec<ResourceChange>,
/// §9.4 interceptor markers, keyed `"{service}/{op}"` with the script as the
/// value (create/update/delete like `vars`).
#[serde(default)]
pub interceptors: Vec<ResourceChange>,
}
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<OwnershipPreview>,
/// §3 M3: environments the governing project marks confirm-required — applying
/// to one needs `pic apply --approve <env>`. 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<String>,
/// 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<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, 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<String>,
/// §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<String>,
#[serde(default)]
pub description: Option<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<TreeNode>,
}
/// §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<String>,
/// §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<String>,
/// §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 <env>` 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<String, EnvPolicyDecl>,
}
/// §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<String> {
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<ProjectDecl>,
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<String>,
/// §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<ProjectImpact>,
}
/// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manifest_parent: Option<String>,
}
/// 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<OwnershipPreview>,
/// §6 M2: for a GROUP node, its structural state (in-sync / diverged).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub structure: Option<StructurePreview>,
/// 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<String>,
}
/// 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<NodePlan>,
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<String>,
}
// ----------------------------------------------------------------------------
// Current state snapshot
// ----------------------------------------------------------------------------
/// The app's live state, loaded once for a diff.
#[derive(Debug, Default)]
pub struct CurrentState {
pub scripts: Vec<Script>,
pub routes: Vec<Route>,
pub triggers: Vec<Trigger>,
pub secret_names: Vec<String>,
/// App's OWN `vars` at scope `*` (key, value), non-tombstone — the set
/// the manifest reconciles. Inherited group vars and tombstones are
/// excluded (the manifest manages only the app's own real values).
pub vars: Vec<(String, serde_json::Value)>,
/// Extension-point marker names declared directly at this node (§5.5).
pub extension_point_names: Vec<String>,
/// Shared group collections declared directly at this node (§11.6), as
/// `(name, kind)` pairs.
pub collections: Vec<(String, String)>,
/// §11 tail per-app suppression markers at this node (app-only), as
/// `(target_kind, reference)` pairs.
pub suppressions: Vec<(String, String)>,
/// v1.2 Workflows owned directly by this node (app-owned; empty for a group).
pub workflows: Vec<crate::workflow_repo::Workflow>,
/// §9.4 interceptor markers declared directly at this node, as
/// `(service, op, script)` triples.
pub interceptors: Vec<(String, String, String)>,
}
/// One row of the read-only extension-point report (§5.5).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionPointInfo {
pub name: String,
/// True when this node owns the marker (vs inheriting it from an ancestor).
pub declared_here: bool,
/// Resolved provider for an app (`app override` / `inherited default`), or
/// `None` when unset (no provider — a plan error) or for a group node.
pub provider: Option<String>,
}
/// One row of the read-only §11.6 shared-collection report.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollectionInfo {
pub name: String,
pub kind: String,
}
/// One row of the read-only §11 tail suppression report (`pic suppress ls
/// --app`). `target_kind` is `trigger` | `route`; `reference` is the handler
/// script name (trigger) or path (route) the app declines.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuppressionInfo {
pub target_kind: String,
pub reference: String,
}
/// One row of the read-only §11 tail trigger-template report (`pic triggers ls
/// --group`). `target` is the kind-specific identity bit (collection glob or
/// topic pattern); `script` is the handler script's name.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TriggerTemplateInfo {
pub kind: String,
pub target: String,
pub script: String,
pub enabled: bool,
/// §11 tail: `true` for a sealed (non-suppressible) template.
pub sealed: bool,
/// §11.6: `true` for a shared-collection template.
pub shared: bool,
/// Full `TriggerDetails` as internally-tagged JSON (`{ "kind": …, … }`),
/// so `pic pull --group` can round-trip a template's ops / schedule /
/// timezone / queue name / visibility timeout, not just the display target.
/// `#[serde(default)]` on the read side so an older server (which omits it)
/// still deserializes; the display command reads `target`, not this.
#[serde(default)]
pub details: serde_json::Value,
/// Wire dispatch mode (`sync`/`async`) — round-trip only.
#[serde(default)]
pub dispatch_mode: String,
/// Per-trigger retry ceiling — round-trip only.
#[serde(default)]
pub retry_max_attempts: u32,
}
/// One row of the read-only §11 tail route-template report (`pic routes ls
/// --group`). The binding tuple a descendant app inherits, plus the handler
/// script's name. The stored route `name` is a UUID for reconcile-created
/// rows, so the semantic bits are shown instead.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RouteTemplateInfo {
/// `None` = any method. Carried raw (not the "ANY" display munge) so
/// `pic pull --group` round-trips it; the display command applies the munge.
pub method: Option<String>,
pub host_kind: HostKind,
/// Raw host (empty for `Any`, suffix for `Wildcard`), not the display munge.
pub host: String,
pub host_param_name: Option<String>,
pub path_kind: PathKind,
pub path: String,
pub script: String,
pub dispatch_mode: DispatchMode,
pub enabled: bool,
/// §11 tail: `true` for a sealed (non-suppressible) template.
pub sealed: bool,
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum ApplyError {
#[error("app not found: {0}")]
AppNotFound(String),
#[error("invalid manifest: {0}")]
Invalid(String),
#[error(
"live state changed since `pic plan` (someone edited this app's scripts, \
routes, triggers, or secrets); re-run `pic plan` to review, then apply — \
or `pic apply --force` to skip the check"
)]
StateMoved,
#[error("forbidden")]
Forbidden,
/// §3 M3: applying to a confirm-required environment without an explicit
/// per-env approval. Maps to 409 — actionable (`--approve <env>`).
#[error(
"environment `{0}` requires explicit approval to apply; \
re-run with `--approve {0}` (a blanket `--yes` does not cover it)"
)]
ApprovalRequired(String),
/// §7 multi-repo ownership: this apply's `[project]` does not own a node it
/// touches (or the node is unclaimed and the apply declared no project).
/// Maps to 409 — actionable (`use --takeover`, `declare [project]`).
#[error("ownership conflict: {0}")]
OwnershipConflict(String),
/// §6/§7: a node is above/outside this project's declared `parent_group`
/// attach point — you can only apply within its subtree. Maps to 422.
#[error("outside attach point: {0}")]
OutsideAttachPoint(String),
/// §6 M2: a group's SERVER parent differs from the manifest's declared
/// (directory-nesting) parent, and no `--force-local-structure` /
/// `--adopt-server-structure` was given. Maps to 422 — actionable.
#[error("structural divergence: {0}")]
StructuralDivergence(String),
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("backend: {0}")]
Backend(String),
}
// ----------------------------------------------------------------------------
// Service
// ----------------------------------------------------------------------------
/// Reconcile engine. Holds trait-object repos for the read/diff path; the
/// transactional write path (`apply`) reuses the same handles plus `pool`.
#[derive(Clone)]
pub struct ApplyService {
/// Pool for the transactional write path (`apply`).
pub pool: PgPool,
pub scripts: Arc<dyn ScriptRepository>,
pub routes: Arc<dyn RouteRepository>,
pub triggers: Arc<dyn TriggerRepo>,
pub secrets: Arc<dyn SecretsRepo>,
/// App-owned `vars` reconciliation (read/diff path; the transactional
/// write goes through `set_app_var_tx` / `delete_app_var_tx`).
pub vars: Arc<dyn VarsRepo>,
pub apps: Arc<dyn AppRepository>,
/// Group lookups (Phase 5): resolve a group slug→id for group/tree apply.
pub groups: Arc<dyn crate::group_repo::GroupRepository>,
/// Project registry (§7 multi-repo ownership): the READ side. The claim
/// write path registers projects via `upsert_project_tx` inside the apply
/// transaction, not through this repo.
pub projects: Arc<dyn crate::project_repo::ProjectRepository>,
/// Module resolver (Phase 4b): lexical, origin-aware module lookup used by
/// the single-node dangling-import plan check (§5.5).
pub modules: Arc<dyn picloud_shared::ModuleSource>,
/// App domain claims — validates a route's host belongs to the app.
pub domains: Arc<dyn AppDomainRepository>,
pub authz: Arc<dyn AuthzRepo>,
pub validator: Arc<dyn ScriptValidator>,
pub sandbox_ceiling: SandboxCeiling,
/// Default retry/dispatch settings filled when a bundle trigger omits them.
pub trigger_config: TriggerConfig,
/// Shared route snapshot, rebuilt once after a successful apply.
pub route_table: Arc<RouteTable>,
/// Master key — seals email triggers' resolved inbound secrets.
pub master_key: MasterKey,
}
impl ApplyService {
/// Compute the diff between `bundle` and app `app_id`'s live state.
/// Read-only: validates the bundle, loads current state, diffs.
///
/// # Errors
/// `Invalid` for a malformed bundle; `Backend` for repo failures.
pub async fn plan(
&self,
app_id: AppId,
bundle: &Bundle,
project: Option<&ProjectDecl>,
) -> Result<PlanResult, ApplyError> {
self.plan_owner(ApplyOwner::App(app_id), bundle, project)
.await
}
/// The sorted names of every gated env in a computed policy (`confirm`
/// entries), surfaced as `plan.approvals_required`. Callers pass the
/// governing policy so plan's preview matches what apply gates on.
fn gated_envs_from(policy: &std::collections::BTreeMap<String, bool>) -> Vec<String> {
let mut gated: Vec<String> = policy
.iter()
.filter(|&(_, confirm)| *confirm)
.map(|(env, _)| env.clone())
.collect();
gated.sort_unstable();
gated
}
/// Owner-generic plan (Phase 5): diff `bundle` against an app OR group node.
/// A group node has only scripts + vars — routes/triggers are rejected, and
/// the app-only inherited-target + route-host validation is skipped.
///
/// # Errors
/// `Invalid` for a malformed bundle; `Backend` for repo failures.
pub async fn plan_owner(
&self,
owner: ApplyOwner,
bundle: &Bundle,
project: Option<&ProjectDecl>,
) -> Result<PlanResult, ApplyError> {
let inherited = self.resolve_inherited_targets_for(owner, bundle).await?;
self.validate_bundle_for(
matches!(owner, ApplyOwner::Group(_)),
bundle,
&keys_set(&inherited),
)?;
self.check_imports_resolve(owner, bundle).await?;
self.check_extension_points_provided(owner, bundle).await?;
if let ApplyOwner::App(app_id) = owner {
self.validate_route_hosts(app_id, bundle).await?;
}
// §6/§7 M3: preview the attach-point ceiling (same rule as apply) so a
// `pic plan` outside the subtree fails early, consistent with apply.
if let Some(pg_slug) = project.and_then(|p| p.parent_group.as_deref()) {
let pg = self.resolve_group(pg_slug).await?;
self.check_within_attach(owner, pg, pg_slug).await?;
}
let current = self.load_current(owner).await?;
let current_names = self.resolve_current_target_names(&current).await?;
validate_email_secrets_present(bundle, &current.secret_names)?;
let ownership = self
.ownership_preview(owner, project.map(|p| p.slug.as_str()))
.await?;
Ok(PlanResult {
plan: compute_diff_with_names(&current, bundle, &current_names),
// Fingerprint of the live state this plan was computed against, so
// `apply` can refuse if the node changed underneath it (§4.2).
state_token: state_token_with_names(&current, &current_names),
ownership,
approvals_required: Self::gated_envs_from(
&self.governing_env_policy(owner, project).await?,
),
warnings: self.plan_warnings(owner, bundle).await?,
})
}
/// Inherited group-script targets for an app node; a group node has no
/// routes/triggers to bind, so there is nothing to inherit (empty map).
async fn resolve_inherited_targets_for(
&self,
owner: ApplyOwner,
bundle: &Bundle,
) -> Result<HashMap<String, ScriptId>, ApplyError> {
match owner {
ApplyOwner::App(app_id) => self.resolve_inherited_targets(app_id, bundle).await,
// §11 tail: a group trigger or ROUTE TEMPLATE binds the group's OWN
// endpoint scripts (declared here or pre-existing). Surface the ones
// a template references but the bundle doesn't declare, so
// `validate_bundle` accepts them (the reconcile's name_to_id already
// includes the group's current scripts).
ApplyOwner::Group(group_id) => {
let declared: HashSet<String> = bundle
.scripts
.iter()
.map(|s| s.name.to_lowercase())
.collect();
let referenced: HashSet<String> = bundle
.triggers
.iter()
.map(|t| t.script().to_lowercase())
.chain(bundle.routes.iter().map(|r| r.script.to_lowercase()))
.collect();
let by_name: HashMap<String, ScriptId> = self
.scripts
.list_for_group(group_id)
.await
.map_err(map_repo)?
.into_iter()
.filter(|s| s.kind == ScriptKind::Endpoint)
.map(|s| (s.name.to_lowercase(), s.id))
.collect();
let mut out = HashMap::new();
for name in referenced {
if declared.contains(&name) {
continue;
}
if let Some(id) = by_name.get(&name) {
out.insert(name, *id);
}
}
Ok(out)
}
}
}
/// `validate_bundle`, plus a group-node guard: a group owns only scripts +
/// vars (+ declared secret names), so a `[group]` manifest carrying routes
/// or triggers is a 422 — those are app concerns.
#[allow(clippy::too_many_lines)]
fn validate_bundle_for(
&self,
is_group: bool,
bundle: &Bundle,
inherited_endpoints: &HashSet<String>,
) -> Result<(), ApplyError> {
if is_group {
// §11 tail: a group MAY declare ROUTE TEMPLATES (validated as binding
// a group-owned endpoint by `validate_bundle` via the inherited
// targets) and TRIGGER TEMPLATES. The stateless EVENT kinds
// (kv/docs/files/pubsub) resolve live via the chain union at
// dispatch; §4.5 M5 the stateful CRON + QUEUE + EMAIL kinds are
// allowed and MATERIALIZED into a per-descendant-app row. An email
// template resolves its inbound secret against the GROUP's own secret
// store once at apply (shared-group-secret model); materialization
// copies the sealed bytes verbatim.
for t in &bundle.triggers {
// §11.6: a `shared` trigger watches a SHARED collection/namespace.
// kv/docs/files watch a shared collection (name = the glob); D2
// pubsub watches a shared TOPIC namespace (name = the topic
// pattern's ROOT segment, e.g. `events.*` → `events`). The watched
// name must be declared shared of the matching kind on the SAME
// group; a wildcard can't be statically resolved, so it's allowed
// (runtime match).
if t.shared() {
let (watched, coll_kind) = match t {
BundleTrigger::Kv { .. }
| BundleTrigger::Docs { .. }
| BundleTrigger::Files { .. } => {
(t.collection_glob().unwrap_or_default(), t.kind_str())
}
BundleTrigger::Pubsub { topic_pattern, .. } => {
(topic_pattern.split('.').next().unwrap_or(""), "topic")
}
BundleTrigger::Queue { queue_name, .. } => (queue_name.as_str(), "queue"),
// §11.6 B2: a shared dead_letter watches the group's
// shared queue exhaustion, not a named collection store —
// no declared-collection requirement. The group/shared
// gate for it lives below.
BundleTrigger::DeadLetter { .. } => continue,
_ => {
return Err(ApplyError::Invalid(format!(
"a `shared` trigger must be a kv/docs/files/pubsub/queue kind; \
`{}` has no shared collection store",
t.kind_str()
)));
}
};
let is_wildcard = watched.contains('*');
let declared = bundle
.collections
.iter()
.any(|c| c.kind == coll_kind && c.name.eq_ignore_ascii_case(watched));
if !is_wildcard && !declared {
return Err(ApplyError::Invalid(format!(
"a `shared` trigger watches `{watched}`, which this group \
does not declare as a shared {coll_kind} collection (add \
it to `collections`)"
)));
}
}
// §11.6 B2: a group `dead_letter` template is only meaningful as
// `shared = true` — it fires on the group's SHARED queue
// exhaustion. A non-shared group dead_letter would never fire (no
// per-app queue to watch at the group level), so reject it.
if matches!(t, BundleTrigger::DeadLetter { .. }) && !t.shared() {
return Err(ApplyError::Invalid(
"a group `dead_letter` trigger must be `shared = true` — it \
fires on the group's shared-queue exhaustion"
.into(),
));
}
}
}
// §11.6: shared collections are owned by GROUPS. Reject them on an app
// node — the inverse of the group route/trigger guard above. The CLI
// already prevents this (`ManifestApp` has no `collections` field), so
// this is defense-in-depth against a hand-rolled wire `Bundle`; without
// it the reconcile would insert an inert `app_id`-owned marker row that
// no resolver ever reads.
if !is_group && !bundle.collections.is_empty() {
return Err(app_only_reject(
"manifest cannot declare shared collections",
"they are owned by a group",
));
}
// §11 tail: `sealed` is a GROUP-template property — it makes an
// inherited template non-suppressible. On an app-owned route/trigger it
// is meaningless (nothing inherits it), so reject it rather than persist
// a misleading `sealed` app row the filters never consult. Defense in
// depth over the CLI (which authors `sealed` freely but only a group
// node inherits down).
if !is_group {
if bundle.routes.iter().any(|r| r.sealed) {
return Err(app_only_reject(
"route cannot be `sealed`",
"sealing marks a group template as non-suppressible; an app \
route is never inherited",
));
}
if bundle.triggers.iter().any(BundleTrigger::sealed) {
return Err(app_only_reject(
"trigger cannot be `sealed`",
"sealing marks a group template as non-suppressible; an app \
trigger is never inherited",
));
}
// §11.6: `shared` watches a group's SHARED collection — meaningless
// for an app (apps don't own shared collections).
if bundle.triggers.iter().any(BundleTrigger::shared) {
return Err(app_only_reject(
"trigger cannot be `shared`",
"a shared-collection trigger is owned by the group that \
declares the collection",
));
}
// §11.6 B2: a `dead_letter` trigger is authored only as a group-owned
// shared template (it watches the group's shared-queue exhaustion).
// An app-owned dead_letter handler is created via `pic triggers`, not
// the declarative manifest.
if bundle
.triggers
.iter()
.any(|t| matches!(t, BundleTrigger::DeadLetter { .. }))
{
return Err(app_only_reject(
"trigger cannot be a `dead_letter` kind",
"a declarative dead_letter is a group-owned shared template; \
create an app dead-letter handler with `pic triggers`",
));
}
}
// §11 tail M1: both an app and a group may declare suppressions — an
// app declines an inherited template for itself, a group for its whole
// subtree. Both are inheritance-only (the dispatch filters gate to
// group-owned templates on the suppressing owner's chain), so no
// owner-kind guard here.
//
// v1.2 Workflows: app-owned only (M1). Reject on a group node (the
// `group_id` column ships unused). Then structurally validate each
// definition (unique/acyclic steps, deps exist, when/template parse).
if is_group && !bundle.workflows.is_empty() {
return Err(ApplyError::Invalid(
"a group cannot declare workflows — they are app-owned (group-owned \
workflow templates are a future addition)"
.into(),
));
}
// Duplicate workflow names collide in the reconcile diff (keyed by
// `lower(name)`), silently dropping all but one. Reject them up front —
// case-insensitively, matching the diff key and the DB's partial-unique
// index on `(app_id, lower(name))`.
let mut seen_workflows: HashSet<String> = HashSet::new();
for w in &bundle.workflows {
if !seen_workflows.insert(w.name.to_lowercase()) {
return Err(ApplyError::Invalid(format!(
"duplicate workflow name `{}` (names are case-insensitive)",
w.name
)));
}
}
for w in &bundle.workflows {
validate_workflow_definition(&w.name, &w.definition).map_err(ApplyError::Invalid)?;
}
// §9.4 interceptors (MVP): `service = "kv"`, `op ∈ {set, delete}`,
// authored on app OR group. One marker per `(service, op)` — a duplicate
// would collide on the reconcile key and the DB's partial-unique index.
let mut seen_hooks: HashSet<(String, String)> = HashSet::new();
for i in &bundle.interceptors {
if i.service != "kv" {
return Err(ApplyError::Invalid(format!(
"interceptor service `{}` is not supported — only `kv` (MVP)",
i.service
)));
}
if i.op != "set" && i.op != "delete" {
return Err(ApplyError::Invalid(format!(
"interceptor op `{}` is not supported for kv — only `set` / `delete`",
i.op
)));
}
if i.script.trim().is_empty() {
return Err(ApplyError::Invalid(
"an interceptor must name a `script`".into(),
));
}
if !seen_hooks.insert((i.service.clone(), i.op.clone())) {
return Err(ApplyError::Invalid(format!(
"duplicate interceptor for `{}/{}`",
i.service, i.op
)));
}
}
self.validate_bundle(bundle, inherited_endpoints)
}
/// Reconcile app `app_id` to `bundle` in a single transaction. Creates
/// and updates are always applied; deletions of resources absent from the
/// bundle are executed only when `prune` is set (secrets are never pruned).
///
/// # Errors
/// `Invalid` for a bad bundle; `Backend` for repo/transaction failures.
/// Reconcile ONE node's scripts/routes/triggers/vars inside an existing
/// transaction (Phase 5). Returns the node's final `name -> script_id` map
/// (current + created), which the tree apply records so a later app node
/// can bind a route to a group script created earlier in the SAME tx. The
/// single-node `apply_owner` and the multi-node tree apply both call this.
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
async fn reconcile_node_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
owner: ApplyOwner,
bundle: &Bundle,
plan: &Plan,
current: &CurrentState,
current_names: &HashMap<ScriptId, String>,
inherited: &HashMap<String, ScriptId>,
prune: bool,
actor: AdminUserId,
report: &mut ApplyReport,
) -> Result<HashMap<String, ScriptId>, ApplyError> {
let bundle_scripts: HashMap<String, &BundleScript> = bundle
.scripts
.iter()
.map(|s| (s.name.to_lowercase(), s))
.collect();
let current_scripts: HashMap<String, &Script> = current
.scripts
.iter()
.map(|s| (s.name.to_lowercase(), s))
.collect();
// name -> id for resolving route/trigger targets (existing + created).
let mut name_to_id: HashMap<String, ScriptId> = current
.scripts
.iter()
.map(|s| (s.name.to_lowercase(), s.id))
.collect();
// 1. Scripts — create + update first so routes/triggers resolve.
for ch in &plan.scripts {
let key = ch.key.to_lowercase();
match ch.op {
Op::Create => {
let bs = bundle_scripts[&key];
let (script_app_id, script_group_id) = owner.script_owner();
let new = NewScript {
app_id: script_app_id,
group_id: script_group_id,
name: bs.name.clone(),
description: bs.description.clone(),
source: bs.source.clone(),
kind: bs.kind,
timeout_seconds: bs.timeout_seconds,
memory_limit_mb: bs.memory_limit_mb,
sandbox: bs.sandbox,
enabled: bs.enabled,
imports: self.script_imports(bs)?,
};
let created = insert_script_tx(&mut *tx, &new).await.map_err(map_repo)?;
name_to_id.insert(created.name.to_lowercase(), created.id);
report.scripts_created += 1;
}
Op::Update => {
let bs = bundle_scripts[&key];
let cur = current_scripts[&key];
let patch = ScriptPatch {
name: None,
// Sparse: `None` (omitted in the manifest) leaves the
// stored description untouched; `Some(text)` sets it.
// Mirrors `script_update_reason`'s leave-as-is rule.
description: bs.description.clone().map(Some),
source: Some(bs.source.clone()),
timeout_seconds: bs.timeout_seconds,
memory_limit_mb: bs.memory_limit_mb,
sandbox: bs.sandbox,
kind: Some(bs.kind),
// Declarative: always reconcile to the manifest's value.
enabled: Some(bs.enabled),
imports: Some(self.script_imports(bs)?),
};
update_script_tx(&mut *tx, cur.id, &patch)
.await
.map_err(map_repo)?;
report.scripts_updated += 1;
}
Op::NoOp | Op::Delete => {}
}
}
// Phase 4: route/trigger targets the manifest doesn't define as app-own
// scripts may bind to inherited group endpoints. `or_insert` keeps
// app-own precedence (those names aren't in `inherited` by
// construction, but be defensive about CoW).
for (name, id) in inherited {
name_to_id.entry(name.clone()).or_insert(*id);
}
// 2. Routes — identity is the (method, host, path) tuple; an
// "update" (rebind / dispatch change) is delete + insert.
let current_routes: HashMap<String, &Route> = current
.routes
.iter()
.map(|r| {
(
route_key(
r.method.as_deref(),
r.host_kind,
&r.host,
r.path_kind,
&r.path,
),
r,
)
})
.collect();
let bundle_routes: HashMap<String, &BundleRoute> = bundle
.routes
.iter()
.map(|r| {
(
route_key(
r.method.as_deref(),
r.host_kind,
&r.host,
r.path_kind,
&r.path,
),
r,
)
})
.collect();
// 2. Routes — delete before insert so a binding freed by an update's
// delete-half or a prune removal can be reused by a create in the
// SAME apply without tripping the per-app UNIQUE binding index.
// Phase A — deletes: update replacements always; removals when pruning.
for ch in &plan.routes {
match ch.op {
Op::Update => {
let cur = current_routes[&ch.key];
delete_route_tx(&mut *tx, cur.id).await.map_err(map_repo)?;
}
Op::Delete if prune => {
if let Some(cur) = current_routes.get(&ch.key) {
delete_route_tx(&mut *tx, cur.id).await.map_err(map_repo)?;
report.routes_deleted += 1;
}
}
_ => {}
}
}
// Phase B — inserts: creates and update replacements.
for ch in &plan.routes {
let is_update = match ch.op {
Op::Create => false,
Op::Update => true,
_ => continue,
};
let br = bundle_routes[&ch.key];
let sid = resolve_script(&name_to_id, &br.script)?;
// §11 tail: an app node writes an app-owned route; a group node
// writes a route TEMPLATE (group_id owner) that expands into every
// descendant app's slice at rebuild.
insert_bundle_route(&mut *tx, owner.as_script_owner(), sid, br).await?;
if is_update {
report.routes_updated += 1;
} else {
report.routes_created += 1;
}
}
// 3. Triggers — semantic identity captures the whole definition,
// so the only op is Create (a change is delete-old + create-new;
// the stale one is removed by prune). Additive: create only.
let bundle_triggers: HashMap<String, &BundleTrigger> =
bundle.triggers.iter().map(|t| (t.identity(), t)).collect();
for ch in &plan.triggers {
if ch.op != Op::Create {
continue;
}
// §11 tail: an app node owns triggers; a group node owns trigger
// TEMPLATES (event kinds only — validated in `validate_bundle_for`).
let bt = bundle_triggers[&ch.key];
let sid = resolve_script(&name_to_id, bt.script())?;
if let BundleTrigger::Email {
inbound_secret_ref, ..
} = bt
{
// Resolve the referenced secret against the OWNER's store,
// decrypt it, and re-seal it into the email trigger's detail
// row — the value never travels in the manifest. §4.5 M5: an
// app trigger resolves the app's secret; a group EMAIL TEMPLATE
// resolves the group's own secret once here, and materialization
// copies the sealed bytes verbatim to each descendant.
let (ct, nonce, version) = self
.resolve_and_seal(owner.secret_owner(), inbound_secret_ref)
.await?;
insert_email_trigger_tx(
&mut *tx,
owner.as_script_owner(),
sid,
actor,
&ct,
&nonce,
version,
)
.await
.map_err(map_trig)?;
} else {
let (dispatch, retry_max, backoff, base) = self.trigger_settings(bt);
let details = bundle_trigger_details(
bt,
self.trigger_config.queue_default_visibility_timeout_secs,
);
insert_trigger_tx(
&mut *tx,
owner.as_script_owner(),
sid,
actor,
dispatch,
retry_max,
backoff,
base,
// §11 tail: a sealed group template is non-suppressible
// (rejected on an app owner in `validate_bundle_for`).
bt.sealed(),
// §11.6: a shared-collection group trigger.
bt.shared(),
&details,
)
.await
.map_err(map_trig)?;
}
report.triggers_created += 1;
}
// 3b. Vars — upsert every Create/Update at app scope `*`, in-tx so a
// later failure rolls them back with everything else.
for ch in &plan.vars {
match ch.op {
Op::Create => {
owner
.set_var_tx(&mut *tx, &ch.key, &bundle.vars[&ch.key])
.await?;
report.vars_created += 1;
}
Op::Update => {
owner
.set_var_tx(&mut *tx, &ch.key, &bundle.vars[&ch.key])
.await?;
report.vars_updated += 1;
}
Op::NoOp | Op::Delete => {}
}
}
// 3c. Extension-point markers (§5.5) — insert each Create (idempotent).
// Name-only identity, so there is no Update; deletes happen in prune.
for ch in &plan.extension_points {
if ch.op == Op::Create {
crate::extension_point_repo::insert_extension_point_tx(
&mut *tx,
owner.as_script_owner(),
&ch.key,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.extension_points_created += 1;
}
}
// 3c.5. §9.4 interceptor markers — upsert each Create/Update by
// `(service, op)`. A changed script is an in-place Update; deletes happen
// in prune.
for ch in &plan.interceptors {
if ch.op == Op::Create || ch.op == Op::Update {
let bi = bundle
.interceptors
.iter()
.find(|i| format!("{}/{}", i.service, i.op) == ch.key)
.ok_or_else(|| {
ApplyError::Backend("internal: interceptor plan/bundle mismatch".into())
})?;
crate::interceptor_repo::insert_interceptor_tx(
&mut *tx,
owner.as_script_owner(),
&bi.service,
&bi.op,
&bi.script,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
if ch.op == Op::Create {
report.interceptors_created += 1;
} else {
report.interceptors_updated += 1;
}
}
}
// 3d. Shared group-collection markers (§11.6) — insert each Create
// (idempotent). Name-only identity; deletes happen in prune. Pruning a
// marker hides the store but does NOT drop its `group_kv_entries` data
// (that dies only with the owning group).
for ch in &plan.collections {
if ch.op == Op::Create {
let kind = ch.detail.as_deref().unwrap_or("kv");
crate::group_collection_repo::insert_collection_tx(
&mut *tx,
owner.as_script_owner(),
&ch.key,
kind,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.collections_created += 1;
}
}
// 3e. Per-app suppression markers (§11 tail) — insert each Create
// (idempotent). Owner-polymorphic (§11 tail M1): an app declines for
// itself, a group for its whole subtree. Deletes happen in prune → the
// template re-inherits. Key is `"{target_kind}:{reference}"`; split on
// the FIRST `:` so a route reference containing `:` (a param path
// `/users/:id`) survives.
for ch in &plan.suppressions {
if ch.op == Op::Create {
let (kind, reference) = ch.key.split_once(':').expect("suppression key has ':'");
crate::suppression_repo::insert_suppression_tx(
&mut *tx,
owner.as_script_owner(),
kind,
reference,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.suppressions_created += 1;
}
}
// 4. Prune (only with --prune): delete stale triggers, then scripts
// (route removals already happened in the route delete-pass above).
// Secret pruning is deliberately deferred (destructive + irreversible):
// stale secrets are surfaced in the plan but never deleted here.
if prune {
// Include inherited group-script bindings (Phase 4) so a trigger
// bound to a group script resolves its identity and is pruned when
// dropped from the manifest — not silently orphaned.
let id_to_name: HashMap<ScriptId, String> = current
.scripts
.iter()
.map(|s| (s.id, s.name.clone()))
.chain(current_names.iter().map(|(id, n)| (*id, n.clone())))
.collect();
let trigger_id_by_identity: HashMap<String, TriggerId> = current
.triggers
.iter()
.filter_map(|t| current_trigger_identity(t, &id_to_name).map(|i| (i, t.id)))
.collect();
for ch in &plan.triggers {
if ch.op == Op::Delete {
if let Some(id) = trigger_id_by_identity.get(&ch.key) {
delete_trigger_tx(&mut *tx, *id).await.map_err(map_trig)?;
report.triggers_deleted += 1;
}
}
}
// Scripts owning a trigger the manifest can't represent (email,
// dead_letter) must not be silently pruned: deleting the script
// would cascade-destroy that trigger (and its sealed secret) via
// the `triggers.script_id ON DELETE CASCADE` FK, defeating the
// "never prune email triggers" guarantee. Refuse and point the
// operator at the explicit removal path.
let protected: HashSet<ScriptId> = current
.triggers
.iter()
.filter(|t| {
matches!(
t.details,
TriggerDetails::Email { .. } | TriggerDetails::DeadLetter { .. }
)
})
.map(|t| t.script_id)
.collect();
let script_id_by_name: HashMap<String, ScriptId> = current
.scripts
.iter()
.map(|s| (s.name.to_lowercase(), s.id))
.collect();
for ch in &plan.scripts {
if ch.op == Op::Delete {
if let Some(id) = script_id_by_name.get(&ch.key.to_lowercase()) {
if protected.contains(id) {
return Err(ApplyError::Invalid(format!(
"cannot prune script `{}`: it still has an email or \
dead-letter trigger that the manifest can't represent; \
remove the trigger with `pic triggers rm` first",
ch.key
)));
}
delete_script_tx(&mut *tx, *id).await.map_err(map_repo)?;
report.scripts_deleted += 1;
}
}
}
// Vars are prunable config (unlike secrets, which are never
// pruned): a live app var the manifest dropped is deleted.
for ch in &plan.vars {
if ch.op == Op::Delete {
owner.delete_var_tx(&mut *tx, &ch.key).await?;
report.vars_deleted += 1;
}
}
// Extension-point markers are prunable config too (§5.5).
for ch in &plan.extension_points {
if ch.op == Op::Delete {
crate::extension_point_repo::delete_extension_point_tx(
&mut *tx,
owner.as_script_owner(),
&ch.key,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.extension_points_deleted += 1;
}
}
// §9.4 interceptor markers are prunable config too. The delete keys
// by `(service, op)` (the marker's identity), so a script-change
// Update above is never clobbered here.
for ch in &plan.interceptors {
if ch.op == Op::Delete {
let (service, op) = ch.key.split_once('/').unwrap_or((ch.key.as_str(), ""));
crate::interceptor_repo::delete_interceptor_tx(
&mut *tx,
owner.as_script_owner(),
service,
op,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.interceptors_deleted += 1;
}
}
// Shared group-collection markers are prunable config too (§11.6).
// Only the marker is removed here — the data survives until the
// owning group is deleted.
for ch in &plan.collections {
if ch.op == Op::Delete {
let kind = ch.detail.as_deref().unwrap_or("kv");
crate::group_collection_repo::delete_collection_tx(
&mut *tx,
owner.as_script_owner(),
&ch.key,
kind,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.collections_deleted += 1;
}
}
// Suppression markers are prunable config too (§11 tail).
// Removing a marker re-inherits the template.
for ch in &plan.suppressions {
if ch.op == Op::Delete {
let (kind, reference) =
ch.key.split_once(':').expect("suppression key has ':'");
crate::suppression_repo::delete_suppression_tx(
&mut *tx,
owner.as_script_owner(),
kind,
reference,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.suppressions_deleted += 1;
}
}
}
// v1.2 Workflows (app-owned in M1). Create/Update always; Delete only
// under --prune (mirrors scripts). Function / sub-workflow references
// are resolved at RUN time, so there is no in-tx ordering dependency on
// the scripts applied above.
if let ApplyOwner::App(app_id) = owner {
let cur_by_name: HashMap<String, &crate::workflow_repo::Workflow> = current
.workflows
.iter()
.map(|w| (w.name.to_lowercase(), w))
.collect();
let desired_by_name: HashMap<String, &BundleWorkflow> = bundle
.workflows
.iter()
.map(|w| (w.name.to_lowercase(), w))
.collect();
for ch in &plan.workflows {
let key = ch.key.to_lowercase();
match ch.op {
Op::Create => {
let w = desired_by_name[&key];
crate::workflow_repo::insert_workflow_tx(
&mut *tx,
app_id,
&w.name,
&w.definition,
w.enabled,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.workflows_created += 1;
}
Op::Update => {
let w = desired_by_name[&key];
let cur = cur_by_name[&key];
crate::workflow_repo::update_workflow_tx(
&mut *tx,
cur.id,
&w.definition,
w.enabled,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.workflows_updated += 1;
}
Op::Delete if prune => {
let cur = cur_by_name[&key];
crate::workflow_repo::delete_workflow_tx(&mut *tx, cur.id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.workflows_deleted += 1;
}
Op::NoOp | Op::Delete => {}
}
}
}
Ok(name_to_id)
}
pub async fn apply(
&self,
app_id: AppId,
bundle: &Bundle,
prune: bool,
claim: &OwnershipClaim<'_>,
expected_token: Option<&str>,
) -> Result<ApplyReport, ApplyError> {
self.apply_owner(
ApplyOwner::App(app_id),
bundle,
prune,
claim,
expected_token,
)
.await
}
/// Owner-generic apply (Phase 5): reconcile an app OR group node to `bundle`
/// in a single transaction. A group node reconciles only its scripts + vars.
///
/// # Errors
/// `Invalid` for a bad bundle; `Backend` for repo/transaction failures.
#[allow(clippy::too_many_lines)]
pub async fn apply_owner(
&self,
owner: ApplyOwner,
bundle: &Bundle,
prune: bool,
claim: &OwnershipClaim<'_>,
expected_token: Option<&str>,
) -> Result<ApplyReport, ApplyError> {
let actor = claim.principal.user_id;
let inherited = self.resolve_inherited_targets_for(owner, bundle).await?;
self.validate_bundle_for(
matches!(owner, ApplyOwner::Group(_)),
bundle,
&keys_set(&inherited),
)?;
self.check_imports_resolve(owner, bundle).await?;
self.check_extension_points_provided(owner, bundle).await?;
if let ApplyOwner::App(app_id) = owner {
self.validate_route_hosts(app_id, bundle).await?;
}
// §6/§7 attach point: refuse a node outside the project's declared
// `parent_group` subtree (read-only, before the tx).
if let Some(pg_slug) = claim
.project
.as_ref()
.and_then(|d| d.parent_group.as_deref())
{
let pg = self.resolve_group(pg_slug).await?;
self.check_within_attach(owner, pg, pg_slug).await?;
}
let mut tx = self
.pool
.begin()
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
// Serialize concurrent applies to the same app. NOTE: this lock
// serializes apply-vs-apply only — `load_current` below reads on the
// pool (a separate connection), so an *interactive* admin write
// committing between the read and our writes can surface as a
// unique-constraint conflict (clean tx rollback, no corruption) or a
// stale no-op delete. Reading current state through `&mut *tx` is the
// proper fix and is left as a follow-up. (The queue one-consumer
// invariant is closed independently in `insert_trigger_tx`.)
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(apply_lock_key(owner))
.execute(&mut *tx)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
// §7 ownership: register the declared project (first apply claims), then
// gate this node. A group node claims/verifies itself; an app node must
// match its nearest claimed ancestor. Runs under the lock above so two
// repos racing to claim the same group serialize. Short-circuits before
// any diff work on a conflict (409).
let declared_project = match &claim.project {
Some(decl) => Some(upsert_project_tx(&mut tx, decl, actor).await?),
None => None,
};
match owner {
ApplyOwner::Group(gid) => {
self.claim_group_owner(
&mut tx,
gid,
declared_project,
claim.takeover,
claim.principal,
)
.await?;
}
ApplyOwner::App(app_id) => {
self.check_app_owner_single(&mut tx, app_id, declared_project)
.await?;
}
}
let current = self.load_current(owner).await?;
// Phase 4: names of group scripts the current routes/triggers bind to,
// so the token / diff / prune resolve inherited bindings (see method).
let current_names = self.resolve_current_target_names(&current).await?;
// Bound-plan check (§4.2): if the caller passed the token from a prior
// `pic plan`, refuse when the live state changed since. Computed from
// the same `load_current` snapshot the diff uses, before any mutation
// (so a stale apply rolls back nothing). NOTE: like the diff, this read
// is on the pool, not `&mut *tx` (see the lock comment above), so it
// catches drift between plan and apply but shares that same narrow
// apply-vs-interactive-write window — it is not a substitute for the
// tx-scoped read the follow-up will add.
if let Some(expected) = expected_token {
if state_token_with_names(&current, &current_names) != expected {
return Err(ApplyError::StateMoved);
}
}
// Surface a missing email-secret reference here so `plan` and `apply`
// agree, rather than only failing deep in `resolve_and_seal` below.
validate_email_secrets_present(bundle, &current.secret_names)?;
let plan = compute_diff_with_names(&current, bundle, &current_names);
let mut report = ApplyReport::default();
// The desired-state warnings — identical to what `pic plan` previews, so
// plan and apply agree (see `plan_warnings`).
report
.warnings
.extend(self.plan_warnings(owner, bundle).await?);
self.reconcile_node_tx(
&mut tx,
owner,
bundle,
&plan,
&current,
&current_names,
&inherited,
prune,
actor,
&mut report,
)
.await?;
tx.commit()
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
// Post-commit: rebuild the orchestrator's route snapshot once. A
// failure here doesn't undo the committed DB write — the table
// self-heals on the next route write or restart. §11 tail: a GROUP node
// may now declare route TEMPLATES, so refresh on either owner (the
// rebuild expands templates into every descendant app's slice).
{
if let Err(e) = self.refresh_route_table().await {
tracing::warn!(error = %e, "apply: route table refresh failed");
report
.warnings
.push("route table refresh failed; it will self-heal".into());
}
}
// §4.5 M5: re-materialize stateful (cron/queue) group templates into
// per-descendant app rows. Post-commit + best-effort like the route
// rebuild; per-app skips (e.g. a queue slot already taken) surface as
// warnings.
report.warnings.extend(self.rematerialize().await);
Ok(report)
}
// ------------------------------------------------------------------------
// Tree apply (Phase 5): a project subtree reconciled in ONE transaction.
// ------------------------------------------------------------------------
/// Diff a whole project subtree, read-only. Returns a per-node plan + one
/// combined bound-plan token.
///
/// # Errors
/// `Invalid` for a malformed/unknown bundle; `Backend` for repo failures.
pub async fn plan_tree(
&self,
bundle: &TreeBundle,
project: Option<&ProjectDecl>,
) -> Result<TreePlanResult, ApplyError> {
let existing = self.load_existing_groups(bundle).await?;
let resolved: HashMap<String, GroupId> =
existing.iter().map(|(k, g)| (k.clone(), g.id)).collect();
let (prepared, to_create, token) = self.prepare_tree(bundle, &resolved, project).await?;
// §6/§7 M3: attach-point ceiling preview (consistent with apply_tree).
if let Some(pg_slug) = project.and_then(|p| p.parent_group.as_deref()) {
let pg = self.resolve_group(pg_slug).await?;
for p in &prepared {
self.check_within_attach(p.owner, pg, pg_slug).await?;
}
}
let declared = project.map(|p| p.slug.as_str());
let mut nodes = Vec::with_capacity(prepared.len() + to_create.len());
for p in prepared {
let ownership = self.ownership_preview(p.owner, declared).await?;
// §6 M2: for an existing group node, preview a structural divergence
// (server parent ≠ manifest parent) before apply.
let structure = self.divergence_preview(p.node, &existing).await?;
let warnings = self.plan_warnings(p.owner, &p.node.bundle).await?;
nodes.push(NodePlan {
kind: p.node.kind,
slug: p.node.slug.clone(),
plan: p.plan,
ownership,
structure,
warnings,
});
}
// §6: a to-create group is previewed with a full-create plan; if the
// repo declares a `[project]`, the create will also CLAIM it. It has no
// node id yet (no chain to walk), so only the pure desired-state warnings
// apply — a disabled binding; unreachable/suppress need an existing owner.
for c in to_create {
let ownership = declared.map(|d| preview_ownership(true, None, Some(d)));
nodes.push(NodePlan {
kind: c.node.kind,
slug: c.node.slug.clone(),
plan: c.plan,
ownership,
structure: None,
warnings: disabled_target_warnings(&c.node.bundle),
});
}
Ok(TreePlanResult {
nodes,
state_token: token,
approvals_required: Self::gated_envs_from(
&self.governing_env_policy_tree(bundle, project).await?,
),
})
}
/// Reconcile a whole project subtree in a single transaction (all-or-
/// nothing). Groups reconcile before apps, so an app route/trigger can bind
/// a group script created earlier in the SAME transaction (resolved via the
/// in-memory `group_script_index`, since uncommitted rows aren't visible to
/// the pool-based inherited resolver).
///
/// # Errors
/// `Invalid` for a bad bundle; `StateMoved` if the bound token is stale;
/// `OwnershipConflict` (§7) if a node is owned by another project;
/// `Backend` for repo/transaction failures.
#[allow(clippy::too_many_lines)]
pub async fn apply_tree(
&self,
bundle: &TreeBundle,
prune: bool,
claim: &OwnershipClaim<'_>,
expected_token: Option<&str>,
structure_mode: StructureMode,
) -> Result<ApplyReport, ApplyError> {
let actor = claim.principal.user_id;
let mut tx = self
.pool
.begin()
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
// §6 Phase 0: reconcile the group tree SHAPE — create every group the
// manifest declares (by directory nesting) but the server lacks, and
// (M2) reparent a diverged group per `structure_mode` — IN this tx
// (parents-first), returning the complete slug → id map and the set of
// structurally-changed groups. Enforces the attach ceiling + RBAC per
// mutation and takes the coarse structural lock only when it mutates.
// With all groups now resolvable, `prepare_tree` sees the whole tree
// (created groups load an empty current → a full-create plan), so its
// to-create list is empty.
let (resolved, structurally_changed) = self
.reconcile_group_structure_tx(
&mut tx,
bundle,
claim.project.as_ref(),
claim.principal,
structure_mode,
)
.await?;
let (prepared, to_create, token) = self
.prepare_tree(bundle, &resolved, claim.project.as_ref())
.await?;
if !to_create.is_empty() {
return Err(ApplyError::Backend(
"internal: a group remained unresolved after create".into(),
));
}
if let Some(expected) = expected_token {
if token != expected {
return Err(ApplyError::StateMoved);
}
}
// §6/§7 attach point: every EXISTING node in the tree must be within the
// project's declared `parent_group` subtree. A group CREATED in Phase 0
// was already checked there (and its uncommitted row isn't visible to
// `check_within_attach`'s pool-based ancestor walk), so skip it.
if let Some(pg_slug) = claim
.project
.as_ref()
.and_then(|d| d.parent_group.as_deref())
{
let pg = self.resolve_group(pg_slug).await?;
for p in &prepared {
if let ApplyOwner::Group(gid) = p.owner {
if structurally_changed.contains(&gid) {
continue;
}
}
self.check_within_attach(p.owner, pg, pg_slug).await?;
}
}
// §6 content-write authz, in-tx defense-in-depth. `authz_tree` (API
// layer) SKIPS a group node absent at that time (it is to-create),
// deferring to the create-gate in `reconcile_group_structure_tx`. That
// gate only fires when the group is STILL absent at reconcile — so a
// group created by a racer in the window would otherwise have its
// content written with no authz. Re-check content-write caps here for
// every group NOT structurally created/reparented by this apply: a group
// WE created is covered by create-RBAC (`GroupAdmin(parent)` cascades)
// and its uncommitted row is invisible to the pool-based authz cascade
// anyway, so it must not be re-checked; a pre-existing (incl.
// racer-created) group IS committed, so its ancestry resolves and a
// missing cap denies. Runs before the write loops; a denial rolls back.
for p in &prepared {
if let ApplyOwner::Group(gid) = p.owner {
if !structurally_changed.contains(&gid) {
self.require_group_node_writes(claim.principal, gid, &p.node.bundle, prune)
.await?;
}
}
}
// Lock every node's apply key, in sorted order, so concurrent applies
// touching any shared node serialize and the ordering can't deadlock.
let mut lock_keys: Vec<i64> = prepared.iter().map(|p| apply_lock_key(p.owner)).collect();
lock_keys.sort_unstable();
lock_keys.dedup();
for k in lock_keys {
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(k)
.execute(&mut *tx)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
}
let mut report = ApplyReport::default();
let mut group_script_index: HashMap<GroupId, HashMap<String, ScriptId>> = HashMap::new();
// §7 ownership: one project for the whole tree; claim/verify every group
// node in Phase A (recording it in `claimed_in_tree`), then check every
// app in Phase B against its nearest claimed ancestor. A conflict aborts
// the whole transaction.
let declared_project = match &claim.project {
Some(decl) => Some(upsert_project_tx(&mut tx, decl, actor).await?),
None => None,
};
let declared_slug = claim.project.as_ref().map(|d| d.slug.clone());
let mut claimed_in_tree: HashMap<GroupId, (ProjectId, String)> = HashMap::new();
// Phase A — groups first: reconcile each and record its post-create
// `name -> id` so descendant app nodes can resolve inherited bindings.
for p in &prepared {
if let ApplyOwner::Group(gid) = p.owner {
self.claim_group_owner(
&mut tx,
gid,
declared_project,
claim.takeover,
claim.principal,
)
.await?;
if let (Some(pid), Some(slug)) = (declared_project, &declared_slug) {
claimed_in_tree.insert(gid, (pid, slug.clone()));
}
report
.warnings
.extend(disabled_target_warnings(&p.node.bundle));
let name_to_id = self
.reconcile_node_tx(
&mut tx,
p.owner,
&p.node.bundle,
&p.plan,
&p.current,
&p.current_names,
&HashMap::new(),
prune,
actor,
&mut report,
)
.await?;
group_script_index.insert(gid, name_to_id);
}
}
// Phase B — apps: resolve inherited targets against in-tree groups (the
// ids just created) and pre-existing ancestors, then reconcile.
for p in &prepared {
if let ApplyOwner::App(_) = p.owner {
// §7: an app must match its nearest claimed ancestor — an in-tree
// group (just claimed in Phase A) or a committed ancestor.
let nearest =
nearest_claimed_in_tx(&mut tx, &p.app_chain, &claimed_in_tree).await?;
decide_app_owner(nearest, declared_project)
.map_err(ApplyError::OwnershipConflict)?;
report
.warnings
.extend(disabled_target_warnings(&p.node.bundle));
report
.warnings
.extend(unreachable_endpoint_warnings(&p.node.bundle));
let inherited = self
.tree_inherited_for_app(&p.app_chain, &p.node.bundle, &group_script_index)
.await?;
self.reconcile_node_tx(
&mut tx,
p.owner,
&p.node.bundle,
&p.plan,
&p.current,
&p.current_names,
&inherited,
prune,
actor,
&mut report,
)
.await?;
}
}
tx.commit()
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
// One post-commit route refresh covers the whole tree. §11 tail: a
// group node may declare route TEMPLATES affecting descendant apps even
// when no app node is in this apply, so refresh unconditionally (a full
// rebuild; cheap relative to a tree apply).
if let Err(e) = self.refresh_route_table().await {
tracing::warn!(error = %e, "apply_tree: route table refresh failed");
report
.warnings
.push("route table refresh failed; it will self-heal".into());
}
// §4.5 M5: one post-commit materialization pass covers the whole tree.
report.warnings.extend(self.rematerialize().await);
Ok(report)
}
/// §6: load every EXISTING group node (lowercased slug → its full row) on
/// the pool, skipping ones that don't exist yet (to-create, previewed by the
/// plan path). Returns the whole `Group` so the divergence preview reads
/// `parent_id` (and resolves an in-tree parent's slug) without re-fetching.
async fn load_existing_groups(
&self,
bundle: &TreeBundle,
) -> Result<HashMap<String, picloud_shared::Group>, ApplyError> {
let mut map = HashMap::new();
for n in &bundle.nodes {
if n.kind == NodeKind::Group {
if let Some(g) = self
.groups
.get_by_slug(&n.slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
map.insert(n.slug.to_lowercase(), g);
}
}
}
Ok(map)
}
/// §6 M2: preview an EXISTING group node's structural divergence — `Some`
/// only when the server's parent differs from the manifest's declared
/// (directory-nesting) parent. `None` for an app node, a to-create group, or
/// an in-sync group. Reads the node's own row from the pre-loaded `existing`
/// map (no per-node `get_by_slug`), resolves the server parent's slug from
/// that map when the parent is another tree node (else one `get_by_id`), and
/// compares parent slugs **case-insensitively** so this preview agrees with
/// the apply path's id-based check (which lowercases) on a mixed-case slug.
async fn divergence_preview(
&self,
node: &TreeNode,
existing: &HashMap<String, picloud_shared::Group>,
) -> Result<Option<StructurePreview>, ApplyError> {
if node.kind != NodeKind::Group {
return Ok(None);
}
let Some(group) = existing.get(&node.slug.to_lowercase()) else {
return Ok(None); // to-create — not a divergence
};
let server_parent = match group.parent_id {
Some(pid) => match existing.values().find(|g| g.id == pid) {
Some(g) => Some(g.slug.clone()),
None => self
.groups
.get_by_id(pid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.map(|g| g.slug),
},
None => None,
};
let norm = |s: Option<&str>| s.map(str::to_lowercase);
if norm(server_parent.as_deref()) == norm(node.parent.as_deref()) {
return Ok(None); // in-sync
}
Ok(Some(StructurePreview {
status: "diverged".to_string(),
server_parent,
manifest_parent: node.parent.clone(),
}))
}
/// §6: resolve a group node's DECLARED (directory-nesting) parent slug to a
/// `GroupId` — an in-tree group resolved earlier this tree wins, else a
/// committed server group; `None` = instance root. Errors if a declared
/// parent slug resolves to nothing.
async fn resolve_declared_parent(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
map: &HashMap<String, GroupId>,
n: &TreeNode,
) -> Result<Option<GroupId>, ApplyError> {
let Some(pslug) = &n.parent else {
return Ok(None);
};
if let Some(&g) = map.get(&pslug.to_lowercase()) {
return Ok(Some(g));
}
crate::group_repo::read_group_id_by_slug_tx(tx, pslug)
.await
.map_err(map_group_repo_err)?
.map(Some)
.ok_or_else(|| {
ApplyError::Invalid(format!(
"group `{}` declares parent `{pslug}`, which does not exist — a parent \
group must be declared before its children in the tree (the CLI orders \
nodes parents-first by directory nesting)",
n.slug
))
})
}
/// §6: is `parent` within the attach-point subtree? `attach = None` (no
/// ceiling) → always true. A just-structurally-changed group in `ok` (its
/// own placement already validated within) short-circuits the ancestor walk.
async fn parent_within_attach(
&self,
parent: Option<GroupId>,
attach: Option<GroupId>,
ok: &HashSet<GroupId>,
) -> Result<bool, ApplyError> {
let Some(attach) = attach else {
return Ok(true);
};
Ok(match parent {
Some(p) if p == attach || ok.contains(&p) => true,
Some(p) => self
.groups
.ancestors(p)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.iter()
.any(|g| g.id == attach),
None => false,
})
}
/// §6 M1+M2: reconcile the group tree SHAPE inside the apply tx (parents-
/// first). A group the server lacks is CREATED (`create_group_node_tx`); a
/// group whose server parent differs from the manifest's declared parent is
/// a structural divergence, resolved per `mode` (`reparent_diverged_group_tx`
/// — refuse / reparent / adopt-server). Returns the complete slug → id map
/// (so `prepare_tree` resolves the whole tree) and the set of groups
/// STRUCTURALLY changed here (created or reparented) — the caller skips them
/// in the pool-based attach re-check (their uncommitted row isn't visible).
/// Enforces the attach ceiling + RBAC per mutation (in the helpers); the
/// coarse structural lock is taken only once, on the first mutation.
async fn reconcile_group_structure_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
bundle: &TreeBundle,
project: Option<&ProjectDecl>,
principal: &Principal,
mode: StructureMode,
) -> Result<(HashMap<String, GroupId>, HashSet<GroupId>), ApplyError> {
use crate::group_repo::{
read_group_id_by_slug_tx, read_group_node_tx, GROUP_STRUCTURAL_LOCK_KEY,
};
let attach_slug = project.and_then(|p| p.parent_group.as_deref());
let attach_gid = match attach_slug {
Some(slug) => Some(
read_group_id_by_slug_tx(tx, slug)
.await
.map_err(map_group_repo_err)?
.ok_or_else(|| {
ApplyError::Invalid(format!("attach point group `{slug}` does not exist"))
})?,
),
None => None,
};
let ctx = ReconcileCtx {
attach_gid,
attach_slug,
principal,
mode,
};
let mut map: HashMap<String, GroupId> = HashMap::new();
let mut changed: HashSet<GroupId> = HashSet::new();
let mut within_attach: HashSet<GroupId> = HashSet::new();
let mut locked = false;
for n in &bundle.nodes {
if n.kind != NodeKind::Group {
continue;
}
let key = n.slug.to_lowercase();
let mut existing = read_group_node_tx(tx, &n.slug)
.await
.map_err(map_group_repo_err)?;
let declared_parent = self.resolve_declared_parent(tx, &map, n).await?;
// A structural change (create or reparent) is needed if the group is
// missing, or exists with a divergent parent under ForceLocal.
let diverged = matches!(&existing, Some((_, sp)) if *sp != declared_parent);
let needs_mutation =
existing.is_none() || (diverged && mode == StructureMode::ForceLocal);
if needs_mutation && !locked {
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(GROUP_STRUCTURAL_LOCK_KEY)
.execute(&mut **tx)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
locked = true;
// Re-read under the lock — a racer may have created/moved it.
existing = read_group_node_tx(tx, &n.slug)
.await
.map_err(map_group_repo_err)?;
}
match existing {
None => {
let gid = self
.create_group_node_tx(tx, n, declared_parent, &ctx, &within_attach)
.await?;
changed.insert(gid);
if attach_gid.is_some() {
within_attach.insert(gid);
}
map.insert(key, gid);
}
Some((gid, server_parent)) => {
map.insert(key, gid);
if server_parent == declared_parent {
continue; // in-sync
}
if self
.reparent_diverged_group_tx(
tx,
n,
(gid, server_parent),
declared_parent,
&ctx,
&within_attach,
)
.await?
{
changed.insert(gid);
if attach_gid.is_some() {
within_attach.insert(gid);
}
}
}
}
}
Ok((map, changed))
}
/// §6: create one missing group node under its declared parent, attach-
/// ceiling + RBAC gated (a root group needs `InstanceCreateGroup`, a subgroup
/// `GroupAdmin(parent)`). Returns the new id; the caller records it in the
/// changed / within-attach / slug→id sets.
async fn create_group_node_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
n: &TreeNode,
declared_parent: Option<GroupId>,
ctx: &ReconcileCtx<'_>,
within_attach: &HashSet<GroupId>,
) -> Result<GroupId, ApplyError> {
crate::groups_api::validate_slug_format(&n.slug).map_err(ApplyError::Invalid)?;
if !self
.parent_within_attach(declared_parent, ctx.attach_gid, within_attach)
.await?
{
return Err(ApplyError::OutsideAttachPoint(format!(
"group `{}` would be created outside this project's attach point `{}`",
n.slug,
ctx.attach_slug.unwrap_or_default()
)));
}
let cap = match declared_parent {
Some(p) => Capability::GroupAdmin(p),
None => Capability::InstanceCreateGroup,
};
require(self.authz.as_ref(), ctx.principal, cap)
.await
.map_err(map_authz)?;
let name = n.name.clone().unwrap_or_else(|| n.slug.clone());
crate::group_repo::create_group_tx(
tx,
&n.slug,
&name,
n.description.as_deref(),
declared_parent,
)
.await
.map_err(map_group_repo_err)
}
/// §6 M2: resolve an EXISTING group node whose server parent diverges from
/// the manifest, per `mode`. `Refuse` → `StructuralDivergence` (422);
/// `AdoptServer` → keep the server shape (`Ok(false)`); `ForceLocal` →
/// §5.6-gated (`GroupAdmin` on node + source + destination) + attach-
/// ceilinged reparent to the declared parent (`Ok(true)`). `Ok(false)` = no
/// structural change (the caller records the change only on `Ok(true)`).
async fn reparent_diverged_group_tx(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
n: &TreeNode,
existing: (GroupId, Option<GroupId>),
declared_parent: Option<GroupId>,
ctx: &ReconcileCtx<'_>,
within_attach: &HashSet<GroupId>,
) -> Result<bool, ApplyError> {
let (gid, server_parent) = existing;
match ctx.mode {
StructureMode::AdoptServer => Ok(false), // keep the server shape
StructureMode::Refuse => Err(ApplyError::StructuralDivergence(format!(
"group `{}` is under a different parent on the server than the manifest \
declares — pass --force-local-structure to reparent it, or \
--adopt-server-structure to keep the server's placement",
n.slug
))),
StructureMode::ForceLocal => {
// §5.6 RBAC: admin on the node + source + destination.
require(
self.authz.as_ref(),
ctx.principal,
Capability::GroupAdmin(gid),
)
.await
.map_err(map_authz)?;
for side in [server_parent, declared_parent].into_iter().flatten() {
require(
self.authz.as_ref(),
ctx.principal,
Capability::GroupAdmin(side),
)
.await
.map_err(map_authz)?;
}
if !self
.parent_within_attach(declared_parent, ctx.attach_gid, within_attach)
.await?
{
return Err(ApplyError::OutsideAttachPoint(format!(
"reparenting group `{}` would move it outside this project's attach \
point `{}`",
n.slug,
ctx.attach_slug.unwrap_or_default()
)));
}
crate::group_repo::reparent_group_tx(tx, gid, declared_parent)
.await
.map_err(map_group_repo_err)?;
Ok(true)
}
}
}
/// Resolve nodes to owners, validate each (apps' route/trigger targets may
/// bind to in-tree ancestor group scripts), compute each node's plan, and
/// fold a combined bound-plan token. Shared by `plan_tree` / `apply_tree`.
///
/// `resolved_ids` maps every group node's lowercased slug → its `GroupId`.
/// The caller pre-populates it: the plan path with the groups that already
/// exist (a missing one becomes a `ToCreateGroup` preview), the apply path
/// with existing groups PLUS the ones it just created in-tx (so the returned
/// to-create list is empty). A to-create group's token part is identical to
/// what it will be once created (empty current), so the plan and apply
/// tokens match across a create.
#[allow(clippy::too_many_lines)]
async fn prepare_tree<'a>(
&self,
bundle: &'a TreeBundle,
resolved_ids: &HashMap<String, GroupId>,
project: Option<&ProjectDecl>,
) -> Result<(Vec<PreparedNode<'a>>, Vec<ToCreateGroup<'a>>, String), ApplyError> {
if bundle.nodes.is_empty() {
return Err(ApplyError::Invalid("project tree has no nodes".into()));
}
// Register in-tree group nodes first: their (resolved) ids and endpoint
// script names, which an app node's binding validation may reference.
// A group node whose slug is not in `resolved_ids` is to-create.
let mut group_id_by_slug: HashMap<String, GroupId> = HashMap::new();
let mut group_script_names: HashMap<GroupId, HashSet<String>> = HashMap::new();
let mut seen_slugs: HashSet<String> = HashSet::new();
for n in &bundle.nodes {
let key = format!("{:?}:{}", n.kind, n.slug.to_lowercase());
if !seen_slugs.insert(key) {
return Err(ApplyError::Invalid(format!(
"project tree lists node `{}` more than once",
n.slug
)));
}
if n.kind == NodeKind::Group {
if let Some(&gid) = resolved_ids.get(&n.slug.to_lowercase()) {
group_id_by_slug.insert(n.slug.to_lowercase(), gid);
group_script_names.insert(
gid,
n.bundle
.scripts
.iter()
.filter(|s| s.kind == ScriptKind::Endpoint)
.map(|s| s.name.to_lowercase())
.collect(),
);
}
}
}
let mut prepared = Vec::with_capacity(bundle.nodes.len());
let mut to_create: Vec<ToCreateGroup<'a>> = Vec::new();
let mut token_parts: Vec<String> = Vec::new();
let mut versioned_groups: BTreeSet<GroupId> = BTreeSet::new();
for n in &bundle.nodes {
// §6: a group node not in `resolved_ids` is to-create — validate its
// bundle, diff against an empty current (a full create), and preview
// it. The apply path creates it before this content reconcile.
if n.kind == NodeKind::Group && !group_id_by_slug.contains_key(&n.slug.to_lowercase()) {
self.validate_bundle_for(true, &n.bundle, &HashSet::new())?;
let current = CurrentState::default();
let current_names = self.resolve_current_target_names(&current).await?;
validate_email_secrets_present(&n.bundle, &current.secret_names)?;
let plan = compute_diff_with_names(&current, &n.bundle, &current_names);
token_parts.push(format!(
"n|{}|{}",
n.slug.to_lowercase(),
state_token_with_names(&current, &current_names)
));
to_create.push(ToCreateGroup { node: n, plan });
continue;
}
let (owner, app_chain) = match n.kind {
NodeKind::Group => {
let gid = group_id_by_slug[&n.slug.to_lowercase()];
versioned_groups.insert(gid);
(ApplyOwner::Group(gid), Vec::new())
}
NodeKind::App => {
let app = self.resolve_app(&n.slug).await?;
let chain = self
.groups
.ancestors(app.group_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
for g in &chain {
versioned_groups.insert(g.id);
}
(
ApplyOwner::App(app.id),
chain.iter().map(|g| g.id).collect::<Vec<_>>(),
)
}
};
// Inherited endpoint NAMES the bindings may reference: pre-existing
// (pool resolver) plus in-tree ancestor group scripts.
let inherited_names: HashSet<String> = match owner {
ApplyOwner::App(app_id) => {
let mut names =
keys_set(&self.resolve_inherited_targets(app_id, &n.bundle).await?);
for gid in &app_chain {
if let Some(s) = group_script_names.get(gid) {
names.extend(s.iter().cloned());
}
}
names
}
ApplyOwner::Group(_) => HashSet::new(),
};
self.validate_bundle_for(
matches!(owner, ApplyOwner::Group(_)),
&n.bundle,
&inherited_names,
)?;
if let ApplyOwner::App(app_id) = owner {
self.validate_route_hosts(app_id, &n.bundle).await?;
}
let current = self.load_current(owner).await?;
let current_names = self.resolve_current_target_names(&current).await?;
validate_email_secrets_present(&n.bundle, &current.secret_names)?;
let plan = compute_diff_with_names(&current, &n.bundle, &current_names);
token_parts.push(format!(
"n|{}|{}",
n.slug.to_lowercase(),
state_token_with_names(&current, &current_names)
));
prepared.push(PreparedNode {
owner,
node: n,
current,
current_names,
plan,
app_chain,
});
}
// §5.5 / Phase 4b: the single-node path runs `check_imports_resolve` and
// `check_extension_points_provided`, but the tree path cannot — a node
// may legitimately import a module another in-tree (uncommitted) node
// declares, so a pool-based provider check would false-positive. Both
// lean on the runtime backstop here. Log it so the gap isn't silent.
if prepared
.iter()
.any(|p| matches!(p.owner, ApplyOwner::App(_)))
{
tracing::debug!(
nodes = prepared.len(),
"tree apply: per-node import / extension-point provider checks \
deferred to the runtime backstop (single-node-only at plan time)"
);
}
// Fold in every in-scope group's structure version, so a reparent or a
// new app under the subtree between plan and apply trips StateMoved.
for gid in &versioned_groups {
if let Some(g) = self
.groups
.get_by_id(*gid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
token_parts.push(format!("gs|{gid}|{}", g.structure_version));
}
}
// §3 M3: fold the env approval policy (desired state) into the token, so a
// policy edit between plan and apply trips StateMoved. Sorted key=confirm
// pairs for order-independence. Both plan_tree and apply_tree pass the
// same (root) project here, so the fold is symmetric.
if let Some(p) = project {
let mut envs: Vec<String> = p
.environments
.iter()
.map(|(name, pol)| format!("{name}={}", pol.confirm))
.collect();
envs.sort_unstable();
token_parts.push(format!("proj|{}", envs.join(",")));
}
token_parts.sort_unstable();
Ok((prepared, to_create, combine_tokens(&token_parts)))
}
/// Resolve an app node's inherited route/trigger targets to script ids,
/// nearest-ancestor-wins, considering BOTH in-tree group nodes (their
/// in-tx `name -> id`, incl. just-created scripts) and pre-existing
/// out-of-tree ancestor groups (their committed rows).
async fn tree_inherited_for_app(
&self,
app_chain: &[GroupId],
bundle: &Bundle,
index: &HashMap<GroupId, HashMap<String, ScriptId>>,
) -> Result<HashMap<String, ScriptId>, ApplyError> {
let own: HashSet<String> = bundle
.scripts
.iter()
.map(|s| s.name.to_lowercase())
.collect();
let mut referenced: HashSet<String> = HashSet::new();
for r in &bundle.routes {
referenced.insert(r.script.to_lowercase());
}
for t in &bundle.triggers {
referenced.insert(t.script().to_lowercase());
}
referenced.retain(|n| !own.contains(n));
if referenced.is_empty() {
return Ok(HashMap::new());
}
// Per-ancestor name→id maps, nearest-first.
let mut chain_maps: Vec<HashMap<String, ScriptId>> = Vec::with_capacity(app_chain.len());
for gid in app_chain {
if let Some(idx) = index.get(gid) {
chain_maps.push(idx.clone());
} else {
let m = self
.scripts
.list_for_group(*gid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.into_iter()
.filter(|s| s.kind == ScriptKind::Endpoint)
.map(|s| (s.name.to_lowercase(), s.id))
.collect();
chain_maps.push(m);
}
}
let mut out = HashMap::new();
for name in referenced {
for m in &chain_maps {
if let Some(id) = m.get(&name) {
out.insert(name.clone(), *id);
break;
}
}
}
Ok(out)
}
/// Group-node write caps (editor-tier): scripts → `GroupScriptsWrite`, vars
/// → `GroupVarsWrite`, both required on prune. The single-group and tree
/// apply authorization (`apply_api::authz_tree`) call this at the API layer;
/// the tree apply ALSO re-checks it in-tx (see `apply_tree`) to close the
/// window where a group absent at authz time appears before reconcile.
pub(crate) async fn require_group_node_writes(
&self,
principal: &Principal,
group_id: GroupId,
bundle: &Bundle,
prune: bool,
) -> Result<(), ApplyError> {
if prune || !bundle.scripts.is_empty() {
require(
self.authz.as_ref(),
principal,
Capability::GroupScriptsWrite(group_id),
)
.await
.map_err(map_authz)?;
}
if prune || !bundle.vars.is_empty() {
require(
self.authz.as_ref(),
principal,
Capability::GroupVarsWrite(group_id),
)
.await
.map_err(map_authz)?;
}
Ok(())
}
// ------------------------------------------------------------------------
// §7 ownership claim — the ApplyService side (executes the pure verdict).
// ------------------------------------------------------------------------
/// Claim / verify ownership of a GROUP node inside the apply tx. Reads the
/// live owner under the caller's advisory lock, runs the pure policy, and
/// applies the verdict: first-apply claims, a re-apply by the owner is a
/// no-op, a conflict is a 409, and a `--takeover` reassign additionally
/// requires `GroupAdmin` on the target (ownership ⟂ RBAC, §7.4).
async fn claim_group_owner(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
gid: GroupId,
declared: Option<ProjectId>,
takeover: bool,
principal: &Principal,
) -> Result<(), ApplyError> {
let current = read_group_owner_tx(tx, gid).await?;
match decide_group_claim(current, declared, takeover) {
GroupClaimAction::Noop => Ok(()),
GroupClaimAction::Claim(pid) => write_group_owner_tx(tx, gid, pid).await,
GroupClaimAction::Takeover(pid) => {
require(self.authz.as_ref(), principal, Capability::GroupAdmin(gid))
.await
.map_err(map_authz)?;
write_group_owner_tx(tx, gid, pid).await
}
GroupClaimAction::Conflict(msg) => Err(ApplyError::OwnershipConflict(msg)),
}
}
/// Verify an APP node's ownership (single-node path). The app claims
/// nothing — it must match its nearest claimed ancestor group. The ancestor
/// STRUCTURE is read on the pool (`ancestors()`), but the ownership VALUE is
/// then read WITHIN the tx via `nearest_claimed_in_tx` — the same mechanism
/// the tree path uses — so the check and this apply's writes see one
/// consistent snapshot instead of a separate pool connection (`in_tree` is
/// empty; a single app node claims no groups itself). An unclaimed subtree
/// stays open. (Fully serializing against a concurrent claim on an
/// out-of-tree ancestor is part of the tracked pool→tx follow-up — see the
/// `apply_owner` advisory-lock note.)
async fn check_app_owner_single(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
app_id: AppId,
declared: Option<ProjectId>,
) -> Result<(), ApplyError> {
let Some(app) = self
.apps
.get_by_id(app_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
else {
return Ok(()); // resolved earlier in the handler; defensive.
};
let chain = self
.groups
.ancestors(app.group_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let chain_ids: Vec<GroupId> = chain.iter().map(|g| g.id).collect();
let nearest = nearest_claimed_in_tx(tx, &chain_ids, &HashMap::new()).await?;
decide_app_owner(nearest, declared).map_err(ApplyError::OwnershipConflict)
}
/// The nearest ancestor (nearest-first `chain`) with an owner, as
/// `(id, slug)`. Resolves the owning project's slug lazily — only when a
/// claim exists — for the conflict message.
async fn nearest_claimed_from_chain(
&self,
chain: &[picloud_shared::Group],
) -> Result<Option<(ProjectId, String)>, ApplyError> {
let Some(pid) = chain.iter().find_map(|g| g.owner_project) else {
return Ok(None);
};
let slug = self
.projects
.get_by_id(pid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.map(|p| p.slug)
.unwrap_or_default();
Ok(Some((pid, slug)))
}
/// §3 M3 (hermetic gate, server-authoritative): the id of the project that
/// GOVERNS `owner` — its nearest-claimed ancestor (inclusive: a group node
/// uses its own claim first, then walks up; an app node walks its group
/// chain). This is the same ownership walk `ownership_preview` uses, and it
/// is deliberately independent of any client-supplied `[project]` — the
/// server decides which project's approval policy applies.
async fn governing_project_id(
&self,
owner: ApplyOwner,
) -> Result<Option<ProjectId>, ApplyError> {
let anchor = match owner {
ApplyOwner::Group(g) => g,
ApplyOwner::App(a) => {
let Some(app) = self
.apps
.get_by_id(a)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
else {
return Ok(None);
};
app.group_id
}
};
let chain = self
.groups
.ancestors(anchor)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
Ok(chain.iter().find_map(|g| g.owner_project))
}
/// §3 M3 (hermetic gate): the EFFECTIVE per-env approval policy for an apply
/// to `owner` — `env -> confirm` — computed as `governing declared`. The
/// GOVERNING side is loaded from the project the server RESOLVED as owning
/// the target node (`governing_project_id`), NOT from the client's declared
/// slug — so a request that omits or spoofs `[project]` still trips a gate
/// the owning project established. The declared side only STRENGTHENS (union
/// on confirm), never weakens. A persisted-read error surfaces as `Backend`
/// (fail-closed — a gate is never silently dropped).
pub async fn governing_env_policy(
&self,
owner: ApplyOwner,
declared: Option<&ProjectDecl>,
) -> Result<std::collections::BTreeMap<String, bool>, ApplyError> {
let mut effective = match self.governing_project_id(owner).await? {
Some(pid) => self
.projects
.get_environments_by_id(pid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?,
None => std::collections::BTreeMap::new(),
};
if let Some(decl) = declared {
for (env, pol) in &decl.environments {
let entry = effective.entry(env.clone()).or_insert(false);
*entry = *entry || pol.confirm;
}
}
Ok(effective)
}
/// §3 M3 (hermetic gate) for a TREE apply: the union of every declared
/// node's governing policy with the declared side. Each existing node is
/// resolved to its owning project server-side; a to-create group node has
/// nothing persisted to gate (its claim is established by this same apply)
/// so it contributes only the declared policy. Un-bypassable for the real
/// case — applying to existing nodes under a claimed, gated subtree.
pub async fn governing_env_policy_tree(
&self,
bundle: &TreeBundle,
declared: Option<&ProjectDecl>,
) -> Result<std::collections::BTreeMap<String, bool>, ApplyError> {
let mut effective: std::collections::BTreeMap<String, bool> =
std::collections::BTreeMap::new();
for node in &bundle.nodes {
// The project that governs this node. An existing node uses its own
// ownership walk. A to-create GROUP has no id yet, so resolve the
// governing project from its DECLARED parent's chain — a gated,
// claimed pre-existing ancestor still governs a node created beneath
// it, closing the whole-block-omission gap for a fresh subtree node.
let governing_pid = match node.kind {
NodeKind::App => {
// Apps are never to-create in the tree path (resolve errors),
// so a miss here means the app truly doesn't exist yet.
match crate::app_repo::resolve_app(self.apps.as_ref(), &node.slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
Some(l) => self.governing_project_id(ApplyOwner::App(l.app.id)).await?,
None => continue,
}
}
NodeKind::Group => match self
.groups
.get_by_slug(&node.slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
Some(g) => self.governing_project_id(ApplyOwner::Group(g.id)).await?,
None => match node.parent.as_deref() {
Some(parent_slug) => match self
.groups
.get_by_slug(parent_slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
Some(parent) => {
self.governing_project_id(ApplyOwner::Group(parent.id))
.await?
}
// Parent is itself to-create (no persisted claim yet,
// so no persisted gate from it) or absent → nothing to
// govern this node with.
None => continue,
},
None => continue,
},
},
};
if let Some(pid) = governing_pid {
for (env, confirm) in self
.projects
.get_environments_by_id(pid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
let entry = effective.entry(env).or_insert(false);
*entry = *entry || confirm;
}
}
}
if let Some(decl) = declared {
for (env, pol) in &decl.environments {
let entry = effective.entry(env.clone()).or_insert(false);
*entry = *entry || pol.confirm;
}
}
Ok(effective)
}
// ------------------------------------------------------------------------
// §6/§7 attach point — a project's ceiling. Every applied node must live
// strictly within the subtree rooted at the declared `parent_group`.
// ------------------------------------------------------------------------
/// Enforce the ceiling for one node: `pg` must be a PROPER ancestor of a
/// group node (you can't apply the attach point itself), or an ancestor
/// (inclusive) of an app node's group. Reject otherwise.
async fn check_within_attach(
&self,
owner: ApplyOwner,
pg: GroupId,
pg_slug: &str,
) -> Result<(), ApplyError> {
let (anchor, is_group) = match owner {
ApplyOwner::Group(g) => (g, true),
ApplyOwner::App(a) => {
let app = self
.apps
.get_by_id(a)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.ok_or_else(|| ApplyError::AppNotFound(a.to_string()))?;
(app.group_id, false)
}
};
let chain = self
.groups
.ancestors(anchor)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let in_subtree = chain.iter().any(|g| g.id == pg);
// A group node equal to the attach point is the ceiling itself → above.
let is_attach_itself = is_group && anchor == pg;
if !in_subtree || is_attach_itself {
return Err(ApplyError::OutsideAttachPoint(format!(
"node is not within this project's attach point '{pg_slug}' — apply only \
to '{pg_slug}' and its descendants"
)));
}
Ok(())
}
/// A group's OWN owning-project slug (not inherited), read on the pool.
async fn read_group_owner_pool(&self, gid: GroupId) -> Result<Option<String>, ApplyError> {
let g = self
.groups
.get_by_id(gid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
match g.and_then(|g| g.owner_project) {
None => Ok(None),
Some(pid) => Ok(self
.projects
.get_by_id(pid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.map(|p| p.slug)),
}
}
/// §7 M3 preview: the ownership outcome a node's apply would produce, or
/// `None` when no `[project]` is declared (nothing to preview). Read-only.
/// A group node previews against its OWN owner; an app node against its
/// nearest claimed ancestor.
async fn ownership_preview(
&self,
owner: ApplyOwner,
declared: Option<&str>,
) -> Result<Option<OwnershipPreview>, ApplyError> {
if declared.is_none() {
return Ok(None);
}
let (is_group, current) = match owner {
ApplyOwner::Group(g) => (true, self.read_group_owner_pool(g).await?),
ApplyOwner::App(a) => {
let current = match self
.apps
.get_by_id(a)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
Some(app) => {
let chain = self
.groups
.ancestors(app.group_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
self.nearest_claimed_from_chain(&chain)
.await?
.map(|(_, s)| s)
}
None => None,
};
(false, current)
}
};
let mut preview = preview_ownership(is_group, current.as_deref(), declared);
// §7 M3.2: for a group node, surface the cross-repo blast radius — the
// descendant apps owned by OTHER projects a config change here reaches.
if let ApplyOwner::Group(g) = owner {
preview.blast_radius = self.group_blast_radius(g, declared).await?;
}
Ok(Some(preview))
}
/// §7 M3.2: descendant apps of `gid` grouped by their nearest-claimed-
/// ancestor project, EXCLUDING `exclude` (the planning project) — the apps
/// a change to `gid`'s inherited config fans out to across other repos.
/// Resolved in one set-based recursive query (see below).
async fn group_blast_radius(
&self,
gid: GroupId,
exclude: Option<&str>,
) -> Result<Vec<ProjectImpact>, ApplyError> {
// One set-based query: every app in gid's subtree, resolved to its
// nearest claimed ancestor project, grouped and counted — replacing the
// per-descendant-group `ancestors()` round-trips (N+1) with a single
// recursive walk. `subtree` = gid + descendant groups; `app_chain` =
// (app, ancestor group, depth) for every subtree app walked to the root;
// `nearest` picks the shallowest claimed ancestor per app. Apps owned by
// `exclude` (the planning project) or by nothing are dropped.
let rows: Vec<(String, i64)> = sqlx::query_as(
"WITH RECURSIVE subtree AS (
SELECT id, 0 AS depth FROM groups WHERE id = $1
UNION ALL
SELECT g.id, s.depth + 1 FROM groups g JOIN subtree s ON g.parent_id = s.id
WHERE s.depth < 64
),
app_chain AS (
SELECT a.id AS app_id, a.group_id AS gid, 0 AS depth
FROM apps a WHERE a.group_id IN (SELECT id FROM subtree)
UNION ALL
SELECT ac.app_id, g.parent_id, ac.depth + 1
FROM app_chain ac JOIN groups g ON g.id = ac.gid
WHERE g.parent_id IS NOT NULL AND ac.depth < 64
),
nearest AS (
SELECT DISTINCT ON (ac.app_id) ac.app_id, g.owner_project
FROM app_chain ac JOIN groups g ON g.id = ac.gid
WHERE g.owner_project IS NOT NULL
ORDER BY ac.app_id, ac.depth ASC
)
SELECT p.slug, COUNT(*) AS n
FROM nearest n JOIN projects p ON p.id = n.owner_project
WHERE p.slug IS DISTINCT FROM $2
GROUP BY p.slug
ORDER BY p.slug",
)
.bind(gid.into_inner())
.bind(exclude)
.fetch_all(&self.pool)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
Ok(rows
.into_iter()
.map(|(project, n)| ProjectImpact {
project,
apps: u32::try_from(n).unwrap_or(u32::MAX),
})
.collect())
}
async fn resolve_app(&self, ident: &str) -> Result<picloud_shared::App, ApplyError> {
crate::app_repo::resolve_app(self.apps.as_ref(), ident)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.map(|l| l.app)
.ok_or_else(|| ApplyError::AppNotFound(ident.to_string()))
}
async fn resolve_group(&self, ident: &str) -> Result<GroupId, ApplyError> {
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
self.groups.get_by_id(uuid.into()).await
} else {
self.groups.get_by_slug(ident).await
};
found
.map_err(|e| ApplyError::Backend(e.to_string()))?
.map(|g| g.id)
.ok_or_else(|| ApplyError::AppNotFound(format!("group {ident}")))
}
fn script_imports(&self, bs: &BundleScript) -> Result<Vec<String>, ApplyError> {
let res = if bs.kind == ScriptKind::Module {
self.validator.validate_module(&bs.source)
} else {
self.validator.validate(&bs.source)
};
res.map(|v| v.imports)
.map_err(|e| ApplyError::Invalid(format!("script `{}`: {e}", bs.name)))
}
fn trigger_settings(
&self,
bt: &BundleTrigger,
) -> (
TriggerDispatchMode,
u32,
crate::trigger_config::BackoffShape,
u32,
) {
let (dispatch, retry_max) = match bt {
BundleTrigger::Kv {
dispatch_mode,
retry_max_attempts,
..
}
| BundleTrigger::Docs {
dispatch_mode,
retry_max_attempts,
..
}
| BundleTrigger::Files {
dispatch_mode,
retry_max_attempts,
..
}
| BundleTrigger::Cron {
dispatch_mode,
retry_max_attempts,
..
}
| BundleTrigger::Pubsub {
dispatch_mode,
retry_max_attempts,
..
}
| BundleTrigger::Email {
dispatch_mode,
retry_max_attempts,
..
}
| BundleTrigger::Queue {
dispatch_mode,
retry_max_attempts,
..
}
| BundleTrigger::DeadLetter {
dispatch_mode,
retry_max_attempts,
..
} => (
dispatch_mode.unwrap_or(TriggerDispatchMode::Async),
retry_max_attempts.unwrap_or(self.trigger_config.retry_max_attempts),
),
};
(
dispatch,
retry_max,
self.trigger_config.retry_backoff,
self.trigger_config.retry_base_ms,
)
}
async fn refresh_route_table(&self) -> Result<(), ApplyError> {
// §11 tail: rebuild via the live expansion so a group route TEMPLATE
// created in this apply lands in every descendant app's slice.
crate::route_admin::rebuild_route_table(self.routes.as_ref(), &self.route_table)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))
}
/// §4.5 M5: reconcile materialized stateful-template copies. Best-effort —
/// a failure is logged (the copies self-heal on the next mutation) and any
/// per-app skip warnings are returned for the apply report.
async fn rematerialize(&self) -> Vec<String> {
match crate::materialize::rematerialize_stateful_templates(&self.pool).await {
Ok(warnings) => warnings,
Err(e) => {
tracing::warn!(error = %e, "apply: stateful-template materialization failed");
vec!["stateful-template materialization failed; it will self-heal".into()]
}
}
}
/// Resolve a referenced secret to plaintext, then re-seal it for an
/// email trigger's inbound-secret column. The value never appears in
/// the manifest — only the secret's name does. §4.5 M5: `owner` is the
/// app for an app-owned trigger, or the GROUP for a group email template
/// (which resolves against the group's own secret store once at apply;
/// materialization then copies the sealed bytes to each descendant).
async fn resolve_and_seal(
&self,
owner: crate::secrets_service::SecretOwner,
name: &str,
) -> Result<(Vec<u8>, Vec<u8>, i16), ApplyError> {
let stored = self
.secrets
.get(owner, "*", name)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.ok_or_else(|| {
ApplyError::Invalid(format!(
"email trigger references secret `{name}`, which is not set \
(push it with `pic secret set {name}`)"
))
})?;
let plaintext = crate::secrets_service::open(&self.master_key, owner, name, &stored)
.map_err(|e| ApplyError::Invalid(format!("could not read secret `{name}`: {e}")))?;
// The inbound HMAC must be a non-empty string — an empty/whitespace
// key is forgeable (preserves the spirit of audit 2026-06-11 H-B2).
match plaintext.as_str() {
Some(s) if !s.trim().is_empty() => {}
_ => {
return Err(ApplyError::Invalid(format!(
"secret `{name}` must be a non-empty string to sign an email trigger"
)))
}
}
// §M5.5 / audit H-D1: seal v1 with AAD bound to the SEALING owner (the
// app for a standalone trigger, the group for a template — stable across
// materialized copies). The email inbound path recovers the same owner to
// open it.
let (ct, nonce, version) = crate::secrets_service::seal_email(
&self.master_key,
owner,
&plaintext,
crate::secrets_service::DEFAULT_SECRET_MAX_VALUE_BYTES,
)
.map_err(|e| ApplyError::Invalid(format!("could not seal secret `{name}`: {e}")))?;
Ok((ct, nonce.to_vec(), version))
}
/// Validate each bundle route's host against the app's domain claims —
/// the cross-state check the interactive route API enforces. Async, so
/// it runs alongside `plan`/`apply`, not in the sync `validate_bundle`.
async fn validate_route_hosts(&self, app_id: AppId, bundle: &Bundle) -> Result<(), ApplyError> {
for r in &bundle.routes {
crate::route_admin::validate_route_host_against_app(
self.domains.as_ref(),
app_id,
r.host_kind,
&r.host,
)
.await
.map_err(|e| ApplyError::Invalid(format!("route host claim: {e}")))?;
}
Ok(())
}
/// Bundle-local validation: catches the errors visible without the
/// live state (syntax, ceilings, dangling/wrong-kind targets, intra-
/// bundle duplicates, reserved paths, host/path structural validity).
/// Host-claim validation runs separately in `validate_route_hosts`.
///
/// NOTE (known gap vs. the interactive route API): cross-pattern route
/// conflict-vs-live (e.g. a param route overlapping a live exact route)
/// is NOT checked — the DB unique index catches only identical tuples,
/// so overlapping patterns are accepted.
#[allow(clippy::too_many_lines)]
/// Phase 4: resolve route/trigger target names that the manifest does NOT
/// declare as app-own scripts to inherited group-owned endpoint scripts on
/// the app's chain. Returns `name(lowercased) → script_id` for the ones
/// that resolve to a group endpoint. App-own names (in `bundle.scripts`)
/// are skipped — they bind through the normal `name_to_id` path and take
/// precedence (CoW). Resolved before the apply transaction; the group
/// scripts pre-exist (committed), so a pool read is correct and stable.
async fn resolve_inherited_targets(
&self,
app_id: AppId,
bundle: &Bundle,
) -> Result<HashMap<String, ScriptId>, ApplyError> {
let own: HashSet<String> = bundle
.scripts
.iter()
.map(|s| s.name.to_lowercase())
.collect();
let mut referenced: HashSet<String> = HashSet::new();
for r in &bundle.routes {
referenced.insert(r.script.to_lowercase());
}
for t in &bundle.triggers {
referenced.insert(t.script().to_lowercase());
}
let mut out = HashMap::new();
for name in referenced {
if own.contains(&name) {
continue;
}
if let Some(s) = self
.scripts
.get_by_name_inherited(app_id, &name)
.await
.map_err(map_repo)?
{
// Only genuinely-inherited group endpoints qualify as
// route/trigger targets. A depth-0 app-own match is already
// covered by `name_to_id`; skip it so the manifest keeps
// precedence. Group modules (Phase 4b) exist but are never
// bindable targets — the kind guard excludes them.
if s.group_id.is_some() && s.kind == ScriptKind::Endpoint {
out.insert(name, s.id);
}
}
}
Ok(out)
}
/// Phase 4: resolve the names of group-owned scripts the app's CURRENT
/// routes/triggers are bound to, `script_id → name`. These ids aren't in
/// `current.scripts` (app-own only), so this is what lets the diff /
/// state-token / prune resolve an existing inherited binding back to its
/// name — independent of whether the bundle still references it (so a
/// dropped inherited binding still diffs to a Delete, and an inherited-bound
/// trigger still moves the state token).
async fn resolve_current_target_names(
&self,
current: &CurrentState,
) -> Result<HashMap<ScriptId, String>, ApplyError> {
let own: HashSet<ScriptId> = current.scripts.iter().map(|s| s.id).collect();
let mut ids: HashSet<ScriptId> = HashSet::new();
for r in &current.routes {
if !own.contains(&r.script_id) {
ids.insert(r.script_id);
}
}
for t in &current.triggers {
if !own.contains(&t.script_id) {
ids.insert(t.script_id);
}
}
let mut out = HashMap::new();
for id in ids {
if let Some(s) = self.scripts.get(id).await.map_err(map_repo)? {
out.insert(id, s.name);
}
}
Ok(out)
}
/// §5.5 dangling-import plan check (Phase 4b, single-node path). Every
/// `import "<m>"` in a bundle script must resolve to a module declared in
/// the same bundle OR reachable lexically up the node's chain — otherwise
/// the apply would write a script that 404s its import at runtime. Lexical:
/// the node's owner IS the defining node for its own scripts, so resolution
/// roots at `owner` (an app node reaches ancestor group modules; a group
/// node walks its own ancestry, sealed from apps below).
///
/// Not run on the tree path: a tree node may import a module declared by
/// another node created in the same transaction (not yet committed), so the
/// in-tree set would have to be threaded through; the runtime check is the
/// backstop there.
async fn check_imports_resolve(
&self,
owner: ApplyOwner,
bundle: &Bundle,
) -> Result<(), ApplyError> {
// Modules this bundle declares (lowercased) satisfy their own imports.
let local: HashSet<String> = bundle
.scripts
.iter()
.filter(|s| s.kind == ScriptKind::Module)
.map(|s| s.name.to_lowercase())
.collect();
let origin = match owner {
ApplyOwner::App(a) => picloud_shared::ScriptOwner::App(a),
ApplyOwner::Group(g) => picloud_shared::ScriptOwner::Group(g),
};
// Resolve each distinct imported name once.
let mut checked: HashSet<String> = HashSet::new();
for s in &bundle.scripts {
for imp in self.script_imports(s)? {
let key = imp.to_lowercase();
if local.contains(&key) || !checked.insert(key) {
continue;
}
let found = self
.modules
.resolve(origin, &imp)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
if found.is_none() {
return Err(ApplyError::Invalid(format!(
"script `{}` imports unknown module `{imp}` \
(not declared here and not inherited from an ancestor)",
s.name
)));
}
}
}
Ok(())
}
/// Read-only extension-point report for a node (§5.5). For an **app**:
/// every EP visible on its chain, each flagged `declared_here` (owned by
/// the app, vs inherited) with its resolved `provider` (`app override` /
/// `inherited default` / `null` = unset). For a **group**: its own declared
/// names (no single app to resolve a provider against). Backs the read-only
/// `extension-points ls` and the `pull` round-trip.
pub async fn extension_point_report(
&self,
owner: ApplyOwner,
) -> Result<Vec<ExtensionPointInfo>, ApplyError> {
let pool = &self.pool;
match owner {
ApplyOwner::Group(g) => {
let names =
crate::extension_point_repo::list_for_owner(pool, ScriptOwner::Group(g))
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
Ok(names
.into_iter()
.map(|name| ExtensionPointInfo {
name,
declared_here: true,
provider: None,
})
.collect())
}
ApplyOwner::App(a) => {
let own: HashSet<String> =
crate::extension_point_repo::list_for_owner(pool, ScriptOwner::App(a))
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.into_iter()
.map(|n| n.to_lowercase())
.collect();
let visible = crate::extension_point_repo::list_on_app_chain(pool, a)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let mut out = Vec::with_capacity(visible.len());
for name in visible {
let provider = match self
.modules
.resolve(ScriptOwner::App(a), &name)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.and_then(|m| m.owner())
{
Some(ScriptOwner::App(_)) => Some("app override".to_string()),
Some(ScriptOwner::Group(_)) => Some("inherited default".to_string()),
None => None,
};
out.push(ExtensionPointInfo {
declared_here: own.contains(&name.to_lowercase()),
name,
provider,
});
}
Ok(out)
}
}
}
/// Read-only §11.6 shared-collection report for a node: the shared
/// collections (`name` + `kind`) declared directly at it. Group-only in
/// practice (the CLI rejects app-declared collections). Backs
/// `pic collections ls`.
/// Read-only §11 tail report: a group's own trigger TEMPLATES, surfaced as
/// (kind, target, handler script name, enabled). The stored `name` is a UUID
/// default for reconcile-created rows, so the semantic bits are shown
/// instead. Backs `pic triggers ls --group`.
pub async fn trigger_report(
&self,
owner: ApplyOwner,
) -> Result<Vec<TriggerTemplateInfo>, ApplyError> {
let ApplyOwner::Group(group_id) = owner else {
return Ok(Vec::new());
};
let triggers = self
.triggers
.list_for_group(group_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let scripts = self
.scripts
.list_for_group(group_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let name_by_id: HashMap<ScriptId, String> =
scripts.iter().map(|s| (s.id, s.name.clone())).collect();
Ok(triggers
.into_iter()
.map(|t| {
let (kind, target) = match &t.details {
TriggerDetails::Kv {
collection_glob, ..
} => ("kv", collection_glob.clone()),
TriggerDetails::Docs {
collection_glob, ..
} => ("docs", collection_glob.clone()),
TriggerDetails::Files {
collection_glob, ..
} => ("files", collection_glob.clone()),
TriggerDetails::Pubsub { topic_pattern } => ("pubsub", topic_pattern.clone()),
TriggerDetails::Cron { .. } => ("cron", String::new()),
TriggerDetails::Queue { queue_name, .. } => ("queue", queue_name.clone()),
TriggerDetails::Email { .. } => ("email", String::new()),
TriggerDetails::DeadLetter { .. } => ("dead_letter", String::new()),
};
TriggerTemplateInfo {
kind: kind.to_string(),
target,
script: name_by_id.get(&t.script_id).cloned().unwrap_or_default(),
enabled: t.enabled,
sealed: t.sealed,
shared: t.shared,
details: serde_json::to_value(&t.details).unwrap_or(serde_json::Value::Null),
dispatch_mode: t.dispatch_mode.as_str().to_string(),
retry_max_attempts: t.retry_max_attempts,
}
})
.collect())
}
/// Read-only §11 tail report: a group's own route TEMPLATES, surfaced as
/// the inherited binding tuple + handler script name. Backs
/// `pic routes ls --group`.
pub async fn route_report(
&self,
owner: ApplyOwner,
) -> Result<Vec<RouteTemplateInfo>, ApplyError> {
let ApplyOwner::Group(group_id) = owner else {
return Ok(Vec::new());
};
let routes = self
.routes
.list_for_group(group_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let scripts = self
.scripts
.list_for_group(group_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let name_by_id: HashMap<ScriptId, String> =
scripts.iter().map(|s| (s.id, s.name.clone())).collect();
Ok(routes
.into_iter()
.map(|r| RouteTemplateInfo {
method: r.method.clone(),
host_kind: r.host_kind,
host: r.host.clone(),
host_param_name: r.host_param_name.clone(),
path_kind: r.path_kind,
path: r.path.clone(),
script: name_by_id.get(&r.script_id).cloned().unwrap_or_default(),
dispatch_mode: r.dispatch_mode,
enabled: r.enabled,
sealed: r.sealed,
})
.collect())
}
pub async fn collection_report(
&self,
owner: ApplyOwner,
) -> Result<Vec<CollectionInfo>, ApplyError> {
let rows =
crate::group_collection_repo::list_all_for_owner(&self.pool, owner.as_script_owner())
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
Ok(rows
.into_iter()
.map(|(name, kind)| CollectionInfo { name, kind })
.collect())
}
/// The desired-state warnings a plan/apply of `bundle` onto `owner` would
/// emit — the ones knowable BEFORE committing, so `pic plan` previews exactly
/// what `apply` reports (post-commit side-effect warnings like route-table
/// refresh or materialization skips are apply-only). Covers: an enabled
/// binding on a disabled script (deployed-but-unreachable), an endpoint with
/// no route/trigger (apps only — a group endpoint is reached by inheritance),
/// and a `[suppress]` that matches no inherited template.
async fn plan_warnings(
&self,
owner: ApplyOwner,
bundle: &Bundle,
) -> Result<Vec<String>, ApplyError> {
let mut warnings = Vec::new();
warnings.extend(disabled_target_warnings(bundle));
if let ApplyOwner::App(_) = owner {
warnings.extend(unreachable_endpoint_warnings(bundle));
}
warnings.extend(self.dangling_suppress_warnings(owner, bundle).await?);
warnings.extend(workflow_nesting_warnings(bundle));
Ok(warnings)
}
/// §11 tail typo guard: a suppress reference that matches **no** inherited
/// template on the app's chain does nothing — return a warning for each such
/// dangling reference (soft; never fails the apply). Trigger refs match an
/// ancestor-group trigger's handler script name (case-insensitive); route
/// refs match an ancestor-group route's path.
async fn dangling_suppress_warnings(
&self,
owner: ApplyOwner,
bundle: &Bundle,
) -> Result<Vec<String>, ApplyError> {
if bundle.suppress_triggers.is_empty() && bundle.suppress_routes.is_empty() {
return Ok(Vec::new());
}
// §11 tail M1: the set of templates a suppression can decline lives on
// the OWNER's chain — an app walks its ancestor groups (`CHAIN_LEVELS_CTE`
// bound to the app), a group walks itself + its ancestors
// (`GROUP_CHAIN_LEVELS_CTE` bound to the group). Both CTEs expose a
// `chain` view with a `group_owner` column, so the queries below are
// identical modulo which CTE + bind.
let (chain_cte, owner_uuid) = match owner {
ApplyOwner::App(a) => (CHAIN_LEVELS_CTE, a.into_inner()),
ApplyOwner::Group(g) => (
crate::config_resolver::GROUP_CHAIN_LEVELS_CTE,
g.into_inner(),
),
};
// Inherited trigger handler names + route paths, each tagged with whether
// ANY matching template is un-sealed (i.e. actually suppressible).
// `bool_or(NOT sealed)` = true iff at least one match can be declined;
// false = every match is `sealed` (§11 tail), so the suppression is
// inert; absent = no inherited template at all (a typo).
let trig_names: Vec<(String, bool)> = sqlx::query_as(&format!(
"{chain_cte} \
SELECT LOWER(s.name), bool_or(NOT t.sealed) FROM triggers t \
JOIN scripts s ON s.id = t.script_id \
JOIN chain c ON t.group_id = c.group_owner \
WHERE t.group_id IS NOT NULL \
GROUP BY LOWER(s.name)",
))
.bind(owner_uuid)
.fetch_all(&self.pool)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let inherited_handlers: HashMap<String, bool> = trig_names.into_iter().collect();
let route_paths: Vec<(String, bool)> = sqlx::query_as(&format!(
"{chain_cte} \
SELECT r.path, bool_or(NOT r.sealed) FROM routes r \
JOIN chain c ON r.group_id = c.group_owner \
WHERE r.group_id IS NOT NULL \
GROUP BY r.path",
))
.bind(owner_uuid)
.fetch_all(&self.pool)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let inherited_paths: HashMap<String, bool> = route_paths.into_iter().collect();
let mut out = Vec::new();
for r in &bundle.suppress_triggers {
match inherited_handlers.get(&r.to_lowercase()) {
None => out.push(format!(
"suppress: no inherited trigger bound to script `{r}` — the \
suppression has no effect"
)),
Some(false) => out.push(format!(
"suppress: the inherited trigger bound to script `{r}` is \
sealed — the suppression has no effect"
)),
Some(true) => {}
}
}
for r in &bundle.suppress_routes {
match inherited_paths.get(r) {
None => out.push(format!(
"suppress: no inherited route at path `{r}` — the \
suppression has no effect"
)),
Some(false) => out.push(format!(
"suppress: the inherited route at path `{r}` is sealed — the \
suppression has no effect"
)),
Some(true) => {}
}
}
Ok(out)
}
/// Read-only §11 tail report: an owner's own suppression markers (the
/// inherited templates it declines). An app declines for itself, a group
/// for its subtree. Backs `pic suppress ls --app` / `--group`.
pub async fn suppression_report(
&self,
owner: ApplyOwner,
) -> Result<Vec<SuppressionInfo>, ApplyError> {
let rows = crate::suppression_repo::list_for_owner(&self.pool, owner.as_script_owner())
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
Ok(rows
.into_iter()
.map(|(target_kind, reference)| SuppressionInfo {
target_kind,
reference,
})
.collect())
}
/// §5.5 no-provider plan check (app nodes only). Every extension point
/// visible to the app — declared in this bundle or inherited from an
/// ancestor — must have a provider: a module of that name the app provides
/// (in this bundle, or resolvable up its chain as an override or a default
/// body). Otherwise the import would fail at runtime, so refuse at plan.
/// Group nodes have no single inheriting app, so the check is per-app.
/// Single-node only (the tree path leans on the runtime backstop).
async fn check_extension_points_provided(
&self,
owner: ApplyOwner,
bundle: &Bundle,
) -> Result<(), ApplyError> {
let ApplyOwner::App(app_id) = owner else {
return Ok(());
};
// A same-apply app module provides its EP without being committed yet.
let local: HashSet<String> = bundle
.scripts
.iter()
.filter(|s| s.kind == ScriptKind::Module)
.map(|s| s.name.to_lowercase())
.collect();
// EP names visible to the app: its own (this bundle) inherited.
let mut names: HashSet<String> = bundle
.extension_points
.iter()
.map(|n| n.to_lowercase())
.collect();
for n in crate::extension_point_repo::list_on_app_chain(&self.pool, app_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
names.insert(n.to_lowercase());
}
for name in names {
if local.contains(&name) {
continue;
}
let found = self
.modules
.resolve(picloud_shared::ScriptOwner::App(app_id), &name)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
if found.is_none() {
return Err(ApplyError::Invalid(format!(
"extension point `{name}` has no provider for this app — \
declare a module named `{name}` here or ensure a default \
body exists up-chain"
)));
}
}
Ok(())
}
/// `inherited_endpoints` (lowercased names) are group-owned endpoint
/// scripts reachable from the app's chain that a route/trigger may bind to
/// even though the manifest doesn't declare them (Phase 4). They count as
/// known endpoints for the binding checks below.
#[allow(clippy::too_many_lines)]
fn validate_bundle(
&self,
bundle: &Bundle,
inherited_endpoints: &HashSet<String>,
) -> Result<(), ApplyError> {
// Scripts: unique names; each source compiles for its kind.
let mut names: HashSet<String> = HashSet::new();
for s in &bundle.scripts {
if !names.insert(s.name.to_lowercase()) {
return Err(ApplyError::Invalid(format!(
"duplicate script name `{}`",
s.name
)));
}
let res = if s.kind == ScriptKind::Module {
// Parity with the interactive create path (`api.rs`): a module
// may not shadow a built-in SDK namespace, or `import "kv"`
// could resolve to a user module instead of the real bridge.
if crate::api::RESERVED_MODULE_NAMES.contains(&s.name.as_str()) {
return Err(ApplyError::Invalid(format!(
"script `{}`: reserved module name (shadows a built-in SDK namespace)",
s.name
)));
}
self.validator.validate_module(&s.source)
} else {
self.validator.validate(&s.source)
};
res.map_err(|e| ApplyError::Invalid(format!("script `{}`: {e}", s.name)))?;
if let Some(sb) = &s.sandbox {
self.sandbox_ceiling
.check(sb)
.map_err(|e| ApplyError::Invalid(format!("script `{}`: {e}", s.name)))?;
}
}
let endpoints: HashSet<&str> = bundle
.scripts
.iter()
.filter(|s| s.kind == ScriptKind::Endpoint)
.map(|s| s.name.as_str())
.collect();
let all_names: HashSet<&str> = bundle.scripts.iter().map(|s| s.name.as_str()).collect();
// Routes: reserved paths; bind to an endpoint script; unique tuples.
let mut route_keys: HashSet<String> = HashSet::new();
for r in &bundle.routes {
reject_reserved_path(&r.path)?;
// Structural validity (parity with the interactive route API):
// a route whose path/host doesn't parse would otherwise be written
// and then silently dropped by the route-table compile.
pattern::parse_path(r.path_kind, &r.path)
.map_err(|e| ApplyError::Invalid(format!("route path `{}`: {e}", r.path)))?;
pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())
.map_err(|e| ApplyError::Invalid(format!("route host `{}`: {e}", r.host)))?;
let inherited = inherited_endpoints.contains(&r.script.to_lowercase());
if !all_names.contains(r.script.as_str()) && !inherited {
return Err(ApplyError::Invalid(format!(
"route {} binds to unknown script `{}`",
route_key(
r.method.as_deref(),
r.host_kind,
&r.host,
r.path_kind,
&r.path
),
r.script
)));
}
// Inherited targets are resolved as endpoints by construction
// (the inherited-name resolver excludes group modules); only
// check kind for app-own names.
if !inherited && !endpoints.contains(r.script.as_str()) {
return Err(ApplyError::Invalid(format!(
"route binds to `{}` which is a module, not an endpoint",
r.script
)));
}
let key = route_key(
r.method.as_deref(),
r.host_kind,
&r.host,
r.path_kind,
&r.path,
);
if !route_keys.insert(key.clone()) {
return Err(ApplyError::Invalid(format!("duplicate route `{key}`")));
}
}
// Triggers: bind to an endpoint script; unique semantic identity.
let mut trig_keys: HashSet<String> = HashSet::new();
for t in &bundle.triggers {
let inherited = inherited_endpoints.contains(&t.script().to_lowercase());
if !all_names.contains(t.script()) && !inherited {
return Err(ApplyError::Invalid(format!(
"{} trigger binds to unknown script `{}`",
t.kind_str(),
t.script()
)));
}
if !inherited && !endpoints.contains(t.script()) {
return Err(ApplyError::Invalid(format!(
"{} trigger binds to `{}` which is a module, not an endpoint",
t.kind_str(),
t.script()
)));
}
validate_trigger_shape(t)?;
let id = t.identity();
if !trig_keys.insert(id.clone()) {
return Err(ApplyError::Invalid(format!("duplicate trigger `{id}`")));
}
}
// Vars: kebab keys (same rule the vars admin API enforces), so a
// manifest can't write a key the interactive surface would reject.
for key in bundle.vars.keys() {
validate_var_key(key)?;
}
// Extension points (§5.5): unique names; none may shadow a built-in
// SDK namespace (same guard as module names — an `import "kv"` must
// never resolve to a user-supplied provider).
let mut ep_names: HashSet<String> = HashSet::new();
for name in &bundle.extension_points {
if !ep_names.insert(name.to_lowercase()) {
return Err(ApplyError::Invalid(format!(
"duplicate extension point `{name}`"
)));
}
if crate::api::RESERVED_MODULE_NAMES.contains(&name.as_str()) {
return Err(ApplyError::Invalid(format!(
"extension point `{name}`: reserved module name \
(shadows a built-in SDK namespace)"
)));
}
}
// Shared group collections (§11.6): a known kind + unique (name, kind).
// No reserved-name guard — a collection name is a data namespace, not an
// importable module, so it can't shadow an SDK namespace.
let mut seen_collections: HashSet<(String, &str)> = HashSet::new();
for c in &bundle.collections {
if c.name.is_empty() {
return Err(ApplyError::Invalid(
"shared collection name must not be empty".into(),
));
}
if !COLLECTION_KINDS.contains(&c.kind.as_str()) {
return Err(ApplyError::Invalid(format!(
"shared collection `{}`: unknown kind `{}` (want one of {COLLECTION_KINDS:?})",
c.name, c.kind
)));
}
if !seen_collections.insert((c.name.to_lowercase(), c.kind.as_str())) {
return Err(ApplyError::Invalid(format!(
"duplicate shared collection `{}` (kind `{}`)",
c.name, c.kind
)));
}
}
Ok(())
}
async fn load_current(&self, owner: ApplyOwner) -> Result<CurrentState, ApplyError> {
// A group node owns scripts + vars + route/trigger TEMPLATES (§11 tail);
// secrets stay empty (app-only — the bundle for a group declares none).
let (scripts, routes, triggers, secret_names, var_owner) = match owner {
ApplyOwner::App(app_id) => (
self.scripts
.list_for_app(app_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?,
self.routes
.list_for_app(app_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?,
self.triggers
.list_for_app(app_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?,
self.all_secret_names(crate::secrets_service::SecretOwner::App(app_id))
.await?,
VarOwner::App(app_id),
),
ApplyOwner::Group(group_id) => (
self.scripts
.list_for_group(group_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?,
// §11 tail: a group owns route + trigger TEMPLATES; load them so
// the diff sees existing markers (NoOp on re-apply, prune-able).
self.routes
.list_for_group(group_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?,
self.triggers
.list_for_group(group_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?,
// §4.5 M5.5: a group's own set secrets — the plan check for an
// email TEMPLATE resolves `inbound_secret_ref` against the
// GROUP store, and the state token folds them in. Informational
// in the secrets diff only (apply never writes secrets).
self.all_secret_names(crate::secrets_service::SecretOwner::Group(group_id))
.await?,
VarOwner::Group(group_id),
),
};
// The owner's OWN vars, narrowed to scope `*` and real values: the
// manifest reconciles env-agnostic config, not inherited vars (not in
// this owner's rows) nor tombstones (an imperative suppress the manifest
// can't express).
let vars = self
.vars
.list_for_owner(var_owner)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.into_iter()
.filter(|r| r.environment_scope == "*" && !r.is_tombstone)
.map(|r| (r.key, r.value))
.collect();
// Extension-point markers declared directly at this node (§5.5).
let extension_point_names =
crate::extension_point_repo::list_for_owner(&self.pool, owner.as_script_owner())
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
// Shared group-collection markers declared directly at this node
// (§11.6), all kinds.
let collections =
crate::group_collection_repo::list_all_for_owner(&self.pool, owner.as_script_owner())
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
// §11 tail suppression markers declared directly at this node
// (§11 tail M1: owner-polymorphic — an app declines for itself, a group
// for its subtree).
let suppressions =
crate::suppression_repo::list_for_owner(&self.pool, owner.as_script_owner())
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
// v1.2 Workflows are app-owned (M1); a group node owns none.
let workflows = match owner {
ApplyOwner::App(app_id) => {
crate::workflow_repo::list_workflows_for_app(&self.pool, app_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
}
ApplyOwner::Group(_) => Vec::new(),
};
// §9.4 interceptor markers declared directly at this node.
let interceptors =
crate::interceptor_repo::list_for_owner(&self.pool, owner.as_script_owner())
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.into_iter()
.map(|m| (m.service, m.op, m.script))
.collect();
Ok(CurrentState {
scripts,
routes,
triggers,
secret_names,
vars,
extension_point_names,
collections,
suppressions,
workflows,
interceptors,
})
}
async fn all_secret_names(
&self,
owner: crate::secrets_service::SecretOwner,
) -> Result<Vec<String>, ApplyError> {
let mut names = Vec::new();
let mut cursor: Option<String> = None;
loop {
let page = self
.secrets
.list_names(owner, cursor.as_deref(), 200)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
names.extend(page.names);
match page.next_cursor {
Some(c) => cursor = Some(c),
None => break,
}
}
Ok(names)
}
}
// ----------------------------------------------------------------------------
// Diff (pure)
// ----------------------------------------------------------------------------
/// Diff desired `bundle` against `current`. Pure — unit-tested directly.
#[must_use]
pub fn compute_diff(current: &CurrentState, bundle: &Bundle) -> Plan {
compute_diff_with_names(current, bundle, &HashMap::new())
}
/// `compute_diff` with `current_names` — the names of group-owned (inherited)
/// scripts the app's CURRENT routes/triggers are bound to, `script_id → name`
/// (Phase 4). Those ids aren't in `current.scripts` (app-own only), so without
/// them a current binding to a group script resolves to no name: it reads as a
/// perpetual rebind, and a binding dropped from the manifest never diffs to a
/// Delete (so `--prune` can't remove it). The public `compute_diff` passes an
/// empty map (no inheritance).
fn compute_diff_with_names(
current: &CurrentState,
bundle: &Bundle,
current_names: &HashMap<ScriptId, String>,
) -> Plan {
let script_name_by_id: HashMap<ScriptId, String> = current
.scripts
.iter()
.map(|s| (s.id, s.name.clone()))
// Inherited group bindings' ids → names. Disjoint from app-own ids by
// construction, so order doesn't matter.
.chain(current_names.iter().map(|(id, n)| (*id, n.clone())))
.collect();
Plan {
scripts: diff_scripts(current, bundle),
routes: diff_routes(current, bundle, &script_name_by_id),
triggers: diff_triggers(current, bundle, &script_name_by_id),
secrets: diff_secrets(current, bundle),
vars: diff_vars(current, bundle),
extension_points: diff_extension_points(current, bundle),
collections: diff_collections(current, bundle),
suppressions: diff_suppressions(current, bundle),
workflows: diff_workflows(current, bundle),
interceptors: diff_interceptors(current, bundle),
}
}
/// Diff §9.4 interceptor markers by `"{service}/{op}"` key with the script name
/// as the value — create/update/noop/delete like `vars`. A changed script for
/// the same `(service, op)` is an `Update`; a live marker the manifest stops
/// declaring is a `Delete` (applied only under `--prune`).
fn diff_interceptors(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
let key = |service: &str, op: &str| format!("{service}/{op}");
let live: HashMap<String, &str> = current
.interceptors
.iter()
.map(|(s, o, script)| (key(s, o), script.as_str()))
.collect();
let mut out = Vec::new();
for bi in &bundle.interceptors {
let k = key(&bi.service, &bi.op);
match live.get(&k) {
Some(cur) if *cur == bi.script => out.push(ResourceChange {
op: Op::NoOp,
key: k,
detail: None,
}),
Some(_) => out.push(ResourceChange {
op: Op::Update,
key: k,
detail: Some("interceptor script changed".into()),
}),
None => out.push(ResourceChange {
op: Op::Create,
key: k,
detail: Some(bi.script.clone()),
}),
}
}
for (s, o, _) in &current.interceptors {
let present = bundle
.interceptors
.iter()
.any(|bi| bi.service == *s && bi.op == *o);
if !present {
out.push(ResourceChange {
op: Op::Delete,
key: key(s, o),
detail: Some("on server, not declared".into()),
});
}
}
out
}
/// Diff workflows by `lower(name)`. Like scripts, a workflow has a stable name
/// identity with a mutable body, so a changed definition (or `enabled`) is an
/// `Update`, not a delete+create. Live workflows absent from the manifest are
/// `Delete` (applied only under `--prune`).
fn diff_workflows(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
let by_name: HashMap<String, &crate::workflow_repo::Workflow> = current
.workflows
.iter()
.map(|w| (w.name.to_lowercase(), w))
.collect();
let desired: HashSet<String> = bundle
.workflows
.iter()
.map(|w| w.name.to_lowercase())
.collect();
let mut out = Vec::new();
for w in &bundle.workflows {
match by_name.get(&w.name.to_lowercase()) {
None => out.push(ResourceChange {
op: Op::Create,
key: w.name.clone(),
detail: None,
}),
Some(cur) => {
let reason = if cur.definition != w.definition {
Some("definition changed".to_string())
} else if cur.enabled != w.enabled {
Some(if w.enabled { "enabled" } else { "disabled" }.to_string())
} else {
None
};
out.push(ResourceChange {
op: if reason.is_some() {
Op::Update
} else {
Op::NoOp
},
key: w.name.clone(),
detail: reason,
});
}
}
}
for w in &current.workflows {
if !desired.contains(&w.name.to_lowercase()) {
out.push(ResourceChange {
op: Op::Delete,
key: w.name.clone(),
detail: None,
});
}
}
out
}
/// Pure structural validation of a workflow definition (mirrors
/// `validate_trigger_shape`): non-empty; unique step names; each step targets
/// exactly one of function/workflow; `depends_on` references exist and aren't
/// self-loops; the DAG is acyclic; every `when` + input template parses and
/// only references declared steps. Returns a human error string on failure.
fn validate_workflow_definition(
wf_name: &str,
def: &picloud_shared::workflow::WorkflowDefinition,
) -> Result<(), String> {
use std::collections::{BTreeSet, HashMap as Map};
if def.steps.is_empty() {
return Err(format!("workflow `{wf_name}` has no steps"));
}
let mut names: BTreeSet<String> = BTreeSet::new();
for s in &def.steps {
if s.name.trim().is_empty() {
return Err(format!(
"workflow `{wf_name}` has a step with an empty name"
));
}
if !names.insert(s.name.clone()) {
return Err(format!(
"workflow `{wf_name}` has a duplicate step name `{}`",
s.name
));
}
}
for s in &def.steps {
if s.target().is_none() {
return Err(format!(
"workflow `{wf_name}` step `{}` must set exactly one of `function` or `workflow`",
s.name
));
}
for dep in &s.depends_on {
if dep == &s.name {
return Err(format!(
"workflow `{wf_name}` step `{}` depends on itself",
s.name
));
}
if !names.contains(dep) {
return Err(format!(
"workflow `{wf_name}` step `{}` depends on undeclared step `{dep}`",
s.name
));
}
}
if let Some(w) = &s.when {
crate::workflow_expr::validate(w, &names).map_err(|e| {
format!(
"workflow `{wf_name}` step `{}` has a bad `when`: {e}",
s.name
)
})?;
}
crate::workflow_template::validate(&s.input, &names).map_err(|e| {
format!(
"workflow `{wf_name}` step `{}` has a bad input template: {e}",
s.name
)
})?;
}
// DAG acyclicity via Kahn topological sort. Count DISTINCT deps: a step that
// lists the same dependency twice inflates its in-degree past what the
// single-decrement in the drain loop below can undo, which would otherwise
// report a spurious cycle for a valid DAG.
let mut indeg: Map<&str, usize> = def.steps.iter().map(|s| (s.name.as_str(), 0)).collect();
for s in &def.steps {
let distinct: BTreeSet<&str> = s.depends_on.iter().map(String::as_str).collect();
*indeg.get_mut(s.name.as_str()).unwrap() += distinct.len();
}
let mut queue: Vec<&str> = indeg
.iter()
.filter(|(_, d)| **d == 0)
.map(|(n, _)| *n)
.collect();
let mut seen = 0usize;
while let Some(n) = queue.pop() {
seen += 1;
for s in &def.steps {
if s.depends_on.iter().any(|d| d == n) {
let e = indeg.get_mut(s.name.as_str()).unwrap();
*e -= 1;
if *e == 0 {
queue.push(s.name.as_str());
}
}
}
}
if seen != def.steps.len() {
return Err(format!("workflow `{wf_name}` has a dependency cycle"));
}
Ok(())
}
/// Diff app-owned vars by key. Unlike secrets (whose values live out-of-band),
/// a var's value IS in the manifest, so equality is value-sensitive: a changed
/// value is an `Update`. Live vars absent from the manifest are `Delete`
/// (applied only under `--prune`).
fn diff_vars(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
let live: HashMap<&str, &serde_json::Value> =
current.vars.iter().map(|(k, v)| (k.as_str(), v)).collect();
let mut out = Vec::new();
for (key, value) in &bundle.vars {
match live.get(key.as_str()) {
Some(cur) if *cur == value => out.push(ResourceChange {
op: Op::NoOp,
key: key.clone(),
detail: None,
}),
Some(_) => out.push(ResourceChange {
op: Op::Update,
key: key.clone(),
detail: Some("value changed".into()),
}),
None => out.push(ResourceChange {
op: Op::Create,
key: key.clone(),
detail: None,
}),
}
}
for (key, _) in &current.vars {
if !bundle.vars.contains_key(key) {
out.push(ResourceChange {
op: Op::Delete,
key: key.clone(),
detail: Some("on server, not declared".into()),
});
}
}
out
}
/// Kebab var key (`^[a-z0-9][a-z0-9-]{0,127}$`) — mirrors `vars_api`'s
/// `validate_key` so the manifest and the interactive surface agree.
fn validate_var_key(key: &str) -> Result<(), ApplyError> {
let ok = !key.is_empty()
&& key.len() <= 128
&& key
.chars()
.next()
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
&& key
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
if ok {
Ok(())
} else {
Err(ApplyError::Invalid(format!(
"var key `{key}` must be 1128 chars of lowercase letters, digits, and hyphens, \
starting with a letter or digit"
)))
}
}
/// Upsert one app-owned var at scope `*`, in the apply transaction. Mirrors
/// `PostgresVarsRepo::set` for `VarOwner::App` (the partial-index predicate is
/// restated in the conflict target) but runs against `&mut tx` so it commits
/// atomically with the rest of the apply. Clears any tombstone.
async fn set_app_var_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
app_id: AppId,
key: &str,
value: &serde_json::Value,
) -> Result<(), ApplyError> {
sqlx::query(
"INSERT INTO vars (app_id, environment_scope, key, value, is_tombstone) \
VALUES ($1, '*', $2, $3, FALSE) \
ON CONFLICT (app_id, environment_scope, key) WHERE app_id IS NOT NULL DO UPDATE \
SET value = EXCLUDED.value, is_tombstone = FALSE, updated_at = NOW()",
)
.bind(app_id.into_inner())
.bind(key)
.bind(value)
.execute(&mut **tx)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
Ok(())
}
/// Delete one app-owned var at scope `*`, in the apply transaction.
async fn delete_app_var_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
app_id: AppId,
key: &str,
) -> Result<(), ApplyError> {
sqlx::query("DELETE FROM vars WHERE app_id = $1 AND environment_scope = '*' AND key = $2")
.bind(app_id.into_inner())
.bind(key)
.execute(&mut **tx)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
Ok(())
}
/// Upsert one group-owned var at scope `*`, in the apply transaction (Phase 5).
/// Mirrors `set_app_var_tx` for `VarOwner::Group`. Env-scoped group vars
/// (`@staging`) stay imperative for now — a group manifest declares only
/// env-agnostic (`*`) group config.
async fn set_group_var_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
key: &str,
value: &serde_json::Value,
) -> Result<(), ApplyError> {
sqlx::query(
"INSERT INTO vars (group_id, environment_scope, key, value, is_tombstone) \
VALUES ($1, '*', $2, $3, FALSE) \
ON CONFLICT (group_id, environment_scope, key) WHERE group_id IS NOT NULL DO UPDATE \
SET value = EXCLUDED.value, is_tombstone = FALSE, updated_at = NOW()",
)
.bind(group_id.into_inner())
.bind(key)
.bind(value)
.execute(&mut **tx)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
Ok(())
}
/// Delete one group-owned var at scope `*`, in the apply transaction (Phase 5).
async fn delete_group_var_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
key: &str,
) -> Result<(), ApplyError> {
sqlx::query("DELETE FROM vars WHERE group_id = $1 AND environment_scope = '*' AND key = $2")
.bind(group_id.into_inner())
.bind(key)
.execute(&mut **tx)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
Ok(())
}
// ----------------------------------------------------------------------------
// §7 multi-repo ownership — the claim state machine + its tx helpers.
//
// A project is single-owner-per-NODE: each group node is owned by exactly one
// project (`groups.owner_project`); apps inherit ownership from their nearest
// claimed ancestor group. The pure `decide_*` fns are the whole policy; the
// apply path executes their verdict (the UPDATE / the takeover authz gate)
// under the per-node advisory lock. See docs/design/groups-and-project-tool.md §7.
// ----------------------------------------------------------------------------
/// What a group-node apply should do about ownership, given the current owner,
/// the declared project, and whether `--takeover` was passed. Generic over the
/// identity key `K` so the same policy serves both the apply path (`K =
/// ProjectId`, comparing the assigned ids) and the plan preview (`K = String`,
/// comparing slugs before any id is assigned).
#[derive(Debug, PartialEq, Eq)]
enum GroupClaimAction<K> {
/// Nothing to write (unclaimed + no project, or already owned by us).
Noop,
/// First-apply-claims: set `owner_project`.
Claim(K),
/// Reassign to us — capability-gated (`GroupAdmin`) at the call site.
Takeover(K),
/// Refuse (409); the string is the actionable message.
Conflict(String),
}
/// Pure ownership policy for a GROUP node. `current` is the live owner as
/// `(key, slug)`; `declared` is this apply's project key. See §7.3.
fn decide_group_claim<K: Eq>(
current: Option<(K, String)>,
declared: Option<K>,
takeover: bool,
) -> GroupClaimAction<K> {
match (current, declared) {
// Unclaimed: a declared project claims it; no project is a no-op (the
// node stays UI/API-owned — the backward-compatible path).
(None, Some(p)) => GroupClaimAction::Claim(p),
(None, None) => GroupClaimAction::Noop,
// Already ours: re-apply by the owning repo.
(Some((c, _)), Some(p)) if c == p => GroupClaimAction::Noop,
// Owned by someone else: takeover (gated) or refuse.
(Some((_, cslug)), Some(p)) => {
if takeover {
GroupClaimAction::Takeover(p)
} else {
GroupClaimAction::Conflict(format!(
"group is owned by project '{cslug}'; use --takeover (requires group-admin) \
to reassign it"
))
}
}
// A no-project apply must not silently write into a claimed subtree.
(Some((_, cslug)), None) => GroupClaimAction::Conflict(format!(
"group is owned by project '{cslug}'; declare a matching [project] (or --takeover)"
)),
}
}
/// Pure ownership policy for an APP node. Apps never claim — they must match
/// their nearest claimed ancestor group (`nearest`, as `(key, slug)`); an
/// unclaimed subtree is open (backward-compatible). Generic over the identity
/// key `K` for the same reason as [`decide_group_claim`]. See §7.
fn decide_app_owner<K: Eq>(
nearest: Option<(K, String)>,
declared: Option<K>,
) -> Result<(), String> {
match (nearest, declared) {
(None, _) => Ok(()),
(Some((c, _)), Some(p)) if c == p => Ok(()),
(Some((_, cslug)), Some(_)) => Err(format!(
"the nearest owning group belongs to project '{cslug}', not this apply's project; \
apply from '{cslug}', or --takeover its group from a [group] node"
)),
(Some((_, cslug)), None) => Err(format!(
"this app is under a subtree owned by project '{cslug}'; declare [project] '{cslug}'"
)),
}
}
/// §7: validate a `[project]` slug — the same FORMAT rule as app/group slugs
/// (`^[a-z0-9][a-z0-9-]{0,62}$`), reusing the shared validator. A project slug
/// is intentionally NOT reserved-word-checked: unlike a group/app slug it is
/// never used in a route or host, so no path can collide with it.
fn validate_project_slug(slug: &str) -> Result<(), ApplyError> {
crate::groups_api::validate_slug_format(slug)
.map_err(|why| ApplyError::Invalid(format!("project slug {slug:?}: {why}")))
}
/// Register (or fetch) a project by slug inside the apply tx, returning its id.
/// First apply with a new slug inserts, falling back to the slug when the
/// manifest omits `name` (the column is NOT NULL). A re-apply updates the
/// display name ONLY when the manifest actually declares one — a name-less
/// `[project]` block (e.g. a clone scaffolded without a name) preserves the
/// previously-set name and `created_by` rather than clobbering the name back
/// to the slug. `$2` (the optional declared name) is bound once and referenced
/// in both the INSERT fallback and the ON CONFLICT guard.
async fn upsert_project_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
decl: &ProjectDecl,
actor: AdminUserId,
) -> Result<ProjectId, ApplyError> {
validate_project_slug(&decl.slug)?;
let (id,): (uuid::Uuid,) = sqlx::query_as(
"INSERT INTO projects (slug, name, created_by) VALUES ($1, COALESCE($2, $1), $3) \
ON CONFLICT (slug) DO UPDATE SET name = COALESCE($2, projects.name) \
RETURNING id",
)
.bind(&decl.slug)
.bind(decl.name.clone())
.bind(actor.into_inner())
.fetch_one(&mut **tx)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
// §3 M3 (hermetic gate): persist the declared per-env approval policy as the
// authoritative source. Declarative replace (delete-then-insert) so a
// removed env drops out — the gate reads `persisted declared`, so an
// ungating still has to pass the prior gate at apply time (see
// `apply_api::env_gate_check`). Same tx as the project row + node claim.
sqlx::query("DELETE FROM project_environments WHERE project_id = $1")
.bind(id)
.execute(&mut **tx)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
for (env_name, pol) in &decl.environments {
sqlx::query(
"INSERT INTO project_environments (project_id, env_name, confirm) \
VALUES ($1, $2, $3)",
)
.bind(id)
.bind(env_name)
.bind(pol.confirm)
.execute(&mut **tx)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
}
Ok(id.into())
}
/// Read a group's current owner as `(project id, project slug)` inside the tx,
/// or `None` when unclaimed. Serialized with the write via the caller's
/// per-node advisory lock.
async fn read_group_owner_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
gid: GroupId,
) -> Result<Option<(ProjectId, String)>, ApplyError> {
let row: Option<(Option<uuid::Uuid>, Option<String>)> = sqlx::query_as(
"SELECT g.owner_project, p.slug FROM groups g \
LEFT JOIN projects p ON p.id = g.owner_project WHERE g.id = $1",
)
.bind(gid.into_inner())
.fetch_optional(&mut **tx)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
Ok(row.and_then(|(pid, slug)| pid.map(|p| (p.into(), slug.unwrap_or_default()))))
}
/// Assign `owner_project` for a group inside the tx. Deliberately does NOT bump
/// `structure_version` — a claim is not a diff/inheritance change, so it must
/// not invalidate a pending bound plan (hazard b in the design).
async fn write_group_owner_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
gid: GroupId,
pid: ProjectId,
) -> Result<(), ApplyError> {
sqlx::query("UPDATE groups SET owner_project = $1 WHERE id = $2")
.bind(pid.into_inner())
.bind(gid.into_inner())
.execute(&mut **tx)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
Ok(())
}
/// Nearest claimed ancestor for an app, resolved in-tx: an in-tree group
/// (Phase-A overlay) wins over a committed ancestor. `app_chain` is nearest-
/// first. Used by the tree path (the single-node path folds `ancestors()`).
async fn nearest_claimed_in_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
app_chain: &[GroupId],
in_tree: &HashMap<GroupId, (ProjectId, String)>,
) -> Result<Option<(ProjectId, String)>, ApplyError> {
for gid in app_chain {
if let Some(hit) = in_tree.get(gid) {
return Ok(Some(hit.clone()));
}
if let Some(hit) = read_group_owner_tx(tx, *gid).await? {
return Ok(Some(hit));
}
}
Ok(None)
}
/// Map an authz denial to the apply surface (`Denied` → 403, repo error → 500).
/// Used by the takeover capability gate, keeping "lacks group-admin" (403)
/// distinct from a 409 ownership conflict.
fn map_authz(d: AuthzDenied) -> ApplyError {
match d {
AuthzDenied::Denied => ApplyError::Forbidden,
AuthzDenied::Repo(e) => ApplyError::AuthzRepo(e.to_string()),
}
}
/// §6: map a group-repo failure from the declarative create path — a real DB
/// error is a 500, everything else (slug/parent conflict) is a 422 with the
/// actionable message.
fn map_group_repo_err(e: crate::group_repo::GroupRepositoryError) -> ApplyError {
match e {
crate::group_repo::GroupRepositoryError::Db(e) => ApplyError::Backend(e.to_string()),
other => ApplyError::Invalid(other.to_string()),
}
}
/// Owner of an apply node (Phase 5): an app or a group. The apply engine is
/// owner-generic — only script-create ownership, var writes, the current-state
/// load, and the advisory lock differ; the diff and all other CRUD are shared.
/// A group node has only scripts + vars (no routes / triggers / secrets).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApplyOwner {
App(AppId),
Group(GroupId),
}
impl ApplyOwner {
/// `(app_id, group_id)` for a `NewScript` — exactly one is `Some`.
fn script_owner(self) -> (Option<AppId>, Option<GroupId>) {
match self {
Self::App(a) => (Some(a), None),
Self::Group(g) => (None, Some(g)),
}
}
/// As the shared [`ScriptOwner`] — for the module/extension-point repos.
fn as_script_owner(self) -> picloud_shared::ScriptOwner {
match self {
Self::App(a) => picloud_shared::ScriptOwner::App(a),
Self::Group(g) => picloud_shared::ScriptOwner::Group(g),
}
}
/// As a [`SecretOwner`] — for resolving an email trigger's inbound secret
/// against the owner's store (§4.5 M5: an app trigger vs a group template).
fn secret_owner(self) -> crate::secrets_service::SecretOwner {
match self {
Self::App(a) => crate::secrets_service::SecretOwner::App(a),
Self::Group(g) => crate::secrets_service::SecretOwner::Group(g),
}
}
async fn set_var_tx(
self,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
key: &str,
value: &serde_json::Value,
) -> Result<(), ApplyError> {
match self {
Self::App(a) => set_app_var_tx(tx, a, key, value).await,
Self::Group(g) => set_group_var_tx(tx, g, key, value).await,
}
}
async fn delete_var_tx(
self,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
key: &str,
) -> Result<(), ApplyError> {
match self {
Self::App(a) => delete_app_var_tx(tx, a, key).await,
Self::Group(g) => delete_group_var_tx(tx, g, key).await,
}
}
}
fn diff_scripts(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
let by_name: HashMap<String, &Script> = current
.scripts
.iter()
.map(|s| (s.name.to_lowercase(), s))
.collect();
let desired: HashSet<String> = bundle
.scripts
.iter()
.map(|s| s.name.to_lowercase())
.collect();
let mut out = Vec::new();
for s in &bundle.scripts {
match by_name.get(&s.name.to_lowercase()) {
None => out.push(ResourceChange {
op: Op::Create,
key: s.name.clone(),
detail: None,
}),
Some(cur) => {
if let Some(reason) = script_update_reason(cur, s) {
out.push(ResourceChange {
op: Op::Update,
key: s.name.clone(),
detail: Some(reason),
});
} else {
out.push(ResourceChange {
op: Op::NoOp,
key: s.name.clone(),
detail: None,
});
}
}
}
}
for s in &current.scripts {
if !desired.contains(&s.name.to_lowercase()) {
out.push(ResourceChange {
op: Op::Delete,
key: s.name.clone(),
detail: None,
});
}
}
out
}
/// Returns `Some(reason)` if the desired script differs from current. A
/// `None` optional field means "unspecified — leave as-is", so it never
/// forces an update.
fn script_update_reason(cur: &Script, desired: &BundleScript) -> Option<String> {
if cur.source != desired.source {
return Some("source changed".into());
}
if cur.kind != desired.kind {
return Some("kind changed".into());
}
// Declarative (default true): always reconcile to the manifest's value.
if cur.enabled != desired.enabled {
return Some(if desired.enabled {
"enabled".into()
} else {
"disabled".into()
});
}
// Sparse like the other optional fields: an omitted (`None`) description
// means "leave as-is", so only an explicitly-set value that differs
// forces an update. (Clearing a description is done in the dashboard.)
if let Some(d) = &desired.description {
if cur.description.as_ref() != Some(d) {
return Some("description changed".into());
}
}
if let Some(t) = desired.timeout_seconds {
if i64::from(cur.timeout_seconds) != i64::from(t) {
return Some("timeout changed".into());
}
}
if let Some(m) = desired.memory_limit_mb {
if i64::from(cur.memory_limit_mb) != i64::from(m) {
return Some("memory changed".into());
}
}
if let Some(sb) = desired.sandbox {
if cur.sandbox != sb {
return Some("sandbox changed".into());
}
}
None
}
fn diff_routes(
current: &CurrentState,
bundle: &Bundle,
name_by_id: &HashMap<ScriptId, String>,
) -> Vec<ResourceChange> {
let by_key: HashMap<String, &Route> = current
.routes
.iter()
.map(|r| {
(
route_key(
r.method.as_deref(),
r.host_kind,
&r.host,
r.path_kind,
&r.path,
),
r,
)
})
.collect();
let desired: HashSet<String> = bundle
.routes
.iter()
.map(|r| {
route_key(
r.method.as_deref(),
r.host_kind,
&r.host,
r.path_kind,
&r.path,
)
})
.collect();
let mut out = Vec::new();
for r in &bundle.routes {
let key = route_key(
r.method.as_deref(),
r.host_kind,
&r.host,
r.path_kind,
&r.path,
);
match by_key.get(&key) {
None => out.push(ResourceChange {
op: Op::Create,
key,
detail: Some(format!("{}", r.script)),
}),
Some(cur) => {
let cur_script = name_by_id.get(&cur.script_id).map(String::as_str);
if cur_script != Some(r.script.as_str())
|| cur.dispatch_mode != r.dispatch_mode
|| cur.host_param_name != r.host_param_name
|| cur.enabled != r.enabled
// §11 tail: a sealed toggle on a group route template must
// re-insert the row so the rebuild sees the new flag.
|| cur.sealed != r.sealed
{
out.push(ResourceChange {
op: Op::Update,
key,
detail: Some(format!("{}", r.script)),
});
} else {
out.push(ResourceChange {
op: Op::NoOp,
key,
detail: None,
});
}
}
}
}
for r in &current.routes {
let key = route_key(
r.method.as_deref(),
r.host_kind,
&r.host,
r.path_kind,
&r.path,
);
if !desired.contains(&key) {
out.push(ResourceChange {
op: Op::Delete,
key,
detail: None,
});
}
}
out
}
fn diff_triggers(
current: &CurrentState,
bundle: &Bundle,
name_by_id: &HashMap<ScriptId, String>,
) -> Vec<ResourceChange> {
let by_id: HashMap<String, &Trigger> = current
.triggers
.iter()
.filter_map(|t| current_trigger_identity(t, name_by_id).map(|id| (id, t)))
.collect();
let desired: HashSet<String> = bundle
.triggers
.iter()
.map(BundleTrigger::identity)
.collect();
let mut out = Vec::new();
for t in &bundle.triggers {
let id = t.identity();
// Semantic identity captures the whole definition for these kinds,
// so a match is a no-op (changing a field changes the identity).
if by_id.contains_key(&id) {
out.push(ResourceChange {
op: Op::NoOp,
key: id,
detail: None,
});
} else {
out.push(ResourceChange {
op: Op::Create,
key: id,
detail: None,
});
}
}
for t in &current.triggers {
// Never prune email triggers: `pull` can't represent them (the
// server stores the sealed secret, not the `inbound_secret_ref`), so
// their absence from the manifest is ambiguous, not a delete intent.
// Manage email triggers with `pic triggers rm`.
if matches!(t.details, TriggerDetails::Email { .. }) {
continue;
}
if let Some(id) = current_trigger_identity(t, name_by_id) {
if !desired.contains(&id) {
out.push(ResourceChange {
op: Op::Delete,
key: id,
detail: None,
});
}
}
}
out
}
fn diff_secrets(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
let live: HashSet<&str> = current.secret_names.iter().map(String::as_str).collect();
let declared: HashSet<&str> = bundle.secrets.iter().map(String::as_str).collect();
let mut out = Vec::new();
for name in &bundle.secrets {
if live.contains(name.as_str()) {
out.push(ResourceChange {
op: Op::NoOp,
key: name.clone(),
detail: None,
});
} else {
out.push(ResourceChange {
op: Op::Create,
key: name.clone(),
detail: Some("declared but unset — push with `pic secret set`".into()),
});
}
}
for name in &current.secret_names {
if !declared.contains(name.as_str()) {
out.push(ResourceChange {
op: Op::Delete,
key: name.clone(),
detail: Some("on server, not declared".into()),
});
}
}
out
}
/// Diff extension-point markers by name (§5.5). Name-only identity like
/// secrets, but — unlike secrets — the markers are real declarations the
/// manifest owns: a declared-but-absent name is a Create (the apply inserts
/// it), and a live-but-undeclared name is a Delete (pruned under `--prune`),
/// mirroring `vars`.
fn diff_extension_points(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
let live: HashSet<&str> = current
.extension_point_names
.iter()
.map(String::as_str)
.collect();
let declared: HashSet<&str> = bundle.extension_points.iter().map(String::as_str).collect();
let mut out = Vec::new();
for name in &bundle.extension_points {
out.push(ResourceChange {
op: if live.contains(name.as_str()) {
Op::NoOp
} else {
Op::Create
},
key: name.clone(),
detail: None,
});
}
for name in &current.extension_point_names {
if !declared.contains(name.as_str()) {
out.push(ResourceChange {
op: Op::Delete,
key: name.clone(),
detail: Some("on server, not declared".into()),
});
}
}
out
}
/// Diff shared group-collection markers by `(name, kind)` (§11.6). A
/// declared-but-absent marker is a Create, a live-but-undeclared one is a
/// Delete (pruned under `--prune`). Each change carries `key = name`,
/// `detail = Some(kind)` so the reconcile knows which store to touch and
/// `pic plan` renders the kind. Identity is `(LOWER(name), kind)`, so the same
/// name as both a `kv` and a `docs` collection are distinct markers.
fn diff_collections(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
let live: HashSet<(String, &str)> = current
.collections
.iter()
.map(|(n, k)| (n.to_lowercase(), k.as_str()))
.collect();
let declared: HashSet<(String, &str)> = bundle
.collections
.iter()
.map(|c| (c.name.to_lowercase(), c.kind.as_str()))
.collect();
let mut out = Vec::new();
for c in &bundle.collections {
out.push(ResourceChange {
op: if live.contains(&(c.name.to_lowercase(), c.kind.as_str())) {
Op::NoOp
} else {
Op::Create
},
key: c.name.clone(),
detail: Some(c.kind.clone()),
});
}
for (name, kind) in &current.collections {
if !declared.contains(&(name.to_lowercase(), kind.as_str())) {
out.push(ResourceChange {
op: Op::Delete,
key: name.clone(),
detail: Some(kind.clone()),
});
}
}
out
}
/// §11 tail per-app suppression diff. Reconciles the app's declared
/// `suppress_triggers` (handler script names) + `suppress_routes` (paths) into
/// `template_suppressions` markers. Keyed `"{target_kind}:{reference}"` so a
/// trigger ref and a route ref of the same string are distinct markers; the
/// reconcile splits the key back on the first `:`.
fn diff_suppressions(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
// Desired (kind, reference) pairs from the two manifest lists.
let desired: Vec<(&str, &str)> = bundle
.suppress_triggers
.iter()
.map(|r| (crate::suppression_repo::TARGET_TRIGGER, r.as_str()))
.chain(
bundle
.suppress_routes
.iter()
.map(|r| (crate::suppression_repo::TARGET_ROUTE, r.as_str())),
)
.collect();
let live: HashSet<(&str, &str)> = current
.suppressions
.iter()
.map(|(k, r)| (k.as_str(), r.as_str()))
.collect();
let declared: HashSet<(&str, &str)> = desired.iter().copied().collect();
let mut out = Vec::new();
for (kind, reference) in &desired {
out.push(ResourceChange {
op: if live.contains(&(*kind, *reference)) {
Op::NoOp
} else {
Op::Create
},
key: format!("{kind}:{reference}"),
detail: Some((*kind).to_string()),
});
}
for (kind, reference) in &current.suppressions {
if !declared.contains(&(kind.as_str(), reference.as_str())) {
out.push(ResourceChange {
op: Op::Delete,
key: format!("{kind}:{reference}"),
detail: Some(kind.clone()),
});
}
}
out
}
/// Reject any email trigger whose referenced secret isn't set, so `plan` and
/// `apply` give the same answer (apply also fails in `resolve_and_seal`, but
/// only after taking the lock — surfacing it here keeps plan honest).
///
/// This checks the secret *reference exists*. `resolve_and_seal` additionally
/// requires the resolved value to be a non-empty string; `plan` deliberately
/// does not decrypt secrets, so a reference to a set-but-empty secret passes
/// `plan` and is caught at `apply` — the one residual plan/apply divergence,
/// and a deliberate one (plan stays read-only and crypto-free).
fn validate_email_secrets_present(
bundle: &Bundle,
secret_names: &[String],
) -> Result<(), ApplyError> {
for t in &bundle.triggers {
if let BundleTrigger::Email {
script,
inbound_secret_ref,
..
} = t
{
if !secret_names.iter().any(|n| n == inbound_secret_ref) {
return Err(ApplyError::Invalid(format!(
"email trigger on `{script}` references secret \
`{inbound_secret_ref}`, which is not set; push it with \
`pic secret set` first"
)));
}
}
}
Ok(())
}
/// Semantic identity for a live trigger. Returns `None` only for
/// `dead_letter` (not representable in the manifest), so it's ignored by the
/// diff entirely. `email` returns `Some` so a declared email trigger matches
/// as NoOp, but `diff_triggers` separately refuses to emit a Delete for it.
fn current_trigger_identity(t: &Trigger, name_by_id: &HashMap<ScriptId, String>) -> Option<String> {
let script = name_by_id.get(&t.script_id)?;
// §11 tail: `sealed` (and §11.6 `shared`) are part of the identity for the
// event kinds (mirroring `BundleTrigger::identity`), so toggling either on a
// group template re-diffs.
let sealed = t.sealed;
let shared = t.shared;
match &t.details {
TriggerDetails::Kv {
collection_glob,
ops,
} => Some(format!(
"kv|{script}|{collection_glob}|{}|{sealed}|{shared}",
sorted_csv(ops.iter().copied().map(KvEventOp::as_str))
)),
TriggerDetails::Docs {
collection_glob,
ops,
} => Some(format!(
"docs|{script}|{collection_glob}|{}|{sealed}|{shared}",
sorted_csv(ops.iter().copied().map(DocsEventOp::as_str))
)),
TriggerDetails::Files {
collection_glob,
ops,
} => Some(format!(
"files|{script}|{collection_glob}|{}|{sealed}|{shared}",
sorted_csv(ops.iter().copied().map(FilesEventOp::as_str))
)),
TriggerDetails::Cron {
schedule, timezone, ..
} => Some(format!("cron|{script}|{schedule}|{timezone}")),
TriggerDetails::Pubsub { topic_pattern } => {
Some(format!("pubsub|{script}|{topic_pattern}|{sealed}|{shared}"))
}
TriggerDetails::Email { .. } => Some(format!("email|{script}")),
TriggerDetails::Queue { queue_name, .. } => Some(format!("queue|{queue_name}|{shared}")),
// §11.6 B2: a GROUP-owned shared dead_letter is declarative (a `[group]`
// template), so it participates in the diff like the other group
// templates — its identity mirrors `BundleTrigger::identity`. An
// APP-owned dead_letter (interactive API, not manifest-representable)
// stays `None`, so the diff neither matches nor prunes it (same as
// email).
TriggerDetails::DeadLetter { source_filter, .. } => {
if t.group_id.is_some() && shared {
Some(format!(
"dead_letter|{script}|{}|{shared}",
source_filter.as_deref().unwrap_or("")
))
} else {
None
}
}
}
}
fn route_key(
method: Option<&str>,
host_kind: HostKind,
host: &str,
path_kind: PathKind,
path: &str,
) -> String {
let m = method.map_or_else(|| "ANY".to_string(), str::to_uppercase);
format!(
"{m} {} {} {} {path}",
host_kind_str(host_kind),
host,
path_kind_str(path_kind)
)
}
fn host_kind_str(k: HostKind) -> &'static str {
match k {
HostKind::Any => "any",
HostKind::Strict => "strict",
HostKind::Wildcard => "wildcard",
}
}
fn path_kind_str(k: PathKind) -> &'static str {
match k {
PathKind::Exact => "exact",
PathKind::Prefix => "prefix",
PathKind::Param => "param",
}
}
/// Sort + comma-join op strings so order is insignificant for identity.
fn sorted_csv<'a>(ops: impl Iterator<Item = &'a str>) -> String {
let set: BTreeSet<&str> = ops.collect();
set.into_iter().collect::<Vec<_>>().join(",")
}
fn reject_reserved_path(path: &str) -> Result<(), ApplyError> {
if RESERVED_PATH_EXACT.contains(&path)
|| RESERVED_PATH_PREFIXES.iter().any(|p| path.starts_with(p))
{
return Err(ApplyError::Invalid(format!("path `{path}` is reserved")));
}
Ok(())
}
/// §4.7 reachability warning: an *enabled* endpoint script with no route and
/// no trigger has no event surface — it's only reachable via the
/// execute-by-id bypass / `invoke()`. Modules are exempt (never invoked
/// directly); disabled endpoints are intentionally inert, so skip them.
fn unreachable_endpoint_warnings(bundle: &Bundle) -> Vec<String> {
let bound: HashSet<&str> = bundle
.routes
.iter()
.map(|r| r.script.as_str())
.chain(bundle.triggers.iter().map(BundleTrigger::script))
.collect();
bundle
.scripts
.iter()
.filter(|s| s.kind == ScriptKind::Endpoint && s.enabled && !bound.contains(s.name.as_str()))
.map(|s| {
format!(
"endpoint `{}` has no route or trigger — only reachable via the \
execute-by-id bypass / invoke()",
s.name
)
})
.collect()
}
/// §4.7 reachability warnings: an *enabled* route or trigger bound to a
/// script the manifest marks *disabled* is deployed but unreachable (the
/// route 404s, the trigger won't fire). Valid desired state, so a warning —
/// not an error — keyed on the bundle alone.
/// Consistent phrasing for a GROUP-only feature declared on an APP node — the
/// several such guards in `validate_bundle_for` (§11.6 shared collections, §11
/// tail `sealed`, shared triggers). Renders `an app <what> — <why>` so the
/// app/group boundary reads the same everywhere.
fn app_only_reject(what: &str, why: &str) -> ApplyError {
ApplyError::Invalid(format!("an app {what}{why}"))
}
fn disabled_target_warnings(bundle: &Bundle) -> Vec<String> {
let disabled: HashSet<&str> = bundle
.scripts
.iter()
.filter(|s| !s.enabled)
.map(|s| s.name.as_str())
.collect();
if disabled.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
for r in &bundle.routes {
if r.enabled && disabled.contains(r.script.as_str()) {
out.push(format!(
"route `{}` is enabled but its script `{}` is disabled — it will 404",
route_key(
r.method.as_deref(),
r.host_kind,
&r.host,
r.path_kind,
&r.path
),
r.script
));
}
}
for t in &bundle.triggers {
if disabled.contains(t.script()) {
out.push(format!(
"{} trigger targets disabled script `{}` — it will not fire",
t.kind_str(),
t.script()
));
}
}
out
}
/// M4 soft-warn: a `workflow`-kind step that (directly or transitively, within
/// this bundle) nests back into its own workflow forms a cycle. This is not an
/// error — the runtime depth ceiling (`PICLOUD_WORKFLOW_MAX_DEPTH`) bounds it —
/// but it's almost always a mistake, so we surface it at plan time. Only edges
/// to workflows present in the same bundle are considered (a reference to an
/// external workflow can't be resolved here).
fn workflow_nesting_warnings(bundle: &Bundle) -> Vec<String> {
use std::collections::{BTreeSet, HashMap, HashSet};
if bundle.workflows.is_empty() {
return Vec::new();
}
let names: HashSet<String> = bundle
.workflows
.iter()
.map(|w| w.name.to_lowercase())
.collect();
// from → the set of in-bundle workflows it nests into.
let mut edges: HashMap<String, BTreeSet<String>> = HashMap::new();
for w in &bundle.workflows {
let from = w.name.to_lowercase();
for s in &w.definition.steps {
if let Some(t) = s.workflow.as_deref() {
let t = t.to_lowercase();
if names.contains(&t) {
edges.entry(from.clone()).or_default().insert(t);
}
}
}
}
let mut out = Vec::new();
for w in &bundle.workflows {
let start = w.name.to_lowercase();
// Reachability from `start`: if we can get back to `start`, it's cyclic.
let mut seen: HashSet<String> = HashSet::new();
let mut stack: Vec<String> = edges.get(&start).into_iter().flatten().cloned().collect();
let mut cyclic = false;
while let Some(n) = stack.pop() {
if n == start {
cyclic = true;
break;
}
if seen.insert(n.clone()) {
if let Some(next) = edges.get(&n) {
stack.extend(next.iter().cloned());
}
}
}
if cyclic {
out.push(format!(
"workflow `{}` can nest into itself (a sub-workflow cycle) — runs are \
bounded by PICLOUD_WORKFLOW_MAX_DEPTH, not infinite, but this is likely a bug",
w.name
));
}
}
out
}
/// Per-kind structural validation for one desired trigger — kept at parity
/// with the interactive trigger API so `apply` can't write a trigger the
/// dashboard would have rejected. Pure (no live state), so it's unit-tested
/// directly. The script-binding checks live in `validate_bundle`.
fn validate_trigger_shape(t: &BundleTrigger) -> Result<(), ApplyError> {
match t {
BundleTrigger::Email {
inbound_secret_ref, ..
} if inbound_secret_ref.trim().is_empty() => {
return Err(ApplyError::Invalid(format!(
"email trigger on `{}` needs inbound_secret_ref (a secret name)",
t.script()
)));
}
BundleTrigger::Queue {
queue_name,
visibility_timeout_secs,
..
} => {
if queue_name.trim().is_empty() {
return Err(ApplyError::Invalid("queue trigger needs queue_name".into()));
}
if let Some(v) = visibility_timeout_secs {
// Parity with the interactive API: the floor is the dispatcher
// tick/reclaim-cadence minimum (`triggers_api`), and the ceiling
// matches `trigger_repo`'s queue check. Apply previously allowed
// a [5, 29] floor the dashboard rejects.
let min = crate::triggers_api::MIN_QUEUE_VISIBILITY_TIMEOUT_SECS;
if !(min..=3600).contains(v) {
return Err(ApplyError::Invalid(format!(
"queue `{queue_name}`: visibility_timeout_secs must be in [{min}, 3600]"
)));
}
}
}
BundleTrigger::Cron {
schedule, timezone, ..
} => {
crate::cron_scheduler::validate_schedule(schedule).map_err(|e| {
ApplyError::Invalid(format!(
"cron trigger on `{}`: invalid schedule: {e}",
t.script()
))
})?;
crate::cron_scheduler::validate_timezone(timezone).map_err(|e| {
ApplyError::Invalid(format!(
"cron trigger on `{}`: invalid timezone: {e}",
t.script()
))
})?;
}
// Parity with the interactive trigger API, which rejects an empty
// collection_glob (`create_{kv,docs,files}_trigger`).
BundleTrigger::Kv {
collection_glob, ..
}
| BundleTrigger::Docs {
collection_glob, ..
}
| BundleTrigger::Files {
collection_glob, ..
} if collection_glob.trim().is_empty() => {
return Err(ApplyError::Invalid(format!(
"{} trigger on `{}` needs a non-empty collection_glob",
t.kind_str(),
t.script()
)));
}
// Parity with `create_pubsub_trigger`'s topic-pattern check — otherwise
// a malformed pattern is written and silently never matches at dispatch.
BundleTrigger::Pubsub { topic_pattern, .. } => {
picloud_shared::validate_topic_pattern(topic_pattern).map_err(|e| {
ApplyError::Invalid(format!(
"pubsub trigger on `{}`: invalid topic_pattern: {e}",
t.script()
))
})?;
}
_ => {}
}
Ok(())
}
/// Summary of what an `apply` changed.
#[derive(Debug, Default, Serialize)]
pub struct ApplyReport {
pub scripts_created: u32,
pub scripts_updated: u32,
pub scripts_deleted: u32,
pub routes_created: u32,
pub routes_updated: u32,
pub routes_deleted: u32,
pub triggers_created: u32,
pub triggers_deleted: u32,
#[serde(default)]
pub vars_created: u32,
#[serde(default)]
pub vars_updated: u32,
#[serde(default)]
pub vars_deleted: u32,
#[serde(default)]
pub extension_points_created: u32,
#[serde(default)]
pub extension_points_deleted: u32,
#[serde(default)]
pub collections_created: u32,
#[serde(default)]
pub collections_deleted: u32,
#[serde(default)]
pub suppressions_created: u32,
#[serde(default)]
pub suppressions_deleted: u32,
#[serde(default)]
pub workflows_created: u32,
#[serde(default)]
pub workflows_updated: u32,
#[serde(default)]
pub workflows_deleted: u32,
#[serde(default)]
pub interceptors_created: u32,
#[serde(default)]
pub interceptors_updated: u32,
#[serde(default)]
pub interceptors_deleted: u32,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
/// The key set of a name→id map, for passing the inherited target *names*
/// into `validate_bundle` without leaking the ids.
fn keys_set(m: &HashMap<String, ScriptId>) -> HashSet<String> {
m.keys().cloned().collect()
}
/// A tree node resolved to its owner + loaded current state + computed plan,
/// ready to reconcile. `app_chain` is the ordered (nearest-first) ancestor
/// group ids for an app node (empty for a group node).
struct PreparedNode<'a> {
owner: ApplyOwner,
node: &'a TreeNode,
current: CurrentState,
current_names: HashMap<ScriptId, String>,
plan: Plan,
app_chain: Vec<GroupId>,
}
/// §6: a GROUP node whose slug does NOT yet exist on the server — it will be
/// CREATED (its declared parent inferred from directory nesting). Its `current`
/// is empty and `plan` is a full create; the plan path previews it, and the
/// apply path creates it in-tx before reconciling this content. Kept separate
/// from `PreparedNode` because it has no `GroupId` until created.
struct ToCreateGroup<'a> {
node: &'a TreeNode,
plan: Plan,
}
/// §6: the invariant context threaded through one `reconcile_group_structure_tx`
/// pass — the attach ceiling (id for the subtree check, slug for messages) and
/// the acting principal (for per-mutation RBAC). Groups the args the create /
/// reparent helpers share.
struct ReconcileCtx<'a> {
attach_gid: Option<GroupId>,
attach_slug: Option<&'a str>,
principal: &'a Principal,
mode: StructureMode,
}
/// FNV-1a over a set of already-sorted per-resource token parts — the same
/// hashing `state_token` uses, lifted to combine many nodes into one tree
/// token. Order-independent because the caller sorts `parts`.
fn combine_tokens(parts: &[String]) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for p in parts {
for b in p.as_bytes() {
h ^= u64::from(*b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h ^= u64::from(b'\n');
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{h:016x}")
}
fn resolve_script(
name_to_id: &HashMap<String, ScriptId>,
name: &str,
) -> Result<ScriptId, ApplyError> {
name_to_id
.get(&name.to_lowercase())
.copied()
.ok_or_else(|| ApplyError::Invalid(format!("binding references unknown script `{name}`")))
}
async fn insert_bundle_route(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
owner: ScriptOwner,
script_id: ScriptId,
br: &BundleRoute,
) -> Result<(), ApplyError> {
let new = NewRoute {
owner,
script_id,
host_kind: br.host_kind,
host: br.host.clone(),
host_param_name: br.host_param_name.clone(),
path_kind: br.path_kind,
path: br.path.clone(),
method: br.method.clone(),
dispatch_mode: br.dispatch_mode,
enabled: br.enabled,
// §11 tail: a sealed group route template is non-suppressible. Rejected
// on an app owner earlier in `validate_bundle_for`.
sealed: br.sealed,
};
insert_route_tx(tx, &new).await.map_err(map_repo)?;
Ok(())
}
fn bundle_trigger_details(bt: &BundleTrigger, default_visibility: u32) -> TriggerDetails {
match bt {
BundleTrigger::Kv {
collection_glob,
ops,
..
} => TriggerDetails::Kv {
collection_glob: collection_glob.clone(),
ops: ops.clone(),
},
BundleTrigger::Docs {
collection_glob,
ops,
..
} => TriggerDetails::Docs {
collection_glob: collection_glob.clone(),
ops: ops.clone(),
},
BundleTrigger::Files {
collection_glob,
ops,
..
} => TriggerDetails::Files {
collection_glob: collection_glob.clone(),
ops: ops.clone(),
},
BundleTrigger::Cron {
schedule, timezone, ..
} => TriggerDetails::Cron {
schedule: schedule.clone(),
timezone: timezone.clone(),
last_fired_at: None,
},
BundleTrigger::Pubsub { topic_pattern, .. } => TriggerDetails::Pubsub {
topic_pattern: topic_pattern.clone(),
},
BundleTrigger::Queue {
queue_name,
visibility_timeout_secs,
..
} => TriggerDetails::Queue {
queue_name: queue_name.clone(),
visibility_timeout_secs: visibility_timeout_secs.unwrap_or(default_visibility),
last_fired_at: None,
},
BundleTrigger::DeadLetter { source_filter, .. } => TriggerDetails::DeadLetter {
source_filter: source_filter.clone(),
trigger_id_filter: None,
script_id_filter: None,
},
BundleTrigger::Email { .. } => unreachable!("email handled separately"),
}
}
fn map_repo(e: ScriptRepositoryError) -> ApplyError {
match e {
ScriptRepositoryError::Conflict(m) => ApplyError::Invalid(m),
ScriptRepositoryError::NotFound(_) => {
ApplyError::Invalid("a referenced resource was not found".into())
}
ScriptRepositoryError::Db(e) => ApplyError::Backend(e.to_string()),
}
}
fn map_trig(e: TriggerRepoError) -> ApplyError {
match e {
TriggerRepoError::Invalid(m) => ApplyError::Invalid(m),
TriggerRepoError::NotFound(_) => ApplyError::Invalid("trigger not found".into()),
TriggerRepoError::Db(e) => ApplyError::Backend(e.to_string()),
}
}
/// A stable fingerprint of an app's live state, covering exactly what the
/// reconcile diff keys on: script identity + write-counter (`version` bumps on
/// any script edit), route identity + binding/attrs, trigger semantic identity
/// (via `current_trigger_identity`, so dead-letter triggers — which the diff
/// ignores — are excluded), and secret names. Any out-of-band change to those
/// flips the token, so `plan`-then-`apply` can detect the app moving underneath.
///
/// Deliberately mirrors the diff's inputs so a mismatch means the *reviewed
/// plan* would now differ — not merely that some unrelated row changed.
///
/// Uses FNV-1a (deterministic across process restarts, unlike `DefaultHasher`'s
/// per-process seed) so a token stored by `pic plan` still matches on a later
/// `pic apply`. A hash collision can only ever yield a false "unchanged", which
/// is the same risk class as not checking at all — never a false refusal.
#[must_use]
pub fn state_token(current: &CurrentState) -> String {
state_token_with_names(current, &HashMap::new())
}
/// `state_token` with `current_names` (group-bound binding ids → names, Phase
/// 4) so a trigger bound to an inherited group script contributes to the
/// fingerprint. Without it such a trigger is silently excluded and the
/// bound-plan (StateMoved) check has a false-negative for that binding class.
fn state_token_with_names(
current: &CurrentState,
current_names: &HashMap<ScriptId, String>,
) -> String {
let mut parts: Vec<String> = Vec::with_capacity(
current.scripts.len()
+ current.routes.len()
+ current.triggers.len()
+ current.secret_names.len()
+ current.vars.len()
+ current.extension_point_names.len()
+ current.collections.len(),
);
for s in &current.scripts {
parts.push(format!(
"s|{}|{}|{}",
s.name.to_lowercase(),
s.version,
s.enabled
));
}
for r in &current.routes {
parts.push(format!(
"r|{}|{:?}|{:?}|{}|{}",
route_key(
r.method.as_deref(),
r.host_kind,
&r.host,
r.path_kind,
&r.path
),
r.script_id,
r.dispatch_mode,
r.host_param_name.as_deref().unwrap_or(""),
r.enabled,
));
}
// Mirror exactly what the trigger diff keys on: `current_trigger_identity`
// excludes dead-letter triggers (the diff ignores them) and keys on the
// semantic identity — NOT raw id/enabled. Hashing dead-letter rows or the
// (currently inert) `enabled` flag would force a false refusal over a
// change the manifest can't represent.
let script_name_by_id: HashMap<ScriptId, String> = current
.scripts
.iter()
.map(|s| (s.id, s.name.clone()))
.chain(current_names.iter().map(|(id, n)| (*id, n.clone())))
.collect();
for t in &current.triggers {
if let Some(id) = current_trigger_identity(t, &script_name_by_id) {
parts.push(format!("t|{id}"));
}
}
for n in &current.secret_names {
parts.push(format!("k|{n}"));
}
// Vars are value-sensitive (the value lives in the manifest), so the token
// moves when a value changes — the bound-plan check then catches an edit
// between plan and apply. `to_string` on a serde_json::Value is stable
// (serde_json sorts object keys by default).
for (key, value) in &current.vars {
parts.push(format!(
"v|{key}|{}",
serde_json::to_string(value).unwrap_or_default()
));
}
// Extension-point markers are name-only, like secret names.
for n in &current.extension_point_names {
parts.push(format!("ep|{n}"));
}
// Shared group-collection markers carry a kind (§11.6), so a kind change
// moves the token.
for (name, kind) in &current.collections {
parts.push(format!("coll|{kind}|{name}"));
}
// Order-independent: sort the per-resource tokens before hashing.
parts.sort_unstable();
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for p in &parts {
for b in p.as_bytes() {
h ^= u64::from(*b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h ^= u64::from(b'\n');
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{h:016x}")
}
/// Per-node advisory lock key, namespaced so it can't collide with the
/// queue-trigger lock space. App and group ids live in disjoint UUID spaces and
/// are tagged by owner kind, so an app and a group never share a key.
fn apply_lock_key(owner: ApplyOwner) -> i64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
"picloud-apply".hash(&mut h);
match owner {
ApplyOwner::App(a) => {
"app".hash(&mut h);
a.into_inner().hash(&mut h);
}
ApplyOwner::Group(g) => {
"group".hash(&mut h);
g.into_inner().hash(&mut h);
}
}
i64::from_ne_bytes(h.finish().to_ne_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn script(name: &str, source: &str) -> Script {
Script {
id: ScriptId::from(uuid::Uuid::new_v4()),
app_id: Some(AppId::from(uuid::Uuid::nil())),
group_id: None,
name: name.to_string(),
description: None,
version: 1,
source: source.to_string(),
kind: ScriptKind::Endpoint,
timeout_seconds: 30,
sandbox: ScriptSandbox::empty(),
memory_limit_mb: 256,
enabled: true,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
fn bundle_script(name: &str, source: &str) -> BundleScript {
BundleScript {
name: name.to_string(),
source: source.to_string(),
kind: ScriptKind::Endpoint,
description: None,
timeout_seconds: None,
memory_limit_mb: None,
sandbox: None,
enabled: true,
}
}
#[test]
fn empty_app_is_all_create() {
let current = CurrentState::default();
let bundle = Bundle {
scripts: vec![bundle_script("a", "1")],
routes: vec![],
triggers: vec![],
secrets: vec!["S".into()],
vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
collections: Vec::new(),
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
workflows: Vec::new(),
interceptors: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts.len(), 1);
assert_eq!(plan.scripts[0].op, Op::Create);
assert_eq!(plan.secrets[0].op, Op::Create); // declared-but-unset
assert!(!plan.is_noop());
}
#[test]
fn identical_is_all_noop() {
let s = script("a", "let x = 1;");
let current = CurrentState {
scripts: vec![s.clone()],
secret_names: vec!["S".into()],
..CurrentState::default()
};
let bundle = Bundle {
scripts: vec![bundle_script("a", "let x = 1;")],
routes: vec![],
triggers: vec![],
secrets: vec!["S".into()],
vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
collections: Vec::new(),
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
workflows: Vec::new(),
interceptors: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert!(plan.is_noop(), "expected all no-op, got {plan:?}");
}
#[test]
fn changed_source_is_update() {
let current = CurrentState {
scripts: vec![script("a", "old")],
..CurrentState::default()
};
let bundle = Bundle {
scripts: vec![bundle_script("a", "new")],
routes: vec![],
triggers: vec![],
secrets: vec![],
vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
collections: Vec::new(),
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
workflows: Vec::new(),
interceptors: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts[0].op, Op::Update);
}
#[test]
fn missing_from_bundle_is_delete() {
let current = CurrentState {
scripts: vec![script("gone", "x")],
secret_names: vec!["OLD".into()],
..CurrentState::default()
};
let bundle = Bundle {
scripts: vec![],
routes: vec![],
triggers: vec![],
secrets: vec![],
vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
collections: Vec::new(),
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
workflows: Vec::new(),
interceptors: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert_eq!(plan.scripts[0].op, Op::Delete);
assert_eq!(plan.secrets[0].op, Op::Delete);
}
#[test]
fn route_rebind_is_update() {
let s = script("a", "x");
let sid = s.id;
let route = Route {
id: uuid::Uuid::new_v4(),
app_id: Some(AppId::from(uuid::Uuid::nil())),
group_id: None,
script_id: sid,
host_kind: HostKind::Any,
host: String::new(),
host_param_name: None,
path_kind: PathKind::Exact,
path: "/p".into(),
method: Some("POST".into()),
dispatch_mode: DispatchMode::Sync,
enabled: true,
sealed: false,
created_at: Utc::now(),
};
let current = CurrentState {
scripts: vec![s, script("b", "y")],
routes: vec![route],
..CurrentState::default()
};
// Same tuple, bound to a different script → update.
let bundle = Bundle {
scripts: vec![],
routes: vec![BundleRoute {
script: "b".into(),
method: Some("POST".into()),
host_kind: HostKind::Any,
host: String::new(),
host_param_name: None,
path_kind: PathKind::Exact,
path: "/p".into(),
dispatch_mode: DispatchMode::Sync,
enabled: true,
sealed: false,
}],
triggers: vec![],
secrets: vec![],
vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
collections: Vec::new(),
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
workflows: Vec::new(),
interceptors: Vec::new(),
};
let plan = compute_diff(&current, &bundle);
assert_eq!(plan.routes[0].op, Op::Update);
}
fn empty_bundle() -> Bundle {
Bundle {
scripts: vec![],
routes: vec![],
triggers: vec![],
secrets: vec![],
vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
collections: Vec::new(),
suppress_triggers: Vec::new(),
suppress_routes: Vec::new(),
workflows: Vec::new(),
interceptors: Vec::new(),
}
}
fn trig(script_id: ScriptId, details: TriggerDetails) -> Trigger {
use crate::trigger_config::BackoffShape;
use crate::trigger_repo::TriggerKind;
let kind = match &details {
TriggerDetails::Cron { .. } => TriggerKind::Cron,
TriggerDetails::DeadLetter { .. } => TriggerKind::DeadLetter,
TriggerDetails::Queue { .. } => TriggerKind::Queue,
// Other kinds aren't distinguished by these tests (the diff keys
// on `details`, not `kind`).
_ => TriggerKind::Kv,
};
Trigger {
id: TriggerId::from(uuid::Uuid::new_v4()),
app_id: Some(AppId::from(uuid::Uuid::nil())),
group_id: None,
script_id,
name: "t".into(),
kind,
enabled: true,
sealed: false,
shared: false,
materialized: false,
dispatch_mode: TriggerDispatchMode::Async,
retry_max_attempts: 3,
retry_backoff: BackoffShape::Exponential,
retry_base_ms: 1000,
registered_by_principal: AdminUserId::from(uuid::Uuid::nil()),
created_at: Utc::now(),
updated_at: Utc::now(),
details,
}
}
#[test]
fn script_update_only_on_real_change() {
// `script("a", ..)` defaults to timeout 30, mem 256, empty sandbox.
let current = CurrentState {
scripts: vec![script("a", "let x = 1;")],
..CurrentState::default()
};
// Same source, optional fields unset → NoOp ("None = leave as-is").
let mut b = empty_bundle();
b.scripts = vec![bundle_script("a", "let x = 1;")];
assert_eq!(compute_diff(&current, &b).scripts[0].op, Op::NoOp);
// Explicit timeout that MATCHES current → still NoOp.
let mut same = empty_bundle();
let mut s = bundle_script("a", "let x = 1;");
s.timeout_seconds = Some(30);
same.scripts = vec![s];
assert_eq!(compute_diff(&current, &same).scripts[0].op, Op::NoOp);
// Explicit timeout that DIFFERS → Update.
let mut chg = empty_bundle();
let mut s2 = bundle_script("a", "let x = 1;");
s2.timeout_seconds = Some(99);
chg.scripts = vec![s2];
assert_eq!(compute_diff(&current, &chg).scripts[0].op, Op::Update);
// kind change → Update.
let mut chg2 = empty_bundle();
let mut s3 = bundle_script("a", "let x = 1;");
s3.kind = ScriptKind::Module;
chg2.scripts = vec![s3];
assert_eq!(compute_diff(&current, &chg2).scripts[0].op, Op::Update);
}
#[test]
fn description_is_sparse() {
// An omitted (`None`) description must mean "leave as-is", matching
// the other optional fields — not "clear it".
let mut cur = script("a", "let x = 1;");
cur.description = Some("kept".into());
let current = CurrentState {
scripts: vec![cur],
..CurrentState::default()
};
// Omitted → NoOp (leave the stored description alone).
let mut omit = empty_bundle();
omit.scripts = vec![bundle_script("a", "let x = 1;")];
assert_eq!(compute_diff(&current, &omit).scripts[0].op, Op::NoOp);
// Same explicit value → NoOp.
let mut same = empty_bundle();
let mut s = bundle_script("a", "let x = 1;");
s.description = Some("kept".into());
same.scripts = vec![s];
assert_eq!(compute_diff(&current, &same).scripts[0].op, Op::NoOp);
// Different explicit value → Update.
let mut chg = empty_bundle();
let mut s2 = bundle_script("a", "let x = 1;");
s2.description = Some("changed".into());
chg.scripts = vec![s2];
assert_eq!(compute_diff(&current, &chg).scripts[0].op, Op::Update);
}
#[test]
fn trigger_shape_validation_matches_interactive_api() {
// Empty collection_glob is rejected (kv/docs/files).
assert!(validate_trigger_shape(&BundleTrigger::Kv {
script: "h".into(),
collection_glob: " ".into(),
ops: vec![],
dispatch_mode: None,
retry_max_attempts: None,
sealed: false,
shared: false,
})
.is_err());
// A non-empty glob passes.
assert!(validate_trigger_shape(&BundleTrigger::Docs {
script: "h".into(),
collection_glob: "users".into(),
ops: vec![],
dispatch_mode: None,
retry_max_attempts: None,
sealed: false,
shared: false,
})
.is_ok());
// A malformed pubsub topic_pattern (mid-pattern wildcard) is rejected;
// a valid one passes.
assert!(validate_trigger_shape(&BundleTrigger::Pubsub {
script: "h".into(),
topic_pattern: "user.*.created".into(),
dispatch_mode: None,
retry_max_attempts: None,
sealed: false,
shared: false,
})
.is_err());
assert!(validate_trigger_shape(&BundleTrigger::Pubsub {
script: "h".into(),
topic_pattern: "orders.created".into(),
dispatch_mode: None,
retry_max_attempts: None,
sealed: false,
shared: false,
})
.is_ok());
// Queue visibility floor matches the interactive API (>= 30): a value
// below the floor is rejected; the floor itself and `None` are ok.
let queue = |vis| BundleTrigger::Queue {
script: "h".into(),
queue_name: "jobs".into(),
visibility_timeout_secs: vis,
dispatch_mode: None,
retry_max_attempts: None,
shared: false,
};
assert!(validate_trigger_shape(&queue(Some(10))).is_err());
assert!(validate_trigger_shape(&queue(Some(
crate::triggers_api::MIN_QUEUE_VISIBILITY_TIMEOUT_SECS
)))
.is_ok());
assert!(validate_trigger_shape(&queue(None)).is_ok());
// Empty email inbound_secret_ref is rejected.
assert!(validate_trigger_shape(&BundleTrigger::Email {
script: "h".into(),
inbound_secret_ref: " ".into(),
dispatch_mode: None,
retry_max_attempts: None,
})
.is_err());
}
#[test]
fn state_token_is_stable_order_independent_and_sensitive() {
let a = script("a", "x"); // version 1
let b = script("b", "y");
let base = CurrentState {
scripts: vec![a.clone(), b.clone()],
secret_names: vec!["S".into()],
..CurrentState::default()
};
// Deterministic + order-independent.
let reordered = CurrentState {
scripts: vec![b.clone(), a.clone()],
secret_names: vec!["S".into()],
..CurrentState::default()
};
assert_eq!(state_token(&base), state_token(&reordered));
// A script edit bumps `version`, which must flip the token even if the
// source-by-name set is otherwise unchanged (out-of-band redeploy).
let mut a2 = a.clone();
a2.version += 1;
let bumped = CurrentState {
scripts: vec![a2, b.clone()],
secret_names: vec!["S".into()],
..CurrentState::default()
};
assert_ne!(state_token(&base), state_token(&bumped));
// Adding a secret name flips it too.
let with_secret = CurrentState {
scripts: vec![a, b],
secret_names: vec!["S".into(), "T".into()],
..CurrentState::default()
};
assert_ne!(state_token(&base), state_token(&with_secret));
}
#[test]
fn diff_vars_create_update_noop_delete() {
let current = CurrentState {
vars: vec![
("region".into(), serde_json::json!("eu")),
("keep".into(), serde_json::json!(1)),
("stale".into(), serde_json::json!(true)),
],
..CurrentState::default()
};
let mut vars = std::collections::BTreeMap::new();
vars.insert("region".to_string(), serde_json::json!("us")); // value changed
vars.insert("keep".to_string(), serde_json::json!(1)); // identical
vars.insert("new".to_string(), serde_json::json!("x")); // not live
// "stale" is live but undeclared → Delete.
let bundle = Bundle {
vars,
..empty_bundle()
};
let plan = compute_diff(&current, &bundle);
let op = |k: &str| plan.vars.iter().find(|c| c.key == k).unwrap().op;
assert_eq!(op("region"), Op::Update);
assert_eq!(op("keep"), Op::NoOp);
assert_eq!(op("new"), Op::Create);
assert_eq!(op("stale"), Op::Delete);
assert!(!plan.is_noop());
}
#[test]
fn state_token_is_sensitive_to_var_values_and_order_independent() {
let base = CurrentState {
vars: vec![("region".into(), serde_json::json!("eu"))],
..CurrentState::default()
};
// A value change must flip the token (covers the bound-plan check for
// an out-of-band `pic vars set` between plan and apply).
let changed = CurrentState {
vars: vec![("region".into(), serde_json::json!("us"))],
..CurrentState::default()
};
assert_ne!(state_token(&base), state_token(&changed));
// Order-independent across multiple vars.
let ab = CurrentState {
vars: vec![
("a".into(), serde_json::json!(1)),
("b".into(), serde_json::json!(2)),
],
..CurrentState::default()
};
let ba = CurrentState {
vars: vec![
("b".into(), serde_json::json!(2)),
("a".into(), serde_json::json!(1)),
],
..CurrentState::default()
};
assert_eq!(state_token(&ab), state_token(&ba));
}
#[test]
fn state_token_ignores_dead_letter_triggers() {
// The diff ignores dead-letter triggers (not manifest-representable),
// so the token must too — otherwise an out-of-band dead-letter change
// would force a spurious `--force`.
let s = script("h", "x");
let sid = s.id;
let base = CurrentState {
scripts: vec![s.clone()],
..CurrentState::default()
};
let with_dl = CurrentState {
scripts: vec![s],
triggers: vec![trig(
sid,
TriggerDetails::DeadLetter {
source_filter: None,
trigger_id_filter: None,
script_id_filter: None,
},
)],
..CurrentState::default()
};
assert_eq!(
state_token(&base),
state_token(&with_dl),
"dead-letter triggers must not affect the token"
);
}
#[test]
fn enabled_is_declarative_and_diffed() {
// A live-active script the manifest marks disabled → Update; the token
// is sensitive to the flag so an out-of-band toggle is detectable.
let cur = script("a", "let x = 1;"); // enabled: true
let base = CurrentState {
scripts: vec![cur.clone()],
..CurrentState::default()
};
let mut disable = empty_bundle();
let mut bs = bundle_script("a", "let x = 1;");
bs.enabled = false;
disable.scripts = vec![bs];
assert_eq!(compute_diff(&base, &disable).scripts[0].op, Op::Update);
let mut flipped = cur;
flipped.enabled = false;
let toggled = CurrentState {
scripts: vec![flipped],
..CurrentState::default()
};
assert_ne!(
state_token(&base),
state_token(&toggled),
"token must flip on an enabled change"
);
}
#[test]
fn warns_on_enabled_binding_to_disabled_script() {
let mut b = empty_bundle();
let mut s = bundle_script("h", "x");
s.enabled = false;
b.scripts = vec![s];
b.routes = vec![BundleRoute {
script: "h".into(),
method: None,
host_kind: HostKind::Any,
host: String::new(),
host_param_name: None,
path_kind: PathKind::Exact,
path: "/h".into(),
dispatch_mode: DispatchMode::Sync,
enabled: true,
sealed: false,
}];
let w = disabled_target_warnings(&b);
assert!(
w.iter().any(|m| m.contains("will 404")),
"expected a 404 reachability warning: {w:?}"
);
// No warning when the script is active.
b.scripts[0].enabled = true;
assert!(disabled_target_warnings(&b).is_empty());
}
#[test]
fn warns_on_unreachable_endpoint() {
// §4.7: an enabled endpoint with no route and no trigger is flagged.
let mut b = empty_bundle();
b.scripts = vec![bundle_script("orphan", "x")];
let w = unreachable_endpoint_warnings(&b);
assert!(
w.iter().any(|m| m.contains("no route or trigger")),
"expected an unreachable-endpoint warning: {w:?}"
);
// Bound by a route → no warning.
b.routes = vec![BundleRoute {
script: "orphan".into(),
method: None,
host_kind: HostKind::Any,
host: String::new(),
host_param_name: None,
path_kind: PathKind::Exact,
path: "/o".into(),
dispatch_mode: DispatchMode::Sync,
enabled: true,
sealed: false,
}];
assert!(unreachable_endpoint_warnings(&b).is_empty());
// A module is exempt even with no binding.
let mut m = empty_bundle();
let mut lib = bundle_script("lib", "x");
lib.kind = ScriptKind::Module;
m.scripts = vec![lib];
assert!(unreachable_endpoint_warnings(&m).is_empty());
}
#[test]
fn trigger_diff_create_noop_delete() {
let s = script("h", "x");
let sid = s.id;
let cron = |sched: &str| TriggerDetails::Cron {
schedule: sched.into(),
timezone: "UTC".into(),
last_fired_at: None,
};
let current = CurrentState {
scripts: vec![s],
triggers: vec![trig(sid, cron("0 0 * * * *"))],
..CurrentState::default()
};
let bundle_cron = |sched: &str| BundleTrigger::Cron {
script: "h".into(),
schedule: sched.into(),
timezone: "UTC".into(),
dispatch_mode: None,
retry_max_attempts: None,
};
// Same schedule → NoOp.
let mut same = empty_bundle();
same.triggers = vec![bundle_cron("0 0 * * * *")];
let p = compute_diff(&current, &same);
assert!(p.triggers.iter().all(|c| c.op == Op::NoOp), "{p:?}");
// Removed → Delete.
let p = compute_diff(&current, &empty_bundle());
assert_eq!(p.triggers.len(), 1);
assert_eq!(p.triggers[0].op, Op::Delete);
// Different schedule → Create (new) + Delete (old).
let mut chg = empty_bundle();
chg.triggers = vec![bundle_cron("0 5 * * * *")];
let p = compute_diff(&current, &chg);
assert!(p.triggers.iter().any(|c| c.op == Op::Create), "{p:?}");
assert!(p.triggers.iter().any(|c| c.op == Op::Delete), "{p:?}");
}
#[test]
fn kv_ops_order_insensitive() {
let s = script("h", "x");
let sid = s.id;
let current = CurrentState {
scripts: vec![s],
triggers: vec![trig(
sid,
TriggerDetails::Kv {
collection_glob: "users".into(),
ops: vec![KvEventOp::Update, KvEventOp::Insert],
},
)],
..CurrentState::default()
};
let mut b = empty_bundle();
b.triggers = vec![BundleTrigger::Kv {
script: "h".into(),
collection_glob: "users".into(),
ops: vec![KvEventOp::Insert, KvEventOp::Update], // reversed
dispatch_mode: None,
retry_max_attempts: None,
sealed: false,
shared: false,
}];
let p = compute_diff(&current, &b);
assert!(
p.triggers.iter().all(|c| c.op == Op::NoOp),
"ops order must not matter: {p:?}"
);
}
#[test]
fn sealed_is_part_of_trigger_identity() {
// §11 tail: toggling `sealed` on a trigger template is a definitional
// change — it must re-diff (Create the sealed row + Delete the old),
// not silently NoOp. A current UNSEALED kv trigger vs a desired SEALED
// one at the same script/glob/ops.
let s = script("h", "x");
let sid = s.id;
let mut cur = trig(
sid,
TriggerDetails::Kv {
collection_glob: "users".into(),
ops: vec![KvEventOp::Insert],
},
);
cur.sealed = false;
let current = CurrentState {
scripts: vec![s],
triggers: vec![cur],
..CurrentState::default()
};
let mut b = empty_bundle();
b.triggers = vec![BundleTrigger::Kv {
script: "h".into(),
collection_glob: "users".into(),
ops: vec![KvEventOp::Insert],
dispatch_mode: None,
retry_max_attempts: None,
sealed: true,
shared: false,
}];
let p = compute_diff(&current, &b);
assert!(
p.triggers.iter().any(|c| c.op == Op::Create),
"sealing must Create the sealed row: {p:?}"
);
assert!(
p.triggers.iter().any(|c| c.op == Op::Delete),
"sealing must Delete the stale unsealed row: {p:?}"
);
}
#[test]
fn sealed_toggle_updates_route() {
// §11 tail: a route template carries `sealed`; toggling it is an Update
// (routes have an Update op, unlike triggers). Same binding tuple, only
// `sealed` differs.
let s = script("h", "x");
let sid = s.id;
let mut route = Route {
id: uuid::Uuid::new_v4(),
app_id: None,
group_id: Some(GroupId::from(uuid::Uuid::nil())),
script_id: sid,
host_kind: HostKind::Any,
host: String::new(),
host_param_name: None,
path_kind: PathKind::Exact,
path: "/h".into(),
method: None,
dispatch_mode: DispatchMode::Sync,
enabled: true,
sealed: false,
created_at: Utc::now(),
};
route.sealed = false;
let current = CurrentState {
scripts: vec![s],
routes: vec![route],
..CurrentState::default()
};
let mut b = empty_bundle();
b.routes = vec![BundleRoute {
script: "h".into(),
method: None,
host_kind: HostKind::Any,
host: String::new(),
host_param_name: None,
path_kind: PathKind::Exact,
path: "/h".into(),
dispatch_mode: DispatchMode::Sync,
enabled: true,
sealed: true,
}];
let p = compute_diff(&current, &b);
assert!(
p.routes.iter().any(|c| c.op == Op::Update),
"toggling sealed on a route must Update it: {p:?}"
);
}
#[test]
fn dead_letter_trigger_is_ignored_by_diff() {
// Dead-letter triggers can't be expressed in the manifest, so the
// diff must neither match nor prune them.
let s = script("h", "x");
let sid = s.id;
let current = CurrentState {
scripts: vec![s],
triggers: vec![trig(
sid,
TriggerDetails::DeadLetter {
source_filter: None,
trigger_id_filter: None,
script_id_filter: None,
},
)],
..CurrentState::default()
};
let p = compute_diff(&current, &empty_bundle());
assert!(p.triggers.is_empty(), "dead-letter must be ignored: {p:?}");
}
#[test]
fn email_trigger_is_never_pruned() {
// `pull` can't represent email triggers (the server keeps the sealed
// secret, not the `inbound_secret_ref`), so their absence from a
// bundle is ambiguous — the diff must not emit a Delete for them.
let s = script("h", "x");
let sid = s.id;
let current = CurrentState {
scripts: vec![s],
triggers: vec![trig(
sid,
TriggerDetails::Email {
has_inbound_secret: true,
},
)],
..CurrentState::default()
};
let p = compute_diff(&current, &empty_bundle());
assert!(
!p.triggers.iter().any(|c| c.op == Op::Delete),
"email trigger must not be pruned: {p:?}"
);
}
#[test]
fn queue_rebind_is_noop_documented_limitation() {
// Queue identity is `queue|{queue_name}` (script-independent), so a
// rebind to a different script diffs as NoOp — a known limitation.
let a = script("a", "x");
let aid = a.id;
let current = CurrentState {
scripts: vec![a, script("b", "y")],
triggers: vec![trig(
aid,
TriggerDetails::Queue {
queue_name: "jobs".into(),
visibility_timeout_secs: 30,
last_fired_at: None,
},
)],
..CurrentState::default()
};
let mut b = empty_bundle();
b.triggers = vec![BundleTrigger::Queue {
script: "b".into(), // rebind a→b
queue_name: "jobs".into(),
visibility_timeout_secs: None,
dispatch_mode: None,
retry_max_attempts: None,
shared: false,
}];
let p = compute_diff(&current, &b);
assert!(
p.triggers.iter().all(|c| c.op == Op::NoOp),
"queue identity is queue_name-only: {p:?}"
);
}
// ------------------------------------------------------------------------
// §7 ownership — the pure claim policy (the whole decision, DB-free).
// ------------------------------------------------------------------------
#[test]
fn group_claim_unclaimed_claims_only_with_a_project() {
let p = ProjectId::new();
// First apply with a project claims; without a project it stays UI-owned.
assert_eq!(
decide_group_claim(None, Some(p), false),
GroupClaimAction::Claim(p)
);
assert_eq!(
decide_group_claim::<ProjectId>(None, None, false),
GroupClaimAction::Noop
);
}
#[test]
fn group_claim_by_owner_is_a_noop() {
let p = ProjectId::new();
assert_eq!(
decide_group_claim(Some((p, "acme".into())), Some(p), false),
GroupClaimAction::Noop
);
}
#[test]
fn group_claim_foreign_owner_conflicts_unless_takeover() {
let owner = ProjectId::new();
let me = ProjectId::new();
// A different project is refused without --takeover…
assert!(matches!(
decide_group_claim(Some((owner, "platform".into())), Some(me), false),
GroupClaimAction::Conflict(_)
));
// …and reassigns with it (the authz gate is applied at the call site).
assert_eq!(
decide_group_claim(Some((owner, "platform".into())), Some(me), true),
GroupClaimAction::Takeover(me)
);
// A no-project apply must not silently write into a claimed node.
assert!(matches!(
decide_group_claim(Some((owner, "platform".into())), None, false),
GroupClaimAction::Conflict(_)
));
}
#[test]
fn app_owner_matches_nearest_or_conflicts() {
let owner = ProjectId::new();
let me = ProjectId::new();
// An unclaimed subtree is open (backward-compatible).
assert!(decide_app_owner(None, Some(me)).is_ok());
assert!(decide_app_owner::<ProjectId>(None, None).is_ok());
// Same project as the nearest claimed ancestor → ok.
assert!(decide_app_owner(Some((owner, "x".into())), Some(owner)).is_ok());
// A different project, or none, under a claimed subtree → conflict.
assert!(decide_app_owner(Some((owner, "platform".into())), Some(me)).is_err());
assert!(decide_app_owner(Some((owner, "platform".into())), None).is_err());
}
#[test]
fn ownership_preview_reflects_claim_owned_conflict() {
// Group node: unclaimed + project → claim; already ours → owned; foreign
// → conflict (owner named); no project → unclaimed.
assert_eq!(preview_ownership(true, None, Some("p")).action, "claim");
assert_eq!(preview_ownership(true, None, None).action, "unclaimed");
let owned = preview_ownership(true, Some("p"), Some("p"));
assert_eq!(owned.action, "owned");
assert_eq!(owned.owner.as_deref(), Some("p"));
let conflict = preview_ownership(true, Some("plat"), Some("teamb"));
assert_eq!(conflict.action, "conflict");
assert_eq!(conflict.owner.as_deref(), Some("plat"));
// App node never claims — unclaimed subtree stays unclaimed.
assert_eq!(
preview_ownership(false, None, Some("p")).action,
"unclaimed"
);
assert_eq!(
preview_ownership(false, Some("plat"), Some("teamb")).action,
"conflict"
);
}
#[test]
fn project_decl_gates_only_confirm_required_envs() {
// §3 M3: the server re-derives the gate from ProjectDecl.environments.
let mut environments = std::collections::BTreeMap::new();
environments.insert("production".to_string(), EnvPolicyDecl { confirm: true });
environments.insert("staging".to_string(), EnvPolicyDecl { confirm: false });
let decl = ProjectDecl {
slug: "p".into(),
name: None,
parent_group: None,
environments,
};
assert!(decl.confirm_required("production"));
assert!(!decl.confirm_required("staging"));
assert!(!decl.confirm_required("unknown"));
assert_eq!(decl.gated_envs(), vec!["production".to_string()]);
// A project with no policy gates nothing (backward-compatible default).
let bare = ProjectDecl {
slug: "p".into(),
name: None,
parent_group: None,
environments: std::collections::BTreeMap::new(),
};
assert!(!bare.confirm_required("production"));
assert!(bare.gated_envs().is_empty());
}
// ---- v1.2 Workflows: definition validation ----------------------------
fn wf_def(steps: serde_json::Value) -> picloud_shared::workflow::WorkflowDefinition {
serde_json::from_value(serde_json::json!({ "steps": steps })).expect("valid def json")
}
#[test]
fn workflow_validation_accepts_a_valid_dag() {
let def = wf_def(serde_json::json!([
{ "name": "a", "function": "fa" },
{ "name": "b", "function": "fb", "depends_on": ["a"],
"when": "steps.a.output.ok == true",
"input": { "x": "{{ steps.a.output.value }}" } },
{ "name": "c", "workflow": "sub", "depends_on": ["a", "b"] },
]));
assert!(validate_workflow_definition("w", &def).is_ok());
}
#[test]
fn workflow_validation_rejects_empty_and_dupes() {
assert!(validate_workflow_definition("w", &wf_def(serde_json::json!([]))).is_err());
let dupe = wf_def(serde_json::json!([
{ "name": "a", "function": "fa" },
{ "name": "a", "function": "fb" },
]));
assert!(validate_workflow_definition("w", &dupe)
.unwrap_err()
.contains("duplicate step name"));
}
#[test]
fn workflow_validation_rejects_bad_target_and_deps() {
// neither function nor workflow
let no_target = wf_def(serde_json::json!([{ "name": "a" }]));
assert!(validate_workflow_definition("w", &no_target)
.unwrap_err()
.contains("exactly one"));
// both function and workflow
let both = wf_def(serde_json::json!([{ "name": "a", "function": "f", "workflow": "w" }]));
assert!(validate_workflow_definition("w", &both).is_err());
// dependency on an undeclared step
let bad_dep = wf_def(serde_json::json!([
{ "name": "a", "function": "fa", "depends_on": ["ghost"] },
]));
assert!(validate_workflow_definition("w", &bad_dep)
.unwrap_err()
.contains("undeclared step"));
// self-dependency
let self_dep = wf_def(serde_json::json!([
{ "name": "a", "function": "fa", "depends_on": ["a"] },
]));
assert!(validate_workflow_definition("w", &self_dep)
.unwrap_err()
.contains("depends on itself"));
}
#[test]
fn workflow_validation_tolerates_duplicate_depends_on() {
// A step listing the same dep twice is a valid DAG — the topo sort must
// not report a spurious cycle from the double-counted in-degree.
let dup_dep = wf_def(serde_json::json!([
{ "name": "a", "function": "fa" },
{ "name": "b", "function": "fb", "depends_on": ["a", "a"] },
]));
assert!(validate_workflow_definition("w", &dup_dep).is_ok());
}
#[test]
fn workflow_validation_detects_cycles() {
let cyclic = wf_def(serde_json::json!([
{ "name": "a", "function": "fa", "depends_on": ["c"] },
{ "name": "b", "function": "fb", "depends_on": ["a"] },
{ "name": "c", "function": "fc", "depends_on": ["b"] },
]));
assert!(validate_workflow_definition("w", &cyclic)
.unwrap_err()
.contains("cycle"));
}
#[test]
fn workflow_validation_rejects_bad_when_and_template() {
let bad_when = wf_def(serde_json::json!([
{ "name": "a", "function": "fa" },
{ "name": "b", "function": "fb", "depends_on": ["a"], "when": "steps.ghost.output.x" },
]));
assert!(validate_workflow_definition("w", &bad_when).is_err());
let bad_tmpl = wf_def(serde_json::json!([
{ "name": "a", "function": "fa", "input": { "x": "{{ steps.ghost.output }}" } },
]));
assert!(validate_workflow_definition("w", &bad_tmpl).is_err());
}
fn bundle_workflow(name: &str, steps: serde_json::Value) -> BundleWorkflow {
BundleWorkflow {
name: name.into(),
definition: wf_def(steps),
enabled: true,
}
}
#[test]
fn workflow_nesting_warning_flags_self_and_mutual_cycles() {
// Direct self-nest.
let mut b = empty_bundle();
b.workflows = vec![bundle_workflow(
"loop",
serde_json::json!([{ "name": "s", "workflow": "loop" }]),
)];
let w = workflow_nesting_warnings(&b);
assert_eq!(w.len(), 1);
assert!(w[0].contains("loop") && w[0].contains("cycle"));
// Mutual cycle w1 -> w2 -> w1.
let mut b = empty_bundle();
b.workflows = vec![
bundle_workflow("w1", serde_json::json!([{ "name": "s", "workflow": "w2" }])),
bundle_workflow("w2", serde_json::json!([{ "name": "s", "workflow": "w1" }])),
];
assert_eq!(workflow_nesting_warnings(&b).len(), 2);
// Acyclic nesting (parent -> child, child is a leaf) → no warning.
let mut b = empty_bundle();
b.workflows = vec![
bundle_workflow(
"parent",
serde_json::json!([{ "name": "s", "workflow": "child" }]),
),
bundle_workflow(
"child",
serde_json::json!([{ "name": "s", "function": "f" }]),
),
];
assert!(workflow_nesting_warnings(&b).is_empty());
}
}