//! Declarative reconcile engine for a single app.
//!
//! Takes a desired-state [`Bundle`] (an app's scripts, routes, triggers,
//! and the names of its secrets) and either diffs it against the live
//! database ([`ApplyService::plan`], read-only) or reconciles it in one
//! transaction ([`ApplyService::apply`]).
//!
//! Identity keys match the DB UNIQUE constraints so the diff is stable:
//! scripts by `lower(name)`, routes by the `(method, host, path)` tuple,
//! triggers by their per-kind semantic tuple, secrets by name.
//!
//! **Trigger-identity limitation:** a trigger's identity *is* its semantic
//! definition, so the diff only ever Creates or Deletes (a change is
//! delete-old + create-new). For queue the identity is `queue|{queue_name}`
//! (script-independent — it encodes the one-consumer-per-queue invariant)
//! and for email it is `email|{script}`. Consequence: rebinding a queue to
//! a different script, changing its visibility timeout, or rotating an email
//! trigger's secret/reference diffs as a **NoOp and is not applied**. To
//! change those, recreate the trigger (drop it from the manifest, apply
//! `--prune`, then re-add it).
//!
//! Other known limitations vs. the interactive API: a script **rename**
//! diffs as delete+create, so under `--prune` it loses the old script's id,
//! version counter, and execution logs; an `endpoint`→`module` kind flip is
//! not guarded against *live* (un-pruned) routes/triggers still bound to the
//! script; and cross-pattern route conflict-vs-live is not checked (only
//! identical tuples collide). See `validate_bundle`.

use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::Arc;

use picloud_orchestrator_core::routing::{pattern, RouteTable};
use picloud_shared::{
    AdminUserId, AppId, DispatchMode, DocsEventOp, FilesEventOp, GroupId, HostKind, KvEventOp,
    MasterKey, PathKind, Principal, ProjectId, Route, Script, ScriptId, ScriptKind, ScriptOwner,
    ScriptSandbox, ScriptValidator, TriggerId,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;

use crate::app_domain_repo::AppDomainRepository;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::config_resolver::CHAIN_LEVELS_CTE;
use crate::repo::{
    delete_script_tx, insert_script_tx, update_script_tx, NewScript, ScriptPatch, ScriptRepository,
    ScriptRepositoryError,
};
use crate::route_repo::{delete_route_tx, insert_route_tx, NewRoute, RouteRepository};
use crate::sandbox::SandboxCeiling;
use crate::secrets_repo::SecretsRepo;
use crate::trigger_config::TriggerConfig;
use crate::trigger_repo::{
    delete_trigger_tx, insert_email_trigger_tx, insert_trigger_tx, Trigger, TriggerDetails,
    TriggerDispatchMode, TriggerRepo, TriggerRepoError,
};
use crate::vars_repo::{VarOwner, VarsRepo};

/// Reserved path prefixes that user routes may not claim (mirrors the
/// route-create rejection — see CLAUDE.md "Reserved path prefixes").
const RESERVED_PATH_PREFIXES: &[&str] = &["/api/", "/admin/"];
const RESERVED_PATH_EXACT: &[&str] = &["/healthz", "/version"];

// ----------------------------------------------------------------------------
// Wire types — desired state (request) and the computed plan (response)
// ----------------------------------------------------------------------------

/// Desired state for one app, sent by `pic plan` / `pic apply`.
#[derive(Debug, Clone, Deserialize)]
pub struct Bundle {
    #[serde(default)]
    pub scripts: Vec<BundleScript>,
    #[serde(default)]
    pub routes: Vec<BundleRoute>,
    #[serde(default)]
    pub triggers: Vec<BundleTrigger>,
    /// Declared secret *names* only; values are pushed out-of-band.
    #[serde(default)]
    pub secrets: Vec<String>,
    /// Declared app-owned config `vars` — key → JSON value. Unlike secrets,
    /// var values are non-secret and live inline in the manifest. Written at
    /// app scope `*` (an app *is* an environment, §2 — per-env scoping is a
    /// group concern, deferred to the nested-manifest model). Tombstones are
    /// not expressible here; use `pic vars set --tombstone`.
    #[serde(default)]
    pub vars: std::collections::BTreeMap<String, serde_json::Value>,
    /// Declared extension-point *names* (§5.5) — module names this node offers
    /// for descendants to provide/override. Name-only, like `secrets`; the
    /// optional default body is a co-located `kind = module` script.
    #[serde(default)]
    pub extension_points: Vec<String>,
    /// Declared shared group collections (§11.6) — `(name, kind)` markers this
    /// node offers as cross-app-shared (`kind` ∈ `kv`/`docs`). The data lives in
    /// the per-kind store (`group_kv_entries` / `group_docs`). Authored on
    /// `[group]` nodes only (the CLI rejects it on `[app]`).
    #[serde(default)]
    pub collections: Vec<CollectionSpec>,
    /// §11 tail per-app opt-out: handler SCRIPT names whose inherited group
    /// triggers this app declines. App-only (rejected on a group node).
    #[serde(default)]
    pub suppress_triggers: Vec<String>,
    /// §11 tail per-app opt-out: PATHS whose inherited group route this app
    /// declines (the binding 404s instead of serving). App-only.
    #[serde(default)]
    pub suppress_routes: Vec<String>,
    /// v1.2 Workflows: declared DAG workflow definitions. App-owned (M1); a
    /// `[group]` carrying workflows is rejected in `validate_bundle_for`.
    #[serde(default)]
    pub workflows: Vec<BundleWorkflow>,
    /// §9.4 service interceptors: before-op allow/deny hooks. One entry per
    /// `(service, op)` guarded, naming the interceptor script. App- OR
    /// group-owned; resolved nearest-owner-wins on a descendant app's chain.
    #[serde(default)]
    pub interceptors: Vec<BundleInterceptor>,
}

/// One §9.4 interceptor marker on the wire: which `(service, op)` it guards and
/// the interceptor script name. The CLI expands a manifest `[[interceptors]]`
/// entry's `ops = [...]` into one of these per op.
#[derive(Debug, Clone, Deserialize)]
pub struct BundleInterceptor {
    pub service: String,
    pub op: String,
    pub script: String,
}

/// One declared workflow on the wire: a name + its (already-parsed) DAG.
#[derive(Debug, Clone, Deserialize)]
pub struct BundleWorkflow {
    pub name: String,
    pub definition: picloud_shared::workflow::WorkflowDefinition,
    /// Three-state lifecycle (§4.3); omitted ⇒ active.
    #[serde(default = "picloud_shared::default_true")]
    pub enabled: bool,
}

/// One declared shared-collection marker on the wire: a name + its store kind.
#[derive(Debug, Clone, Deserialize)]
pub struct CollectionSpec {
    pub name: String,
    /// Storage kind; defaults to `kv` for back-compat with the name-only form.
    #[serde(default = "default_collection_kind")]
    pub kind: String,
}

fn default_collection_kind() -> String {
    "kv".to_string()
}

/// The shared-collection kinds the apply engine accepts.
const COLLECTION_KINDS: &[&str] = &["kv", "docs", "files", "topic", "queue"];

#[derive(Debug, Clone, Deserialize)]
pub struct BundleScript {
    pub name: String,
    pub source: String,
    #[serde(default)]
    pub kind: ScriptKind,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub timeout_seconds: Option<i32>,
    #[serde(default)]
    pub memory_limit_mb: Option<i32>,
    #[serde(default)]
    pub sandbox: Option<ScriptSandbox>,
    /// Three-state lifecycle (§4.3); omitted ⇒ active. Declarative (not
    /// leave-as-is): a script absent the key is `enabled = true`.
    #[serde(default = "picloud_shared::default_true")]
    pub enabled: bool,
}

#[derive(Debug, Clone, Deserialize)]
pub struct BundleRoute {
    /// Name of the script this route binds to.
    pub script: String,
    #[serde(default)]
    pub method: Option<String>,
    pub host_kind: HostKind,
    #[serde(default)]
    pub host: String,
    #[serde(default)]
    pub host_param_name: Option<String>,
    pub path_kind: PathKind,
    pub path: String,
    #[serde(default)]
    pub dispatch_mode: DispatchMode,
    /// Three-state lifecycle (§4.3); omitted ⇒ active.
    #[serde(default = "picloud_shared::default_true")]
    pub enabled: bool,
    /// §11 tail: `sealed = true` on a group route template makes it
    /// non-suppressible — a descendant's `[suppress]` cannot decline it.
    /// Rejected (in `validate_bundle_for`) on an app owner. Omitted ⇒ unsealed.
    #[serde(default)]
    pub sealed: bool,
}

/// Desired trigger, tagged by kind on the wire (`{ "kind": "cron", … }`).
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum BundleTrigger {
    Kv {
        script: String,
        collection_glob: String,
        #[serde(default)]
        ops: Vec<KvEventOp>,
        #[serde(default)]
        dispatch_mode: Option<TriggerDispatchMode>,
        #[serde(default)]
        retry_max_attempts: Option<u32>,
        /// §11 tail: sealed group template (event kinds only). See
        /// [`BundleTrigger::sealed`].
        #[serde(default)]
        sealed: bool,
        /// §11.6: shared-collection group trigger. See [`BundleTrigger::shared`].
        #[serde(default)]
        shared: bool,
    },
    Docs {
        script: String,
        collection_glob: String,
        #[serde(default)]
        ops: Vec<DocsEventOp>,
        #[serde(default)]
        dispatch_mode: Option<TriggerDispatchMode>,
        #[serde(default)]
        retry_max_attempts: Option<u32>,
        #[serde(default)]
        sealed: bool,
        #[serde(default)]
        shared: bool,
    },
    Files {
        script: String,
        collection_glob: String,
        #[serde(default)]
        ops: Vec<FilesEventOp>,
        #[serde(default)]
        dispatch_mode: Option<TriggerDispatchMode>,
        #[serde(default)]
        retry_max_attempts: Option<u32>,
        #[serde(default)]
        sealed: bool,
        #[serde(default)]
        shared: bool,
    },
    Cron {
        script: String,
        schedule: String,
        #[serde(default = "default_timezone")]
        timezone: String,
        #[serde(default)]
        dispatch_mode: Option<TriggerDispatchMode>,
        #[serde(default)]
        retry_max_attempts: Option<u32>,
    },
    Pubsub {
        script: String,
        topic_pattern: String,
        #[serde(default)]
        dispatch_mode: Option<TriggerDispatchMode>,
        #[serde(default)]
        retry_max_attempts: Option<u32>,
        #[serde(default)]
        sealed: bool,
        /// §11.6 D2: `true` for a shared-TOPIC group trigger — fires when a
        /// subtree app publishes to the group's shared topic namespace, not on
        /// per-app publishes.
        #[serde(default)]
        shared: bool,
    },
    Email {
        script: String,
        /// Name of the secret (set via `pic secret set`) holding the
        /// inbound HMAC value; resolved + sealed at apply time. Never the
        /// value itself.
        inbound_secret_ref: String,
        #[serde(default)]
        dispatch_mode: Option<TriggerDispatchMode>,
        #[serde(default)]
        retry_max_attempts: Option<u32>,
    },
    Queue {
        script: String,
        queue_name: String,
        #[serde(default)]
        visibility_timeout_secs: Option<u32>,
        #[serde(default)]
        dispatch_mode: Option<TriggerDispatchMode>,
        #[serde(default)]
        retry_max_attempts: Option<u32>,
        /// §11.6 D3: `true` for a shared-QUEUE group consumer — materializes a
        /// competing consumer per descendant app, all claiming the group store.
        #[serde(default)]
        shared: bool,
    },
}

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>,
    /// v1.2 Workflows: workflow definitions, keyed by name.
    #[serde(default)]
    pub workflows: Vec<ResourceChange>,
    /// §9.4 interceptor markers, keyed `"{service}/{op}"` with the script as the
    /// value (create/update/delete like `vars`).
    #[serde(default)]
    pub interceptors: Vec<ResourceChange>,
}

impl Plan {
    /// True when every resource is a no-op (nothing to apply).
    #[must_use]
    pub fn is_noop(&self) -> bool {
        self.scripts
            .iter()
            .chain(&self.routes)
            .chain(&self.triggers)
            .chain(&self.secrets)
            .chain(&self.vars)
            .chain(&self.extension_points)
            .chain(&self.collections)
            .chain(&self.suppressions)
            .chain(&self.workflows)
            .chain(&self.interceptors)
            .all(|c| c.op == Op::NoOp)
    }
}

/// What `plan` returns: the diff plus a fingerprint of the live state it was
/// computed against. The token is flattened onto the plan JSON, so the wire
/// shape stays `{ scripts, routes, triggers, secrets, state_token }`.
#[derive(Debug, Clone, Serialize)]
pub struct PlanResult {
    #[serde(flatten)]
    pub plan: Plan,
    pub state_token: String,
    /// §7 M3: the ownership outcome this apply would produce (claim / owned /
    /// conflict / unclaimed). Present only when a `[project]` was declared.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub ownership: Option<OwnershipPreview>,
    /// §3 M3: environments the governing project marks confirm-required — applying
    /// to one needs `pic apply --approve <env>`. Surfaced so CI sees the gate at
    /// `plan` time, before an `apply` refuses.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub approvals_required: Vec<String>,
    /// The desired-state warnings `apply` would emit, computed at plan so `pic
    /// plan` is a faithful preview for CI: an enabled binding on a disabled
    /// script (deployed-but-unreachable), an endpoint with no route/trigger, a
    /// `[suppress]` that matches no inherited template. Post-commit side-effect
    /// warnings (route-table refresh, materialization skips) are apply-only.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub warnings: Vec<String>,
}

// ----------------------------------------------------------------------------
// Tree apply (Phase 5): a whole project subtree applied in one transaction.
// ----------------------------------------------------------------------------

/// Whether a tree node is an app or a group.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NodeKind {
    App,
    Group,
}

/// One node of a project tree: its kind, slug, and its desired-state bundle (a
/// group's bundle has no routes/triggers). Request-only (deserialized from the
/// CLI's tree bundle).
#[derive(Debug, Clone, Deserialize)]
pub struct TreeNode {
    pub kind: NodeKind,
    pub slug: String,
    /// §6: for a GROUP node, the declared parent group slug inferred from the
    /// repo's directory nesting (`None` = the repo's attach point / instance
    /// root). Absent for an app node, and absent on the wire from a pre-§6 CLI
    /// (`#[serde(default)]`) — in which case a group is resolved as before and
    /// must pre-exist. Used to CREATE a missing group and (M2) to detect a
    /// server/manifest structural divergence.
    #[serde(default)]
    pub parent: Option<String>,
    /// §6: a GROUP node's display name / description, used only when the group
    /// is CREATED (a missing group defaults its name to the slug). Content
    /// reconcile never touches them.
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub description: Option<String>,
    pub bundle: Bundle,
}

/// A whole project subtree submitted to `apply_tree`.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct TreeBundle {
    #[serde(default)]
    pub nodes: Vec<TreeNode>,
}

/// §7 multi-repo ownership: the `[project]` identity an apply declares. The
/// slug is committed in the repo's root manifest (stable across clones); the
/// first apply with a new slug registers the project (server-assigned UUID).
#[derive(Debug, Clone, Deserialize)]
pub struct ProjectDecl {
    pub slug: String,
    #[serde(default)]
    pub name: Option<String>,
    /// §7/§6 attach point: the pre-existing group this repo binds under. Every
    /// applied node must be strictly within its subtree; `None` = instance root
    /// (no ceiling).
    #[serde(default)]
    pub parent_group: Option<String>,
    /// §3 M3 per-env approval policy (the root manifest's `[project.environments]`).
    /// A `confirm = true` env makes applying to it a gated, admin-gated + audited
    /// step (`--approve <env>` required; a blanket `--yes` does NOT cover it). The
    /// server re-derives the gate from THIS field — the CLI's client-side check is
    /// convenience, this is the authoritative source. `#[serde(default)]` keeps a
    /// pre-M3 client (which omits it) backward-compatible (no gate).
    #[serde(default)]
    pub environments: std::collections::BTreeMap<String, EnvPolicyDecl>,
}

/// §3 M3: one environment's server-side apply policy (mirrors the manifest's
/// `EnvPolicy`). `confirm = true` gates applying to this env.
#[derive(Debug, Clone, Deserialize)]
pub struct EnvPolicyDecl {
    #[serde(default)]
    pub confirm: bool,
}

impl ProjectDecl {
    /// Does environment `env` require explicit approval to apply (§3 M3)?
    #[must_use]
    pub fn confirm_required(&self, env: &str) -> bool {
        self.environments.get(env).is_some_and(|p| p.confirm)
    }

    /// Names of every confirm-required environment, sorted (for `plan` to surface
    /// so CI sees the gate before an `apply` refuses).
    #[must_use]
    pub fn gated_envs(&self) -> Vec<String> {
        self.environments
            .iter()
            .filter(|(_, p)| p.confirm)
            .map(|(name, _)| name.clone())
            .collect()
    }
}

/// The ownership context threaded through an apply: the declared project (if
/// any), whether the caller passed `--takeover`, and the principal (its
/// `user_id` is the actor; a takeover additionally requires `GroupAdmin`).
pub struct OwnershipClaim<'a> {
    pub project: Option<ProjectDecl>,
    pub takeover: bool,
    pub principal: &'a Principal,
}

/// §7 M3 plan preview: what applying this node WOULD do about ownership, so
/// `pic plan` surfaces a conflict before `pic apply`. `action` ∈ `claim`
/// (unclaimed → this project claims it), `owned` (already this project),
/// `conflict` (owned by `owner`), `unclaimed` (no project, or an app in open
/// territory). `owner` is the current owner's slug for `owned`/`conflict`.
#[derive(Debug, Clone, Serialize)]
pub struct OwnershipPreview {
    pub action: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub owner: Option<String>,
    /// §7 M3.2 cross-repo blast radius: for a GROUP node, the descendant apps
    /// owned by OTHER projects that a config change here fans out to, grouped by
    /// project. Empty for an app node or when nothing else is affected.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub blast_radius: Vec<ProjectImpact>,
}

/// One entry of a group plan's blast radius: another project and how many of
/// its apps inherit from the group being changed.
#[derive(Debug, Clone, Serialize)]
pub struct ProjectImpact {
    pub project: String,
    pub apps: u32,
}

/// Pure preview policy (read-only, slug-only — no registration): compares the
/// node's current owner slug against this apply's declared project slug. The
/// blast radius (a DB fan-out) is filled in by the caller for group nodes.
fn preview_ownership(
    is_group: bool,
    current: Option<&str>,
    declared: Option<&str>,
) -> OwnershipPreview {
    // Project the SAME pure policy the apply path runs onto a display label,
    // keyed on slugs (plan has no assigned project id yet) and takeover-agnostic
    // (`takeover = false`; plan never mutates ownership). Deriving the preview
    // here — rather than re-encoding the arms — keeps plan and apply from
    // drifting: one decision, two projections.
    let cur = current.map(|s| (s.to_string(), s.to_string()));
    let decl = declared.map(str::to_string);
    let action = if is_group {
        match decide_group_claim(cur, decl, false) {
            GroupClaimAction::Claim(_) => "claim",
            // Noop covers both "already ours" and "unclaimed + no project"; the
            // current owner disambiguates.
            GroupClaimAction::Noop if current.is_none() => "unclaimed",
            GroupClaimAction::Noop => "owned",
            // Takeover can't arise with `takeover = false`; treat defensively.
            GroupClaimAction::Conflict(_) | GroupClaimAction::Takeover(_) => "conflict",
        }
    } else {
        match decide_app_owner(cur, decl) {
            Ok(()) if current.is_none() => "unclaimed",
            Ok(()) => "owned",
            Err(_) => "conflict",
        }
    };
    OwnershipPreview {
        action: action.to_string(),
        owner: current.map(str::to_string),
        blast_radius: Vec::new(),
    }
}

/// §6 M2: how a tree apply resolves a group whose SERVER parent differs from the
/// manifest's declared (directory-nesting) parent — Terraform-style
/// detect-and-refuse. Request-only; `Refuse` is the default (a pre-M2 CLI omits
/// it), so a divergence never silently reshapes the tree.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum StructureMode {
    /// Refuse the apply on any structural divergence (422).
    #[default]
    Refuse,
    /// Reparent each diverged group to the manifest's declared parent.
    ForceLocal,
    /// Accept the server's shape; reconcile content in place, no reparent.
    AdoptServer,
}

/// §6 M2: a group node's structural state under the manifest — `in-sync` when
/// the server parent matches the declared (directory-nesting) parent, else
/// `diverged` (naming both parents). Previewed by `pic plan`.
#[derive(Debug, Clone, Serialize)]
pub struct StructurePreview {
    pub status: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub server_parent: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub manifest_parent: Option<String>,
}

/// One node's diff within a tree plan.
#[derive(Debug, Clone, Serialize)]
pub struct NodePlan {
    pub kind: NodeKind,
    pub slug: String,
    #[serde(flatten)]
    pub plan: Plan,
    /// §7 M3: this node's ownership outcome under the tree's `[project]`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub ownership: Option<OwnershipPreview>,
    /// §6 M2: for a GROUP node, its structural state (in-sync / diverged).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub structure: Option<StructurePreview>,
    /// The desired-state warnings this node's apply would emit (same set as
    /// [`PlanResult::warnings`]), so `pic plan --dir` previews them per node.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub warnings: Vec<String>,
}

/// What `plan_tree` returns: a per-node diff plus ONE combined bound-plan token
/// (fingerprint of every node's content + every in-scope group's structure
/// version, so a reparent or content edit between plan and apply is caught).
#[derive(Debug, Clone, Serialize)]
pub struct TreePlanResult {
    pub nodes: Vec<NodePlan>,
    pub state_token: String,
    /// §3 M3: environments the project marks confirm-required (see
    /// [`PlanResult::approvals_required`]). Surfaced so CI sees the gate at plan.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub approvals_required: Vec<String>,
}

// ----------------------------------------------------------------------------
// Current state snapshot
// ----------------------------------------------------------------------------

/// The app's live state, loaded once for a diff.
#[derive(Debug, Default)]
pub struct CurrentState {
    pub scripts: Vec<Script>,
    pub routes: Vec<Route>,
    pub triggers: Vec<Trigger>,
    pub secret_names: Vec<String>,
    /// App's OWN `vars` at scope `*` (key, value), non-tombstone — the set
    /// the manifest reconciles. Inherited group vars and tombstones are
    /// excluded (the manifest manages only the app's own real values).
    pub vars: Vec<(String, serde_json::Value)>,
    /// Extension-point marker names declared directly at this node (§5.5).
    pub extension_point_names: Vec<String>,
    /// Shared group collections declared directly at this node (§11.6), as
    /// `(name, kind)` pairs.
    pub collections: Vec<(String, String)>,
    /// §11 tail per-app suppression markers at this node (app-only), as
    /// `(target_kind, reference)` pairs.
    pub suppressions: Vec<(String, String)>,
    /// v1.2 Workflows owned directly by this node (app-owned; empty for a group).
    pub workflows: Vec<crate::workflow_repo::Workflow>,
    /// §9.4 interceptor markers declared directly at this node, as
    /// `(service, op, script)` triples.
    pub interceptors: Vec<(String, String, String)>,
}

/// One row of the read-only extension-point report (§5.5).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionPointInfo {
    pub name: String,
    /// True when this node owns the marker (vs inheriting it from an ancestor).
    pub declared_here: bool,
    /// Resolved provider for an app (`app override` / `inherited default`), or
    /// `None` when unset (no provider — a plan error) or for a group node.
    pub provider: Option<String>,
}

/// One row of the read-only §11.6 shared-collection report.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollectionInfo {
    pub name: String,
    pub kind: String,
}

/// One row of the read-only §11 tail suppression report (`pic suppress ls
/// --app`). `target_kind` is `trigger` | `route`; `reference` is the handler
/// script name (trigger) or path (route) the app declines.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuppressionInfo {
    pub target_kind: String,
    pub reference: String,
}

/// One row of the read-only §11 tail trigger-template report (`pic triggers ls
/// --group`). `target` is the kind-specific identity bit (collection glob or
/// topic pattern); `script` is the handler script's name.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TriggerTemplateInfo {
    pub kind: String,
    pub target: String,
    pub script: String,
    pub enabled: bool,
    /// §11 tail: `true` for a sealed (non-suppressible) template.
    pub sealed: bool,
    /// §11.6: `true` for a shared-collection template.
    pub shared: bool,
}

/// 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,
    /// §3 M3: applying to a confirm-required environment without an explicit
    /// per-env approval. Maps to 409 — actionable (`--approve <env>`).
    #[error(
        "environment `{0}` requires explicit approval to apply; \
         re-run with `--approve {0}` (a blanket `--yes` does not cover it)"
    )]
    ApprovalRequired(String),
    /// §7 multi-repo ownership: this apply's `[project]` does not own a node it
    /// touches (or the node is unclaimed and the apply declared no project).
    /// Maps to 409 — actionable (`use --takeover`, `declare [project]`).
    #[error("ownership conflict: {0}")]
    OwnershipConflict(String),
    /// §6/§7: a node is above/outside this project's declared `parent_group`
    /// attach point — you can only apply within its subtree. Maps to 422.
    #[error("outside attach point: {0}")]
    OutsideAttachPoint(String),
    /// §6 M2: a group's SERVER parent differs from the manifest's declared
    /// (directory-nesting) parent, and no `--force-local-structure` /
    /// `--adopt-server-structure` was given. Maps to 422 — actionable.
    #[error("structural divergence: {0}")]
    StructuralDivergence(String),
    #[error("authorization repo error: {0}")]
    AuthzRepo(String),
    #[error("backend: {0}")]
    Backend(String),
}

// ----------------------------------------------------------------------------
// Service
// ----------------------------------------------------------------------------

/// Reconcile engine. Holds trait-object repos for the read/diff path; the
/// transactional write path (`apply`) reuses the same handles plus `pool`.
#[derive(Clone)]
pub struct ApplyService {
    /// Pool for the transactional write path (`apply`).
    pub pool: PgPool,
    pub scripts: Arc<dyn ScriptRepository>,
    pub routes: Arc<dyn RouteRepository>,
    pub triggers: Arc<dyn TriggerRepo>,
    pub secrets: Arc<dyn SecretsRepo>,
    /// App-owned `vars` reconciliation (read/diff path; the transactional
    /// write goes through `set_app_var_tx` / `delete_app_var_tx`).
    pub vars: Arc<dyn VarsRepo>,
    pub apps: Arc<dyn AppRepository>,
    /// Group lookups (Phase 5): resolve a group slug→id for group/tree apply.
    pub groups: Arc<dyn crate::group_repo::GroupRepository>,
    /// Project registry (§7 multi-repo ownership): the READ side. The claim
    /// write path registers projects via `upsert_project_tx` inside the apply
    /// transaction, not through this repo.
    pub projects: Arc<dyn crate::project_repo::ProjectRepository>,
    /// Module resolver (Phase 4b): lexical, origin-aware module lookup used by
    /// the single-node dangling-import plan check (§5.5).
    pub modules: Arc<dyn picloud_shared::ModuleSource>,
    /// App domain claims — validates a route's host belongs to the app.
    pub domains: Arc<dyn AppDomainRepository>,
    pub authz: Arc<dyn AuthzRepo>,
    pub validator: Arc<dyn ScriptValidator>,
    pub sandbox_ceiling: SandboxCeiling,
    /// Default retry/dispatch settings filled when a bundle trigger omits them.
    pub trigger_config: TriggerConfig,
    /// Shared route snapshot, rebuilt once after a successful apply.
    pub route_table: Arc<RouteTable>,
    /// Master key — seals email triggers' resolved inbound secrets.
    pub master_key: MasterKey,
}

impl ApplyService {
    /// Compute the diff between `bundle` and app `app_id`'s live state.
    /// Read-only: validates the bundle, loads current state, diffs.
    ///
    /// # Errors
    /// `Invalid` for a malformed bundle; `Backend` for repo failures.
    pub async fn plan(
        &self,
        app_id: AppId,
        bundle: &Bundle,
        project: Option<&ProjectDecl>,
    ) -> Result<PlanResult, ApplyError> {
        self.plan_owner(ApplyOwner::App(app_id), bundle, project)
            .await
    }

    /// The sorted names of every gated env in a computed policy (`confirm`
    /// entries), surfaced as `plan.approvals_required`. Callers pass the
    /// governing policy so plan's preview matches what apply gates on.
    fn gated_envs_from(policy: &std::collections::BTreeMap<String, bool>) -> Vec<String> {
        let mut gated: Vec<String> = policy
            .iter()
            .filter(|&(_, confirm)| *confirm)
            .map(|(env, _)| env.clone())
            .collect();
        gated.sort_unstable();
        gated
    }

