Files
PiCloud/crates/manager-core/src/apply_service.rs
MechaCat02 30fe160f16 feat(apply): cross-repo blast-radius in the plan preview
§7 M3 (part 2): planning a change to a GROUP node now reports the descendant
apps owned by OTHER projects that the change fans out to — the cross-repo
signal an operator needs before touching shared config.

- OwnershipPreview gains `blast_radius: Vec<ProjectImpact>`; `group_blast_radius`
  walks the group's subtree (recursive CTE) and groups descendant apps by their
  nearest-claimed-ancestor project, excluding the planning project. Ancestor
  resolution is memoized per group (one walk per distinct descendant group, not
  per app).
- `pic plan` renders `blast_radius` rows (mode-agnostic).
- the apply_ownership journey gains a plan-preview case pinning both the
  ownership annotation (claim / conflict) and the blast radius (two other
  projects' apps surfaced) end to end.

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

5449 lines
211 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>,
}
/// 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,
},
}
fn default_timezone() -> String {
"UTC".to_string()
}
impl BundleTrigger {
/// The script name this trigger fires.
#[must_use]
pub fn script(&self) -> &str {
match self {
Self::Kv { script, .. }
| Self::Docs { script, .. }
| Self::Files { script, .. }
| Self::Cron { script, .. }
| Self::Pubsub { script, .. }
| Self::Email { script, .. }
| Self::Queue { script, .. } => script,
}
}
/// Whether this trigger is an email trigger, which resolves and decrypts
/// a stored secret by reference at apply time (gating an extra capability).
#[must_use]
pub fn is_email(&self) -> bool {
matches!(self, Self::Email { .. })
}
/// §11 tail: whether this template is `sealed` (non-suppressible). Only the
/// event kinds (which can be group templates) carry the flag; the app-only
/// kinds (cron/email/queue) are never sealed.
#[must_use]
pub fn sealed(&self) -> bool {
match self {
Self::Kv { sealed, .. }
| Self::Docs { sealed, .. }
| Self::Files { sealed, .. }
| Self::Pubsub { sealed, .. } => *sealed,
Self::Cron { .. } | Self::Email { .. } | Self::Queue { .. } => false,
}
}
/// §11.6: whether this template watches a SHARED collection. The collection-
/// scoped event kinds (kv/docs/files) and pubsub (D2: a shared TOPIC) can be
/// shared; cron/email/queue are never shared here (a shared queue consumer
/// is authored as a `[[triggers.queue]]` and handled via materialization).
#[must_use]
pub fn shared(&self) -> bool {
match self {
Self::Kv { shared, .. }
| Self::Docs { shared, .. }
| Self::Files { shared, .. }
| Self::Pubsub { shared, .. }
| Self::Queue { shared, .. } => *shared,
Self::Cron { .. } | Self::Email { .. } => false,
}
}
/// The watched collection glob for the collection-scoped kinds (kv/docs/
/// files); `None` for the others.
#[must_use]
pub fn collection_glob(&self) -> Option<&str> {
match self {
Self::Kv {
collection_glob, ..
}
| Self::Docs {
collection_glob, ..
}
| Self::Files {
collection_glob, ..
} => Some(collection_glob),
_ => None,
}
}
/// Stable semantic identity (the per-kind tuple), used for the diff.
#[must_use]
pub fn identity(&self) -> String {
match self {
// §11 tail: `sealed` is part of the identity for the event kinds, so
// toggling it re-diffs (a Create + a Delete-on-prune of the stale
// row) like any other definitional change on a template.
Self::Kv {
script,
collection_glob,
ops,
sealed,
shared,
..
} => format!(
"kv|{script}|{collection_glob}|{}|{sealed}|{shared}",
sorted_csv(ops.iter().copied().map(KvEventOp::as_str))
),
Self::Docs {
script,
collection_glob,
ops,
sealed,
shared,
..
} => format!(
"docs|{script}|{collection_glob}|{}|{sealed}|{shared}",
sorted_csv(ops.iter().copied().map(DocsEventOp::as_str))
),
Self::Files {
script,
collection_glob,
ops,
sealed,
shared,
..
} => format!(
"files|{script}|{collection_glob}|{}|{sealed}|{shared}",
sorted_csv(ops.iter().copied().map(FilesEventOp::as_str))
),
Self::Cron {
script,
schedule,
timezone,
..
} => format!("cron|{script}|{schedule}|{timezone}"),
Self::Pubsub {
script,
topic_pattern,
sealed,
shared,
..
} => format!("pubsub|{script}|{topic_pattern}|{sealed}|{shared}"),
Self::Email { script, .. } => format!("email|{script}"),
Self::Queue {
queue_name, shared, ..
} => format!("queue|{queue_name}|{shared}"),
}
}
#[must_use]
pub fn kind_str(&self) -> &'static str {
match self {
Self::Kv { .. } => "kv",
Self::Docs { .. } => "docs",
Self::Files { .. } => "files",
Self::Cron { .. } => "cron",
Self::Pubsub { .. } => "pubsub",
Self::Email { .. } => "email",
Self::Queue { .. } => "queue",
}
}
}
/// One change in the plan. `key` is the human-renderable identity.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ResourceChange {
pub op: Op,
pub key: String,
/// Optional one-line note (e.g. why an update, or "value must be set").
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<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>,
}
impl Plan {
/// True when every resource is a no-op (nothing to apply).
#[must_use]
pub fn is_noop(&self) -> bool {
self.scripts
.iter()
.chain(&self.routes)
.chain(&self.triggers)
.chain(&self.secrets)
.chain(&self.vars)
.chain(&self.extension_points)
.chain(&self.collections)
.all(|c| c.op == Op::NoOp)
}
}
/// What `plan` returns: the diff plus a fingerprint of the live state it was
/// computed against. The token is flattened onto the plan JSON, so the wire
/// shape stays `{ scripts, routes, triggers, secrets, state_token }`.
#[derive(Debug, Clone, Serialize)]
pub struct PlanResult {
#[serde(flatten)]
pub plan: Plan,
pub state_token: String,
/// §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>,
}
// ----------------------------------------------------------------------------
// Tree apply (Phase 5): a whole project subtree applied in one transaction.
// ----------------------------------------------------------------------------
/// Whether a tree node is an app or a group.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NodeKind {
App,
Group,
}
/// One node of a project tree: its kind, slug (must pre-exist on the server),
/// and its desired-state bundle (a group's bundle has no routes/triggers).
/// Request-only (deserialized from the CLI's tree bundle).
#[derive(Debug, Clone, Deserialize)]
pub struct TreeNode {
pub kind: NodeKind,
pub slug: String,
pub bundle: Bundle,
}
/// A whole project subtree submitted to `apply_tree`.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct TreeBundle {
#[serde(default)]
pub nodes: Vec<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>,
}
/// 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 {
let mk = |action: &str, owner: Option<&str>| OwnershipPreview {
action: action.to_string(),
owner: owner.map(str::to_string),
blast_radius: Vec::new(),
};
match (current, declared) {
// A group node claims when unclaimed + a project is declared.
(None, Some(_)) if is_group => mk("claim", None),
// Otherwise unclaimed (an app never claims; no project → nothing).
(None, _) => mk("unclaimed", None),
(Some(c), Some(d)) if c == d => mk("owned", Some(c)),
(Some(c), _) => mk("conflict", Some(c)),
}
}
/// 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>,
}
/// 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,
}
// ----------------------------------------------------------------------------
// 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)>,
}
/// 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,
}
/// 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 {
pub method: String,
pub host: String,
pub path_kind: String,
pub path: String,
pub script: String,
pub dispatch: String,
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,
/// §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),
#[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
}
/// 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(owner, 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,
})
}
/// 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.
fn validate_bundle_for(
&self,
owner: ApplyOwner,
bundle: &Bundle,
inherited_endpoints: &HashSet<String>,
) -> Result<(), ApplyError> {
if matches!(owner, ApplyOwner::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"),
_ => {
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: 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 matches!(owner, ApplyOwner::App(_)) && !bundle.collections.is_empty() {
return Err(ApplyError::Invalid(
"an app manifest cannot declare shared collections — \
they are owned by a group"
.into(),
));
}
// §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 matches!(owner, ApplyOwner::App(_)) {
if bundle.routes.iter().any(|r| r.sealed) {
return Err(ApplyError::Invalid(
"an app route cannot be `sealed` — sealing marks a group \
template as non-suppressible; an app route is never inherited"
.into(),
));
}
if bundle.triggers.iter().any(BundleTrigger::sealed) {
return Err(ApplyError::Invalid(
"an app trigger cannot be `sealed` — sealing marks a group \
template as non-suppressible; an app trigger is never inherited"
.into(),
));
}
// §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(ApplyError::Invalid(
"an app trigger cannot be `shared` — a shared-collection \
trigger is owned by the group that declares the collection"
.into(),
));
}
}
// §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.
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) = 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)
.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;
}
}
// 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;
}
}
// 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;
}
}
}
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(owner, 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(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();
// §4.7 warning: an enabled binding pointing at a disabled script is
// deployed-but-unreachable. Not an error (it's valid desired state),
// but surfaced so the operator isn't surprised by a silent 404.
report.warnings.extend(disabled_target_warnings(bundle));
// A group endpoint is reached by *inheritance* (a descendant app binds
// it by name), never by its own route, so the "no route/trigger"
// warning is noise for every group script — emit it for apps only.
if let ApplyOwner::App(_) = owner {
report
.warnings
.extend(unreachable_endpoint_warnings(bundle));
}
// §11 tail: a suppress reference that matches no inherited template (or
// only sealed ones) silently does nothing — warn (typo guard), don't
// fail. M1: runs for a group node too (it walks the group's own chain).
report
.warnings
.extend(self.dangling_suppress_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 (prepared, token) = self.prepare_tree(bundle).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());
for p in prepared {
let ownership = self.ownership_preview(p.owner, declared).await?;
nodes.push(NodePlan {
kind: p.node.kind,
slug: p.node.slug.clone(),
plan: p.plan,
ownership,
});
}
Ok(TreePlanResult {
nodes,
state_token: token,
})
}
/// 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>,
) -> Result<ApplyReport, ApplyError> {
let actor = claim.principal.user_id;
let (prepared, token) = self.prepare_tree(bundle).await?;
if let Some(expected) = expected_token {
if token != expected {
return Err(ApplyError::StateMoved);
}
}
// §6/§7 attach point: every node in the tree must be within 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?;
for p in &prepared {
self.check_within_attach(p.owner, pg, pg_slug).await?;
}
}
let mut tx = self
.pool
.begin()
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
// 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)
}
/// 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`.
#[allow(clippy::too_many_lines)]
async fn prepare_tree<'a>(
&self,
bundle: &'a TreeBundle,
) -> Result<(Vec<PreparedNode<'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.
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 {
let gid = self.resolve_group(&n.slug).await?;
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 token_parts: Vec<String> = Vec::new();
let mut versioned_groups: BTreeSet<GroupId> = BTreeSet::new();
for n in &bundle.nodes {
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(owner, &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));
}
}
token_parts.sort_unstable();
Ok((prepared, 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)
}
// ------------------------------------------------------------------------
// §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. Resolves the
/// chain via `ancestors()` (which now carries `owner_project`); an unclaimed
/// subtree is open. The narrow read-then-commit window is accepted (an app
/// writes no ownership; hazard c).
async fn check_app_owner_single(
&self,
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 nearest = self.nearest_claimed_from_chain(&chain).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)))
}
// ------------------------------------------------------------------------
// §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.
/// Ancestor resolution is memoized per group, so the cost is one walk per
/// distinct descendant group, not per app.
async fn group_blast_radius(
&self,
gid: GroupId,
exclude: Option<&str>,
) -> Result<Vec<ProjectImpact>, ApplyError> {
// All apps in gid's subtree (gid + descendant groups).
let rows: Vec<(uuid::Uuid, uuid::Uuid)> = sqlx::query_as(
"WITH RECURSIVE subtree AS (
SELECT id FROM groups WHERE id = $1
UNION ALL
SELECT g.id FROM groups g JOIN subtree s ON g.parent_id = s.id
)
SELECT a.id, a.group_id FROM apps a WHERE a.group_id IN (SELECT id FROM subtree)",
)
.bind(gid.into_inner())
.fetch_all(&self.pool)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let mut owner_by_group: HashMap<GroupId, Option<String>> = HashMap::new();
let mut counts: std::collections::BTreeMap<String, u32> = std::collections::BTreeMap::new();
for (_app, group_id) in rows {
let ag = GroupId::from(group_id);
let owner = if let Some(o) = owner_by_group.get(&ag) {
o.clone()
} else {
let chain = self
.groups
.ancestors(ag)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let o = self
.nearest_claimed_from_chain(&chain)
.await?
.map(|(_, s)| s);
owner_by_group.insert(ag, o.clone());
o
};
if let Some(slug) = owner {
if Some(slug.as_str()) != exclude {
*counts.entry(slug).or_default() += 1;
}
}
}
Ok(counts
.into_iter()
.map(|(project, apps)| ProjectImpact { project, apps })
.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,
..
} => (
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>), 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"
)))
}
}
let (ct, nonce) = crate::secrets_service::seal_legacy(
&self.master_key,
&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()))
}
/// 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,
}
})
.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().unwrap_or_else(|| "ANY".into()),
host: match r.host_kind {
HostKind::Any => "any".to_string(),
HostKind::Strict => format!("strict:{}", r.host),
HostKind::Wildcard => format!("*.{}", r.host),
},
path_kind: path_kind_str(r.path_kind).to_string(),
path: r.path.clone(),
script: name_by_id.get(&r.script_id).cloned().unwrap_or_default(),
dispatch: r.dispatch_mode.as_str().to_string(),
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())
}
/// §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()))?;
Ok(CurrentState {
scripts,
routes,
triggers,
secret_names,
vars,
extension_point_names,
collections,
suppressions,
})
}
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),
}
}
/// 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.
#[derive(Debug, PartialEq, Eq)]
enum GroupClaimAction {
/// Nothing to write (unclaimed + no project, or already owned by us).
Noop,
/// First-apply-claims: set `owner_project`.
Claim(ProjectId),
/// Reassign to us — capability-gated (`GroupAdmin`) at the call site.
Takeover(ProjectId),
/// Refuse (409); the string is the actionable message.
Conflict(String),
}
/// Pure ownership policy for a GROUP node. `current` is the live owner as
/// `(id, slug)`; `declared` is this apply's project id. See §7.3.
fn decide_group_claim(
current: Option<(ProjectId, String)>,
declared: Option<ProjectId>,
takeover: bool,
) -> GroupClaimAction {
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 `(id, slug)`); an
/// unclaimed subtree is open (backward-compatible). See §7.
fn decide_app_owner(
nearest: Option<(ProjectId, String)>,
declared: Option<ProjectId>,
) -> 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 rule as app/group slugs
/// (`^[a-z0-9][a-z0-9-]{0,62}$`).
fn validate_project_slug(slug: &str) -> Result<(), ApplyError> {
let bad = |why: &str| ApplyError::Invalid(format!("project slug {slug:?}: {why}"));
if slug.is_empty() || slug.len() > 63 {
return Err(bad("must be 163 characters"));
}
let first = slug.chars().next().expect("non-empty checked above");
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err(bad("must start with a lowercase letter or digit"));
}
if !slug
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(bad(
"may contain only lowercase letters, digits, and hyphens",
));
}
Ok(())
}
/// Register (or fetch) a project by slug inside the apply tx, returning its id.
/// First apply with a new slug inserts; a re-apply updates the display name
/// (the manifest is authoritative) but preserves the original `created_by`.
async fn upsert_project_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
decl: &ProjectDecl,
actor: AdminUserId,
) -> Result<ProjectId, ApplyError> {
validate_project_slug(&decl.slug)?;
let name = decl.name.clone().unwrap_or_else(|| decl.slug.clone());
let (id,): (uuid::Uuid,) = sqlx::query_as(
"INSERT INTO projects (slug, name, created_by) VALUES ($1, $2, $3) \
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name \
RETURNING id",
)
.bind(&decl.slug)
.bind(&name)
.bind(actor.into_inner())
.fetch_one(&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()),
}
}
/// 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}")),
TriggerDetails::DeadLetter { .. } => 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.
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
}
/// 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(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>,
}
/// 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::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(),
};
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(),
};
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(),
};
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(),
};
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(),
};
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(),
}
}
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(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(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"
);
}
}