    /// Owner-generic plan (Phase 5): diff `bundle` against an app OR group node.
    /// A group node has only scripts + vars — routes/triggers are rejected, and
    /// the app-only inherited-target + route-host validation is skipped.
    ///
    /// # Errors
    /// `Invalid` for a malformed bundle; `Backend` for repo failures.
    pub async fn plan_owner(
        &self,
        owner: ApplyOwner,
        bundle: &Bundle,
        project: Option<&ProjectDecl>,
    ) -> Result<PlanResult, ApplyError> {
        let inherited = self.resolve_inherited_targets_for(owner, bundle).await?;
        self.validate_bundle_for(
            matches!(owner, ApplyOwner::Group(_)),
            bundle,
            &keys_set(&inherited),
        )?;
        self.check_imports_resolve(owner, bundle).await?;
        self.check_extension_points_provided(owner, bundle).await?;
        if let ApplyOwner::App(app_id) = owner {
            self.validate_route_hosts(app_id, bundle).await?;
        }
        // §6/§7 M3: preview the attach-point ceiling (same rule as apply) so a
        // `pic plan` outside the subtree fails early, consistent with apply.
        if let Some(pg_slug) = project.and_then(|p| p.parent_group.as_deref()) {
            let pg = self.resolve_group(pg_slug).await?;
            self.check_within_attach(owner, pg, pg_slug).await?;
        }
        let current = self.load_current(owner).await?;
        let current_names = self.resolve_current_target_names(&current).await?;
        validate_email_secrets_present(bundle, &current.secret_names)?;
        let ownership = self
            .ownership_preview(owner, project.map(|p| p.slug.as_str()))
            .await?;
        Ok(PlanResult {
            plan: compute_diff_with_names(&current, bundle, &current_names),
            // Fingerprint of the live state this plan was computed against, so
            // `apply` can refuse if the node changed underneath it (§4.2).
            state_token: state_token_with_names(&current, &current_names),
            ownership,
            approvals_required: Self::gated_envs_from(
                &self.governing_env_policy(owner, project).await?,
            ),
            warnings: self.plan_warnings(owner, bundle).await?,
        })
    }

    /// Inherited group-script targets for an app node; a group node has no
    /// routes/triggers to bind, so there is nothing to inherit (empty map).
    async fn resolve_inherited_targets_for(
        &self,
        owner: ApplyOwner,
        bundle: &Bundle,
    ) -> Result<HashMap<String, ScriptId>, ApplyError> {
        match owner {
            ApplyOwner::App(app_id) => self.resolve_inherited_targets(app_id, bundle).await,
            // §11 tail: a group trigger or ROUTE TEMPLATE binds the group's OWN
            // endpoint scripts (declared here or pre-existing). Surface the ones
            // a template references but the bundle doesn't declare, so
            // `validate_bundle` accepts them (the reconcile's name_to_id already
            // includes the group's current scripts).
            ApplyOwner::Group(group_id) => {
                let declared: HashSet<String> = bundle
                    .scripts
                    .iter()
                    .map(|s| s.name.to_lowercase())
                    .collect();
                let referenced: HashSet<String> = bundle
                    .triggers
                    .iter()
                    .map(|t| t.script().to_lowercase())
                    .chain(bundle.routes.iter().map(|r| r.script.to_lowercase()))
                    .collect();
                let by_name: HashMap<String, ScriptId> = self
                    .scripts
                    .list_for_group(group_id)
                    .await
                    .map_err(map_repo)?
                    .into_iter()
                    .filter(|s| s.kind == ScriptKind::Endpoint)
                    .map(|s| (s.name.to_lowercase(), s.id))
                    .collect();
                let mut out = HashMap::new();
                for name in referenced {
                    if declared.contains(&name) {
                        continue;
                    }
                    if let Some(id) = by_name.get(&name) {
                        out.insert(name, *id);
                    }
                }
                Ok(out)
            }
        }
    }

    /// `validate_bundle`, plus a group-node guard: a group owns only scripts +
    /// vars (+ declared secret names), so a `[group]` manifest carrying routes
    /// or triggers is a 422 — those are app concerns.
    #[allow(clippy::too_many_lines)]
    fn validate_bundle_for(
        &self,
        is_group: bool,
        bundle: &Bundle,
        inherited_endpoints: &HashSet<String>,
    ) -> Result<(), ApplyError> {
        if is_group {
            // §11 tail: a group MAY declare ROUTE TEMPLATES (validated as binding
            // a group-owned endpoint by `validate_bundle` via the inherited
            // targets) and TRIGGER TEMPLATES. The stateless EVENT kinds
            // (kv/docs/files/pubsub) resolve live via the chain union at
            // dispatch; §4.5 M5 the stateful CRON + QUEUE + EMAIL kinds are
            // allowed and MATERIALIZED into a per-descendant-app row. An email
            // template resolves its inbound secret against the GROUP's own secret
            // store once at apply (shared-group-secret model); materialization
            // copies the sealed bytes verbatim.
            for t in &bundle.triggers {
                // §11.6: a `shared` trigger watches a SHARED collection/namespace.
                // kv/docs/files watch a shared collection (name = the glob); D2
                // pubsub watches a shared TOPIC namespace (name = the topic
                // pattern's ROOT segment, e.g. `events.*` → `events`). The watched
                // name must be declared shared of the matching kind on the SAME
                // group; a wildcard can't be statically resolved, so it's allowed
                // (runtime match).
                if t.shared() {
                    let (watched, coll_kind) = match t {
                        BundleTrigger::Kv { .. }
                        | BundleTrigger::Docs { .. }
                        | BundleTrigger::Files { .. } => {
                            (t.collection_glob().unwrap_or_default(), t.kind_str())
                        }
                        BundleTrigger::Pubsub { topic_pattern, .. } => {
                            (topic_pattern.split('.').next().unwrap_or(""), "topic")
                        }
                        BundleTrigger::Queue { queue_name, .. } => (queue_name.as_str(), "queue"),
                        _ => {
                            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 !is_group && !bundle.collections.is_empty() {
            return Err(app_only_reject(
                "manifest cannot declare shared collections",
                "they are owned by a group",
            ));
        }
        // §11 tail: `sealed` is a GROUP-template property — it makes an
        // inherited template non-suppressible. On an app-owned route/trigger it
        // is meaningless (nothing inherits it), so reject it rather than persist
        // a misleading `sealed` app row the filters never consult. Defense in
        // depth over the CLI (which authors `sealed` freely but only a group
        // node inherits down).
        if !is_group {
            if bundle.routes.iter().any(|r| r.sealed) {
                return Err(app_only_reject(
                    "route cannot be `sealed`",
                    "sealing marks a group template as non-suppressible; an app \
                     route is never inherited",
                ));
            }
            if bundle.triggers.iter().any(BundleTrigger::sealed) {
                return Err(app_only_reject(
                    "trigger cannot be `sealed`",
                    "sealing marks a group template as non-suppressible; an app \
                     trigger is never inherited",
                ));
            }
            // §11.6: `shared` watches a group's SHARED collection — meaningless
            // for an app (apps don't own shared collections).
            if bundle.triggers.iter().any(BundleTrigger::shared) {
                return Err(app_only_reject(
                    "trigger cannot be `shared`",
                    "a shared-collection trigger is owned by the group that \
                     declares the collection",
                ));
            }
        }
        // §11 tail M1: both an app and a group may declare suppressions — an
        // app declines an inherited template for itself, a group for its whole
        // subtree. Both are inheritance-only (the dispatch filters gate to
        // group-owned templates on the suppressing owner's chain), so no
        // owner-kind guard here.
        //
        // v1.2 Workflows: app-owned only (M1). Reject on a group node (the
        // `group_id` column ships unused). Then structurally validate each
        // definition (unique/acyclic steps, deps exist, when/template parse).
        if is_group && !bundle.workflows.is_empty() {
            return Err(ApplyError::Invalid(
                "a group cannot declare workflows — they are app-owned (group-owned \
                 workflow templates are a future addition)"
                    .into(),
            ));
        }
        // Duplicate workflow names collide in the reconcile diff (keyed by
        // `lower(name)`), silently dropping all but one. Reject them up front —
        // case-insensitively, matching the diff key and the DB's partial-unique
        // index on `(app_id, lower(name))`.
        let mut seen_workflows: HashSet<String> = HashSet::new();
        for w in &bundle.workflows {
            if !seen_workflows.insert(w.name.to_lowercase()) {
                return Err(ApplyError::Invalid(format!(
                    "duplicate workflow name `{}` (names are case-insensitive)",
                    w.name
                )));
            }
        }
        for w in &bundle.workflows {
            validate_workflow_definition(&w.name, &w.definition).map_err(ApplyError::Invalid)?;
        }
        // §9.4 interceptors (MVP): `service = "kv"`, `op ∈ {set, delete}`,
        // authored on app OR group. One marker per `(service, op)` — a duplicate
        // would collide on the reconcile key and the DB's partial-unique index.
        let mut seen_hooks: HashSet<(String, String)> = HashSet::new();
        for i in &bundle.interceptors {
            if i.service != "kv" {
                return Err(ApplyError::Invalid(format!(
                    "interceptor service `{}` is not supported — only `kv` (MVP)",
                    i.service
                )));
            }
            if i.op != "set" && i.op != "delete" {
                return Err(ApplyError::Invalid(format!(
                    "interceptor op `{}` is not supported for kv — only `set` / `delete`",
                    i.op
                )));
            }
            if i.script.trim().is_empty() {
                return Err(ApplyError::Invalid(
                    "an interceptor must name a `script`".into(),
                ));
            }
            if !seen_hooks.insert((i.service.clone(), i.op.clone())) {
                return Err(ApplyError::Invalid(format!(
                    "duplicate interceptor for `{}/{}`",
                    i.service, i.op
                )));
            }
        }
        self.validate_bundle(bundle, inherited_endpoints)
    }

    /// Reconcile app `app_id` to `bundle` in a single transaction. Creates
    /// and updates are always applied; deletions of resources absent from the
    /// bundle are executed only when `prune` is set (secrets are never pruned).
    ///
    /// # Errors
    /// `Invalid` for a bad bundle; `Backend` for repo/transaction failures.
    /// Reconcile ONE node's scripts/routes/triggers/vars inside an existing
    /// transaction (Phase 5). Returns the node's final `name -> script_id` map
    /// (current + created), which the tree apply records so a later app node
    /// can bind a route to a group script created earlier in the SAME tx. The
    /// single-node `apply_owner` and the multi-node tree apply both call this.
    #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
    async fn reconcile_node_tx(
        &self,
        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
        owner: ApplyOwner,
        bundle: &Bundle,
        plan: &Plan,
        current: &CurrentState,
        current_names: &HashMap<ScriptId, String>,
        inherited: &HashMap<String, ScriptId>,
        prune: bool,
        actor: AdminUserId,
        report: &mut ApplyReport,
    ) -> Result<HashMap<String, ScriptId>, ApplyError> {
        let bundle_scripts: HashMap<String, &BundleScript> = bundle
            .scripts
            .iter()
            .map(|s| (s.name.to_lowercase(), s))
            .collect();
        let current_scripts: HashMap<String, &Script> = current
            .scripts
            .iter()
            .map(|s| (s.name.to_lowercase(), s))
            .collect();
        // name -> id for resolving route/trigger targets (existing + created).
        let mut name_to_id: HashMap<String, ScriptId> = current
            .scripts
            .iter()
            .map(|s| (s.name.to_lowercase(), s.id))
            .collect();

        // 1. Scripts — create + update first so routes/triggers resolve.
        for ch in &plan.scripts {
            let key = ch.key.to_lowercase();
            match ch.op {
                Op::Create => {
                    let bs = bundle_scripts[&key];
                    let (script_app_id, script_group_id) = owner.script_owner();
                    let new = NewScript {
                        app_id: script_app_id,
                        group_id: script_group_id,
                        name: bs.name.clone(),
                        description: bs.description.clone(),
                        source: bs.source.clone(),
                        kind: bs.kind,
                        timeout_seconds: bs.timeout_seconds,
                        memory_limit_mb: bs.memory_limit_mb,
                        sandbox: bs.sandbox,
                        enabled: bs.enabled,
                        imports: self.script_imports(bs)?,
                    };
                    let created = insert_script_tx(&mut *tx, &new).await.map_err(map_repo)?;
                    name_to_id.insert(created.name.to_lowercase(), created.id);
                    report.scripts_created += 1;
                }
                Op::Update => {
                    let bs = bundle_scripts[&key];
                    let cur = current_scripts[&key];
                    let patch = ScriptPatch {
                        name: None,
                        // Sparse: `None` (omitted in the manifest) leaves the
                        // stored description untouched; `Some(text)` sets it.
                        // Mirrors `script_update_reason`'s leave-as-is rule.
                        description: bs.description.clone().map(Some),
                        source: Some(bs.source.clone()),
                        timeout_seconds: bs.timeout_seconds,
                        memory_limit_mb: bs.memory_limit_mb,
                        sandbox: bs.sandbox,
                        kind: Some(bs.kind),
                        // Declarative: always reconcile to the manifest's value.
                        enabled: Some(bs.enabled),
                        imports: Some(self.script_imports(bs)?),
                    };
                    update_script_tx(&mut *tx, cur.id, &patch)
                        .await
                        .map_err(map_repo)?;
                    report.scripts_updated += 1;
                }
                Op::NoOp | Op::Delete => {}
            }
        }

        // Phase 4: route/trigger targets the manifest doesn't define as app-own
        // scripts may bind to inherited group endpoints. `or_insert` keeps
        // app-own precedence (those names aren't in `inherited` by
        // construction, but be defensive about CoW).
        for (name, id) in inherited {
            name_to_id.entry(name.clone()).or_insert(*id);
        }

        // 2. Routes — identity is the (method, host, path) tuple; an
        // "update" (rebind / dispatch change) is delete + insert.
        let current_routes: HashMap<String, &Route> = current
            .routes
            .iter()
            .map(|r| {
                (
                    route_key(
                        r.method.as_deref(),
                        r.host_kind,
                        &r.host,
                        r.path_kind,
                        &r.path,
                    ),
                    r,
                )
            })
            .collect();
        let bundle_routes: HashMap<String, &BundleRoute> = bundle
            .routes
            .iter()
            .map(|r| {
                (
                    route_key(
                        r.method.as_deref(),
                        r.host_kind,
                        &r.host,
                        r.path_kind,
                        &r.path,
                    ),
                    r,
                )
            })
            .collect();
        // 2. Routes — delete before insert so a binding freed by an update's
        // delete-half or a prune removal can be reused by a create in the
        // SAME apply without tripping the per-app UNIQUE binding index.
        // Phase A — deletes: update replacements always; removals when pruning.
        for ch in &plan.routes {
            match ch.op {
                Op::Update => {
                    let cur = current_routes[&ch.key];
                    delete_route_tx(&mut *tx, cur.id).await.map_err(map_repo)?;
                }
                Op::Delete if prune => {
                    if let Some(cur) = current_routes.get(&ch.key) {
                        delete_route_tx(&mut *tx, cur.id).await.map_err(map_repo)?;
                        report.routes_deleted += 1;
                    }
                }
                _ => {}
            }
        }
        // Phase B — inserts: creates and update replacements.
        for ch in &plan.routes {
            let is_update = match ch.op {
                Op::Create => false,
                Op::Update => true,
                _ => continue,
            };
            let br = bundle_routes[&ch.key];
            let sid = resolve_script(&name_to_id, &br.script)?;
            // §11 tail: an app node writes an app-owned route; a group node
            // writes a route TEMPLATE (group_id owner) that expands into every
            // descendant app's slice at rebuild.
            insert_bundle_route(&mut *tx, owner.as_script_owner(), sid, br).await?;
            if is_update {
                report.routes_updated += 1;
            } else {
                report.routes_created += 1;
            }
        }

        // 3. Triggers — semantic identity captures the whole definition,
        // so the only op is Create (a change is delete-old + create-new;
        // the stale one is removed by prune). Additive: create only.
        let bundle_triggers: HashMap<String, &BundleTrigger> =
            bundle.triggers.iter().map(|t| (t.identity(), t)).collect();
        for ch in &plan.triggers {
            if ch.op != Op::Create {
                continue;
            }
            // §11 tail: an app node owns triggers; a group node owns trigger
            // TEMPLATES (event kinds only — validated in `validate_bundle_for`).
            let bt = bundle_triggers[&ch.key];
            let sid = resolve_script(&name_to_id, bt.script())?;
            if let BundleTrigger::Email {
                inbound_secret_ref, ..
            } = bt
            {
                // Resolve the referenced secret against the OWNER's store,
                // decrypt it, and re-seal it into the email trigger's detail
                // row — the value never travels in the manifest. §4.5 M5: an
                // app trigger resolves the app's secret; a group EMAIL TEMPLATE
                // resolves the group's own secret once here, and materialization
                // copies the sealed bytes verbatim to each descendant.
                let (ct, nonce, version) = self
                    .resolve_and_seal(owner.secret_owner(), inbound_secret_ref)
                    .await?;
                insert_email_trigger_tx(
                    &mut *tx,
                    owner.as_script_owner(),
                    sid,
                    actor,
                    &ct,
                    &nonce,
                    version,
                )
                .await
                .map_err(map_trig)?;
            } else {
                let (dispatch, retry_max, backoff, base) = self.trigger_settings(bt);
                let details = bundle_trigger_details(
                    bt,
                    self.trigger_config.queue_default_visibility_timeout_secs,
                );
                insert_trigger_tx(
                    &mut *tx,
                    owner.as_script_owner(),
                    sid,
                    actor,
                    dispatch,
                    retry_max,
                    backoff,
                    base,
                    // §11 tail: a sealed group template is non-suppressible
                    // (rejected on an app owner in `validate_bundle_for`).
                    bt.sealed(),
                    // §11.6: a shared-collection group trigger.
                    bt.shared(),
                    &details,
                )
                .await
                .map_err(map_trig)?;
            }
            report.triggers_created += 1;
        }

        // 3b. Vars — upsert every Create/Update at app scope `*`, in-tx so a
        // later failure rolls them back with everything else.
        for ch in &plan.vars {
            match ch.op {
                Op::Create => {
                    owner
                        .set_var_tx(&mut *tx, &ch.key, &bundle.vars[&ch.key])
                        .await?;
                    report.vars_created += 1;
                }
                Op::Update => {
                    owner
                        .set_var_tx(&mut *tx, &ch.key, &bundle.vars[&ch.key])
                        .await?;
                    report.vars_updated += 1;
                }
                Op::NoOp | Op::Delete => {}
            }
        }

        // 3c. Extension-point markers (§5.5) — insert each Create (idempotent).
        // Name-only identity, so there is no Update; deletes happen in prune.
        for ch in &plan.extension_points {
            if ch.op == Op::Create {
                crate::extension_point_repo::insert_extension_point_tx(
                    &mut *tx,
                    owner.as_script_owner(),
                    &ch.key,
                )
                .await
                .map_err(|e| ApplyError::Backend(e.to_string()))?;
                report.extension_points_created += 1;
            }
        }

        // 3c.5. §9.4 interceptor markers — upsert each Create/Update by
        // `(service, op)`. A changed script is an in-place Update; deletes happen
        // in prune.
        for ch in &plan.interceptors {
            if ch.op == Op::Create || ch.op == Op::Update {
                let bi = bundle
                    .interceptors
                    .iter()
                    .find(|i| format!("{}/{}", i.service, i.op) == ch.key)
                    .ok_or_else(|| {
                        ApplyError::Backend("internal: interceptor plan/bundle mismatch".into())
                    })?;
                crate::interceptor_repo::insert_interceptor_tx(
                    &mut *tx,
                    owner.as_script_owner(),
                    &bi.service,
                    &bi.op,
                    &bi.script,
                )
                .await
                .map_err(|e| ApplyError::Backend(e.to_string()))?;
                if ch.op == Op::Create {
                    report.interceptors_created += 1;
                } else {
                    report.interceptors_updated += 1;
                }
            }
        }

        // 3d. Shared group-collection markers (§11.6) — insert each Create
        // (idempotent). Name-only identity; deletes happen in prune. Pruning a
        // marker hides the store but does NOT drop its `group_kv_entries` data
        // (that dies only with the owning group).
        for ch in &plan.collections {
            if ch.op == Op::Create {
                let kind = ch.detail.as_deref().unwrap_or("kv");
                crate::group_collection_repo::insert_collection_tx(
                    &mut *tx,
                    owner.as_script_owner(),
                    &ch.key,
                    kind,
                )
                .await
                .map_err(|e| ApplyError::Backend(e.to_string()))?;
                report.collections_created += 1;
            }
        }

        // 3e. Per-app suppression markers (§11 tail) — insert each Create
        // (idempotent). Owner-polymorphic (§11 tail M1): an app declines for
        // itself, a group for its whole subtree. Deletes happen in prune → the
        // template re-inherits. Key is `"{target_kind}:{reference}"`; split on
        // the FIRST `:` so a route reference containing `:` (a param path
        // `/users/:id`) survives.
        for ch in &plan.suppressions {
            if ch.op == Op::Create {
                let (kind, reference) = ch.key.split_once(':').expect("suppression key has ':'");
                crate::suppression_repo::insert_suppression_tx(
                    &mut *tx,
                    owner.as_script_owner(),
                    kind,
                    reference,
                )
                .await
                .map_err(|e| ApplyError::Backend(e.to_string()))?;
                report.suppressions_created += 1;
            }
        }

        // 4. Prune (only with --prune): delete stale triggers, then scripts
        // (route removals already happened in the route delete-pass above).
        // Secret pruning is deliberately deferred (destructive + irreversible):
        // stale secrets are surfaced in the plan but never deleted here.
        if prune {
            // Include inherited group-script bindings (Phase 4) so a trigger
            // bound to a group script resolves its identity and is pruned when
            // dropped from the manifest — not silently orphaned.
            let id_to_name: HashMap<ScriptId, String> = current
                .scripts
                .iter()
                .map(|s| (s.id, s.name.clone()))
                .chain(current_names.iter().map(|(id, n)| (*id, n.clone())))
                .collect();
            let trigger_id_by_identity: HashMap<String, TriggerId> = current
                .triggers
                .iter()
                .filter_map(|t| current_trigger_identity(t, &id_to_name).map(|i| (i, t.id)))
                .collect();
            for ch in &plan.triggers {
                if ch.op == Op::Delete {
                    if let Some(id) = trigger_id_by_identity.get(&ch.key) {
                        delete_trigger_tx(&mut *tx, *id).await.map_err(map_trig)?;
                        report.triggers_deleted += 1;
                    }
                }
            }

            // Scripts owning a trigger the manifest can't represent (email,
            // dead_letter) must not be silently pruned: deleting the script
            // would cascade-destroy that trigger (and its sealed secret) via
            // the `triggers.script_id ON DELETE CASCADE` FK, defeating the
            // "never prune email triggers" guarantee. Refuse and point the
            // operator at the explicit removal path.
            let protected: HashSet<ScriptId> = current
                .triggers
                .iter()
                .filter(|t| {
                    matches!(
                        t.details,
                        TriggerDetails::Email { .. } | TriggerDetails::DeadLetter { .. }
                    )
                })
                .map(|t| t.script_id)
                .collect();
            let script_id_by_name: HashMap<String, ScriptId> = current
                .scripts
                .iter()
                .map(|s| (s.name.to_lowercase(), s.id))
                .collect();
            for ch in &plan.scripts {
                if ch.op == Op::Delete {
                    if let Some(id) = script_id_by_name.get(&ch.key.to_lowercase()) {
                        if protected.contains(id) {
                            return Err(ApplyError::Invalid(format!(
                                "cannot prune script `{}`: it still has an email or \
                                 dead-letter trigger that the manifest can't represent; \
                                 remove the trigger with `pic triggers rm` first",
                                ch.key
                            )));
                        }
                        delete_script_tx(&mut *tx, *id).await.map_err(map_repo)?;
                        report.scripts_deleted += 1;
                    }
                }
            }

            // Vars are prunable config (unlike secrets, which are never
            // pruned): a live app var the manifest dropped is deleted.
            for ch in &plan.vars {
                if ch.op == Op::Delete {
                    owner.delete_var_tx(&mut *tx, &ch.key).await?;
                    report.vars_deleted += 1;
                }
            }

            // Extension-point markers are prunable config too (§5.5).
            for ch in &plan.extension_points {
                if ch.op == Op::Delete {
                    crate::extension_point_repo::delete_extension_point_tx(
                        &mut *tx,
                        owner.as_script_owner(),
                        &ch.key,
                    )
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?;
                    report.extension_points_deleted += 1;
                }
            }

            // §9.4 interceptor markers are prunable config too. The delete keys
            // by `(service, op)` (the marker's identity), so a script-change
            // Update above is never clobbered here.
            for ch in &plan.interceptors {
                if ch.op == Op::Delete {
                    let (service, op) = ch.key.split_once('/').unwrap_or((ch.key.as_str(), ""));
                    crate::interceptor_repo::delete_interceptor_tx(
                        &mut *tx,
                        owner.as_script_owner(),
                        service,
                        op,
                    )
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?;
                    report.interceptors_deleted += 1;
                }
            }

            // Shared group-collection markers are prunable config too (§11.6).
            // Only the marker is removed here — the data survives until the
            // owning group is deleted.
            for ch in &plan.collections {
                if ch.op == Op::Delete {
                    let kind = ch.detail.as_deref().unwrap_or("kv");
                    crate::group_collection_repo::delete_collection_tx(
                        &mut *tx,
                        owner.as_script_owner(),
                        &ch.key,
                        kind,
                    )
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?;
                    report.collections_deleted += 1;
                }
            }

            // Suppression markers are prunable config too (§11 tail).
            // Removing a marker re-inherits the template.
            for ch in &plan.suppressions {
                if ch.op == Op::Delete {
                    let (kind, reference) =
                        ch.key.split_once(':').expect("suppression key has ':'");
                    crate::suppression_repo::delete_suppression_tx(
                        &mut *tx,
                        owner.as_script_owner(),
                        kind,
                        reference,
                    )
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?;
                    report.suppressions_deleted += 1;
                }
            }
        }

        // v1.2 Workflows (app-owned in M1). Create/Update always; Delete only
        // under --prune (mirrors scripts). Function / sub-workflow references
        // are resolved at RUN time, so there is no in-tx ordering dependency on
        // the scripts applied above.
        if let ApplyOwner::App(app_id) = owner {
            let cur_by_name: HashMap<String, &crate::workflow_repo::Workflow> = current
                .workflows
                .iter()
                .map(|w| (w.name.to_lowercase(), w))
                .collect();
            let desired_by_name: HashMap<String, &BundleWorkflow> = bundle
                .workflows
                .iter()
                .map(|w| (w.name.to_lowercase(), w))
                .collect();
            for ch in &plan.workflows {
                let key = ch.key.to_lowercase();
                match ch.op {
                    Op::Create => {
                        let w = desired_by_name[&key];
                        crate::workflow_repo::insert_workflow_tx(
                            &mut *tx,
                            app_id,
                            &w.name,
                            &w.definition,
                            w.enabled,
                        )
                        .await
                        .map_err(|e| ApplyError::Backend(e.to_string()))?;
                        report.workflows_created += 1;
                    }
                    Op::Update => {
                        let w = desired_by_name[&key];
                        let cur = cur_by_name[&key];
                        crate::workflow_repo::update_workflow_tx(
                            &mut *tx,
                            cur.id,
                            &w.definition,
                            w.enabled,
                        )
                        .await
                        .map_err(|e| ApplyError::Backend(e.to_string()))?;
                        report.workflows_updated += 1;
                    }
                    Op::Delete if prune => {
                        let cur = cur_by_name[&key];
                        crate::workflow_repo::delete_workflow_tx(&mut *tx, cur.id)
                            .await
                            .map_err(|e| ApplyError::Backend(e.to_string()))?;
                        report.workflows_deleted += 1;
                    }
                    Op::NoOp | Op::Delete => {}
                }
            }
        }
        Ok(name_to_id)
    }

    pub async fn apply(
        &self,
        app_id: AppId,
        bundle: &Bundle,
        prune: bool,
        claim: &OwnershipClaim<'_>,
        expected_token: Option<&str>,
    ) -> Result<ApplyReport, ApplyError> {
        self.apply_owner(
            ApplyOwner::App(app_id),
            bundle,
            prune,
            claim,
            expected_token,
        )
        .await
    }

    /// Owner-generic apply (Phase 5): reconcile an app OR group node to `bundle`
    /// in a single transaction. A group node reconciles only its scripts + vars.
    ///
    /// # Errors
    /// `Invalid` for a bad bundle; `Backend` for repo/transaction failures.
    #[allow(clippy::too_many_lines)]
    pub async fn apply_owner(
        &self,
        owner: ApplyOwner,
        bundle: &Bundle,
        prune: bool,
        claim: &OwnershipClaim<'_>,
        expected_token: Option<&str>,
    ) -> Result<ApplyReport, ApplyError> {
        let actor = claim.principal.user_id;
        let inherited = self.resolve_inherited_targets_for(owner, bundle).await?;
        self.validate_bundle_for(
            matches!(owner, ApplyOwner::Group(_)),
            bundle,
            &keys_set(&inherited),
        )?;
        self.check_imports_resolve(owner, bundle).await?;
        self.check_extension_points_provided(owner, bundle).await?;
        if let ApplyOwner::App(app_id) = owner {
            self.validate_route_hosts(app_id, bundle).await?;
        }
        // §6/§7 attach point: refuse a node outside the project's declared
        // `parent_group` subtree (read-only, before the tx).
        if let Some(pg_slug) = claim
            .project
            .as_ref()
            .and_then(|d| d.parent_group.as_deref())
        {
            let pg = self.resolve_group(pg_slug).await?;
            self.check_within_attach(owner, pg, pg_slug).await?;
        }

        let mut tx = self
            .pool
            .begin()
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?;
        // Serialize concurrent applies to the same app. NOTE: this lock
        // serializes apply-vs-apply only — `load_current` below reads on the
        // pool (a separate connection), so an *interactive* admin write
        // committing between the read and our writes can surface as a
        // unique-constraint conflict (clean tx rollback, no corruption) or a
        // stale no-op delete. Reading current state through `&mut *tx` is the
        // proper fix and is left as a follow-up. (The queue one-consumer
        // invariant is closed independently in `insert_trigger_tx`.)
        sqlx::query("SELECT pg_advisory_xact_lock($1)")
            .bind(apply_lock_key(owner))
            .execute(&mut *tx)
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?;

        // §7 ownership: register the declared project (first apply claims), then
        // gate this node. A group node claims/verifies itself; an app node must
        // match its nearest claimed ancestor. Runs under the lock above so two
        // repos racing to claim the same group serialize. Short-circuits before
        // any diff work on a conflict (409).
        let declared_project = match &claim.project {
            Some(decl) => Some(upsert_project_tx(&mut tx, decl, actor).await?),
            None => None,
        };
        match owner {
            ApplyOwner::Group(gid) => {
                self.claim_group_owner(
                    &mut tx,
                    gid,
                    declared_project,
                    claim.takeover,
                    claim.principal,
                )
                .await?;
            }
            ApplyOwner::App(app_id) => {
                self.check_app_owner_single(&mut tx, app_id, declared_project)
                    .await?;
            }
        }

        let current = self.load_current(owner).await?;
        // Phase 4: names of group scripts the current routes/triggers bind to,
        // so the token / diff / prune resolve inherited bindings (see method).
        let current_names = self.resolve_current_target_names(&current).await?;
        // Bound-plan check (§4.2): if the caller passed the token from a prior
        // `pic plan`, refuse when the live state changed since. Computed from
        // the same `load_current` snapshot the diff uses, before any mutation
        // (so a stale apply rolls back nothing). NOTE: like the diff, this read
        // is on the pool, not `&mut *tx` (see the lock comment above), so it
        // catches drift between plan and apply but shares that same narrow
        // apply-vs-interactive-write window — it is not a substitute for the
        // tx-scoped read the follow-up will add.
        if let Some(expected) = expected_token {
            if state_token_with_names(&current, &current_names) != expected {
                return Err(ApplyError::StateMoved);
            }
        }
        // Surface a missing email-secret reference here so `plan` and `apply`
        // agree, rather than only failing deep in `resolve_and_seal` below.
        validate_email_secrets_present(bundle, &current.secret_names)?;
        let plan = compute_diff_with_names(&current, bundle, &current_names);
        let mut report = ApplyReport::default();
        // The desired-state warnings — identical to what `pic plan` previews, so
        // plan and apply agree (see `plan_warnings`).
        report
            .warnings
            .extend(self.plan_warnings(owner, bundle).await?);

        self.reconcile_node_tx(
            &mut tx,
            owner,
            bundle,
            &plan,
            &current,
            &current_names,
            &inherited,
            prune,
            actor,
            &mut report,
        )
        .await?;

        tx.commit()
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?;

        // Post-commit: rebuild the orchestrator's route snapshot once. A
        // failure here doesn't undo the committed DB write — the table
        // self-heals on the next route write or restart. §11 tail: a GROUP node
        // may now declare route TEMPLATES, so refresh on either owner (the
        // rebuild expands templates into every descendant app's slice).
        {
            if let Err(e) = self.refresh_route_table().await {
                tracing::warn!(error = %e, "apply: route table refresh failed");
                report
                    .warnings
                    .push("route table refresh failed; it will self-heal".into());
            }
        }
        // §4.5 M5: re-materialize stateful (cron/queue) group templates into
        // per-descendant app rows. Post-commit + best-effort like the route
        // rebuild; per-app skips (e.g. a queue slot already taken) surface as
        // warnings.
        report.warnings.extend(self.rematerialize().await);
        Ok(report)
    }

    // ------------------------------------------------------------------------
    // Tree apply (Phase 5): a project subtree reconciled in ONE transaction.
    // ------------------------------------------------------------------------

    /// Diff a whole project subtree, read-only. Returns a per-node plan + one
    /// combined bound-plan token.
    ///
    /// # Errors
    /// `Invalid` for a malformed/unknown bundle; `Backend` for repo failures.
    pub async fn plan_tree(
        &self,
        bundle: &TreeBundle,
        project: Option<&ProjectDecl>,
    ) -> Result<TreePlanResult, ApplyError> {
        let existing = self.load_existing_groups(bundle).await?;
        let resolved: HashMap<String, GroupId> =
            existing.iter().map(|(k, g)| (k.clone(), g.id)).collect();
        let (prepared, to_create, token) = self.prepare_tree(bundle, &resolved, project).await?;
        // §6/§7 M3: attach-point ceiling preview (consistent with apply_tree).
        if let Some(pg_slug) = project.and_then(|p| p.parent_group.as_deref()) {
            let pg = self.resolve_group(pg_slug).await?;
            for p in &prepared {
                self.check_within_attach(p.owner, pg, pg_slug).await?;
            }
        }
        let declared = project.map(|p| p.slug.as_str());
        let mut nodes = Vec::with_capacity(prepared.len() + to_create.len());
        for p in prepared {
            let ownership = self.ownership_preview(p.owner, declared).await?;
            // §6 M2: for an existing group node, preview a structural divergence
            // (server parent ≠ manifest parent) before apply.
            let structure = self.divergence_preview(p.node, &existing).await?;
            let warnings = self.plan_warnings(p.owner, &p.node.bundle).await?;
            nodes.push(NodePlan {
                kind: p.node.kind,
                slug: p.node.slug.clone(),
                plan: p.plan,
                ownership,
                structure,
                warnings,
            });
        }
        // §6: a to-create group is previewed with a full-create plan; if the
        // repo declares a `[project]`, the create will also CLAIM it. It has no
        // node id yet (no chain to walk), so only the pure desired-state warnings
        // apply — a disabled binding; unreachable/suppress need an existing owner.
        for c in to_create {
            let ownership = declared.map(|d| preview_ownership(true, None, Some(d)));
            nodes.push(NodePlan {
                kind: c.node.kind,
                slug: c.node.slug.clone(),
                plan: c.plan,
                ownership,
                structure: None,
                warnings: disabled_target_warnings(&c.node.bundle),
            });
        }
        Ok(TreePlanResult {
            nodes,
            state_token: token,
            approvals_required: Self::gated_envs_from(
                &self.governing_env_policy_tree(bundle, project).await?,
            ),
        })
    }

    /// Reconcile a whole project subtree in a single transaction (all-or-
    /// nothing). Groups reconcile before apps, so an app route/trigger can bind
    /// a group script created earlier in the SAME transaction (resolved via the
    /// in-memory `group_script_index`, since uncommitted rows aren't visible to
    /// the pool-based inherited resolver).
    ///
    /// # Errors
    /// `Invalid` for a bad bundle; `StateMoved` if the bound token is stale;
    /// `OwnershipConflict` (§7) if a node is owned by another project;
    /// `Backend` for repo/transaction failures.
    #[allow(clippy::too_many_lines)]
    pub async fn apply_tree(
        &self,
        bundle: &TreeBundle,
        prune: bool,
        claim: &OwnershipClaim<'_>,
        expected_token: Option<&str>,
        structure_mode: StructureMode,
    ) -> Result<ApplyReport, ApplyError> {
        let actor = claim.principal.user_id;
        let mut tx = self
            .pool
            .begin()
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?;
        // §6 Phase 0: reconcile the group tree SHAPE — create every group the
        // manifest declares (by directory nesting) but the server lacks, and
        // (M2) reparent a diverged group per `structure_mode` — IN this tx
        // (parents-first), returning the complete slug → id map and the set of
        // structurally-changed groups. Enforces the attach ceiling + RBAC per
        // mutation and takes the coarse structural lock only when it mutates.
        // With all groups now resolvable, `prepare_tree` sees the whole tree
        // (created groups load an empty current → a full-create plan), so its
        // to-create list is empty.
        let (resolved, structurally_changed) = self
            .reconcile_group_structure_tx(
                &mut tx,
                bundle,
                claim.project.as_ref(),
                claim.principal,
                structure_mode,
            )
            .await?;
        let (prepared, to_create, token) = self
            .prepare_tree(bundle, &resolved, claim.project.as_ref())
            .await?;
        if !to_create.is_empty() {
            return Err(ApplyError::Backend(
                "internal: a group remained unresolved after create".into(),
            ));
        }
        if let Some(expected) = expected_token {
            if token != expected {
                return Err(ApplyError::StateMoved);
            }
        }
        // §6/§7 attach point: every EXISTING node in the tree must be within the
        // project's declared `parent_group` subtree. A group CREATED in Phase 0
        // was already checked there (and its uncommitted row isn't visible to
        // `check_within_attach`'s pool-based ancestor walk), so skip it.
        if let Some(pg_slug) = claim
            .project
            .as_ref()
            .and_then(|d| d.parent_group.as_deref())
        {
            let pg = self.resolve_group(pg_slug).await?;
            for p in &prepared {
                if let ApplyOwner::Group(gid) = p.owner {
                    if structurally_changed.contains(&gid) {
                        continue;
                    }
                }
                self.check_within_attach(p.owner, pg, pg_slug).await?;
            }
        }

        // §6 content-write authz, in-tx defense-in-depth. `authz_tree` (API
        // layer) SKIPS a group node absent at that time (it is to-create),
        // deferring to the create-gate in `reconcile_group_structure_tx`. That
        // gate only fires when the group is STILL absent at reconcile — so a
        // group created by a racer in the window would otherwise have its
        // content written with no authz. Re-check content-write caps here for
        // every group NOT structurally created/reparented by this apply: a group
        // WE created is covered by create-RBAC (`GroupAdmin(parent)` cascades)
        // and its uncommitted row is invisible to the pool-based authz cascade
        // anyway, so it must not be re-checked; a pre-existing (incl.
        // racer-created) group IS committed, so its ancestry resolves and a
        // missing cap denies. Runs before the write loops; a denial rolls back.
        for p in &prepared {
            if let ApplyOwner::Group(gid) = p.owner {
                if !structurally_changed.contains(&gid) {
                    self.require_group_node_writes(claim.principal, gid, &p.node.bundle, prune)
                        .await?;
                }
            }
        }

        // Lock every node's apply key, in sorted order, so concurrent applies
        // touching any shared node serialize and the ordering can't deadlock.
        let mut lock_keys: Vec<i64> = prepared.iter().map(|p| apply_lock_key(p.owner)).collect();
        lock_keys.sort_unstable();
        lock_keys.dedup();
        for k in lock_keys {
            sqlx::query("SELECT pg_advisory_xact_lock($1)")
                .bind(k)
                .execute(&mut *tx)
                .await
                .map_err(|e| ApplyError::Backend(e.to_string()))?;
        }

        let mut report = ApplyReport::default();
        let mut group_script_index: HashMap<GroupId, HashMap<String, ScriptId>> = HashMap::new();

        // §7 ownership: one project for the whole tree; claim/verify every group
        // node in Phase A (recording it in `claimed_in_tree`), then check every
        // app in Phase B against its nearest claimed ancestor. A conflict aborts
        // the whole transaction.
        let declared_project = match &claim.project {
            Some(decl) => Some(upsert_project_tx(&mut tx, decl, actor).await?),
            None => None,
        };
        let declared_slug = claim.project.as_ref().map(|d| d.slug.clone());
        let mut claimed_in_tree: HashMap<GroupId, (ProjectId, String)> = HashMap::new();

        // Phase A — groups first: reconcile each and record its post-create
        // `name -> id` so descendant app nodes can resolve inherited bindings.
        for p in &prepared {
            if let ApplyOwner::Group(gid) = p.owner {
                self.claim_group_owner(
                    &mut tx,
                    gid,
                    declared_project,
                    claim.takeover,
                    claim.principal,
                )
                .await?;
                if let (Some(pid), Some(slug)) = (declared_project, &declared_slug) {
                    claimed_in_tree.insert(gid, (pid, slug.clone()));
                }
                report
                    .warnings
                    .extend(disabled_target_warnings(&p.node.bundle));
                let name_to_id = self
                    .reconcile_node_tx(
                        &mut tx,
                        p.owner,
                        &p.node.bundle,
                        &p.plan,
                        &p.current,
                        &p.current_names,
                        &HashMap::new(),
                        prune,
                        actor,
                        &mut report,
                    )
                    .await?;
                group_script_index.insert(gid, name_to_id);
            }
        }

        // Phase B — apps: resolve inherited targets against in-tree groups (the
        // ids just created) and pre-existing ancestors, then reconcile.
        for p in &prepared {
            if let ApplyOwner::App(_) = p.owner {
                // §7: an app must match its nearest claimed ancestor — an in-tree
                // group (just claimed in Phase A) or a committed ancestor.
                let nearest =
                    nearest_claimed_in_tx(&mut tx, &p.app_chain, &claimed_in_tree).await?;
                decide_app_owner(nearest, declared_project)
                    .map_err(ApplyError::OwnershipConflict)?;
                report
                    .warnings
                    .extend(disabled_target_warnings(&p.node.bundle));
                report
                    .warnings
                    .extend(unreachable_endpoint_warnings(&p.node.bundle));
                let inherited = self
                    .tree_inherited_for_app(&p.app_chain, &p.node.bundle, &group_script_index)
                    .await?;
                self.reconcile_node_tx(
                    &mut tx,
                    p.owner,
                    &p.node.bundle,
                    &p.plan,
                    &p.current,
                    &p.current_names,
                    &inherited,
                    prune,
                    actor,
                    &mut report,
                )
                .await?;
            }
        }

        tx.commit()
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?;
        // One post-commit route refresh covers the whole tree. §11 tail: a
        // group node may declare route TEMPLATES affecting descendant apps even
        // when no app node is in this apply, so refresh unconditionally (a full
        // rebuild; cheap relative to a tree apply).
        if let Err(e) = self.refresh_route_table().await {
            tracing::warn!(error = %e, "apply_tree: route table refresh failed");
            report
                .warnings
                .push("route table refresh failed; it will self-heal".into());
        }
        // §4.5 M5: one post-commit materialization pass covers the whole tree.
        report.warnings.extend(self.rematerialize().await);
        Ok(report)
    }

    /// §6: load every EXISTING group node (lowercased slug → its full row) on
    /// the pool, skipping ones that don't exist yet (to-create, previewed by the
    /// plan path). Returns the whole `Group` so the divergence preview reads
    /// `parent_id` (and resolves an in-tree parent's slug) without re-fetching.
    async fn load_existing_groups(
        &self,
        bundle: &TreeBundle,
    ) -> Result<HashMap<String, picloud_shared::Group>, ApplyError> {
        let mut map = HashMap::new();
        for n in &bundle.nodes {
            if n.kind == NodeKind::Group {
                if let Some(g) = self
                    .groups
                    .get_by_slug(&n.slug)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?
                {
                    map.insert(n.slug.to_lowercase(), g);
                }
            }
        }
        Ok(map)
    }

    /// §6 M2: preview an EXISTING group node's structural divergence — `Some`
    /// only when the server's parent differs from the manifest's declared
    /// (directory-nesting) parent. `None` for an app node, a to-create group, or
    /// an in-sync group. Reads the node's own row from the pre-loaded `existing`
    /// map (no per-node `get_by_slug`), resolves the server parent's slug from
    /// that map when the parent is another tree node (else one `get_by_id`), and
    /// compares parent slugs **case-insensitively** so this preview agrees with
    /// the apply path's id-based check (which lowercases) on a mixed-case slug.
    async fn divergence_preview(
        &self,
        node: &TreeNode,
        existing: &HashMap<String, picloud_shared::Group>,
    ) -> Result<Option<StructurePreview>, ApplyError> {
        if node.kind != NodeKind::Group {
            return Ok(None);
        }
        let Some(group) = existing.get(&node.slug.to_lowercase()) else {
            return Ok(None); // to-create — not a divergence
        };
        let server_parent = match group.parent_id {
            Some(pid) => match existing.values().find(|g| g.id == pid) {
                Some(g) => Some(g.slug.clone()),
                None => self
                    .groups
                    .get_by_id(pid)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?
                    .map(|g| g.slug),
            },
            None => None,
        };
        let norm = |s: Option<&str>| s.map(str::to_lowercase);
        if norm(server_parent.as_deref()) == norm(node.parent.as_deref()) {
            return Ok(None); // in-sync
        }
        Ok(Some(StructurePreview {
            status: "diverged".to_string(),
            server_parent,
            manifest_parent: node.parent.clone(),
        }))
    }

    /// §6: resolve a group node's DECLARED (directory-nesting) parent slug to a
    /// `GroupId` — an in-tree group resolved earlier this tree wins, else a
    /// committed server group; `None` = instance root. Errors if a declared
    /// parent slug resolves to nothing.
    async fn resolve_declared_parent(
        &self,
        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
        map: &HashMap<String, GroupId>,
        n: &TreeNode,
    ) -> Result<Option<GroupId>, ApplyError> {
        let Some(pslug) = &n.parent else {
            return Ok(None);
        };
        if let Some(&g) = map.get(&pslug.to_lowercase()) {
            return Ok(Some(g));
        }
        crate::group_repo::read_group_id_by_slug_tx(tx, pslug)
            .await
            .map_err(map_group_repo_err)?
            .map(Some)
            .ok_or_else(|| {
                ApplyError::Invalid(format!(
                    "group `{}` declares parent `{pslug}`, which does not exist — a parent \
                     group must be declared before its children in the tree (the CLI orders \
                     nodes parents-first by directory nesting)",
                    n.slug
                ))
            })
    }

    /// §6: is `parent` within the attach-point subtree? `attach = None` (no
    /// ceiling) → always true. A just-structurally-changed group in `ok` (its
    /// own placement already validated within) short-circuits the ancestor walk.
    async fn parent_within_attach(
        &self,
        parent: Option<GroupId>,
        attach: Option<GroupId>,
        ok: &HashSet<GroupId>,
    ) -> Result<bool, ApplyError> {
        let Some(attach) = attach else {
            return Ok(true);
        };
        Ok(match parent {
            Some(p) if p == attach || ok.contains(&p) => true,
            Some(p) => self
                .groups
                .ancestors(p)
                .await
                .map_err(|e| ApplyError::Backend(e.to_string()))?
                .iter()
                .any(|g| g.id == attach),
            None => false,
        })
    }

    /// §6 M1+M2: reconcile the group tree SHAPE inside the apply tx (parents-
    /// first). A group the server lacks is CREATED (`create_group_node_tx`); a
    /// group whose server parent differs from the manifest's declared parent is
    /// a structural divergence, resolved per `mode` (`reparent_diverged_group_tx`
    /// — refuse / reparent / adopt-server). Returns the complete slug → id map
    /// (so `prepare_tree` resolves the whole tree) and the set of groups
    /// STRUCTURALLY changed here (created or reparented) — the caller skips them
    /// in the pool-based attach re-check (their uncommitted row isn't visible).
    /// Enforces the attach ceiling + RBAC per mutation (in the helpers); the
    /// coarse structural lock is taken only once, on the first mutation.
    async fn reconcile_group_structure_tx(
        &self,
        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
        bundle: &TreeBundle,
        project: Option<&ProjectDecl>,
        principal: &Principal,
        mode: StructureMode,
    ) -> Result<(HashMap<String, GroupId>, HashSet<GroupId>), ApplyError> {
        use crate::group_repo::{
            read_group_id_by_slug_tx, read_group_node_tx, GROUP_STRUCTURAL_LOCK_KEY,
        };

        let attach_slug = project.and_then(|p| p.parent_group.as_deref());
        let attach_gid = match attach_slug {
            Some(slug) => Some(
                read_group_id_by_slug_tx(tx, slug)
                    .await
                    .map_err(map_group_repo_err)?
                    .ok_or_else(|| {
                        ApplyError::Invalid(format!("attach point group `{slug}` does not exist"))
                    })?,
            ),
            None => None,
        };
        let ctx = ReconcileCtx {
            attach_gid,
            attach_slug,
            principal,
            mode,
        };

        let mut map: HashMap<String, GroupId> = HashMap::new();
        let mut changed: HashSet<GroupId> = HashSet::new();
        let mut within_attach: HashSet<GroupId> = HashSet::new();
        let mut locked = false;
        for n in &bundle.nodes {
            if n.kind != NodeKind::Group {
                continue;
            }
            let key = n.slug.to_lowercase();
            let mut existing = read_group_node_tx(tx, &n.slug)
                .await
                .map_err(map_group_repo_err)?;
            let declared_parent = self.resolve_declared_parent(tx, &map, n).await?;

            // A structural change (create or reparent) is needed if the group is
            // missing, or exists with a divergent parent under ForceLocal.
            let diverged = matches!(&existing, Some((_, sp)) if *sp != declared_parent);
            let needs_mutation =
                existing.is_none() || (diverged && mode == StructureMode::ForceLocal);
            if needs_mutation && !locked {
                sqlx::query("SELECT pg_advisory_xact_lock($1)")
                    .bind(GROUP_STRUCTURAL_LOCK_KEY)
                    .execute(&mut **tx)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?;
                locked = true;
                // Re-read under the lock — a racer may have created/moved it.
                existing = read_group_node_tx(tx, &n.slug)
                    .await
                    .map_err(map_group_repo_err)?;
            }

            match existing {
                None => {
                    let gid = self
                        .create_group_node_tx(tx, n, declared_parent, &ctx, &within_attach)
                        .await?;
                    changed.insert(gid);
                    if attach_gid.is_some() {
                        within_attach.insert(gid);
                    }
                    map.insert(key, gid);
                }
                Some((gid, server_parent)) => {
                    map.insert(key, gid);
                    if server_parent == declared_parent {
                        continue; // in-sync
                    }
                    if self
                        .reparent_diverged_group_tx(
                            tx,
                            n,
                            (gid, server_parent),
                            declared_parent,
                            &ctx,
                            &within_attach,
                        )
                        .await?
                    {
                        changed.insert(gid);
                        if attach_gid.is_some() {
                            within_attach.insert(gid);
                        }
                    }
                }
            }
        }
        Ok((map, changed))
    }

    /// §6: create one missing group node under its declared parent, attach-
    /// ceiling + RBAC gated (a root group needs `InstanceCreateGroup`, a subgroup
    /// `GroupAdmin(parent)`). Returns the new id; the caller records it in the
    /// changed / within-attach / slug→id sets.
    async fn create_group_node_tx(
        &self,
        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
        n: &TreeNode,
        declared_parent: Option<GroupId>,
        ctx: &ReconcileCtx<'_>,
        within_attach: &HashSet<GroupId>,
    ) -> Result<GroupId, ApplyError> {
        crate::groups_api::validate_slug_format(&n.slug).map_err(ApplyError::Invalid)?;
        if !self
            .parent_within_attach(declared_parent, ctx.attach_gid, within_attach)
            .await?
        {
            return Err(ApplyError::OutsideAttachPoint(format!(
                "group `{}` would be created outside this project's attach point `{}`",
                n.slug,
                ctx.attach_slug.unwrap_or_default()
            )));
        }
        let cap = match declared_parent {
            Some(p) => Capability::GroupAdmin(p),
            None => Capability::InstanceCreateGroup,
        };
        require(self.authz.as_ref(), ctx.principal, cap)
            .await
            .map_err(map_authz)?;
        let name = n.name.clone().unwrap_or_else(|| n.slug.clone());
        crate::group_repo::create_group_tx(
            tx,
            &n.slug,
            &name,
            n.description.as_deref(),
            declared_parent,
        )
        .await
        .map_err(map_group_repo_err)
    }

    /// §6 M2: resolve an EXISTING group node whose server parent diverges from
    /// the manifest, per `mode`. `Refuse` → `StructuralDivergence` (422);
    /// `AdoptServer` → keep the server shape (`Ok(false)`); `ForceLocal` →
    /// §5.6-gated (`GroupAdmin` on node + source + destination) + attach-
    /// ceilinged reparent to the declared parent (`Ok(true)`). `Ok(false)` = no
    /// structural change (the caller records the change only on `Ok(true)`).
    async fn reparent_diverged_group_tx(
        &self,
        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
        n: &TreeNode,
        existing: (GroupId, Option<GroupId>),
        declared_parent: Option<GroupId>,
        ctx: &ReconcileCtx<'_>,
        within_attach: &HashSet<GroupId>,
    ) -> Result<bool, ApplyError> {
        let (gid, server_parent) = existing;
        match ctx.mode {
            StructureMode::AdoptServer => Ok(false), // keep the server shape
            StructureMode::Refuse => Err(ApplyError::StructuralDivergence(format!(
                "group `{}` is under a different parent on the server than the manifest \
                 declares — pass --force-local-structure to reparent it, or \
                 --adopt-server-structure to keep the server's placement",
                n.slug
            ))),
            StructureMode::ForceLocal => {
                // §5.6 RBAC: admin on the node + source + destination.
                require(
                    self.authz.as_ref(),
                    ctx.principal,
                    Capability::GroupAdmin(gid),
                )
                .await
                .map_err(map_authz)?;
                for side in [server_parent, declared_parent].into_iter().flatten() {
                    require(
                        self.authz.as_ref(),
                        ctx.principal,
                        Capability::GroupAdmin(side),
                    )
                    .await
                    .map_err(map_authz)?;
                }
                if !self
                    .parent_within_attach(declared_parent, ctx.attach_gid, within_attach)
                    .await?
                {
                    return Err(ApplyError::OutsideAttachPoint(format!(
                        "reparenting group `{}` would move it outside this project's attach \
                         point `{}`",
                        n.slug,
                        ctx.attach_slug.unwrap_or_default()
                    )));
                }
                crate::group_repo::reparent_group_tx(tx, gid, declared_parent)
                    .await
                    .map_err(map_group_repo_err)?;
                Ok(true)
            }
        }
    }

    /// Resolve nodes to owners, validate each (apps' route/trigger targets may
    /// bind to in-tree ancestor group scripts), compute each node's plan, and
    /// fold a combined bound-plan token. Shared by `plan_tree` / `apply_tree`.
    ///
    /// `resolved_ids` maps every group node's lowercased slug → its `GroupId`.
    /// The caller pre-populates it: the plan path with the groups that already
    /// exist (a missing one becomes a `ToCreateGroup` preview), the apply path
    /// with existing groups PLUS the ones it just created in-tx (so the returned
    /// to-create list is empty). A to-create group's token part is identical to
    /// what it will be once created (empty current), so the plan and apply
    /// tokens match across a create.
    #[allow(clippy::too_many_lines)]
    async fn prepare_tree<'a>(
        &self,
        bundle: &'a TreeBundle,
        resolved_ids: &HashMap<String, GroupId>,
        project: Option<&ProjectDecl>,
    ) -> Result<(Vec<PreparedNode<'a>>, Vec<ToCreateGroup<'a>>, String), ApplyError> {
        if bundle.nodes.is_empty() {
            return Err(ApplyError::Invalid("project tree has no nodes".into()));
        }
        // Register in-tree group nodes first: their (resolved) ids and endpoint
        // script names, which an app node's binding validation may reference.
        // A group node whose slug is not in `resolved_ids` is to-create.
        let mut group_id_by_slug: HashMap<String, GroupId> = HashMap::new();
        let mut group_script_names: HashMap<GroupId, HashSet<String>> = HashMap::new();
        let mut seen_slugs: HashSet<String> = HashSet::new();
        for n in &bundle.nodes {
            let key = format!("{:?}:{}", n.kind, n.slug.to_lowercase());
            if !seen_slugs.insert(key) {
                return Err(ApplyError::Invalid(format!(
                    "project tree lists node `{}` more than once",
                    n.slug
                )));
            }
            if n.kind == NodeKind::Group {
                if let Some(&gid) = resolved_ids.get(&n.slug.to_lowercase()) {
                    group_id_by_slug.insert(n.slug.to_lowercase(), gid);
                    group_script_names.insert(
                        gid,
                        n.bundle
                            .scripts
                            .iter()
                            .filter(|s| s.kind == ScriptKind::Endpoint)
                            .map(|s| s.name.to_lowercase())
                            .collect(),
                    );
                }
            }
        }

        let mut prepared = Vec::with_capacity(bundle.nodes.len());
        let mut to_create: Vec<ToCreateGroup<'a>> = Vec::new();
        let mut token_parts: Vec<String> = Vec::new();
        let mut versioned_groups: BTreeSet<GroupId> = BTreeSet::new();

        for n in &bundle.nodes {
            // §6: a group node not in `resolved_ids` is to-create — validate its
            // bundle, diff against an empty current (a full create), and preview
            // it. The apply path creates it before this content reconcile.
            if n.kind == NodeKind::Group && !group_id_by_slug.contains_key(&n.slug.to_lowercase()) {
                self.validate_bundle_for(true, &n.bundle, &HashSet::new())?;
                let current = CurrentState::default();
                let current_names = self.resolve_current_target_names(&current).await?;
                validate_email_secrets_present(&n.bundle, &current.secret_names)?;
                let plan = compute_diff_with_names(&current, &n.bundle, &current_names);
                token_parts.push(format!(
                    "n|{}|{}",
                    n.slug.to_lowercase(),
                    state_token_with_names(&current, &current_names)
                ));
                to_create.push(ToCreateGroup { node: n, plan });
                continue;
            }

            let (owner, app_chain) = match n.kind {
                NodeKind::Group => {
                    let gid = group_id_by_slug[&n.slug.to_lowercase()];
                    versioned_groups.insert(gid);
                    (ApplyOwner::Group(gid), Vec::new())
                }
                NodeKind::App => {
                    let app = self.resolve_app(&n.slug).await?;
                    let chain = self
                        .groups
                        .ancestors(app.group_id)
                        .await
                        .map_err(|e| ApplyError::Backend(e.to_string()))?;
                    for g in &chain {
                        versioned_groups.insert(g.id);
                    }
                    (
                        ApplyOwner::App(app.id),
                        chain.iter().map(|g| g.id).collect::<Vec<_>>(),
                    )
                }
            };

            // Inherited endpoint NAMES the bindings may reference: pre-existing
            // (pool resolver) plus in-tree ancestor group scripts.
            let inherited_names: HashSet<String> = match owner {
                ApplyOwner::App(app_id) => {
                    let mut names =
                        keys_set(&self.resolve_inherited_targets(app_id, &n.bundle).await?);
                    for gid in &app_chain {
                        if let Some(s) = group_script_names.get(gid) {
                            names.extend(s.iter().cloned());
                        }
                    }
                    names
                }
                ApplyOwner::Group(_) => HashSet::new(),
            };
            self.validate_bundle_for(
                matches!(owner, ApplyOwner::Group(_)),
                &n.bundle,
                &inherited_names,
            )?;
            if let ApplyOwner::App(app_id) = owner {
                self.validate_route_hosts(app_id, &n.bundle).await?;
            }

            let current = self.load_current(owner).await?;
            let current_names = self.resolve_current_target_names(&current).await?;
            validate_email_secrets_present(&n.bundle, &current.secret_names)?;
            let plan = compute_diff_with_names(&current, &n.bundle, &current_names);
            token_parts.push(format!(
                "n|{}|{}",
                n.slug.to_lowercase(),
                state_token_with_names(&current, &current_names)
            ));
            prepared.push(PreparedNode {
                owner,
                node: n,
                current,
                current_names,
                plan,
                app_chain,
            });
        }

        // §5.5 / Phase 4b: the single-node path runs `check_imports_resolve` and
        // `check_extension_points_provided`, but the tree path cannot — a node
        // may legitimately import a module another in-tree (uncommitted) node
        // declares, so a pool-based provider check would false-positive. Both
        // lean on the runtime backstop here. Log it so the gap isn't silent.
        if prepared
            .iter()
            .any(|p| matches!(p.owner, ApplyOwner::App(_)))
        {
            tracing::debug!(
                nodes = prepared.len(),
                "tree apply: per-node import / extension-point provider checks \
                 deferred to the runtime backstop (single-node-only at plan time)"
            );
        }

        // Fold in every in-scope group's structure version, so a reparent or a
        // new app under the subtree between plan and apply trips StateMoved.
        for gid in &versioned_groups {
            if let Some(g) = self
                .groups
                .get_by_id(*gid)
                .await
                .map_err(|e| ApplyError::Backend(e.to_string()))?
            {
                token_parts.push(format!("gs|{gid}|{}", g.structure_version));
            }
        }
        // §3 M3: fold the env approval policy (desired state) into the token, so a
        // policy edit between plan and apply trips StateMoved. Sorted key=confirm
        // pairs for order-independence. Both plan_tree and apply_tree pass the
        // same (root) project here, so the fold is symmetric.
        if let Some(p) = project {
            let mut envs: Vec<String> = p
                .environments
                .iter()
                .map(|(name, pol)| format!("{name}={}", pol.confirm))
                .collect();
            envs.sort_unstable();
            token_parts.push(format!("proj|{}", envs.join(",")));
        }
        token_parts.sort_unstable();
        Ok((prepared, to_create, combine_tokens(&token_parts)))
    }

    /// Resolve an app node's inherited route/trigger targets to script ids,
    /// nearest-ancestor-wins, considering BOTH in-tree group nodes (their
    /// in-tx `name -> id`, incl. just-created scripts) and pre-existing
    /// out-of-tree ancestor groups (their committed rows).
    async fn tree_inherited_for_app(
        &self,
        app_chain: &[GroupId],
        bundle: &Bundle,
        index: &HashMap<GroupId, HashMap<String, ScriptId>>,
    ) -> Result<HashMap<String, ScriptId>, ApplyError> {
        let own: HashSet<String> = bundle
            .scripts
            .iter()
            .map(|s| s.name.to_lowercase())
            .collect();
        let mut referenced: HashSet<String> = HashSet::new();
        for r in &bundle.routes {
            referenced.insert(r.script.to_lowercase());
        }
        for t in &bundle.triggers {
            referenced.insert(t.script().to_lowercase());
        }
        referenced.retain(|n| !own.contains(n));
        if referenced.is_empty() {
            return Ok(HashMap::new());
        }
        // Per-ancestor name→id maps, nearest-first.
        let mut chain_maps: Vec<HashMap<String, ScriptId>> = Vec::with_capacity(app_chain.len());
        for gid in app_chain {
            if let Some(idx) = index.get(gid) {
                chain_maps.push(idx.clone());
            } else {
                let m = self
                    .scripts
                    .list_for_group(*gid)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?
                    .into_iter()
                    .filter(|s| s.kind == ScriptKind::Endpoint)
                    .map(|s| (s.name.to_lowercase(), s.id))
                    .collect();
                chain_maps.push(m);
            }
        }
        let mut out = HashMap::new();
        for name in referenced {
            for m in &chain_maps {
                if let Some(id) = m.get(&name) {
                    out.insert(name.clone(), *id);
                    break;
                }
            }
        }
        Ok(out)
    }

    /// Group-node write caps (editor-tier): scripts → `GroupScriptsWrite`, vars
    /// → `GroupVarsWrite`, both required on prune. The single-group and tree
    /// apply authorization (`apply_api::authz_tree`) call this at the API layer;
    /// the tree apply ALSO re-checks it in-tx (see `apply_tree`) to close the
    /// window where a group absent at authz time appears before reconcile.
    pub(crate) async fn require_group_node_writes(
        &self,
        principal: &Principal,
        group_id: GroupId,
        bundle: &Bundle,
        prune: bool,
    ) -> Result<(), ApplyError> {
        if prune || !bundle.scripts.is_empty() {
            require(
                self.authz.as_ref(),
                principal,
                Capability::GroupScriptsWrite(group_id),
            )
            .await
            .map_err(map_authz)?;
        }
        if prune || !bundle.vars.is_empty() {
            require(
                self.authz.as_ref(),
                principal,
                Capability::GroupVarsWrite(group_id),
            )
            .await
            .map_err(map_authz)?;
        }
        Ok(())
    }

    // ------------------------------------------------------------------------
    // §7 ownership claim — the ApplyService side (executes the pure verdict).
    // ------------------------------------------------------------------------

    /// Claim / verify ownership of a GROUP node inside the apply tx. Reads the
    /// live owner under the caller's advisory lock, runs the pure policy, and
    /// applies the verdict: first-apply claims, a re-apply by the owner is a
    /// no-op, a conflict is a 409, and a `--takeover` reassign additionally
    /// requires `GroupAdmin` on the target (ownership ⟂ RBAC, §7.4).
    async fn claim_group_owner(
        &self,
        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
        gid: GroupId,
        declared: Option<ProjectId>,
        takeover: bool,
        principal: &Principal,
    ) -> Result<(), ApplyError> {
        let current = read_group_owner_tx(tx, gid).await?;
        match decide_group_claim(current, declared, takeover) {
            GroupClaimAction::Noop => Ok(()),
            GroupClaimAction::Claim(pid) => write_group_owner_tx(tx, gid, pid).await,
            GroupClaimAction::Takeover(pid) => {
                require(self.authz.as_ref(), principal, Capability::GroupAdmin(gid))
                    .await
                    .map_err(map_authz)?;
                write_group_owner_tx(tx, gid, pid).await
            }
            GroupClaimAction::Conflict(msg) => Err(ApplyError::OwnershipConflict(msg)),
        }
    }

    /// Verify an APP node's ownership (single-node path). The app claims
    /// nothing — it must match its nearest claimed ancestor group. The ancestor
    /// STRUCTURE is read on the pool (`ancestors()`), but the ownership VALUE is
    /// then read WITHIN the tx via `nearest_claimed_in_tx` — the same mechanism
    /// the tree path uses — so the check and this apply's writes see one
    /// consistent snapshot instead of a separate pool connection (`in_tree` is
    /// empty; a single app node claims no groups itself). An unclaimed subtree
    /// stays open. (Fully serializing against a concurrent claim on an
    /// out-of-tree ancestor is part of the tracked pool→tx follow-up — see the
    /// `apply_owner` advisory-lock note.)
    async fn check_app_owner_single(
        &self,
        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
        app_id: AppId,
        declared: Option<ProjectId>,
    ) -> Result<(), ApplyError> {
        let Some(app) = self
            .apps
            .get_by_id(app_id)
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?
        else {
            return Ok(()); // resolved earlier in the handler; defensive.
        };
        let chain = self
            .groups
            .ancestors(app.group_id)
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?;
        let chain_ids: Vec<GroupId> = chain.iter().map(|g| g.id).collect();
        let nearest = nearest_claimed_in_tx(tx, &chain_ids, &HashMap::new()).await?;
        decide_app_owner(nearest, declared).map_err(ApplyError::OwnershipConflict)
    }

    /// The nearest ancestor (nearest-first `chain`) with an owner, as
    /// `(id, slug)`. Resolves the owning project's slug lazily — only when a
    /// claim exists — for the conflict message.
    async fn nearest_claimed_from_chain(
        &self,
        chain: &[picloud_shared::Group],
    ) -> Result<Option<(ProjectId, String)>, ApplyError> {
        let Some(pid) = chain.iter().find_map(|g| g.owner_project) else {
            return Ok(None);
        };
        let slug = self
            .projects
            .get_by_id(pid)
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?
            .map(|p| p.slug)
            .unwrap_or_default();
        Ok(Some((pid, slug)))
    }

    /// §3 M3 (hermetic gate, server-authoritative): the id of the project that
    /// GOVERNS `owner` — its nearest-claimed ancestor (inclusive: a group node
    /// uses its own claim first, then walks up; an app node walks its group
    /// chain). This is the same ownership walk `ownership_preview` uses, and it
    /// is deliberately independent of any client-supplied `[project]` — the
    /// server decides which project's approval policy applies.
    async fn governing_project_id(
        &self,
        owner: ApplyOwner,
    ) -> Result<Option<ProjectId>, ApplyError> {
        let anchor = match owner {
            ApplyOwner::Group(g) => g,
            ApplyOwner::App(a) => {
                let Some(app) = self
                    .apps
                    .get_by_id(a)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?
                else {
                    return Ok(None);
                };
                app.group_id
            }
        };
        let chain = self
            .groups
            .ancestors(anchor)
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?;
        Ok(chain.iter().find_map(|g| g.owner_project))
    }

    /// §3 M3 (hermetic gate): the EFFECTIVE per-env approval policy for an apply
    /// to `owner` — `env -> confirm` — computed as `governing ∪ declared`. The
    /// GOVERNING side is loaded from the project the server RESOLVED as owning
    /// the target node (`governing_project_id`), NOT from the client's declared
    /// slug — so a request that omits or spoofs `[project]` still trips a gate
    /// the owning project established. The declared side only STRENGTHENS (union
    /// on confirm), never weakens. A persisted-read error surfaces as `Backend`
    /// (fail-closed — a gate is never silently dropped).
    pub async fn governing_env_policy(
        &self,
        owner: ApplyOwner,
        declared: Option<&ProjectDecl>,
    ) -> Result<std::collections::BTreeMap<String, bool>, ApplyError> {
        let mut effective = match self.governing_project_id(owner).await? {
            Some(pid) => self
                .projects
                .get_environments_by_id(pid)
                .await
                .map_err(|e| ApplyError::Backend(e.to_string()))?,
            None => std::collections::BTreeMap::new(),
        };
        if let Some(decl) = declared {
            for (env, pol) in &decl.environments {
                let entry = effective.entry(env.clone()).or_insert(false);
                *entry = *entry || pol.confirm;
            }
        }
        Ok(effective)
    }

    /// §3 M3 (hermetic gate) for a TREE apply: the union of every declared
    /// node's governing policy with the declared side. Each existing node is
    /// resolved to its owning project server-side; a to-create group node has
    /// nothing persisted to gate (its claim is established by this same apply)
    /// so it contributes only the declared policy. Un-bypassable for the real
    /// case — applying to existing nodes under a claimed, gated subtree.
    pub async fn governing_env_policy_tree(
        &self,
        bundle: &TreeBundle,
        declared: Option<&ProjectDecl>,
    ) -> Result<std::collections::BTreeMap<String, bool>, ApplyError> {
        let mut effective: std::collections::BTreeMap<String, bool> =
            std::collections::BTreeMap::new();
        for node in &bundle.nodes {
            // The project that governs this node. An existing node uses its own
            // ownership walk. A to-create GROUP has no id yet, so resolve the
            // governing project from its DECLARED parent's chain — a gated,
            // claimed pre-existing ancestor still governs a node created beneath
            // it, closing the whole-block-omission gap for a fresh subtree node.
            let governing_pid = match node.kind {
                NodeKind::App => {
                    // Apps are never to-create in the tree path (resolve errors),
                    // so a miss here means the app truly doesn't exist yet.
                    match crate::app_repo::resolve_app(self.apps.as_ref(), &node.slug)
                        .await
                        .map_err(|e| ApplyError::Backend(e.to_string()))?
                    {
                        Some(l) => self.governing_project_id(ApplyOwner::App(l.app.id)).await?,
                        None => continue,
                    }
                }
                NodeKind::Group => match self
                    .groups
                    .get_by_slug(&node.slug)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?
                {
                    Some(g) => self.governing_project_id(ApplyOwner::Group(g.id)).await?,
                    None => match node.parent.as_deref() {
                        Some(parent_slug) => match self
                            .groups
                            .get_by_slug(parent_slug)
                            .await
                            .map_err(|e| ApplyError::Backend(e.to_string()))?
                        {
                            Some(parent) => {
                                self.governing_project_id(ApplyOwner::Group(parent.id))
                                    .await?
                            }
                            // Parent is itself to-create (no persisted claim yet,
                            // so no persisted gate from it) or absent → nothing to
                            // govern this node with.
                            None => continue,
                        },
                        None => continue,
                    },
                },
            };
            if let Some(pid) = governing_pid {
                for (env, confirm) in self
                    .projects
                    .get_environments_by_id(pid)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?
                {
                    let entry = effective.entry(env).or_insert(false);
                    *entry = *entry || confirm;
                }
            }
        }
        if let Some(decl) = declared {
            for (env, pol) in &decl.environments {
                let entry = effective.entry(env.clone()).or_insert(false);
                *entry = *entry || pol.confirm;
            }
        }
        Ok(effective)
    }

    // ------------------------------------------------------------------------
    // §6/§7 attach point — a project's ceiling. Every applied node must live
    // strictly within the subtree rooted at the declared `parent_group`.
    // ------------------------------------------------------------------------

    /// Enforce the ceiling for one node: `pg` must be a PROPER ancestor of a
    /// group node (you can't apply the attach point itself), or an ancestor
    /// (inclusive) of an app node's group. Reject otherwise.
    async fn check_within_attach(
        &self,
        owner: ApplyOwner,
        pg: GroupId,
        pg_slug: &str,
    ) -> Result<(), ApplyError> {
        let (anchor, is_group) = match owner {
            ApplyOwner::Group(g) => (g, true),
            ApplyOwner::App(a) => {
                let app = self
                    .apps
                    .get_by_id(a)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?
                    .ok_or_else(|| ApplyError::AppNotFound(a.to_string()))?;
                (app.group_id, false)
            }
        };
        let chain = self
            .groups
            .ancestors(anchor)
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?;
        let in_subtree = chain.iter().any(|g| g.id == pg);
        // A group node equal to the attach point is the ceiling itself → above.
        let is_attach_itself = is_group && anchor == pg;
        if !in_subtree || is_attach_itself {
            return Err(ApplyError::OutsideAttachPoint(format!(
                "node is not within this project's attach point '{pg_slug}' — apply only \
                 to '{pg_slug}' and its descendants"
            )));
        }
        Ok(())
    }

    /// A group's OWN owning-project slug (not inherited), read on the pool.
    async fn read_group_owner_pool(&self, gid: GroupId) -> Result<Option<String>, ApplyError> {
        let g = self
            .groups
            .get_by_id(gid)
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?;
        match g.and_then(|g| g.owner_project) {
            None => Ok(None),
            Some(pid) => Ok(self
                .projects
                .get_by_id(pid)
                .await
                .map_err(|e| ApplyError::Backend(e.to_string()))?
                .map(|p| p.slug)),
        }
    }

    /// §7 M3 preview: the ownership outcome a node's apply would produce, or
    /// `None` when no `[project]` is declared (nothing to preview). Read-only.
    /// A group node previews against its OWN owner; an app node against its
    /// nearest claimed ancestor.
    async fn ownership_preview(
        &self,
        owner: ApplyOwner,
        declared: Option<&str>,
    ) -> Result<Option<OwnershipPreview>, ApplyError> {
        if declared.is_none() {
            return Ok(None);
        }
        let (is_group, current) = match owner {
            ApplyOwner::Group(g) => (true, self.read_group_owner_pool(g).await?),
            ApplyOwner::App(a) => {
                let current = match self
                    .apps
                    .get_by_id(a)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?
                {
                    Some(app) => {
                        let chain = self
                            .groups
                            .ancestors(app.group_id)
                            .await
                            .map_err(|e| ApplyError::Backend(e.to_string()))?;
                        self.nearest_claimed_from_chain(&chain)
                            .await?
                            .map(|(_, s)| s)
                    }
                    None => None,
                };
                (false, current)
            }
        };
        let mut preview = preview_ownership(is_group, current.as_deref(), declared);
        // §7 M3.2: for a group node, surface the cross-repo blast radius — the
        // descendant apps owned by OTHER projects a config change here reaches.
        if let ApplyOwner::Group(g) = owner {
            preview.blast_radius = self.group_blast_radius(g, declared).await?;
        }
        Ok(Some(preview))
    }

    /// §7 M3.2: descendant apps of `gid` grouped by their nearest-claimed-
    /// ancestor project, EXCLUDING `exclude` (the planning project) — the apps
    /// a change to `gid`'s inherited config fans out to across other repos.
    /// Resolved in one set-based recursive query (see below).
    async fn group_blast_radius(
        &self,
        gid: GroupId,
        exclude: Option<&str>,
    ) -> Result<Vec<ProjectImpact>, ApplyError> {
        // One set-based query: every app in gid's subtree, resolved to its
        // nearest claimed ancestor project, grouped and counted — replacing the
        // per-descendant-group `ancestors()` round-trips (N+1) with a single
        // recursive walk. `subtree` = gid + descendant groups; `app_chain` =
        // (app, ancestor group, depth) for every subtree app walked to the root;
        // `nearest` picks the shallowest claimed ancestor per app. Apps owned by
        // `exclude` (the planning project) or by nothing are dropped.
        let rows: Vec<(String, i64)> = sqlx::query_as(
            "WITH RECURSIVE subtree AS (
                 SELECT id, 0 AS depth FROM groups WHERE id = $1
               UNION ALL
                 SELECT g.id, s.depth + 1 FROM groups g JOIN subtree s ON g.parent_id = s.id
                 WHERE s.depth < 64
             ),
             app_chain AS (
                 SELECT a.id AS app_id, a.group_id AS gid, 0 AS depth
                 FROM apps a WHERE a.group_id IN (SELECT id FROM subtree)
               UNION ALL
                 SELECT ac.app_id, g.parent_id, ac.depth + 1
                 FROM app_chain ac JOIN groups g ON g.id = ac.gid
                 WHERE g.parent_id IS NOT NULL AND ac.depth < 64
             ),
             nearest AS (
                 SELECT DISTINCT ON (ac.app_id) ac.app_id, g.owner_project
                 FROM app_chain ac JOIN groups g ON g.id = ac.gid
                 WHERE g.owner_project IS NOT NULL
                 ORDER BY ac.app_id, ac.depth ASC
             )
             SELECT p.slug, COUNT(*) AS n
             FROM nearest n JOIN projects p ON p.id = n.owner_project
             WHERE p.slug IS DISTINCT FROM $2
             GROUP BY p.slug
             ORDER BY p.slug",
        )
        .bind(gid.into_inner())
        .bind(exclude)
        .fetch_all(&self.pool)
        .await
        .map_err(|e| ApplyError::Backend(e.to_string()))?;

        Ok(rows
            .into_iter()
            .map(|(project, n)| ProjectImpact {
                project,
                apps: u32::try_from(n).unwrap_or(u32::MAX),
            })
            .collect())
    }

    async fn resolve_app(&self, ident: &str) -> Result<picloud_shared::App, ApplyError> {
        crate::app_repo::resolve_app(self.apps.as_ref(), ident)
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?
            .map(|l| l.app)
            .ok_or_else(|| ApplyError::AppNotFound(ident.to_string()))
    }

    async fn resolve_group(&self, ident: &str) -> Result<GroupId, ApplyError> {
        let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
            self.groups.get_by_id(uuid.into()).await
        } else {
            self.groups.get_by_slug(ident).await
        };
        found
            .map_err(|e| ApplyError::Backend(e.to_string()))?
            .map(|g| g.id)
            .ok_or_else(|| ApplyError::AppNotFound(format!("group {ident}")))
    }

    fn script_imports(&self, bs: &BundleScript) -> Result<Vec<String>, ApplyError> {
        let res = if bs.kind == ScriptKind::Module {
            self.validator.validate_module(&bs.source)
        } else {
            self.validator.validate(&bs.source)
        };
        res.map(|v| v.imports)
            .map_err(|e| ApplyError::Invalid(format!("script `{}`: {e}", bs.name)))
    }

    fn trigger_settings(
        &self,
        bt: &BundleTrigger,
    ) -> (
        TriggerDispatchMode,
        u32,
        crate::trigger_config::BackoffShape,
        u32,
    ) {
        let (dispatch, retry_max) = match bt {
            BundleTrigger::Kv {
                dispatch_mode,
                retry_max_attempts,
                ..
            }
            | BundleTrigger::Docs {
                dispatch_mode,
                retry_max_attempts,
                ..
            }
            | BundleTrigger::Files {
                dispatch_mode,
                retry_max_attempts,
                ..
            }
            | BundleTrigger::Cron {
                dispatch_mode,
                retry_max_attempts,
                ..
            }
            | BundleTrigger::Pubsub {
                dispatch_mode,
                retry_max_attempts,
                ..
            }
            | BundleTrigger::Email {
                dispatch_mode,
                retry_max_attempts,
                ..
            }
            | BundleTrigger::Queue {
                dispatch_mode,
                retry_max_attempts,
                ..
            } => (
                dispatch_mode.unwrap_or(TriggerDispatchMode::Async),
                retry_max_attempts.unwrap_or(self.trigger_config.retry_max_attempts),
            ),
        };
        (
            dispatch,
            retry_max,
            self.trigger_config.retry_backoff,
            self.trigger_config.retry_base_ms,
        )
    }

    async fn refresh_route_table(&self) -> Result<(), ApplyError> {
        // §11 tail: rebuild via the live expansion so a group route TEMPLATE
        // created in this apply lands in every descendant app's slice.
        crate::route_admin::rebuild_route_table(self.routes.as_ref(), &self.route_table)
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))
    }

    /// §4.5 M5: reconcile materialized stateful-template copies. Best-effort —
    /// a failure is logged (the copies self-heal on the next mutation) and any
    /// per-app skip warnings are returned for the apply report.
    async fn rematerialize(&self) -> Vec<String> {
        match crate::materialize::rematerialize_stateful_templates(&self.pool).await {
            Ok(warnings) => warnings,
            Err(e) => {
                tracing::warn!(error = %e, "apply: stateful-template materialization failed");
                vec!["stateful-template materialization failed; it will self-heal".into()]
            }
        }
    }

    /// Resolve a referenced secret to plaintext, then re-seal it for an
    /// email trigger's inbound-secret column. The value never appears in
    /// the manifest — only the secret's name does. §4.5 M5: `owner` is the
    /// app for an app-owned trigger, or the GROUP for a group email template
    /// (which resolves against the group's own secret store once at apply;
    /// materialization then copies the sealed bytes to each descendant).
    async fn resolve_and_seal(
        &self,
        owner: crate::secrets_service::SecretOwner,
        name: &str,
    ) -> Result<(Vec<u8>, Vec<u8>, i16), ApplyError> {
        let stored = self
            .secrets
            .get(owner, "*", name)
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?
            .ok_or_else(|| {
                ApplyError::Invalid(format!(
                    "email trigger references secret `{name}`, which is not set \
                     (push it with `pic secret set {name}`)"
                ))
            })?;
        let plaintext = crate::secrets_service::open(&self.master_key, owner, name, &stored)
            .map_err(|e| ApplyError::Invalid(format!("could not read secret `{name}`: {e}")))?;
        // The inbound HMAC must be a non-empty string — an empty/whitespace
        // key is forgeable (preserves the spirit of audit 2026-06-11 H-B2).
        match plaintext.as_str() {
            Some(s) if !s.trim().is_empty() => {}
            _ => {
                return Err(ApplyError::Invalid(format!(
                    "secret `{name}` must be a non-empty string to sign an email trigger"
                )))
            }
        }
        // §M5.5 / audit H-D1: seal v1 with AAD bound to the SEALING owner (the
        // app for a standalone trigger, the group for a template — stable across
        // materialized copies). The email inbound path recovers the same owner to
        // open it.
        let (ct, nonce, version) = crate::secrets_service::seal_email(
            &self.master_key,
            owner,
            &plaintext,
            crate::secrets_service::DEFAULT_SECRET_MAX_VALUE_BYTES,
        )
        .map_err(|e| ApplyError::Invalid(format!("could not seal secret `{name}`: {e}")))?;
        Ok((ct, nonce.to_vec(), version))
    }

    /// Validate each bundle route's host against the app's domain claims —
    /// the cross-state check the interactive route API enforces. Async, so
    /// it runs alongside `plan`/`apply`, not in the sync `validate_bundle`.
    async fn validate_route_hosts(&self, app_id: AppId, bundle: &Bundle) -> Result<(), ApplyError> {
        for r in &bundle.routes {
            crate::route_admin::validate_route_host_against_app(
                self.domains.as_ref(),
                app_id,
                r.host_kind,
                &r.host,
            )
            .await
            .map_err(|e| ApplyError::Invalid(format!("route host claim: {e}")))?;
        }
        Ok(())
    }

    /// Bundle-local validation: catches the errors visible without the
    /// live state (syntax, ceilings, dangling/wrong-kind targets, intra-
    /// bundle duplicates, reserved paths, host/path structural validity).
    /// Host-claim validation runs separately in `validate_route_hosts`.
    ///
    /// NOTE (known gap vs. the interactive route API): cross-pattern route
    /// conflict-vs-live (e.g. a param route overlapping a live exact route)
    /// is NOT checked — the DB unique index catches only identical tuples,
    /// so overlapping patterns are accepted.
    #[allow(clippy::too_many_lines)]
    /// Phase 4: resolve route/trigger target names that the manifest does NOT
    /// declare as app-own scripts to inherited group-owned endpoint scripts on
    /// the app's chain. Returns `name(lowercased) → script_id` for the ones
    /// that resolve to a group endpoint. App-own names (in `bundle.scripts`)
    /// are skipped — they bind through the normal `name_to_id` path and take
    /// precedence (CoW). Resolved before the apply transaction; the group
    /// scripts pre-exist (committed), so a pool read is correct and stable.
    async fn resolve_inherited_targets(
        &self,
        app_id: AppId,
        bundle: &Bundle,
    ) -> Result<HashMap<String, ScriptId>, ApplyError> {
        let own: HashSet<String> = bundle
            .scripts
            .iter()
            .map(|s| s.name.to_lowercase())
            .collect();
        let mut referenced: HashSet<String> = HashSet::new();
        for r in &bundle.routes {
            referenced.insert(r.script.to_lowercase());
        }
        for t in &bundle.triggers {
            referenced.insert(t.script().to_lowercase());
        }
        let mut out = HashMap::new();
        for name in referenced {
            if own.contains(&name) {
                continue;
            }
            if let Some(s) = self
                .scripts
                .get_by_name_inherited(app_id, &name)
                .await
                .map_err(map_repo)?
            {
                // Only genuinely-inherited group endpoints qualify as
                // route/trigger targets. A depth-0 app-own match is already
                // covered by `name_to_id`; skip it so the manifest keeps
                // precedence. Group modules (Phase 4b) exist but are never
                // bindable targets — the kind guard excludes them.
                if s.group_id.is_some() && s.kind == ScriptKind::Endpoint {
                    out.insert(name, s.id);
                }
            }
        }
        Ok(out)
    }

    /// Phase 4: resolve the names of group-owned scripts the app's CURRENT
    /// routes/triggers are bound to, `script_id → name`. These ids aren't in
    /// `current.scripts` (app-own only), so this is what lets the diff /
    /// state-token / prune resolve an existing inherited binding back to its
    /// name — independent of whether the bundle still references it (so a
    /// dropped inherited binding still diffs to a Delete, and an inherited-bound
    /// trigger still moves the state token).
    async fn resolve_current_target_names(
        &self,
        current: &CurrentState,
    ) -> Result<HashMap<ScriptId, String>, ApplyError> {
        let own: HashSet<ScriptId> = current.scripts.iter().map(|s| s.id).collect();
        let mut ids: HashSet<ScriptId> = HashSet::new();
        for r in &current.routes {
            if !own.contains(&r.script_id) {
                ids.insert(r.script_id);
            }
        }
        for t in &current.triggers {
            if !own.contains(&t.script_id) {
                ids.insert(t.script_id);
            }
        }
        let mut out = HashMap::new();
        for id in ids {
            if let Some(s) = self.scripts.get(id).await.map_err(map_repo)? {
                out.insert(id, s.name);
            }
        }
        Ok(out)
    }

    /// §5.5 dangling-import plan check (Phase 4b, single-node path). Every
    /// `import "<m>"` in a bundle script must resolve to a module declared in
    /// the same bundle OR reachable lexically up the node's chain — otherwise
    /// the apply would write a script that 404s its import at runtime. Lexical:
    /// the node's owner IS the defining node for its own scripts, so resolution
    /// roots at `owner` (an app node reaches ancestor group modules; a group
    /// node walks its own ancestry, sealed from apps below).
    ///
    /// Not run on the tree path: a tree node may import a module declared by
    /// another node created in the same transaction (not yet committed), so the
    /// in-tree set would have to be threaded through; the runtime check is the
    /// backstop there.
    async fn check_imports_resolve(
        &self,
        owner: ApplyOwner,
        bundle: &Bundle,
    ) -> Result<(), ApplyError> {
        // Modules this bundle declares (lowercased) satisfy their own imports.
        let local: HashSet<String> = bundle
            .scripts
            .iter()
            .filter(|s| s.kind == ScriptKind::Module)
            .map(|s| s.name.to_lowercase())
            .collect();
        let origin = match owner {
            ApplyOwner::App(a) => picloud_shared::ScriptOwner::App(a),
            ApplyOwner::Group(g) => picloud_shared::ScriptOwner::Group(g),
        };
        // Resolve each distinct imported name once.
        let mut checked: HashSet<String> = HashSet::new();
        for s in &bundle.scripts {
            for imp in self.script_imports(s)? {
                let key = imp.to_lowercase();
                if local.contains(&key) || !checked.insert(key) {
                    continue;
                }
                let found = self
                    .modules
                    .resolve(origin, &imp)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?;
                if found.is_none() {
                    return Err(ApplyError::Invalid(format!(
                        "script `{}` imports unknown module `{imp}` \
                         (not declared here and not inherited from an ancestor)",
                        s.name
                    )));
                }
            }
        }
        Ok(())
    }

    /// Read-only extension-point report for a node (§5.5). For an **app**:
    /// every EP visible on its chain, each flagged `declared_here` (owned by
    /// the app, vs inherited) with its resolved `provider` (`app override` /
    /// `inherited default` / `null` = unset). For a **group**: its own declared
    /// names (no single app to resolve a provider against). Backs the read-only
    /// `extension-points ls` and the `pull` round-trip.
    pub async fn extension_point_report(
        &self,
        owner: ApplyOwner,
    ) -> Result<Vec<ExtensionPointInfo>, ApplyError> {
        let pool = &self.pool;
        match owner {
            ApplyOwner::Group(g) => {
                let names =
                    crate::extension_point_repo::list_for_owner(pool, ScriptOwner::Group(g))
                        .await
                        .map_err(|e| ApplyError::Backend(e.to_string()))?;
                Ok(names
                    .into_iter()
                    .map(|name| ExtensionPointInfo {
                        name,
                        declared_here: true,
                        provider: None,
                    })
                    .collect())
            }
            ApplyOwner::App(a) => {
                let own: HashSet<String> =
                    crate::extension_point_repo::list_for_owner(pool, ScriptOwner::App(a))
                        .await
                        .map_err(|e| ApplyError::Backend(e.to_string()))?
                        .into_iter()
                        .map(|n| n.to_lowercase())
                        .collect();
                let visible = crate::extension_point_repo::list_on_app_chain(pool, a)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?;
                let mut out = Vec::with_capacity(visible.len());
                for name in visible {
                    let provider = match self
                        .modules
                        .resolve(ScriptOwner::App(a), &name)
                        .await
                        .map_err(|e| ApplyError::Backend(e.to_string()))?
                        .and_then(|m| m.owner())
                    {
                        Some(ScriptOwner::App(_)) => Some("app override".to_string()),
                        Some(ScriptOwner::Group(_)) => Some("inherited default".to_string()),
                        None => None,
                    };
                    out.push(ExtensionPointInfo {
                        declared_here: own.contains(&name.to_lowercase()),
                        name,
                        provider,
                    });
                }
                Ok(out)
            }
        }
    }

    /// Read-only §11.6 shared-collection report for a node: the shared
    /// collections (`name` + `kind`) declared directly at it. Group-only in
    /// practice (the CLI rejects app-declared collections). Backs
    /// `pic collections ls`.
    /// Read-only §11 tail report: a group's own trigger TEMPLATES, surfaced as
    /// (kind, target, handler script name, enabled). The stored `name` is a UUID
    /// default for reconcile-created rows, so the semantic bits are shown
    /// instead. Backs `pic triggers ls --group`.
    pub async fn trigger_report(
        &self,
        owner: ApplyOwner,
    ) -> Result<Vec<TriggerTemplateInfo>, ApplyError> {
        let ApplyOwner::Group(group_id) = owner else {
            return Ok(Vec::new());
        };
        let triggers = self
            .triggers
            .list_for_group(group_id)
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?;
        let scripts = self
            .scripts
            .list_for_group(group_id)
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?;
        let name_by_id: HashMap<ScriptId, String> =
            scripts.iter().map(|s| (s.id, s.name.clone())).collect();
        Ok(triggers
            .into_iter()
            .map(|t| {
                let (kind, target) = match &t.details {
                    TriggerDetails::Kv {
                        collection_glob, ..
                    } => ("kv", collection_glob.clone()),
                    TriggerDetails::Docs {
                        collection_glob, ..
                    } => ("docs", collection_glob.clone()),
                    TriggerDetails::Files {
                        collection_glob, ..
                    } => ("files", collection_glob.clone()),
                    TriggerDetails::Pubsub { topic_pattern } => ("pubsub", topic_pattern.clone()),
                    TriggerDetails::Cron { .. } => ("cron", String::new()),
                    TriggerDetails::Queue { queue_name, .. } => ("queue", queue_name.clone()),
                    TriggerDetails::Email { .. } => ("email", String::new()),
                    TriggerDetails::DeadLetter { .. } => ("dead_letter", String::new()),
                };
                TriggerTemplateInfo {
                    kind: kind.to_string(),
                    target,
                    script: name_by_id.get(&t.script_id).cloned().unwrap_or_default(),
                    enabled: t.enabled,
                    sealed: t.sealed,
                    shared: t.shared,
                }
            })
            .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())
    }

    /// The desired-state warnings a plan/apply of `bundle` onto `owner` would
    /// emit — the ones knowable BEFORE committing, so `pic plan` previews exactly
    /// what `apply` reports (post-commit side-effect warnings like route-table
    /// refresh or materialization skips are apply-only). Covers: an enabled
    /// binding on a disabled script (deployed-but-unreachable), an endpoint with
    /// no route/trigger (apps only — a group endpoint is reached by inheritance),
    /// and a `[suppress]` that matches no inherited template.
    async fn plan_warnings(
        &self,
        owner: ApplyOwner,
        bundle: &Bundle,
    ) -> Result<Vec<String>, ApplyError> {
        let mut warnings = Vec::new();
        warnings.extend(disabled_target_warnings(bundle));
        if let ApplyOwner::App(_) = owner {
            warnings.extend(unreachable_endpoint_warnings(bundle));
        }
        warnings.extend(self.dangling_suppress_warnings(owner, bundle).await?);
        warnings.extend(workflow_nesting_warnings(bundle));
        Ok(warnings)
    }

    /// §11 tail typo guard: a suppress reference that matches **no** inherited
    /// template on the app's chain does nothing — return a warning for each such
    /// dangling reference (soft; never fails the apply). Trigger refs match an
    /// ancestor-group trigger's handler script name (case-insensitive); route
    /// refs match an ancestor-group route's path.
    async fn dangling_suppress_warnings(
        &self,
        owner: ApplyOwner,
        bundle: &Bundle,
    ) -> Result<Vec<String>, ApplyError> {
        if bundle.suppress_triggers.is_empty() && bundle.suppress_routes.is_empty() {
            return Ok(Vec::new());
        }
        // §11 tail M1: the set of templates a suppression can decline lives on
        // the OWNER's chain — an app walks its ancestor groups (`CHAIN_LEVELS_CTE`
        // bound to the app), a group walks itself + its ancestors
        // (`GROUP_CHAIN_LEVELS_CTE` bound to the group). Both CTEs expose a
        // `chain` view with a `group_owner` column, so the queries below are
        // identical modulo which CTE + bind.
        let (chain_cte, owner_uuid) = match owner {
            ApplyOwner::App(a) => (CHAIN_LEVELS_CTE, a.into_inner()),
            ApplyOwner::Group(g) => (
                crate::config_resolver::GROUP_CHAIN_LEVELS_CTE,
                g.into_inner(),
            ),
        };
        // Inherited trigger handler names + route paths, each tagged with whether
        // ANY matching template is un-sealed (i.e. actually suppressible).
        // `bool_or(NOT sealed)` = true iff at least one match can be declined;
        // false = every match is `sealed` (§11 tail), so the suppression is
        // inert; absent = no inherited template at all (a typo).
        let trig_names: Vec<(String, bool)> = sqlx::query_as(&format!(
            "{chain_cte} \
             SELECT LOWER(s.name), bool_or(NOT t.sealed) FROM triggers t \
             JOIN scripts s ON s.id = t.script_id \
             JOIN chain c ON t.group_id = c.group_owner \
             WHERE t.group_id IS NOT NULL \
             GROUP BY LOWER(s.name)",
        ))
        .bind(owner_uuid)
        .fetch_all(&self.pool)
        .await
        .map_err(|e| ApplyError::Backend(e.to_string()))?;
        let inherited_handlers: HashMap<String, bool> = trig_names.into_iter().collect();

        let route_paths: Vec<(String, bool)> = sqlx::query_as(&format!(
            "{chain_cte} \
             SELECT r.path, bool_or(NOT r.sealed) FROM routes r \
             JOIN chain c ON r.group_id = c.group_owner \
             WHERE r.group_id IS NOT NULL \
             GROUP BY r.path",
        ))
        .bind(owner_uuid)
        .fetch_all(&self.pool)
        .await
        .map_err(|e| ApplyError::Backend(e.to_string()))?;
        let inherited_paths: HashMap<String, bool> = route_paths.into_iter().collect();

        let mut out = Vec::new();
        for r in &bundle.suppress_triggers {
            match inherited_handlers.get(&r.to_lowercase()) {
                None => out.push(format!(
                    "suppress: no inherited trigger bound to script `{r}` — the \
                     suppression has no effect"
                )),
                Some(false) => out.push(format!(
                    "suppress: the inherited trigger bound to script `{r}` is \
                     sealed — the suppression has no effect"
                )),
                Some(true) => {}
            }
        }
        for r in &bundle.suppress_routes {
            match inherited_paths.get(r) {
                None => out.push(format!(
                    "suppress: no inherited route at path `{r}` — the \
                     suppression has no effect"
                )),
                Some(false) => out.push(format!(
                    "suppress: the inherited route at path `{r}` is sealed — the \
                     suppression has no effect"
                )),
                Some(true) => {}
            }
        }
        Ok(out)
    }

    /// Read-only §11 tail report: an owner's own suppression markers (the
    /// inherited templates it declines). An app declines for itself, a group
    /// for its subtree. Backs `pic suppress ls --app` / `--group`.
    pub async fn suppression_report(
        &self,
        owner: ApplyOwner,
    ) -> Result<Vec<SuppressionInfo>, ApplyError> {
        let rows = crate::suppression_repo::list_for_owner(&self.pool, owner.as_script_owner())
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?;
        Ok(rows
            .into_iter()
            .map(|(target_kind, reference)| SuppressionInfo {
                target_kind,
                reference,
            })
            .collect())
    }

    /// §5.5 no-provider plan check (app nodes only). Every extension point
    /// visible to the app — declared in this bundle or inherited from an
    /// ancestor — must have a provider: a module of that name the app provides
    /// (in this bundle, or resolvable up its chain as an override or a default
    /// body). Otherwise the import would fail at runtime, so refuse at plan.
    /// Group nodes have no single inheriting app, so the check is per-app.
    /// Single-node only (the tree path leans on the runtime backstop).
    async fn check_extension_points_provided(
        &self,
        owner: ApplyOwner,
        bundle: &Bundle,
    ) -> Result<(), ApplyError> {
        let ApplyOwner::App(app_id) = owner else {
            return Ok(());
        };
        // A same-apply app module provides its EP without being committed yet.
        let local: HashSet<String> = bundle
            .scripts
            .iter()
            .filter(|s| s.kind == ScriptKind::Module)
            .map(|s| s.name.to_lowercase())
            .collect();
        // EP names visible to the app: its own (this bundle) ∪ inherited.
        let mut names: HashSet<String> = bundle
            .extension_points
            .iter()
            .map(|n| n.to_lowercase())
            .collect();
        for n in crate::extension_point_repo::list_on_app_chain(&self.pool, app_id)
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?
        {
            names.insert(n.to_lowercase());
        }
        for name in names {
            if local.contains(&name) {
                continue;
            }
            let found = self
                .modules
                .resolve(picloud_shared::ScriptOwner::App(app_id), &name)
                .await
                .map_err(|e| ApplyError::Backend(e.to_string()))?;
            if found.is_none() {
                return Err(ApplyError::Invalid(format!(
                    "extension point `{name}` has no provider for this app — \
                     declare a module named `{name}` here or ensure a default \
                     body exists up-chain"
                )));
            }
        }
        Ok(())
    }

    /// `inherited_endpoints` (lowercased names) are group-owned endpoint
    /// scripts reachable from the app's chain that a route/trigger may bind to
    /// even though the manifest doesn't declare them (Phase 4). They count as
    /// known endpoints for the binding checks below.
    #[allow(clippy::too_many_lines)]
    fn validate_bundle(
        &self,
        bundle: &Bundle,
        inherited_endpoints: &HashSet<String>,
    ) -> Result<(), ApplyError> {
        // Scripts: unique names; each source compiles for its kind.
        let mut names: HashSet<String> = HashSet::new();
        for s in &bundle.scripts {
            if !names.insert(s.name.to_lowercase()) {
                return Err(ApplyError::Invalid(format!(
                    "duplicate script name `{}`",
                    s.name
                )));
            }
            let res = if s.kind == ScriptKind::Module {
                // Parity with the interactive create path (`api.rs`): a module
                // may not shadow a built-in SDK namespace, or `import "kv"`
                // could resolve to a user module instead of the real bridge.
                if crate::api::RESERVED_MODULE_NAMES.contains(&s.name.as_str()) {
                    return Err(ApplyError::Invalid(format!(
                        "script `{}`: reserved module name (shadows a built-in SDK namespace)",
                        s.name
                    )));
                }
                self.validator.validate_module(&s.source)
            } else {
                self.validator.validate(&s.source)
            };
            res.map_err(|e| ApplyError::Invalid(format!("script `{}`: {e}", s.name)))?;
            if let Some(sb) = &s.sandbox {
                self.sandbox_ceiling
                    .check(sb)
                    .map_err(|e| ApplyError::Invalid(format!("script `{}`: {e}", s.name)))?;
            }
        }
        let endpoints: HashSet<&str> = bundle
            .scripts
            .iter()
            .filter(|s| s.kind == ScriptKind::Endpoint)
            .map(|s| s.name.as_str())
            .collect();
        let all_names: HashSet<&str> = bundle.scripts.iter().map(|s| s.name.as_str()).collect();

        // Routes: reserved paths; bind to an endpoint script; unique tuples.
        let mut route_keys: HashSet<String> = HashSet::new();
        for r in &bundle.routes {
            reject_reserved_path(&r.path)?;
            // Structural validity (parity with the interactive route API):
            // a route whose path/host doesn't parse would otherwise be written
            // and then silently dropped by the route-table compile.
            pattern::parse_path(r.path_kind, &r.path)
                .map_err(|e| ApplyError::Invalid(format!("route path `{}`: {e}", r.path)))?;
            pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())
                .map_err(|e| ApplyError::Invalid(format!("route host `{}`: {e}", r.host)))?;
            let inherited = inherited_endpoints.contains(&r.script.to_lowercase());
            if !all_names.contains(r.script.as_str()) && !inherited {
                return Err(ApplyError::Invalid(format!(
                    "route {} binds to unknown script `{}`",
                    route_key(
                        r.method.as_deref(),
                        r.host_kind,
                        &r.host,
                        r.path_kind,
                        &r.path
                    ),
                    r.script
                )));
            }
            // Inherited targets are resolved as endpoints by construction
            // (the inherited-name resolver excludes group modules); only
            // check kind for app-own names.
            if !inherited && !endpoints.contains(r.script.as_str()) {
                return Err(ApplyError::Invalid(format!(
                    "route binds to `{}` which is a module, not an endpoint",
                    r.script
                )));
            }
            let key = route_key(
                r.method.as_deref(),
                r.host_kind,
                &r.host,
                r.path_kind,
                &r.path,
            );
            if !route_keys.insert(key.clone()) {
                return Err(ApplyError::Invalid(format!("duplicate route `{key}`")));
            }
        }

        // Triggers: bind to an endpoint script; unique semantic identity.
        let mut trig_keys: HashSet<String> = HashSet::new();
        for t in &bundle.triggers {
            let inherited = inherited_endpoints.contains(&t.script().to_lowercase());
            if !all_names.contains(t.script()) && !inherited {
                return Err(ApplyError::Invalid(format!(
                    "{} trigger binds to unknown script `{}`",
                    t.kind_str(),
                    t.script()
                )));
            }
            if !inherited && !endpoints.contains(t.script()) {
                return Err(ApplyError::Invalid(format!(
                    "{} trigger binds to `{}` which is a module, not an endpoint",
                    t.kind_str(),
                    t.script()
                )));
            }
            validate_trigger_shape(t)?;
            let id = t.identity();
            if !trig_keys.insert(id.clone()) {
                return Err(ApplyError::Invalid(format!("duplicate trigger `{id}`")));
            }
        }

        // Vars: kebab keys (same rule the vars admin API enforces), so a
        // manifest can't write a key the interactive surface would reject.
        for key in bundle.vars.keys() {
            validate_var_key(key)?;
        }

        // Extension points (§5.5): unique names; none may shadow a built-in
        // SDK namespace (same guard as module names — an `import "kv"` must
        // never resolve to a user-supplied provider).
        let mut ep_names: HashSet<String> = HashSet::new();
        for name in &bundle.extension_points {
            if !ep_names.insert(name.to_lowercase()) {
                return Err(ApplyError::Invalid(format!(
                    "duplicate extension point `{name}`"
                )));
            }
            if crate::api::RESERVED_MODULE_NAMES.contains(&name.as_str()) {
                return Err(ApplyError::Invalid(format!(
                    "extension point `{name}`: reserved module name \
                     (shadows a built-in SDK namespace)"
                )));
            }
        }

        // Shared group collections (§11.6): a known kind + unique (name, kind).
        // No reserved-name guard — a collection name is a data namespace, not an
        // importable module, so it can't shadow an SDK namespace.
        let mut seen_collections: HashSet<(String, &str)> = HashSet::new();
        for c in &bundle.collections {
            if c.name.is_empty() {
                return Err(ApplyError::Invalid(
                    "shared collection name must not be empty".into(),
                ));
            }
            if !COLLECTION_KINDS.contains(&c.kind.as_str()) {
                return Err(ApplyError::Invalid(format!(
                    "shared collection `{}`: unknown kind `{}` (want one of {COLLECTION_KINDS:?})",
                    c.name, c.kind
                )));
            }
            if !seen_collections.insert((c.name.to_lowercase(), c.kind.as_str())) {
                return Err(ApplyError::Invalid(format!(
                    "duplicate shared collection `{}` (kind `{}`)",
                    c.name, c.kind
                )));
            }
        }
        Ok(())
    }

    async fn load_current(&self, owner: ApplyOwner) -> Result<CurrentState, ApplyError> {
        // A group node owns scripts + vars + route/trigger TEMPLATES (§11 tail);
        // secrets stay empty (app-only — the bundle for a group declares none).
        let (scripts, routes, triggers, secret_names, var_owner) = match owner {
            ApplyOwner::App(app_id) => (
                self.scripts
                    .list_for_app(app_id)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?,
                self.routes
                    .list_for_app(app_id)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?,
                self.triggers
                    .list_for_app(app_id)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?,
                self.all_secret_names(crate::secrets_service::SecretOwner::App(app_id))
                    .await?,
                VarOwner::App(app_id),
            ),
            ApplyOwner::Group(group_id) => (
                self.scripts
                    .list_for_group(group_id)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?,
                // §11 tail: a group owns route + trigger TEMPLATES; load them so
                // the diff sees existing markers (NoOp on re-apply, prune-able).
                self.routes
                    .list_for_group(group_id)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?,
                self.triggers
                    .list_for_group(group_id)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?,
                // §4.5 M5.5: a group's own set secrets — the plan check for an
                // email TEMPLATE resolves `inbound_secret_ref` against the
                // GROUP store, and the state token folds them in. Informational
                // in the secrets diff only (apply never writes secrets).
                self.all_secret_names(crate::secrets_service::SecretOwner::Group(group_id))
                    .await?,
                VarOwner::Group(group_id),
            ),
        };
        // The owner's OWN vars, narrowed to scope `*` and real values: the
        // manifest reconciles env-agnostic config, not inherited vars (not in
        // this owner's rows) nor tombstones (an imperative suppress the manifest
        // can't express).
        let vars = self
            .vars
            .list_for_owner(var_owner)
            .await
            .map_err(|e| ApplyError::Backend(e.to_string()))?
            .into_iter()
            .filter(|r| r.environment_scope == "*" && !r.is_tombstone)
            .map(|r| (r.key, r.value))
            .collect();
        // Extension-point markers declared directly at this node (§5.5).
        let extension_point_names =
            crate::extension_point_repo::list_for_owner(&self.pool, owner.as_script_owner())
                .await
                .map_err(|e| ApplyError::Backend(e.to_string()))?;
        // Shared group-collection markers declared directly at this node
        // (§11.6), all kinds.
        let collections =
            crate::group_collection_repo::list_all_for_owner(&self.pool, owner.as_script_owner())
                .await
                .map_err(|e| ApplyError::Backend(e.to_string()))?;
        // §11 tail suppression markers declared directly at this node
        // (§11 tail M1: owner-polymorphic — an app declines for itself, a group
        // for its subtree).
        let suppressions =
            crate::suppression_repo::list_for_owner(&self.pool, owner.as_script_owner())
                .await
                .map_err(|e| ApplyError::Backend(e.to_string()))?;
        // v1.2 Workflows are app-owned (M1); a group node owns none.
        let workflows = match owner {
            ApplyOwner::App(app_id) => {
                crate::workflow_repo::list_workflows_for_app(&self.pool, app_id)
                    .await
                    .map_err(|e| ApplyError::Backend(e.to_string()))?
            }
            ApplyOwner::Group(_) => Vec::new(),
        };
        // §9.4 interceptor markers declared directly at this node.
        let interceptors =
            crate::interceptor_repo::list_for_owner(&self.pool, owner.as_script_owner())
                .await
                .map_err(|e| ApplyError::Backend(e.to_string()))?
                .into_iter()
                .map(|m| (m.service, m.op, m.script))
                .collect();
        Ok(CurrentState {
            scripts,
            routes,
            triggers,
            secret_names,
            vars,
            extension_point_names,
            collections,
            suppressions,
            workflows,
            interceptors,
        })
    }

    async fn all_secret_names(
        &self,
        owner: crate::secrets_service::SecretOwner,
    ) -> Result<Vec<String>, ApplyError> {
        let mut names = Vec::new();
        let mut cursor: Option<String> = None;
        loop {
            let page = self
                .secrets
                .list_names(owner, cursor.as_deref(), 200)
                .await
                .map_err(|e| ApplyError::Backend(e.to_string()))?;
            names.extend(page.names);
            match page.next_cursor {
                Some(c) => cursor = Some(c),
                None => break,
            }
        }
        Ok(names)
    }
}

// ----------------------------------------------------------------------------
// Diff (pure)
// ----------------------------------------------------------------------------

/// Diff desired `bundle` against `current`. Pure — unit-tested directly.
#[must_use]
pub fn compute_diff(current: &CurrentState, bundle: &Bundle) -> Plan {
    compute_diff_with_names(current, bundle, &HashMap::new())
}

/// `compute_diff` with `current_names` — the names of group-owned (inherited)
/// scripts the app's CURRENT routes/triggers are bound to, `script_id → name`
/// (Phase 4). Those ids aren't in `current.scripts` (app-own only), so without
/// them a current binding to a group script resolves to no name: it reads as a
/// perpetual rebind, and a binding dropped from the manifest never diffs to a
/// Delete (so `--prune` can't remove it). The public `compute_diff` passes an
/// empty map (no inheritance).
fn compute_diff_with_names(
    current: &CurrentState,
    bundle: &Bundle,
    current_names: &HashMap<ScriptId, String>,
) -> Plan {
    let script_name_by_id: HashMap<ScriptId, String> = current
        .scripts
        .iter()
        .map(|s| (s.id, s.name.clone()))
        // Inherited group bindings' ids → names. Disjoint from app-own ids by
        // construction, so order doesn't matter.
        .chain(current_names.iter().map(|(id, n)| (*id, n.clone())))
        .collect();

    Plan {
        scripts: diff_scripts(current, bundle),
        routes: diff_routes(current, bundle, &script_name_by_id),
        triggers: diff_triggers(current, bundle, &script_name_by_id),
        secrets: diff_secrets(current, bundle),
        vars: diff_vars(current, bundle),
        extension_points: diff_extension_points(current, bundle),
        collections: diff_collections(current, bundle),
        suppressions: diff_suppressions(current, bundle),
        workflows: diff_workflows(current, bundle),
        interceptors: diff_interceptors(current, bundle),
    }
}

/// Diff §9.4 interceptor markers by `"{service}/{op}"` key with the script name
/// as the value — create/update/noop/delete like `vars`. A changed script for
/// the same `(service, op)` is an `Update`; a live marker the manifest stops
/// declaring is a `Delete` (applied only under `--prune`).
fn diff_interceptors(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
    let key = |service: &str, op: &str| format!("{service}/{op}");
    let live: HashMap<String, &str> = current
        .interceptors
        .iter()
        .map(|(s, o, script)| (key(s, o), script.as_str()))
        .collect();
    let mut out = Vec::new();
    for bi in &bundle.interceptors {
        let k = key(&bi.service, &bi.op);
        match live.get(&k) {
            Some(cur) if *cur == bi.script => out.push(ResourceChange {
                op: Op::NoOp,
                key: k,
                detail: None,
            }),
            Some(_) => out.push(ResourceChange {
                op: Op::Update,
                key: k,
                detail: Some("interceptor script changed".into()),
            }),
            None => out.push(ResourceChange {
                op: Op::Create,
                key: k,
                detail: Some(bi.script.clone()),
            }),
        }
    }
    for (s, o, _) in &current.interceptors {
        let present = bundle
            .interceptors
            .iter()
            .any(|bi| bi.service == *s && bi.op == *o);
        if !present {
            out.push(ResourceChange {
                op: Op::Delete,
                key: key(s, o),
                detail: Some("on server, not declared".into()),
            });
        }
    }
    out
}

/// Diff workflows by `lower(name)`. Like scripts, a workflow has a stable name
/// identity with a mutable body, so a changed definition (or `enabled`) is an
/// `Update`, not a delete+create. Live workflows absent from the manifest are
/// `Delete` (applied only under `--prune`).
fn diff_workflows(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
    let by_name: HashMap<String, &crate::workflow_repo::Workflow> = current
        .workflows
        .iter()
        .map(|w| (w.name.to_lowercase(), w))
        .collect();
    let desired: HashSet<String> = bundle
        .workflows
        .iter()
        .map(|w| w.name.to_lowercase())
        .collect();

    let mut out = Vec::new();
    for w in &bundle.workflows {
        match by_name.get(&w.name.to_lowercase()) {
            None => out.push(ResourceChange {
                op: Op::Create,
                key: w.name.clone(),
                detail: None,
            }),
            Some(cur) => {
                let reason = if cur.definition != w.definition {
                    Some("definition changed".to_string())
                } else if cur.enabled != w.enabled {
                    Some(if w.enabled { "enabled" } else { "disabled" }.to_string())
                } else {
                    None
                };
                out.push(ResourceChange {
                    op: if reason.is_some() {
                        Op::Update
                    } else {
                        Op::NoOp
                    },
                    key: w.name.clone(),
                    detail: reason,
                });
            }
        }
    }
    for w in &current.workflows {
        if !desired.contains(&w.name.to_lowercase()) {
            out.push(ResourceChange {
                op: Op::Delete,
                key: w.name.clone(),
                detail: None,
            });
        }
    }
    out
}

/// Pure structural validation of a workflow definition (mirrors
/// `validate_trigger_shape`): non-empty; unique step names; each step targets
/// exactly one of function/workflow; `depends_on` references exist and aren't
/// self-loops; the DAG is acyclic; every `when` + input template parses and
/// only references declared steps. Returns a human error string on failure.
fn validate_workflow_definition(
    wf_name: &str,
    def: &picloud_shared::workflow::WorkflowDefinition,
) -> Result<(), String> {
    use std::collections::{BTreeSet, HashMap as Map};
    if def.steps.is_empty() {
        return Err(format!("workflow `{wf_name}` has no steps"));
    }
    let mut names: BTreeSet<String> = BTreeSet::new();
    for s in &def.steps {
        if s.name.trim().is_empty() {
            return Err(format!(
                "workflow `{wf_name}` has a step with an empty name"
            ));
        }
        if !names.insert(s.name.clone()) {
            return Err(format!(
                "workflow `{wf_name}` has a duplicate step name `{}`",
                s.name
            ));
        }
    }
    for s in &def.steps {
        if s.target().is_none() {
            return Err(format!(
                "workflow `{wf_name}` step `{}` must set exactly one of `function` or `workflow`",
                s.name
            ));
        }
        for dep in &s.depends_on {
            if dep == &s.name {
                return Err(format!(
                    "workflow `{wf_name}` step `{}` depends on itself",
                    s.name
                ));
            }
            if !names.contains(dep) {
                return Err(format!(
                    "workflow `{wf_name}` step `{}` depends on undeclared step `{dep}`",
                    s.name
                ));
            }
        }
        if let Some(w) = &s.when {
            crate::workflow_expr::validate(w, &names).map_err(|e| {
                format!(
                    "workflow `{wf_name}` step `{}` has a bad `when`: {e}",
                    s.name
                )
            })?;
        }
        crate::workflow_template::validate(&s.input, &names).map_err(|e| {
            format!(
                "workflow `{wf_name}` step `{}` has a bad input template: {e}",
                s.name
            )
        })?;
    }
    // DAG acyclicity via Kahn topological sort. Count DISTINCT deps: a step that
    // lists the same dependency twice inflates its in-degree past what the
    // single-decrement in the drain loop below can undo, which would otherwise
    // report a spurious cycle for a valid DAG.
    let mut indeg: Map<&str, usize> = def.steps.iter().map(|s| (s.name.as_str(), 0)).collect();
    for s in &def.steps {
        let distinct: BTreeSet<&str> = s.depends_on.iter().map(String::as_str).collect();
        *indeg.get_mut(s.name.as_str()).unwrap() += distinct.len();
    }
    let mut queue: Vec<&str> = indeg
        .iter()
        .filter(|(_, d)| **d == 0)
        .map(|(n, _)| *n)
        .collect();
    let mut seen = 0usize;
    while let Some(n) = queue.pop() {
        seen += 1;
        for s in &def.steps {
            if s.depends_on.iter().any(|d| d == n) {
                let e = indeg.get_mut(s.name.as_str()).unwrap();
                *e -= 1;
                if *e == 0 {
                    queue.push(s.name.as_str());
                }
            }
        }
    }
    if seen != def.steps.len() {
        return Err(format!("workflow `{wf_name}` has a dependency cycle"));
    }
    Ok(())
}

/// Diff app-owned vars by key. Unlike secrets (whose values live out-of-band),
/// a var's value IS in the manifest, so equality is value-sensitive: a changed
/// value is an `Update`. Live vars absent from the manifest are `Delete`
/// (applied only under `--prune`).
fn diff_vars(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
    let live: HashMap<&str, &serde_json::Value> =
        current.vars.iter().map(|(k, v)| (k.as_str(), v)).collect();

    let mut out = Vec::new();
    for (key, value) in &bundle.vars {
        match live.get(key.as_str()) {
            Some(cur) if *cur == value => out.push(ResourceChange {
                op: Op::NoOp,
                key: key.clone(),
                detail: None,
            }),
            Some(_) => out.push(ResourceChange {
                op: Op::Update,
                key: key.clone(),
                detail: Some("value changed".into()),
            }),
            None => out.push(ResourceChange {
                op: Op::Create,
                key: key.clone(),
                detail: None,
            }),
        }
    }
    for (key, _) in &current.vars {
        if !bundle.vars.contains_key(key) {
            out.push(ResourceChange {
                op: Op::Delete,
                key: key.clone(),
                detail: Some("on server, not declared".into()),
            });
        }
    }
    out
}

/// Kebab var key (`^[a-z0-9][a-z0-9-]{0,127}$`) — mirrors `vars_api`'s
/// `validate_key` so the manifest and the interactive surface agree.
fn validate_var_key(key: &str) -> Result<(), ApplyError> {
    let ok = !key.is_empty()
        && key.len() <= 128
        && key
            .chars()
            .next()
            .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
        && key
            .chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
    if ok {
        Ok(())
    } else {
        Err(ApplyError::Invalid(format!(
            "var key `{key}` must be 1–128 chars of lowercase letters, digits, and hyphens, \
             starting with a letter or digit"
        )))
    }
}

/// Upsert one app-owned var at scope `*`, in the apply transaction. Mirrors
/// `PostgresVarsRepo::set` for `VarOwner::App` (the partial-index predicate is
/// restated in the conflict target) but runs against `&mut tx` so it commits
/// atomically with the rest of the apply. Clears any tombstone.
async fn set_app_var_tx(
    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    app_id: AppId,
    key: &str,
    value: &serde_json::Value,
) -> Result<(), ApplyError> {
    sqlx::query(
        "INSERT INTO vars (app_id, environment_scope, key, value, is_tombstone) \
         VALUES ($1, '*', $2, $3, FALSE) \
         ON CONFLICT (app_id, environment_scope, key) WHERE app_id IS NOT NULL DO UPDATE \
           SET value = EXCLUDED.value, is_tombstone = FALSE, updated_at = NOW()",
    )
    .bind(app_id.into_inner())
    .bind(key)
    .bind(value)
    .execute(&mut **tx)
    .await
    .map_err(|e| ApplyError::Backend(e.to_string()))?;
    Ok(())
}

/// Delete one app-owned var at scope `*`, in the apply transaction.
async fn delete_app_var_tx(
    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    app_id: AppId,
    key: &str,
) -> Result<(), ApplyError> {
    sqlx::query("DELETE FROM vars WHERE app_id = $1 AND environment_scope = '*' AND key = $2")
        .bind(app_id.into_inner())
        .bind(key)
        .execute(&mut **tx)
        .await
        .map_err(|e| ApplyError::Backend(e.to_string()))?;
    Ok(())
}

/// Upsert one group-owned var at scope `*`, in the apply transaction (Phase 5).
/// Mirrors `set_app_var_tx` for `VarOwner::Group`. Env-scoped group vars
/// (`@staging`) stay imperative for now — a group manifest declares only
/// env-agnostic (`*`) group config.
async fn set_group_var_tx(
    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    group_id: GroupId,
    key: &str,
    value: &serde_json::Value,
) -> Result<(), ApplyError> {
    sqlx::query(
        "INSERT INTO vars (group_id, environment_scope, key, value, is_tombstone) \
         VALUES ($1, '*', $2, $3, FALSE) \
         ON CONFLICT (group_id, environment_scope, key) WHERE group_id IS NOT NULL DO UPDATE \
           SET value = EXCLUDED.value, is_tombstone = FALSE, updated_at = NOW()",
    )
    .bind(group_id.into_inner())
    .bind(key)
    .bind(value)
    .execute(&mut **tx)
    .await
    .map_err(|e| ApplyError::Backend(e.to_string()))?;
    Ok(())
}

/// Delete one group-owned var at scope `*`, in the apply transaction (Phase 5).
async fn delete_group_var_tx(
    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    group_id: GroupId,
    key: &str,
) -> Result<(), ApplyError> {
    sqlx::query("DELETE FROM vars WHERE group_id = $1 AND environment_scope = '*' AND key = $2")
        .bind(group_id.into_inner())
        .bind(key)
        .execute(&mut **tx)
        .await
        .map_err(|e| ApplyError::Backend(e.to_string()))?;
    Ok(())
}

// ----------------------------------------------------------------------------
// §7 multi-repo ownership — the claim state machine + its tx helpers.
//
// A project is single-owner-per-NODE: each group node is owned by exactly one
// project (`groups.owner_project`); apps inherit ownership from their nearest
// claimed ancestor group. The pure `decide_*` fns are the whole policy; the
// apply path executes their verdict (the UPDATE / the takeover authz gate)
// under the per-node advisory lock. See docs/design/groups-and-project-tool.md §7.
// ----------------------------------------------------------------------------

/// What a group-node apply should do about ownership, given the current owner,
/// the declared project, and whether `--takeover` was passed. Generic over the
/// identity key `K` so the same policy serves both the apply path (`K =
/// ProjectId`, comparing the assigned ids) and the plan preview (`K = String`,
/// comparing slugs before any id is assigned).
#[derive(Debug, PartialEq, Eq)]
enum GroupClaimAction<K> {
    /// Nothing to write (unclaimed + no project, or already owned by us).
    Noop,
    /// First-apply-claims: set `owner_project`.
    Claim(K),
    /// Reassign to us — capability-gated (`GroupAdmin`) at the call site.
    Takeover(K),
    /// Refuse (409); the string is the actionable message.
    Conflict(String),
}

/// Pure ownership policy for a GROUP node. `current` is the live owner as
/// `(key, slug)`; `declared` is this apply's project key. See §7.3.
fn decide_group_claim<K: Eq>(
    current: Option<(K, String)>,
    declared: Option<K>,
    takeover: bool,
) -> GroupClaimAction<K> {
    match (current, declared) {
        // Unclaimed: a declared project claims it; no project is a no-op (the
        // node stays UI/API-owned — the backward-compatible path).
        (None, Some(p)) => GroupClaimAction::Claim(p),
        (None, None) => GroupClaimAction::Noop,
        // Already ours: re-apply by the owning repo.
        (Some((c, _)), Some(p)) if c == p => GroupClaimAction::Noop,
        // Owned by someone else: takeover (gated) or refuse.
        (Some((_, cslug)), Some(p)) => {
            if takeover {
                GroupClaimAction::Takeover(p)
            } else {
                GroupClaimAction::Conflict(format!(
                    "group is owned by project '{cslug}'; use --takeover (requires group-admin) \
                     to reassign it"
                ))
            }
        }
        // A no-project apply must not silently write into a claimed subtree.
        (Some((_, cslug)), None) => GroupClaimAction::Conflict(format!(
            "group is owned by project '{cslug}'; declare a matching [project] (or --takeover)"
        )),
    }
}

/// Pure ownership policy for an APP node. Apps never claim — they must match
/// their nearest claimed ancestor group (`nearest`, as `(key, slug)`); an
/// unclaimed subtree is open (backward-compatible). Generic over the identity
/// key `K` for the same reason as [`decide_group_claim`]. See §7.
fn decide_app_owner<K: Eq>(
    nearest: Option<(K, String)>,
    declared: Option<K>,
) -> Result<(), String> {
    match (nearest, declared) {
        (None, _) => Ok(()),
        (Some((c, _)), Some(p)) if c == p => Ok(()),
        (Some((_, cslug)), Some(_)) => Err(format!(
            "the nearest owning group belongs to project '{cslug}', not this apply's project; \
             apply from '{cslug}', or --takeover its group from a [group] node"
        )),
        (Some((_, cslug)), None) => Err(format!(
            "this app is under a subtree owned by project '{cslug}'; declare [project] '{cslug}'"
        )),
    }
}

/// §7: validate a `[project]` slug — the same FORMAT rule as app/group slugs
/// (`^[a-z0-9][a-z0-9-]{0,62}$`), reusing the shared validator. A project slug
/// is intentionally NOT reserved-word-checked: unlike a group/app slug it is
/// never used in a route or host, so no path can collide with it.
fn validate_project_slug(slug: &str) -> Result<(), ApplyError> {
    crate::groups_api::validate_slug_format(slug)
        .map_err(|why| ApplyError::Invalid(format!("project slug {slug:?}: {why}")))
}

/// Register (or fetch) a project by slug inside the apply tx, returning its id.
/// First apply with a new slug inserts, falling back to the slug when the
/// manifest omits `name` (the column is NOT NULL). A re-apply updates the
/// display name ONLY when the manifest actually declares one — a name-less
/// `[project]` block (e.g. a clone scaffolded without a name) preserves the
/// previously-set name and `created_by` rather than clobbering the name back
/// to the slug. `$2` (the optional declared name) is bound once and referenced
/// in both the INSERT fallback and the ON CONFLICT guard.
async fn upsert_project_tx(
    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    decl: &ProjectDecl,
    actor: AdminUserId,
) -> Result<ProjectId, ApplyError> {
    validate_project_slug(&decl.slug)?;
    let (id,): (uuid::Uuid,) = sqlx::query_as(
        "INSERT INTO projects (slug, name, created_by) VALUES ($1, COALESCE($2, $1), $3) \
         ON CONFLICT (slug) DO UPDATE SET name = COALESCE($2, projects.name) \
         RETURNING id",
    )
    .bind(&decl.slug)
    .bind(decl.name.clone())
    .bind(actor.into_inner())
    .fetch_one(&mut **tx)
    .await
    .map_err(|e| ApplyError::Backend(e.to_string()))?;
    // §3 M3 (hermetic gate): persist the declared per-env approval policy as the
    // authoritative source. Declarative replace (delete-then-insert) so a
    // removed env drops out — the gate reads `persisted ∪ declared`, so an
    // ungating still has to pass the prior gate at apply time (see
    // `apply_api::env_gate_check`). Same tx as the project row + node claim.
    sqlx::query("DELETE FROM project_environments WHERE project_id = $1")
        .bind(id)
        .execute(&mut **tx)
        .await
        .map_err(|e| ApplyError::Backend(e.to_string()))?;
    for (env_name, pol) in &decl.environments {
        sqlx::query(
            "INSERT INTO project_environments (project_id, env_name, confirm) \
             VALUES ($1, $2, $3)",
        )
        .bind(id)
        .bind(env_name)
        .bind(pol.confirm)
        .execute(&mut **tx)
        .await
        .map_err(|e| ApplyError::Backend(e.to_string()))?;
    }
    Ok(id.into())
}

/// Read a group's current owner as `(project id, project slug)` inside the tx,
/// or `None` when unclaimed. Serialized with the write via the caller's
/// per-node advisory lock.
async fn read_group_owner_tx(
    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    gid: GroupId,
) -> Result<Option<(ProjectId, String)>, ApplyError> {
    let row: Option<(Option<uuid::Uuid>, Option<String>)> = sqlx::query_as(
        "SELECT g.owner_project, p.slug FROM groups g \
         LEFT JOIN projects p ON p.id = g.owner_project WHERE g.id = $1",
    )
    .bind(gid.into_inner())
    .fetch_optional(&mut **tx)
    .await
    .map_err(|e| ApplyError::Backend(e.to_string()))?;
    Ok(row.and_then(|(pid, slug)| pid.map(|p| (p.into(), slug.unwrap_or_default()))))
}

/// Assign `owner_project` for a group inside the tx. Deliberately does NOT bump
/// `structure_version` — a claim is not a diff/inheritance change, so it must
/// not invalidate a pending bound plan (hazard b in the design).
async fn write_group_owner_tx(
    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    gid: GroupId,
    pid: ProjectId,
) -> Result<(), ApplyError> {
    sqlx::query("UPDATE groups SET owner_project = $1 WHERE id = $2")
        .bind(pid.into_inner())
        .bind(gid.into_inner())
        .execute(&mut **tx)
        .await
        .map_err(|e| ApplyError::Backend(e.to_string()))?;
    Ok(())
}

/// Nearest claimed ancestor for an app, resolved in-tx: an in-tree group
/// (Phase-A overlay) wins over a committed ancestor. `app_chain` is nearest-
/// first. Used by the tree path (the single-node path folds `ancestors()`).
async fn nearest_claimed_in_tx(
    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    app_chain: &[GroupId],
    in_tree: &HashMap<GroupId, (ProjectId, String)>,
) -> Result<Option<(ProjectId, String)>, ApplyError> {
    for gid in app_chain {
        if let Some(hit) = in_tree.get(gid) {
            return Ok(Some(hit.clone()));
        }
        if let Some(hit) = read_group_owner_tx(tx, *gid).await? {
            return Ok(Some(hit));
        }
    }
    Ok(None)
}

/// Map an authz denial to the apply surface (`Denied` → 403, repo error → 500).
/// Used by the takeover capability gate, keeping "lacks group-admin" (403)
/// distinct from a 409 ownership conflict.
fn map_authz(d: AuthzDenied) -> ApplyError {
    match d {
        AuthzDenied::Denied => ApplyError::Forbidden,
        AuthzDenied::Repo(e) => ApplyError::AuthzRepo(e.to_string()),
    }
}

/// §6: map a group-repo failure from the declarative create path — a real DB
/// error is a 500, everything else (slug/parent conflict) is a 422 with the
/// actionable message.
fn map_group_repo_err(e: crate::group_repo::GroupRepositoryError) -> ApplyError {
    match e {
        crate::group_repo::GroupRepositoryError::Db(e) => ApplyError::Backend(e.to_string()),
        other => ApplyError::Invalid(other.to_string()),
    }
}

/// Owner of an apply node (Phase 5): an app or a group. The apply engine is
/// owner-generic — only script-create ownership, var writes, the current-state
/// load, and the advisory lock differ; the diff and all other CRUD are shared.
/// A group node has only scripts + vars (no routes / triggers / secrets).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApplyOwner {
    App(AppId),
    Group(GroupId),
}

impl ApplyOwner {
    /// `(app_id, group_id)` for a `NewScript` — exactly one is `Some`.
    fn script_owner(self) -> (Option<AppId>, Option<GroupId>) {
        match self {
            Self::App(a) => (Some(a), None),
            Self::Group(g) => (None, Some(g)),
        }
    }

    /// As the shared [`ScriptOwner`] — for the module/extension-point repos.
    fn as_script_owner(self) -> picloud_shared::ScriptOwner {
        match self {
            Self::App(a) => picloud_shared::ScriptOwner::App(a),
            Self::Group(g) => picloud_shared::ScriptOwner::Group(g),
        }
    }

    /// As a [`SecretOwner`] — for resolving an email trigger's inbound secret
    /// against the owner's store (§4.5 M5: an app trigger vs a group template).
    fn secret_owner(self) -> crate::secrets_service::SecretOwner {
        match self {
            Self::App(a) => crate::secrets_service::SecretOwner::App(a),
            Self::Group(g) => crate::secrets_service::SecretOwner::Group(g),
        }
    }

    async fn set_var_tx(
        self,
        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
        key: &str,
        value: &serde_json::Value,
    ) -> Result<(), ApplyError> {
        match self {
            Self::App(a) => set_app_var_tx(tx, a, key, value).await,
            Self::Group(g) => set_group_var_tx(tx, g, key, value).await,
        }
    }

    async fn delete_var_tx(
        self,
        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
        key: &str,
    ) -> Result<(), ApplyError> {
        match self {
            Self::App(a) => delete_app_var_tx(tx, a, key).await,
            Self::Group(g) => delete_group_var_tx(tx, g, key).await,
        }
    }
}

fn diff_scripts(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
    let by_name: HashMap<String, &Script> = current
        .scripts
        .iter()
        .map(|s| (s.name.to_lowercase(), s))
        .collect();
    let desired: HashSet<String> = bundle
        .scripts
        .iter()
        .map(|s| s.name.to_lowercase())
        .collect();

    let mut out = Vec::new();
    for s in &bundle.scripts {
        match by_name.get(&s.name.to_lowercase()) {
            None => out.push(ResourceChange {
                op: Op::Create,
                key: s.name.clone(),
                detail: None,
            }),
            Some(cur) => {
                if let Some(reason) = script_update_reason(cur, s) {
                    out.push(ResourceChange {
                        op: Op::Update,
                        key: s.name.clone(),
                        detail: Some(reason),
                    });
                } else {
                    out.push(ResourceChange {
                        op: Op::NoOp,
                        key: s.name.clone(),
                        detail: None,
                    });
                }
            }
        }
    }
    for s in &current.scripts {
        if !desired.contains(&s.name.to_lowercase()) {
            out.push(ResourceChange {
                op: Op::Delete,
                key: s.name.clone(),
                detail: None,
            });
        }
    }
    out
}

/// Returns `Some(reason)` if the desired script differs from current. A
/// `None` optional field means "unspecified — leave as-is", so it never
/// forces an update.
fn script_update_reason(cur: &Script, desired: &BundleScript) -> Option<String> {
    if cur.source != desired.source {
        return Some("source changed".into());
    }
    if cur.kind != desired.kind {
        return Some("kind changed".into());
    }
    // Declarative (default true): always reconcile to the manifest's value.
    if cur.enabled != desired.enabled {
        return Some(if desired.enabled {
            "enabled".into()
        } else {
            "disabled".into()
        });
    }
    // Sparse like the other optional fields: an omitted (`None`) description
    // means "leave as-is", so only an explicitly-set value that differs
    // forces an update. (Clearing a description is done in the dashboard.)
    if let Some(d) = &desired.description {
        if cur.description.as_ref() != Some(d) {
            return Some("description changed".into());
        }
    }
    if let Some(t) = desired.timeout_seconds {
        if i64::from(cur.timeout_seconds) != i64::from(t) {
            return Some("timeout changed".into());
        }
    }
    if let Some(m) = desired.memory_limit_mb {
        if i64::from(cur.memory_limit_mb) != i64::from(m) {
            return Some("memory changed".into());
        }
    }
    if let Some(sb) = desired.sandbox {
        if cur.sandbox != sb {
            return Some("sandbox changed".into());
        }
    }
    None
}

fn diff_routes(
    current: &CurrentState,
    bundle: &Bundle,
    name_by_id: &HashMap<ScriptId, String>,
) -> Vec<ResourceChange> {
    let by_key: HashMap<String, &Route> = current
        .routes
        .iter()
        .map(|r| {
            (
                route_key(
                    r.method.as_deref(),
                    r.host_kind,
                    &r.host,
                    r.path_kind,
                    &r.path,
                ),
                r,
            )
        })
        .collect();
    let desired: HashSet<String> = bundle
        .routes
        .iter()
        .map(|r| {
            route_key(
                r.method.as_deref(),
                r.host_kind,
                &r.host,
                r.path_kind,
                &r.path,
            )
        })
        .collect();

    let mut out = Vec::new();
    for r in &bundle.routes {
        let key = route_key(
            r.method.as_deref(),
            r.host_kind,
            &r.host,
            r.path_kind,
            &r.path,
        );
        match by_key.get(&key) {
            None => out.push(ResourceChange {
                op: Op::Create,
                key,
                detail: Some(format!("→ {}", r.script)),
            }),
            Some(cur) => {
                let cur_script = name_by_id.get(&cur.script_id).map(String::as_str);
                if cur_script != Some(r.script.as_str())
                    || cur.dispatch_mode != r.dispatch_mode
                    || cur.host_param_name != r.host_param_name
                    || cur.enabled != r.enabled
                    // §11 tail: a sealed toggle on a group route template must
                    // re-insert the row so the rebuild sees the new flag.
                    || cur.sealed != r.sealed
                {
                    out.push(ResourceChange {
                        op: Op::Update,
                        key,
                        detail: Some(format!("→ {}", r.script)),
                    });
                } else {
                    out.push(ResourceChange {
                        op: Op::NoOp,
                        key,
                        detail: None,
                    });
                }
            }
        }
    }
    for r in &current.routes {
        let key = route_key(
            r.method.as_deref(),
            r.host_kind,
            &r.host,
            r.path_kind,
            &r.path,
        );
        if !desired.contains(&key) {
            out.push(ResourceChange {
                op: Op::Delete,
                key,
                detail: None,
            });
        }
    }
    out
}

fn diff_triggers(
    current: &CurrentState,
    bundle: &Bundle,
    name_by_id: &HashMap<ScriptId, String>,
) -> Vec<ResourceChange> {
    let by_id: HashMap<String, &Trigger> = current
        .triggers
        .iter()
        .filter_map(|t| current_trigger_identity(t, name_by_id).map(|id| (id, t)))
        .collect();
    let desired: HashSet<String> = bundle
        .triggers
        .iter()
        .map(BundleTrigger::identity)
        .collect();

    let mut out = Vec::new();
    for t in &bundle.triggers {
        let id = t.identity();
        // Semantic identity captures the whole definition for these kinds,
        // so a match is a no-op (changing a field changes the identity).
        if by_id.contains_key(&id) {
            out.push(ResourceChange {
                op: Op::NoOp,
                key: id,
                detail: None,
            });
        } else {
            out.push(ResourceChange {
                op: Op::Create,
                key: id,
                detail: None,
            });
        }
    }
    for t in &current.triggers {
        // Never prune email triggers: `pull` can't represent them (the
        // server stores the sealed secret, not the `inbound_secret_ref`), so
        // their absence from the manifest is ambiguous, not a delete intent.
        // Manage email triggers with `pic triggers rm`.
        if matches!(t.details, TriggerDetails::Email { .. }) {
            continue;
        }
        if let Some(id) = current_trigger_identity(t, name_by_id) {
            if !desired.contains(&id) {
                out.push(ResourceChange {
                    op: Op::Delete,
                    key: id,
                    detail: None,
                });
            }
        }
    }
    out
}

fn diff_secrets(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
    let live: HashSet<&str> = current.secret_names.iter().map(String::as_str).collect();
    let declared: HashSet<&str> = bundle.secrets.iter().map(String::as_str).collect();

    let mut out = Vec::new();
    for name in &bundle.secrets {
        if live.contains(name.as_str()) {
            out.push(ResourceChange {
                op: Op::NoOp,
                key: name.clone(),
                detail: None,
            });
        } else {
            out.push(ResourceChange {
                op: Op::Create,
                key: name.clone(),
                detail: Some("declared but unset — push with `pic secret set`".into()),
            });
        }
    }
    for name in &current.secret_names {
        if !declared.contains(name.as_str()) {
            out.push(ResourceChange {
                op: Op::Delete,
                key: name.clone(),
                detail: Some("on server, not declared".into()),
            });
        }
    }
    out
}

/// Diff extension-point markers by name (§5.5). Name-only identity like
/// secrets, but — unlike secrets — the markers are real declarations the
/// manifest owns: a declared-but-absent name is a Create (the apply inserts
/// it), and a live-but-undeclared name is a Delete (pruned under `--prune`),
/// mirroring `vars`.
fn diff_extension_points(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
    let live: HashSet<&str> = current
        .extension_point_names
        .iter()
        .map(String::as_str)
        .collect();
    let declared: HashSet<&str> = bundle.extension_points.iter().map(String::as_str).collect();

    let mut out = Vec::new();
    for name in &bundle.extension_points {
        out.push(ResourceChange {
            op: if live.contains(name.as_str()) {
                Op::NoOp
            } else {
                Op::Create
            },
            key: name.clone(),
            detail: None,
        });
    }
    for name in &current.extension_point_names {
        if !declared.contains(name.as_str()) {
            out.push(ResourceChange {
                op: Op::Delete,
                key: name.clone(),
                detail: Some("on server, not declared".into()),
            });
        }
    }
    out
}

/// Diff shared group-collection markers by `(name, kind)` (§11.6). A
/// declared-but-absent marker is a Create, a live-but-undeclared one is a
/// Delete (pruned under `--prune`). Each change carries `key = name`,
/// `detail = Some(kind)` so the reconcile knows which store to touch and
/// `pic plan` renders the kind. Identity is `(LOWER(name), kind)`, so the same
/// name as both a `kv` and a `docs` collection are distinct markers.
fn diff_collections(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
    let live: HashSet<(String, &str)> = current
        .collections
        .iter()
        .map(|(n, k)| (n.to_lowercase(), k.as_str()))
        .collect();
    let declared: HashSet<(String, &str)> = bundle
        .collections
        .iter()
        .map(|c| (c.name.to_lowercase(), c.kind.as_str()))
        .collect();

    let mut out = Vec::new();
    for c in &bundle.collections {
        out.push(ResourceChange {
            op: if live.contains(&(c.name.to_lowercase(), c.kind.as_str())) {
                Op::NoOp
            } else {
                Op::Create
            },
            key: c.name.clone(),
            detail: Some(c.kind.clone()),
        });
    }
    for (name, kind) in &current.collections {
        if !declared.contains(&(name.to_lowercase(), kind.as_str())) {
            out.push(ResourceChange {
                op: Op::Delete,
                key: name.clone(),
                detail: Some(kind.clone()),
            });
        }
    }
    out
}

/// §11 tail per-app suppression diff. Reconciles the app's declared
/// `suppress_triggers` (handler script names) + `suppress_routes` (paths) into
/// `template_suppressions` markers. Keyed `"{target_kind}:{reference}"` so a
/// trigger ref and a route ref of the same string are distinct markers; the
/// reconcile splits the key back on the first `:`.
fn diff_suppressions(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
    // Desired (kind, reference) pairs from the two manifest lists.
    let desired: Vec<(&str, &str)> = bundle
        .suppress_triggers
        .iter()
        .map(|r| (crate::suppression_repo::TARGET_TRIGGER, r.as_str()))
        .chain(
            bundle
                .suppress_routes
                .iter()
                .map(|r| (crate::suppression_repo::TARGET_ROUTE, r.as_str())),
        )
        .collect();
    let live: HashSet<(&str, &str)> = current
        .suppressions
        .iter()
        .map(|(k, r)| (k.as_str(), r.as_str()))
        .collect();
    let declared: HashSet<(&str, &str)> = desired.iter().copied().collect();

    let mut out = Vec::new();
    for (kind, reference) in &desired {
        out.push(ResourceChange {
            op: if live.contains(&(*kind, *reference)) {
                Op::NoOp
            } else {
                Op::Create
            },
            key: format!("{kind}:{reference}"),
            detail: Some((*kind).to_string()),
        });
    }
    for (kind, reference) in &current.suppressions {
        if !declared.contains(&(kind.as_str(), reference.as_str())) {
            out.push(ResourceChange {
                op: Op::Delete,
                key: format!("{kind}:{reference}"),
                detail: Some(kind.clone()),
            });
        }
    }
    out
}

/// Reject any email trigger whose referenced secret isn't set, so `plan` and
/// `apply` give the same answer (apply also fails in `resolve_and_seal`, but
/// only after taking the lock — surfacing it here keeps plan honest).
///
/// This checks the secret *reference exists*. `resolve_and_seal` additionally
/// requires the resolved value to be a non-empty string; `plan` deliberately
/// does not decrypt secrets, so a reference to a set-but-empty secret passes
/// `plan` and is caught at `apply` — the one residual plan/apply divergence,
/// and a deliberate one (plan stays read-only and crypto-free).
fn validate_email_secrets_present(
    bundle: &Bundle,
    secret_names: &[String],
) -> Result<(), ApplyError> {
    for t in &bundle.triggers {
        if let BundleTrigger::Email {
            script,
            inbound_secret_ref,
            ..
        } = t
        {
            if !secret_names.iter().any(|n| n == inbound_secret_ref) {
                return Err(ApplyError::Invalid(format!(
                    "email trigger on `{script}` references secret \
                     `{inbound_secret_ref}`, which is not set; push it with \
                     `pic secret set` first"
                )));
            }
        }
    }
    Ok(())
}

/// Semantic identity for a live trigger. Returns `None` only for
/// `dead_letter` (not representable in the manifest), so it's ignored by the
/// diff entirely. `email` returns `Some` so a declared email trigger matches
/// as NoOp, but `diff_triggers` separately refuses to emit a Delete for it.
fn current_trigger_identity(t: &Trigger, name_by_id: &HashMap<ScriptId, String>) -> Option<String> {
    let script = name_by_id.get(&t.script_id)?;
    // §11 tail: `sealed` (and §11.6 `shared`) are part of the identity for the
    // event kinds (mirroring `BundleTrigger::identity`), so toggling either on a
    // group template re-diffs.
    let sealed = t.sealed;
    let shared = t.shared;
    match &t.details {
        TriggerDetails::Kv {
            collection_glob,
            ops,
        } => Some(format!(
            "kv|{script}|{collection_glob}|{}|{sealed}|{shared}",
            sorted_csv(ops.iter().copied().map(KvEventOp::as_str))
        )),
        TriggerDetails::Docs {
            collection_glob,
            ops,
        } => Some(format!(
            "docs|{script}|{collection_glob}|{}|{sealed}|{shared}",
            sorted_csv(ops.iter().copied().map(DocsEventOp::as_str))
        )),
        TriggerDetails::Files {
            collection_glob,
            ops,
        } => Some(format!(
            "files|{script}|{collection_glob}|{}|{sealed}|{shared}",
            sorted_csv(ops.iter().copied().map(FilesEventOp::as_str))
        )),
        TriggerDetails::Cron {
            schedule, timezone, ..
        } => Some(format!("cron|{script}|{schedule}|{timezone}")),
        TriggerDetails::Pubsub { topic_pattern } => {
            Some(format!("pubsub|{script}|{topic_pattern}|{sealed}|{shared}"))
        }
        TriggerDetails::Email { .. } => Some(format!("email|{script}")),
        TriggerDetails::Queue { queue_name, .. } => Some(format!("queue|{queue_name}|{shared}")),
        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.
/// Consistent phrasing for a GROUP-only feature declared on an APP node — the
/// several such guards in `validate_bundle_for` (§11.6 shared collections, §11
/// tail `sealed`, shared triggers). Renders `an app <what> — <why>` so the
/// app/group boundary reads the same everywhere.
fn app_only_reject(what: &str, why: &str) -> ApplyError {
    ApplyError::Invalid(format!("an app {what} — {why}"))
}

fn disabled_target_warnings(bundle: &Bundle) -> Vec<String> {
    let disabled: HashSet<&str> = bundle
        .scripts
        .iter()
        .filter(|s| !s.enabled)
        .map(|s| s.name.as_str())
        .collect();
    if disabled.is_empty() {
        return Vec::new();
    }
    let mut out = Vec::new();
    for r in &bundle.routes {
        if r.enabled && disabled.contains(r.script.as_str()) {
            out.push(format!(
                "route `{}` is enabled but its script `{}` is disabled — it will 404",
                route_key(
                    r.method.as_deref(),
                    r.host_kind,
                    &r.host,
                    r.path_kind,
                    &r.path
                ),
                r.script
            ));
        }
    }
    for t in &bundle.triggers {
        if disabled.contains(t.script()) {
            out.push(format!(
                "{} trigger targets disabled script `{}` — it will not fire",
                t.kind_str(),
                t.script()
            ));
        }
    }
    out
}

/// M4 soft-warn: a `workflow`-kind step that (directly or transitively, within
/// this bundle) nests back into its own workflow forms a cycle. This is not an
/// error — the runtime depth ceiling (`PICLOUD_WORKFLOW_MAX_DEPTH`) bounds it —
/// but it's almost always a mistake, so we surface it at plan time. Only edges
/// to workflows present in the same bundle are considered (a reference to an
/// external workflow can't be resolved here).
fn workflow_nesting_warnings(bundle: &Bundle) -> Vec<String> {
    use std::collections::{BTreeSet, HashMap, HashSet};
    if bundle.workflows.is_empty() {
        return Vec::new();
    }
    let names: HashSet<String> = bundle
        .workflows
        .iter()
        .map(|w| w.name.to_lowercase())
        .collect();
    // from → the set of in-bundle workflows it nests into.
    let mut edges: HashMap<String, BTreeSet<String>> = HashMap::new();
    for w in &bundle.workflows {
        let from = w.name.to_lowercase();
        for s in &w.definition.steps {
            if let Some(t) = s.workflow.as_deref() {
                let t = t.to_lowercase();
                if names.contains(&t) {
                    edges.entry(from.clone()).or_default().insert(t);
                }
            }
        }
    }
    let mut out = Vec::new();
    for w in &bundle.workflows {
        let start = w.name.to_lowercase();
        // Reachability from `start`: if we can get back to `start`, it's cyclic.
        let mut seen: HashSet<String> = HashSet::new();
        let mut stack: Vec<String> = edges.get(&start).into_iter().flatten().cloned().collect();
        let mut cyclic = false;
        while let Some(n) = stack.pop() {
            if n == start {
                cyclic = true;
                break;
            }
            if seen.insert(n.clone()) {
                if let Some(next) = edges.get(&n) {
                    stack.extend(next.iter().cloned());
                }
            }
        }
        if cyclic {
            out.push(format!(
                "workflow `{}` can nest into itself (a sub-workflow cycle) — runs are \
                 bounded by PICLOUD_WORKFLOW_MAX_DEPTH, not infinite, but this is likely a bug",
                w.name
            ));
        }
    }
    out
}

/// Per-kind structural validation for one desired trigger — kept at parity
/// with the interactive trigger API so `apply` can't write a trigger the
/// dashboard would have rejected. Pure (no live state), so it's unit-tested
/// directly. The script-binding checks live in `validate_bundle`.
fn validate_trigger_shape(t: &BundleTrigger) -> Result<(), ApplyError> {
    match t {
        BundleTrigger::Email {
            inbound_secret_ref, ..
        } if inbound_secret_ref.trim().is_empty() => {
            return Err(ApplyError::Invalid(format!(
                "email trigger on `{}` needs inbound_secret_ref (a secret name)",
                t.script()
            )));
        }
        BundleTrigger::Queue {
            queue_name,
            visibility_timeout_secs,
            ..
        } => {
            if queue_name.trim().is_empty() {
                return Err(ApplyError::Invalid("queue trigger needs queue_name".into()));
            }
            if let Some(v) = visibility_timeout_secs {
                // Parity with the interactive API: the floor is the dispatcher
                // tick/reclaim-cadence minimum (`triggers_api`), and the ceiling
                // matches `trigger_repo`'s queue check. Apply previously allowed
                // a [5, 29] floor the dashboard rejects.
                let min = crate::triggers_api::MIN_QUEUE_VISIBILITY_TIMEOUT_SECS;
                if !(min..=3600).contains(v) {
                    return Err(ApplyError::Invalid(format!(
                        "queue `{queue_name}`: visibility_timeout_secs must be in [{min}, 3600]"
                    )));
                }
            }
        }
        BundleTrigger::Cron {
            schedule, timezone, ..
        } => {
            crate::cron_scheduler::validate_schedule(schedule).map_err(|e| {
                ApplyError::Invalid(format!(
                    "cron trigger on `{}`: invalid schedule: {e}",
                    t.script()
                ))
            })?;
            crate::cron_scheduler::validate_timezone(timezone).map_err(|e| {
                ApplyError::Invalid(format!(
                    "cron trigger on `{}`: invalid timezone: {e}",
                    t.script()
                ))
            })?;
        }
        // Parity with the interactive trigger API, which rejects an empty
        // collection_glob (`create_{kv,docs,files}_trigger`).
        BundleTrigger::Kv {
            collection_glob, ..
        }
        | BundleTrigger::Docs {
            collection_glob, ..
        }
        | BundleTrigger::Files {
            collection_glob, ..
        } if collection_glob.trim().is_empty() => {
            return Err(ApplyError::Invalid(format!(
                "{} trigger on `{}` needs a non-empty collection_glob",
                t.kind_str(),
                t.script()
            )));
        }
        // Parity with `create_pubsub_trigger`'s topic-pattern check — otherwise
        // a malformed pattern is written and silently never matches at dispatch.
        BundleTrigger::Pubsub { topic_pattern, .. } => {
            picloud_shared::validate_topic_pattern(topic_pattern).map_err(|e| {
                ApplyError::Invalid(format!(
                    "pubsub trigger on `{}`: invalid topic_pattern: {e}",
                    t.script()
                ))
            })?;
        }
        _ => {}
    }
    Ok(())
}

/// Summary of what an `apply` changed.
#[derive(Debug, Default, Serialize)]
pub struct ApplyReport {
    pub scripts_created: u32,
    pub scripts_updated: u32,
    pub scripts_deleted: u32,
    pub routes_created: u32,
    pub routes_updated: u32,
    pub routes_deleted: u32,
    pub triggers_created: u32,
    pub triggers_deleted: u32,
    #[serde(default)]
    pub vars_created: u32,
    #[serde(default)]
    pub vars_updated: u32,
    #[serde(default)]
    pub vars_deleted: u32,
    #[serde(default)]
    pub extension_points_created: u32,
    #[serde(default)]
    pub extension_points_deleted: u32,
    #[serde(default)]
    pub collections_created: u32,
    #[serde(default)]
    pub collections_deleted: u32,
    #[serde(default)]
    pub suppressions_created: u32,
    #[serde(default)]
    pub suppressions_deleted: u32,
    #[serde(default)]
    pub workflows_created: u32,
    #[serde(default)]
    pub workflows_updated: u32,
    #[serde(default)]
    pub workflows_deleted: u32,
    #[serde(default)]
    pub interceptors_created: u32,
    #[serde(default)]
    pub interceptors_updated: u32,
    #[serde(default)]
    pub interceptors_deleted: u32,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub warnings: Vec<String>,
}

/// The key set of a name→id map, for passing the inherited target *names*
/// into `validate_bundle` without leaking the ids.
fn keys_set(m: &HashMap<String, ScriptId>) -> HashSet<String> {
    m.keys().cloned().collect()
}

/// A tree node resolved to its owner + loaded current state + computed plan,
/// ready to reconcile. `app_chain` is the ordered (nearest-first) ancestor
/// group ids for an app node (empty for a group node).
struct PreparedNode<'a> {
    owner: ApplyOwner,
    node: &'a TreeNode,
    current: CurrentState,
    current_names: HashMap<ScriptId, String>,
    plan: Plan,
    app_chain: Vec<GroupId>,
}

/// §6: a GROUP node whose slug does NOT yet exist on the server — it will be
/// CREATED (its declared parent inferred from directory nesting). Its `current`
/// is empty and `plan` is a full create; the plan path previews it, and the
/// apply path creates it in-tx before reconciling this content. Kept separate
/// from `PreparedNode` because it has no `GroupId` until created.
struct ToCreateGroup<'a> {
    node: &'a TreeNode,
    plan: Plan,
}

/// §6: the invariant context threaded through one `reconcile_group_structure_tx`
/// pass — the attach ceiling (id for the subtree check, slug for messages) and
/// the acting principal (for per-mutation RBAC). Groups the args the create /
/// reparent helpers share.
struct ReconcileCtx<'a> {
    attach_gid: Option<GroupId>,
    attach_slug: Option<&'a str>,
    principal: &'a Principal,
    mode: StructureMode,
}

/// FNV-1a over a set of already-sorted per-resource token parts — the same
/// hashing `state_token` uses, lifted to combine many nodes into one tree
/// token. Order-independent because the caller sorts `parts`.
fn combine_tokens(parts: &[String]) -> String {
    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
    for p in parts {
        for b in p.as_bytes() {
            h ^= u64::from(*b);
            h = h.wrapping_mul(0x0000_0100_0000_01b3);
        }
        h ^= u64::from(b'\n');
        h = h.wrapping_mul(0x0000_0100_0000_01b3);
    }
    format!("{h:016x}")
}

fn resolve_script(
    name_to_id: &HashMap<String, ScriptId>,
    name: &str,
) -> Result<ScriptId, ApplyError> {
    name_to_id
        .get(&name.to_lowercase())
        .copied()
        .ok_or_else(|| ApplyError::Invalid(format!("binding references unknown script `{name}`")))
}

async fn insert_bundle_route(
    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    owner: ScriptOwner,
    script_id: ScriptId,
    br: &BundleRoute,
) -> Result<(), ApplyError> {
    let new = NewRoute {
        owner,
        script_id,
        host_kind: br.host_kind,
        host: br.host.clone(),
        host_param_name: br.host_param_name.clone(),
        path_kind: br.path_kind,
        path: br.path.clone(),
        method: br.method.clone(),
        dispatch_mode: br.dispatch_mode,
        enabled: br.enabled,
        // §11 tail: a sealed group route template is non-suppressible. Rejected
        // on an app owner earlier in `validate_bundle_for`.
        sealed: br.sealed,
    };
    insert_route_tx(tx, &new).await.map_err(map_repo)?;
    Ok(())
}

fn bundle_trigger_details(bt: &BundleTrigger, default_visibility: u32) -> TriggerDetails {
    match bt {
        BundleTrigger::Kv {
            collection_glob,
            ops,
            ..
        } => TriggerDetails::Kv {
            collection_glob: collection_glob.clone(),
            ops: ops.clone(),
        },
        BundleTrigger::Docs {
            collection_glob,
            ops,
            ..
        } => TriggerDetails::Docs {
            collection_glob: collection_glob.clone(),
            ops: ops.clone(),
        },
        BundleTrigger::Files {
            collection_glob,
            ops,
            ..
        } => TriggerDetails::Files {
            collection_glob: collection_glob.clone(),
            ops: ops.clone(),
        },
        BundleTrigger::Cron {
            schedule, timezone, ..
        } => TriggerDetails::Cron {
            schedule: schedule.clone(),
            timezone: timezone.clone(),
            last_fired_at: None,
        },
        BundleTrigger::Pubsub { topic_pattern, .. } => TriggerDetails::Pubsub {
            topic_pattern: topic_pattern.clone(),
        },
        BundleTrigger::Queue {
            queue_name,
            visibility_timeout_secs,
            ..
        } => TriggerDetails::Queue {
            queue_name: queue_name.clone(),
            visibility_timeout_secs: visibility_timeout_secs.unwrap_or(default_visibility),
            last_fired_at: None,
        },
        BundleTrigger::Email { .. } => unreachable!("email handled separately"),
    }
}

fn map_repo(e: ScriptRepositoryError) -> ApplyError {
    match e {
        ScriptRepositoryError::Conflict(m) => ApplyError::Invalid(m),
        ScriptRepositoryError::NotFound(_) => {
            ApplyError::Invalid("a referenced resource was not found".into())
        }
        ScriptRepositoryError::Db(e) => ApplyError::Backend(e.to_string()),
    }
}

fn map_trig(e: TriggerRepoError) -> ApplyError {
    match e {
        TriggerRepoError::Invalid(m) => ApplyError::Invalid(m),
        TriggerRepoError::NotFound(_) => ApplyError::Invalid("trigger not found".into()),
        TriggerRepoError::Db(e) => ApplyError::Backend(e.to_string()),
    }
}

/// A stable fingerprint of an app's live state, covering exactly what the
/// reconcile diff keys on: script identity + write-counter (`version` bumps on
/// any script edit), route identity + binding/attrs, trigger semantic identity
/// (via `current_trigger_identity`, so dead-letter triggers — which the diff
/// ignores — are excluded), and secret names. Any out-of-band change to those
/// flips the token, so `plan`-then-`apply` can detect the app moving underneath.
///
/// Deliberately mirrors the diff's inputs so a mismatch means the *reviewed
/// plan* would now differ — not merely that some unrelated row changed.
///
/// Uses FNV-1a (deterministic across process restarts, unlike `DefaultHasher`'s
/// per-process seed) so a token stored by `pic plan` still matches on a later
/// `pic apply`. A hash collision can only ever yield a false "unchanged", which
/// is the same risk class as not checking at all — never a false refusal.
#[must_use]
pub fn state_token(current: &CurrentState) -> String {
    state_token_with_names(current, &HashMap::new())
}

/// `state_token` with `current_names` (group-bound binding ids → names, Phase
/// 4) so a trigger bound to an inherited group script contributes to the
/// fingerprint. Without it such a trigger is silently excluded and the
/// bound-plan (StateMoved) check has a false-negative for that binding class.
fn state_token_with_names(
    current: &CurrentState,
    current_names: &HashMap<ScriptId, String>,
) -> String {
    let mut parts: Vec<String> = Vec::with_capacity(
        current.scripts.len()
            + current.routes.len()
            + current.triggers.len()
            + current.secret_names.len()
            + current.vars.len()
            + current.extension_point_names.len()
            + current.collections.len(),
    );
    for s in &current.scripts {
        parts.push(format!(
            "s|{}|{}|{}",
            s.name.to_lowercase(),
            s.version,
            s.enabled
        ));
    }
    for r in &current.routes {
        parts.push(format!(
            "r|{}|{:?}|{:?}|{}|{}",
            route_key(
                r.method.as_deref(),
                r.host_kind,
                &r.host,
                r.path_kind,
                &r.path
            ),
            r.script_id,
            r.dispatch_mode,
            r.host_param_name.as_deref().unwrap_or(""),
            r.enabled,
        ));
    }
    // Mirror exactly what the trigger diff keys on: `current_trigger_identity`
    // excludes dead-letter triggers (the diff ignores them) and keys on the
    // semantic identity — NOT raw id/enabled. Hashing dead-letter rows or the
    // (currently inert) `enabled` flag would force a false refusal over a
    // change the manifest can't represent.
    let script_name_by_id: HashMap<ScriptId, String> = current
        .scripts
        .iter()
        .map(|s| (s.id, s.name.clone()))
        .chain(current_names.iter().map(|(id, n)| (*id, n.clone())))
        .collect();
    for t in &current.triggers {
        if let Some(id) = current_trigger_identity(t, &script_name_by_id) {
            parts.push(format!("t|{id}"));
        }
    }
    for n in &current.secret_names {
        parts.push(format!("k|{n}"));
    }
    // Vars are value-sensitive (the value lives in the manifest), so the token
    // moves when a value changes — the bound-plan check then catches an edit
    // between plan and apply. `to_string` on a serde_json::Value is stable
    // (serde_json sorts object keys by default).
    for (key, value) in &current.vars {
        parts.push(format!(
            "v|{key}|{}",
            serde_json::to_string(value).unwrap_or_default()
        ));
    }
    // Extension-point markers are name-only, like secret names.
    for n in &current.extension_point_names {
        parts.push(format!("ep|{n}"));
    }
    // Shared group-collection markers carry a kind (§11.6), so a kind change
    // moves the token.
    for (name, kind) in &current.collections {
        parts.push(format!("coll|{kind}|{name}"));
    }
    // Order-independent: sort the per-resource tokens before hashing.
    parts.sort_unstable();
    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
    for p in &parts {
        for b in p.as_bytes() {
            h ^= u64::from(*b);
            h = h.wrapping_mul(0x0000_0100_0000_01b3);
        }
        h ^= u64::from(b'\n');
        h = h.wrapping_mul(0x0000_0100_0000_01b3);
    }
    format!("{h:016x}")
}

/// Per-node advisory lock key, namespaced so it can't collide with the
/// queue-trigger lock space. App and group ids live in disjoint UUID spaces and
/// are tagged by owner kind, so an app and a group never share a key.
fn apply_lock_key(owner: ApplyOwner) -> i64 {
    use std::hash::{Hash, Hasher};
    let mut h = std::collections::hash_map::DefaultHasher::new();
    "picloud-apply".hash(&mut h);
    match owner {
        ApplyOwner::App(a) => {
            "app".hash(&mut h);
            a.into_inner().hash(&mut h);
        }
        ApplyOwner::Group(g) => {
            "group".hash(&mut h);
            g.into_inner().hash(&mut h);
        }
    }
    i64::from_ne_bytes(h.finish().to_ne_bytes())
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::Utc;

    fn script(name: &str, source: &str) -> Script {
        Script {
            id: ScriptId::from(uuid::Uuid::new_v4()),
            app_id: Some(AppId::from(uuid::Uuid::nil())),
            group_id: None,
            name: name.to_string(),
            description: None,
            version: 1,
            source: source.to_string(),
            kind: ScriptKind::Endpoint,
            timeout_seconds: 30,
            sandbox: ScriptSandbox::empty(),
            memory_limit_mb: 256,
            enabled: true,
            created_at: Utc::now(),
            updated_at: Utc::now(),
        }
    }

    fn bundle_script(name: &str, source: &str) -> BundleScript {
        BundleScript {
            name: name.to_string(),
            source: source.to_string(),
            kind: ScriptKind::Endpoint,
            description: None,
            timeout_seconds: None,
            memory_limit_mb: None,
            sandbox: None,
            enabled: true,
        }
    }

    #[test]
    fn empty_app_is_all_create() {
        let current = CurrentState::default();
        let bundle = Bundle {
            scripts: vec![bundle_script("a", "1")],
            routes: vec![],
            triggers: vec![],
            secrets: vec!["S".into()],
            vars: std::collections::BTreeMap::new(),
            extension_points: Vec::new(),
            collections: Vec::new(),
            suppress_triggers: Vec::new(),
            suppress_routes: Vec::new(),
            workflows: Vec::new(),
            interceptors: Vec::new(),
        };
        let plan = compute_diff(&current, &bundle);
        assert_eq!(plan.scripts.len(), 1);
        assert_eq!(plan.scripts[0].op, Op::Create);
        assert_eq!(plan.secrets[0].op, Op::Create); // declared-but-unset
        assert!(!plan.is_noop());
    }

    #[test]
    fn identical_is_all_noop() {
        let s = script("a", "let x = 1;");
        let current = CurrentState {
            scripts: vec![s.clone()],
            secret_names: vec!["S".into()],
            ..CurrentState::default()
        };
        let bundle = Bundle {
            scripts: vec![bundle_script("a", "let x = 1;")],
            routes: vec![],
            triggers: vec![],
            secrets: vec!["S".into()],
            vars: std::collections::BTreeMap::new(),
            extension_points: Vec::new(),
            collections: Vec::new(),
            suppress_triggers: Vec::new(),
            suppress_routes: Vec::new(),
            workflows: Vec::new(),
            interceptors: Vec::new(),
        };
        let plan = compute_diff(&current, &bundle);
        assert!(plan.is_noop(), "expected all no-op, got {plan:?}");
    }

    #[test]
    fn changed_source_is_update() {
        let current = CurrentState {
            scripts: vec![script("a", "old")],
            ..CurrentState::default()
        };
        let bundle = Bundle {
            scripts: vec![bundle_script("a", "new")],
            routes: vec![],
            triggers: vec![],
            secrets: vec![],
            vars: std::collections::BTreeMap::new(),
            extension_points: Vec::new(),
            collections: Vec::new(),
            suppress_triggers: Vec::new(),
            suppress_routes: Vec::new(),
            workflows: Vec::new(),
            interceptors: Vec::new(),
        };
        let plan = compute_diff(&current, &bundle);
        assert_eq!(plan.scripts[0].op, Op::Update);
    }

    #[test]
    fn missing_from_bundle_is_delete() {
        let current = CurrentState {
            scripts: vec![script("gone", "x")],
            secret_names: vec!["OLD".into()],
            ..CurrentState::default()
        };
        let bundle = Bundle {
            scripts: vec![],
            routes: vec![],
            triggers: vec![],
            secrets: vec![],
            vars: std::collections::BTreeMap::new(),
            extension_points: Vec::new(),
            collections: Vec::new(),
            suppress_triggers: Vec::new(),
            suppress_routes: Vec::new(),
            workflows: Vec::new(),
            interceptors: Vec::new(),
        };
        let plan = compute_diff(&current, &bundle);
        assert_eq!(plan.scripts[0].op, Op::Delete);
        assert_eq!(plan.secrets[0].op, Op::Delete);
    }

    #[test]
    fn route_rebind_is_update() {
        let s = script("a", "x");
        let sid = s.id;
        let route = Route {
            id: uuid::Uuid::new_v4(),
            app_id: Some(AppId::from(uuid::Uuid::nil())),
            group_id: None,
            script_id: sid,
            host_kind: HostKind::Any,
            host: String::new(),
            host_param_name: None,
            path_kind: PathKind::Exact,
            path: "/p".into(),
            method: Some("POST".into()),
            dispatch_mode: DispatchMode::Sync,
            enabled: true,
            sealed: false,
            created_at: Utc::now(),
        };
        let current = CurrentState {
            scripts: vec![s, script("b", "y")],
            routes: vec![route],
            ..CurrentState::default()
        };
        // Same tuple, bound to a different script → update.
        let bundle = Bundle {
            scripts: vec![],
            routes: vec![BundleRoute {
                script: "b".into(),
                method: Some("POST".into()),
                host_kind: HostKind::Any,
                host: String::new(),
                host_param_name: None,
                path_kind: PathKind::Exact,
                path: "/p".into(),
                dispatch_mode: DispatchMode::Sync,
                enabled: true,
                sealed: false,
            }],
            triggers: vec![],
            secrets: vec![],
            vars: std::collections::BTreeMap::new(),
            extension_points: Vec::new(),
            collections: Vec::new(),
            suppress_triggers: Vec::new(),
            suppress_routes: Vec::new(),
            workflows: Vec::new(),
            interceptors: Vec::new(),
        };
        let plan = compute_diff(&current, &bundle);
        assert_eq!(plan.routes[0].op, Op::Update);
    }

    fn empty_bundle() -> Bundle {
        Bundle {
            scripts: vec![],
            routes: vec![],
            triggers: vec![],
            secrets: vec![],
            vars: std::collections::BTreeMap::new(),
            extension_points: Vec::new(),
            collections: Vec::new(),
            suppress_triggers: Vec::new(),
            suppress_routes: Vec::new(),
            workflows: Vec::new(),
            interceptors: Vec::new(),
        }
    }

    fn trig(script_id: ScriptId, details: TriggerDetails) -> Trigger {
        use crate::trigger_config::BackoffShape;
        use crate::trigger_repo::TriggerKind;
        let kind = match &details {
            TriggerDetails::Cron { .. } => TriggerKind::Cron,
            TriggerDetails::DeadLetter { .. } => TriggerKind::DeadLetter,
            TriggerDetails::Queue { .. } => TriggerKind::Queue,
            // Other kinds aren't distinguished by these tests (the diff keys
            // on `details`, not `kind`).
            _ => TriggerKind::Kv,
        };
        Trigger {
            id: TriggerId::from(uuid::Uuid::new_v4()),
            app_id: Some(AppId::from(uuid::Uuid::nil())),
            group_id: None,
            script_id,
            name: "t".into(),
            kind,
            enabled: true,
            sealed: false,
            shared: false,
            materialized: false,
            dispatch_mode: TriggerDispatchMode::Async,
            retry_max_attempts: 3,
            retry_backoff: BackoffShape::Exponential,
            retry_base_ms: 1000,
            registered_by_principal: AdminUserId::from(uuid::Uuid::nil()),
            created_at: Utc::now(),
            updated_at: Utc::now(),
            details,
        }
    }

    #[test]
    fn script_update_only_on_real_change() {
        // `script("a", ..)` defaults to timeout 30, mem 256, empty sandbox.
        let current = CurrentState {
            scripts: vec![script("a", "let x = 1;")],
            ..CurrentState::default()
        };
        // Same source, optional fields unset → NoOp ("None = leave as-is").
        let mut b = empty_bundle();
        b.scripts = vec![bundle_script("a", "let x = 1;")];
        assert_eq!(compute_diff(&current, &b).scripts[0].op, Op::NoOp);
        // Explicit timeout that MATCHES current → still NoOp.
        let mut same = empty_bundle();
        let mut s = bundle_script("a", "let x = 1;");
        s.timeout_seconds = Some(30);
        same.scripts = vec![s];
        assert_eq!(compute_diff(&current, &same).scripts[0].op, Op::NoOp);
        // Explicit timeout that DIFFERS → Update.
        let mut chg = empty_bundle();
        let mut s2 = bundle_script("a", "let x = 1;");
        s2.timeout_seconds = Some(99);
        chg.scripts = vec![s2];
        assert_eq!(compute_diff(&current, &chg).scripts[0].op, Op::Update);
        // kind change → Update.
        let mut chg2 = empty_bundle();
        let mut s3 = bundle_script("a", "let x = 1;");
        s3.kind = ScriptKind::Module;
        chg2.scripts = vec![s3];
        assert_eq!(compute_diff(&current, &chg2).scripts[0].op, Op::Update);
    }

    #[test]
    fn description_is_sparse() {
        // An omitted (`None`) description must mean "leave as-is", matching
        // the other optional fields — not "clear it".
        let mut cur = script("a", "let x = 1;");
        cur.description = Some("kept".into());
        let current = CurrentState {
            scripts: vec![cur],
            ..CurrentState::default()
        };
        // Omitted → NoOp (leave the stored description alone).
        let mut omit = empty_bundle();
        omit.scripts = vec![bundle_script("a", "let x = 1;")];
        assert_eq!(compute_diff(&current, &omit).scripts[0].op, Op::NoOp);
        // Same explicit value → NoOp.
        let mut same = empty_bundle();
        let mut s = bundle_script("a", "let x = 1;");
        s.description = Some("kept".into());
        same.scripts = vec![s];
        assert_eq!(compute_diff(&current, &same).scripts[0].op, Op::NoOp);
        // Different explicit value → Update.
        let mut chg = empty_bundle();
        let mut s2 = bundle_script("a", "let x = 1;");
        s2.description = Some("changed".into());
        chg.scripts = vec![s2];
        assert_eq!(compute_diff(&current, &chg).scripts[0].op, Op::Update);
    }

    #[test]
    fn trigger_shape_validation_matches_interactive_api() {
        // Empty collection_glob is rejected (kv/docs/files).
        assert!(validate_trigger_shape(&BundleTrigger::Kv {
            script: "h".into(),
            collection_glob: "  ".into(),
            ops: vec![],
            dispatch_mode: None,
            retry_max_attempts: None,
            sealed: false,
            shared: false,
        })
        .is_err());
        // A non-empty glob passes.
        assert!(validate_trigger_shape(&BundleTrigger::Docs {
            script: "h".into(),
            collection_glob: "users".into(),
            ops: vec![],
            dispatch_mode: None,
            retry_max_attempts: None,
            sealed: false,
            shared: false,
        })
        .is_ok());
        // A malformed pubsub topic_pattern (mid-pattern wildcard) is rejected;
        // a valid one passes.
        assert!(validate_trigger_shape(&BundleTrigger::Pubsub {
            script: "h".into(),
            topic_pattern: "user.*.created".into(),
            dispatch_mode: None,
            retry_max_attempts: None,
            sealed: false,
            shared: false,
        })
        .is_err());
        assert!(validate_trigger_shape(&BundleTrigger::Pubsub {
            script: "h".into(),
            topic_pattern: "orders.created".into(),
            dispatch_mode: None,
            retry_max_attempts: None,
            sealed: false,
            shared: false,
        })
        .is_ok());
        // Queue visibility floor matches the interactive API (>= 30): a value
        // below the floor is rejected; the floor itself and `None` are ok.
        let queue = |vis| BundleTrigger::Queue {
            script: "h".into(),
            queue_name: "jobs".into(),
            visibility_timeout_secs: vis,
            dispatch_mode: None,
            retry_max_attempts: None,
            shared: false,
        };
        assert!(validate_trigger_shape(&queue(Some(10))).is_err());
        assert!(validate_trigger_shape(&queue(Some(
            crate::triggers_api::MIN_QUEUE_VISIBILITY_TIMEOUT_SECS
        )))
        .is_ok());
        assert!(validate_trigger_shape(&queue(None)).is_ok());
        // Empty email inbound_secret_ref is rejected.
        assert!(validate_trigger_shape(&BundleTrigger::Email {
            script: "h".into(),
            inbound_secret_ref: "  ".into(),
            dispatch_mode: None,
            retry_max_attempts: None,
        })
        .is_err());
    }

    #[test]
    fn state_token_is_stable_order_independent_and_sensitive() {
        let a = script("a", "x"); // version 1
        let b = script("b", "y");
        let base = CurrentState {
            scripts: vec![a.clone(), b.clone()],
            secret_names: vec!["S".into()],
            ..CurrentState::default()
        };
        // Deterministic + order-independent.
        let reordered = CurrentState {
            scripts: vec![b.clone(), a.clone()],
            secret_names: vec!["S".into()],
            ..CurrentState::default()
        };
        assert_eq!(state_token(&base), state_token(&reordered));
        // A script edit bumps `version`, which must flip the token even if the
        // source-by-name set is otherwise unchanged (out-of-band redeploy).
        let mut a2 = a.clone();
        a2.version += 1;
        let bumped = CurrentState {
            scripts: vec![a2, b.clone()],
            secret_names: vec!["S".into()],
            ..CurrentState::default()
        };
        assert_ne!(state_token(&base), state_token(&bumped));
        // Adding a secret name flips it too.
        let with_secret = CurrentState {
            scripts: vec![a, b],
            secret_names: vec!["S".into(), "T".into()],
            ..CurrentState::default()
        };
        assert_ne!(state_token(&base), state_token(&with_secret));
    }

    #[test]
    fn diff_vars_create_update_noop_delete() {
        let current = CurrentState {
            vars: vec![
                ("region".into(), serde_json::json!("eu")),
                ("keep".into(), serde_json::json!(1)),
                ("stale".into(), serde_json::json!(true)),
            ],
            ..CurrentState::default()
        };
        let mut vars = std::collections::BTreeMap::new();
        vars.insert("region".to_string(), serde_json::json!("us")); // value changed
        vars.insert("keep".to_string(), serde_json::json!(1)); // identical
        vars.insert("new".to_string(), serde_json::json!("x")); // not live
                                                                // "stale" is live but undeclared → Delete.
        let bundle = Bundle {
            vars,
            ..empty_bundle()
        };
        let plan = compute_diff(&current, &bundle);
        let op = |k: &str| plan.vars.iter().find(|c| c.key == k).unwrap().op;
        assert_eq!(op("region"), Op::Update);
        assert_eq!(op("keep"), Op::NoOp);
        assert_eq!(op("new"), Op::Create);
        assert_eq!(op("stale"), Op::Delete);
        assert!(!plan.is_noop());
    }

    #[test]
    fn state_token_is_sensitive_to_var_values_and_order_independent() {
        let base = CurrentState {
            vars: vec![("region".into(), serde_json::json!("eu"))],
            ..CurrentState::default()
        };
        // A value change must flip the token (covers the bound-plan check for
        // an out-of-band `pic vars set` between plan and apply).
        let changed = CurrentState {
            vars: vec![("region".into(), serde_json::json!("us"))],
            ..CurrentState::default()
        };
        assert_ne!(state_token(&base), state_token(&changed));
        // Order-independent across multiple vars.
        let ab = CurrentState {
            vars: vec![
                ("a".into(), serde_json::json!(1)),
                ("b".into(), serde_json::json!(2)),
            ],
            ..CurrentState::default()
        };
        let ba = CurrentState {
            vars: vec![
                ("b".into(), serde_json::json!(2)),
                ("a".into(), serde_json::json!(1)),
            ],
            ..CurrentState::default()
        };
        assert_eq!(state_token(&ab), state_token(&ba));
    }

    #[test]
    fn state_token_ignores_dead_letter_triggers() {
        // The diff ignores dead-letter triggers (not manifest-representable),
        // so the token must too — otherwise an out-of-band dead-letter change
        // would force a spurious `--force`.
        let s = script("h", "x");
        let sid = s.id;
        let base = CurrentState {
            scripts: vec![s.clone()],
            ..CurrentState::default()
        };
        let with_dl = CurrentState {
            scripts: vec![s],
            triggers: vec![trig(
                sid,
                TriggerDetails::DeadLetter {
                    source_filter: None,
                    trigger_id_filter: None,
                    script_id_filter: None,
                },
            )],
            ..CurrentState::default()
        };
        assert_eq!(
            state_token(&base),
            state_token(&with_dl),
            "dead-letter triggers must not affect the token"
        );
    }

    #[test]
    fn enabled_is_declarative_and_diffed() {
        // A live-active script the manifest marks disabled → Update; the token
        // is sensitive to the flag so an out-of-band toggle is detectable.
        let cur = script("a", "let x = 1;"); // enabled: true
        let base = CurrentState {
            scripts: vec![cur.clone()],
            ..CurrentState::default()
        };
        let mut disable = empty_bundle();
        let mut bs = bundle_script("a", "let x = 1;");
        bs.enabled = false;
        disable.scripts = vec![bs];
        assert_eq!(compute_diff(&base, &disable).scripts[0].op, Op::Update);

        let mut flipped = cur;
        flipped.enabled = false;
        let toggled = CurrentState {
            scripts: vec![flipped],
            ..CurrentState::default()
        };
        assert_ne!(
            state_token(&base),
            state_token(&toggled),
            "token must flip on an enabled change"
        );
    }

    #[test]
    fn warns_on_enabled_binding_to_disabled_script() {
        let mut b = empty_bundle();
        let mut s = bundle_script("h", "x");
        s.enabled = false;
        b.scripts = vec![s];
        b.routes = vec![BundleRoute {
            script: "h".into(),
            method: None,
            host_kind: HostKind::Any,
            host: String::new(),
            host_param_name: None,
            path_kind: PathKind::Exact,
            path: "/h".into(),
            dispatch_mode: DispatchMode::Sync,
            enabled: true,
            sealed: false,
        }];
        let w = disabled_target_warnings(&b);
        assert!(
            w.iter().any(|m| m.contains("will 404")),
            "expected a 404 reachability warning: {w:?}"
        );
        // No warning when the script is active.
        b.scripts[0].enabled = true;
        assert!(disabled_target_warnings(&b).is_empty());
    }

    #[test]
    fn warns_on_unreachable_endpoint() {
        // §4.7: an enabled endpoint with no route and no trigger is flagged.
        let mut b = empty_bundle();
        b.scripts = vec![bundle_script("orphan", "x")];
        let w = unreachable_endpoint_warnings(&b);
        assert!(
            w.iter().any(|m| m.contains("no route or trigger")),
            "expected an unreachable-endpoint warning: {w:?}"
        );
        // Bound by a route → no warning.
        b.routes = vec![BundleRoute {
            script: "orphan".into(),
            method: None,
            host_kind: HostKind::Any,
            host: String::new(),
            host_param_name: None,
            path_kind: PathKind::Exact,
            path: "/o".into(),
            dispatch_mode: DispatchMode::Sync,
            enabled: true,
            sealed: false,
        }];
        assert!(unreachable_endpoint_warnings(&b).is_empty());
        // A module is exempt even with no binding.
        let mut m = empty_bundle();
        let mut lib = bundle_script("lib", "x");
        lib.kind = ScriptKind::Module;
        m.scripts = vec![lib];
        assert!(unreachable_endpoint_warnings(&m).is_empty());
    }

    #[test]
    fn trigger_diff_create_noop_delete() {
        let s = script("h", "x");
        let sid = s.id;
        let cron = |sched: &str| TriggerDetails::Cron {
            schedule: sched.into(),
            timezone: "UTC".into(),
            last_fired_at: None,
        };
        let current = CurrentState {
            scripts: vec![s],
            triggers: vec![trig(sid, cron("0 0 * * * *"))],
            ..CurrentState::default()
        };
        let bundle_cron = |sched: &str| BundleTrigger::Cron {
            script: "h".into(),
            schedule: sched.into(),
            timezone: "UTC".into(),
            dispatch_mode: None,
            retry_max_attempts: None,
        };
        // Same schedule → NoOp.
        let mut same = empty_bundle();
        same.triggers = vec![bundle_cron("0 0 * * * *")];
        let p = compute_diff(&current, &same);
        assert!(p.triggers.iter().all(|c| c.op == Op::NoOp), "{p:?}");
        // Removed → Delete.
        let p = compute_diff(&current, &empty_bundle());
        assert_eq!(p.triggers.len(), 1);
        assert_eq!(p.triggers[0].op, Op::Delete);
        // Different schedule → Create (new) + Delete (old).
        let mut chg = empty_bundle();
        chg.triggers = vec![bundle_cron("0 5 * * * *")];
        let p = compute_diff(&current, &chg);
        assert!(p.triggers.iter().any(|c| c.op == Op::Create), "{p:?}");
        assert!(p.triggers.iter().any(|c| c.op == Op::Delete), "{p:?}");
    }

    #[test]
    fn kv_ops_order_insensitive() {
        let s = script("h", "x");
        let sid = s.id;
        let current = CurrentState {
            scripts: vec![s],
            triggers: vec![trig(
                sid,
                TriggerDetails::Kv {
                    collection_glob: "users".into(),
                    ops: vec![KvEventOp::Update, KvEventOp::Insert],
                },
            )],
            ..CurrentState::default()
        };
        let mut b = empty_bundle();
        b.triggers = vec![BundleTrigger::Kv {
            script: "h".into(),
            collection_glob: "users".into(),
            ops: vec![KvEventOp::Insert, KvEventOp::Update], // reversed
            dispatch_mode: None,
            retry_max_attempts: None,
            sealed: false,
            shared: false,
        }];
        let p = compute_diff(&current, &b);
        assert!(
            p.triggers.iter().all(|c| c.op == Op::NoOp),
            "ops order must not matter: {p:?}"
        );
    }

    #[test]
    fn sealed_is_part_of_trigger_identity() {
        // §11 tail: toggling `sealed` on a trigger template is a definitional
        // change — it must re-diff (Create the sealed row + Delete the old),
        // not silently NoOp. A current UNSEALED kv trigger vs a desired SEALED
        // one at the same script/glob/ops.
        let s = script("h", "x");
        let sid = s.id;
        let mut cur = trig(
            sid,
            TriggerDetails::Kv {
                collection_glob: "users".into(),
                ops: vec![KvEventOp::Insert],
            },
        );
        cur.sealed = false;
        let current = CurrentState {
            scripts: vec![s],
            triggers: vec![cur],
            ..CurrentState::default()
        };
        let mut b = empty_bundle();
        b.triggers = vec![BundleTrigger::Kv {
            script: "h".into(),
            collection_glob: "users".into(),
            ops: vec![KvEventOp::Insert],
            dispatch_mode: None,
            retry_max_attempts: None,
            sealed: true,
            shared: false,
        }];
        let p = compute_diff(&current, &b);
        assert!(
            p.triggers.iter().any(|c| c.op == Op::Create),
            "sealing must Create the sealed row: {p:?}"
        );
        assert!(
            p.triggers.iter().any(|c| c.op == Op::Delete),
            "sealing must Delete the stale unsealed row: {p:?}"
        );
    }

    #[test]
    fn sealed_toggle_updates_route() {
        // §11 tail: a route template carries `sealed`; toggling it is an Update
        // (routes have an Update op, unlike triggers). Same binding tuple, only
        // `sealed` differs.
        let s = script("h", "x");
        let sid = s.id;
        let mut route = Route {
            id: uuid::Uuid::new_v4(),
            app_id: None,
            group_id: Some(GroupId::from(uuid::Uuid::nil())),
            script_id: sid,
            host_kind: HostKind::Any,
            host: String::new(),
            host_param_name: None,
            path_kind: PathKind::Exact,
            path: "/h".into(),
            method: None,
            dispatch_mode: DispatchMode::Sync,
            enabled: true,
            sealed: false,
            created_at: Utc::now(),
        };
        route.sealed = false;
        let current = CurrentState {
            scripts: vec![s],
            routes: vec![route],
            ..CurrentState::default()
        };
        let mut b = empty_bundle();
        b.routes = vec![BundleRoute {
            script: "h".into(),
            method: None,
            host_kind: HostKind::Any,
            host: String::new(),
            host_param_name: None,
            path_kind: PathKind::Exact,
            path: "/h".into(),
            dispatch_mode: DispatchMode::Sync,
            enabled: true,
            sealed: true,
        }];
        let p = compute_diff(&current, &b);
        assert!(
            p.routes.iter().any(|c| c.op == Op::Update),
            "toggling sealed on a route must Update it: {p:?}"
        );
    }

    #[test]
    fn dead_letter_trigger_is_ignored_by_diff() {
        // Dead-letter triggers can't be expressed in the manifest, so the
        // diff must neither match nor prune them.
        let s = script("h", "x");
        let sid = s.id;
        let current = CurrentState {
            scripts: vec![s],
            triggers: vec![trig(
                sid,
                TriggerDetails::DeadLetter {
                    source_filter: None,
                    trigger_id_filter: None,
                    script_id_filter: None,
                },
            )],
            ..CurrentState::default()
        };
        let p = compute_diff(&current, &empty_bundle());
        assert!(p.triggers.is_empty(), "dead-letter must be ignored: {p:?}");
    }

    #[test]
    fn email_trigger_is_never_pruned() {
        // `pull` can't represent email triggers (the server keeps the sealed
        // secret, not the `inbound_secret_ref`), so their absence from a
        // bundle is ambiguous — the diff must not emit a Delete for them.
        let s = script("h", "x");
        let sid = s.id;
        let current = CurrentState {
            scripts: vec![s],
            triggers: vec![trig(
                sid,
                TriggerDetails::Email {
                    has_inbound_secret: true,
                },
            )],
            ..CurrentState::default()
        };
        let p = compute_diff(&current, &empty_bundle());
        assert!(
            !p.triggers.iter().any(|c| c.op == Op::Delete),
            "email trigger must not be pruned: {p:?}"
        );
    }

    #[test]
    fn queue_rebind_is_noop_documented_limitation() {
        // Queue identity is `queue|{queue_name}` (script-independent), so a
        // rebind to a different script diffs as NoOp — a known limitation.
        let a = script("a", "x");
        let aid = a.id;
        let current = CurrentState {
            scripts: vec![a, script("b", "y")],
            triggers: vec![trig(
                aid,
                TriggerDetails::Queue {
                    queue_name: "jobs".into(),
                    visibility_timeout_secs: 30,
                    last_fired_at: None,
                },
            )],
            ..CurrentState::default()
        };
        let mut b = empty_bundle();
        b.triggers = vec![BundleTrigger::Queue {
            script: "b".into(), // rebind a→b
            queue_name: "jobs".into(),
            visibility_timeout_secs: None,
            dispatch_mode: None,
            retry_max_attempts: None,
            shared: false,
        }];
        let p = compute_diff(&current, &b);
        assert!(
            p.triggers.iter().all(|c| c.op == Op::NoOp),
            "queue identity is queue_name-only: {p:?}"
        );
    }

    // ------------------------------------------------------------------------
    // §7 ownership — the pure claim policy (the whole decision, DB-free).
    // ------------------------------------------------------------------------

    #[test]
    fn group_claim_unclaimed_claims_only_with_a_project() {
        let p = ProjectId::new();
        // First apply with a project claims; without a project it stays UI-owned.
        assert_eq!(
            decide_group_claim(None, Some(p), false),
            GroupClaimAction::Claim(p)
        );
        assert_eq!(
            decide_group_claim::<ProjectId>(None, None, false),
            GroupClaimAction::Noop
        );
    }

    #[test]
    fn group_claim_by_owner_is_a_noop() {
        let p = ProjectId::new();
        assert_eq!(
            decide_group_claim(Some((p, "acme".into())), Some(p), false),
            GroupClaimAction::Noop
        );
    }

    #[test]
    fn group_claim_foreign_owner_conflicts_unless_takeover() {
        let owner = ProjectId::new();
        let me = ProjectId::new();
        // A different project is refused without --takeover…
        assert!(matches!(
            decide_group_claim(Some((owner, "platform".into())), Some(me), false),
            GroupClaimAction::Conflict(_)
        ));
        // …and reassigns with it (the authz gate is applied at the call site).
        assert_eq!(
            decide_group_claim(Some((owner, "platform".into())), Some(me), true),
            GroupClaimAction::Takeover(me)
        );
        // A no-project apply must not silently write into a claimed node.
        assert!(matches!(
            decide_group_claim(Some((owner, "platform".into())), None, false),
            GroupClaimAction::Conflict(_)
        ));
    }

    #[test]
    fn app_owner_matches_nearest_or_conflicts() {
        let owner = ProjectId::new();
        let me = ProjectId::new();
        // An unclaimed subtree is open (backward-compatible).
        assert!(decide_app_owner(None, Some(me)).is_ok());
        assert!(decide_app_owner::<ProjectId>(None, None).is_ok());
        // Same project as the nearest claimed ancestor → ok.
        assert!(decide_app_owner(Some((owner, "x".into())), Some(owner)).is_ok());
        // A different project, or none, under a claimed subtree → conflict.
        assert!(decide_app_owner(Some((owner, "platform".into())), Some(me)).is_err());
        assert!(decide_app_owner(Some((owner, "platform".into())), None).is_err());
    }

    #[test]
    fn ownership_preview_reflects_claim_owned_conflict() {
        // Group node: unclaimed + project → claim; already ours → owned; foreign
        // → conflict (owner named); no project → unclaimed.
        assert_eq!(preview_ownership(true, None, Some("p")).action, "claim");
        assert_eq!(preview_ownership(true, None, None).action, "unclaimed");
        let owned = preview_ownership(true, Some("p"), Some("p"));
        assert_eq!(owned.action, "owned");
        assert_eq!(owned.owner.as_deref(), Some("p"));
        let conflict = preview_ownership(true, Some("plat"), Some("teamb"));
        assert_eq!(conflict.action, "conflict");
        assert_eq!(conflict.owner.as_deref(), Some("plat"));
        // App node never claims — unclaimed subtree stays unclaimed.
        assert_eq!(
            preview_ownership(false, None, Some("p")).action,
            "unclaimed"
        );
        assert_eq!(
            preview_ownership(false, Some("plat"), Some("teamb")).action,
            "conflict"
        );
    }

    #[test]
    fn project_decl_gates_only_confirm_required_envs() {
        // §3 M3: the server re-derives the gate from ProjectDecl.environments.
        let mut environments = std::collections::BTreeMap::new();
        environments.insert("production".to_string(), EnvPolicyDecl { confirm: true });
        environments.insert("staging".to_string(), EnvPolicyDecl { confirm: false });
        let decl = ProjectDecl {
            slug: "p".into(),
            name: None,
            parent_group: None,
            environments,
        };
        assert!(decl.confirm_required("production"));
        assert!(!decl.confirm_required("staging"));
        assert!(!decl.confirm_required("unknown"));
        assert_eq!(decl.gated_envs(), vec!["production".to_string()]);
        // A project with no policy gates nothing (backward-compatible default).
        let bare = ProjectDecl {
            slug: "p".into(),
            name: None,
            parent_group: None,
            environments: std::collections::BTreeMap::new(),
        };
        assert!(!bare.confirm_required("production"));
        assert!(bare.gated_envs().is_empty());
    }

    // ---- v1.2 Workflows: definition validation ----------------------------

    fn wf_def(steps: serde_json::Value) -> picloud_shared::workflow::WorkflowDefinition {
        serde_json::from_value(serde_json::json!({ "steps": steps })).expect("valid def json")
    }

    #[test]
    fn workflow_validation_accepts_a_valid_dag() {
        let def = wf_def(serde_json::json!([
            { "name": "a", "function": "fa" },
            { "name": "b", "function": "fb", "depends_on": ["a"],
              "when": "steps.a.output.ok == true",
              "input": { "x": "{{ steps.a.output.value }}" } },
            { "name": "c", "workflow": "sub", "depends_on": ["a", "b"] },
        ]));
        assert!(validate_workflow_definition("w", &def).is_ok());
    }

    #[test]
    fn workflow_validation_rejects_empty_and_dupes() {
        assert!(validate_workflow_definition("w", &wf_def(serde_json::json!([]))).is_err());
        let dupe = wf_def(serde_json::json!([
            { "name": "a", "function": "fa" },
            { "name": "a", "function": "fb" },
        ]));
        assert!(validate_workflow_definition("w", &dupe)
            .unwrap_err()
            .contains("duplicate step name"));
    }

    #[test]
    fn workflow_validation_rejects_bad_target_and_deps() {
        // neither function nor workflow
        let no_target = wf_def(serde_json::json!([{ "name": "a" }]));
        assert!(validate_workflow_definition("w", &no_target)
            .unwrap_err()
            .contains("exactly one"));
        // both function and workflow
        let both = wf_def(serde_json::json!([{ "name": "a", "function": "f", "workflow": "w" }]));
        assert!(validate_workflow_definition("w", &both).is_err());
        // dependency on an undeclared step
        let bad_dep = wf_def(serde_json::json!([
            { "name": "a", "function": "fa", "depends_on": ["ghost"] },
        ]));
        assert!(validate_workflow_definition("w", &bad_dep)
            .unwrap_err()
            .contains("undeclared step"));
        // self-dependency
        let self_dep = wf_def(serde_json::json!([
            { "name": "a", "function": "fa", "depends_on": ["a"] },
        ]));
        assert!(validate_workflow_definition("w", &self_dep)
            .unwrap_err()
            .contains("depends on itself"));
    }

    #[test]
    fn workflow_validation_tolerates_duplicate_depends_on() {
        // A step listing the same dep twice is a valid DAG — the topo sort must
        // not report a spurious cycle from the double-counted in-degree.
        let dup_dep = wf_def(serde_json::json!([
            { "name": "a", "function": "fa" },
            { "name": "b", "function": "fb", "depends_on": ["a", "a"] },
        ]));
        assert!(validate_workflow_definition("w", &dup_dep).is_ok());
    }

    #[test]
    fn workflow_validation_detects_cycles() {
        let cyclic = wf_def(serde_json::json!([
            { "name": "a", "function": "fa", "depends_on": ["c"] },
            { "name": "b", "function": "fb", "depends_on": ["a"] },
            { "name": "c", "function": "fc", "depends_on": ["b"] },
        ]));
        assert!(validate_workflow_definition("w", &cyclic)
            .unwrap_err()
            .contains("cycle"));
    }

    #[test]
    fn workflow_validation_rejects_bad_when_and_template() {
        let bad_when = wf_def(serde_json::json!([
            { "name": "a", "function": "fa" },
            { "name": "b", "function": "fb", "depends_on": ["a"], "when": "steps.ghost.output.x" },
        ]));
        assert!(validate_workflow_definition("w", &bad_when).is_err());
        let bad_tmpl = wf_def(serde_json::json!([
            { "name": "a", "function": "fa", "input": { "x": "{{ steps.ghost.output }}" } },
        ]));
        assert!(validate_workflow_definition("w", &bad_tmpl).is_err());
    }

    fn bundle_workflow(name: &str, steps: serde_json::Value) -> BundleWorkflow {
        BundleWorkflow {
            name: name.into(),
            definition: wf_def(steps),
            enabled: true,
        }
    }

    #[test]
    fn workflow_nesting_warning_flags_self_and_mutual_cycles() {
        // Direct self-nest.
        let mut b = empty_bundle();
        b.workflows = vec![bundle_workflow(
            "loop",
            serde_json::json!([{ "name": "s", "workflow": "loop" }]),
        )];
        let w = workflow_nesting_warnings(&b);
        assert_eq!(w.len(), 1);
        assert!(w[0].contains("loop") && w[0].contains("cycle"));

        // Mutual cycle w1 -> w2 -> w1.
        let mut b = empty_bundle();
        b.workflows = vec![
            bundle_workflow("w1", serde_json::json!([{ "name": "s", "workflow": "w2" }])),
            bundle_workflow("w2", serde_json::json!([{ "name": "s", "workflow": "w1" }])),
        ];
        assert_eq!(workflow_nesting_warnings(&b).len(), 2);

        // Acyclic nesting (parent -> child, child is a leaf) → no warning.
        let mut b = empty_bundle();
        b.workflows = vec![
            bundle_workflow(
                "parent",
                serde_json::json!([{ "name": "s", "workflow": "child" }]),
            ),
            bundle_workflow(
                "child",
                serde_json::json!([{ "name": "s", "function": "f" }]),
            ),
        ];
        assert!(workflow_nesting_warnings(&b).is_empty());
    }
}
