diff --git a/crates/manager-core/src/app_bootstrap.rs b/crates/manager-core/src/app_bootstrap.rs index 9f4778a..68a0978 100644 --- a/crates/manager-core/src/app_bootstrap.rs +++ b/crates/manager-core/src/app_bootstrap.rs @@ -88,6 +88,7 @@ async fn seed_into( method: None, dispatch_mode: picloud_shared::DispatchMode::Sync, enabled: true, + sealed: false, }) .await?; diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 60fe2f9..e54176f 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -159,6 +159,11 @@ pub struct BundleRoute { /// 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", … }`). @@ -174,6 +179,10 @@ pub enum BundleTrigger { dispatch_mode: Option, #[serde(default)] retry_max_attempts: Option, + /// §11 tail: sealed group template (event kinds only). See + /// [`BundleTrigger::sealed`]. + #[serde(default)] + sealed: bool, }, Docs { script: String, @@ -184,6 +193,8 @@ pub enum BundleTrigger { dispatch_mode: Option, #[serde(default)] retry_max_attempts: Option, + #[serde(default)] + sealed: bool, }, Files { script: String, @@ -194,6 +205,8 @@ pub enum BundleTrigger { dispatch_mode: Option, #[serde(default)] retry_max_attempts: Option, + #[serde(default)] + sealed: bool, }, Cron { script: String, @@ -212,6 +225,8 @@ pub enum BundleTrigger { dispatch_mode: Option, #[serde(default)] retry_max_attempts: Option, + #[serde(default)] + sealed: bool, }, Email { script: String, @@ -262,35 +277,55 @@ impl BundleTrigger { 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, + } + } + /// 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, .. } => format!( - "kv|{script}|{collection_glob}|{}", + "kv|{script}|{collection_glob}|{}|{sealed}", sorted_csv(ops.iter().copied().map(KvEventOp::as_str)) ), Self::Docs { script, collection_glob, ops, + sealed, .. } => format!( - "docs|{script}|{collection_glob}|{}", + "docs|{script}|{collection_glob}|{}|{sealed}", sorted_csv(ops.iter().copied().map(DocsEventOp::as_str)) ), Self::Files { script, collection_glob, ops, + sealed, .. } => format!( - "files|{script}|{collection_glob}|{}", + "files|{script}|{collection_glob}|{}|{sealed}", sorted_csv(ops.iter().copied().map(FilesEventOp::as_str)) ), Self::Cron { @@ -302,8 +337,9 @@ impl BundleTrigger { Self::Pubsub { script, topic_pattern, + sealed, .. - } => format!("pubsub|{script}|{topic_pattern}"), + } => format!("pubsub|{script}|{topic_pattern}|{sealed}"), Self::Email { script, .. } => format!("email|{script}"), Self::Queue { queue_name, .. } => format!("queue|{queue_name}"), } @@ -705,6 +741,28 @@ impl ApplyService { .into(), )); } + // §11 tail: `sealed` is a GROUP-template property — it makes an + // inherited template non-suppressible. On an app-owned route/trigger it + // is meaningless (nothing inherits it), so reject it rather than persist + // a misleading `sealed` app row the filters never consult. Defense in + // depth over the CLI (which authors `sealed` freely but only a group + // node inherits down). + if matches!(owner, ApplyOwner::App(_)) { + if bundle.routes.iter().any(|r| r.sealed) { + return Err(ApplyError::Invalid( + "an app route cannot be `sealed` — sealing marks a group \ + template as non-suppressible; an app route is never inherited" + .into(), + )); + } + if bundle.triggers.iter().any(BundleTrigger::sealed) { + return Err(ApplyError::Invalid( + "an app trigger cannot be `sealed` — sealing marks a group \ + template as non-suppressible; an app trigger is never inherited" + .into(), + )); + } + } // §11 tail: only an APP opts out of inherited templates. A group that // doesn't want a template simply doesn't declare it (the CLI already // rejects `[suppress]` on a `[group]`; this guards a hand-rolled wire @@ -937,6 +995,9 @@ impl ApplyService { retry_max, backoff, base, + // §11 tail: a sealed group template is non-suppressible + // (rejected on an app owner in `validate_bundle_for`). + bt.sealed(), &details, ) .await @@ -2884,6 +2945,9 @@ fn diff_routes( || 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, @@ -3173,33 +3237,36 @@ fn validate_email_secrets_present( /// as NoOp, but `diff_triggers` separately refuses to emit a Delete for it. fn current_trigger_identity(t: &Trigger, name_by_id: &HashMap) -> Option { let script = name_by_id.get(&t.script_id)?; + // §11 tail: `sealed` is part of the identity for the event kinds (mirroring + // `BundleTrigger::identity`), so toggling it on a group template re-diffs. + let sealed = t.sealed; match &t.details { TriggerDetails::Kv { collection_glob, ops, } => Some(format!( - "kv|{script}|{collection_glob}|{}", + "kv|{script}|{collection_glob}|{}|{sealed}", sorted_csv(ops.iter().copied().map(KvEventOp::as_str)) )), TriggerDetails::Docs { collection_glob, ops, } => Some(format!( - "docs|{script}|{collection_glob}|{}", + "docs|{script}|{collection_glob}|{}|{sealed}", sorted_csv(ops.iter().copied().map(DocsEventOp::as_str)) )), TriggerDetails::Files { collection_glob, ops, } => Some(format!( - "files|{script}|{collection_glob}|{}", + "files|{script}|{collection_glob}|{}|{sealed}", 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}")) + Some(format!("pubsub|{script}|{topic_pattern}|{sealed}")) } TriggerDetails::Email { .. } => Some(format!("email|{script}")), TriggerDetails::Queue { queue_name, .. } => Some(format!("queue|{queue_name}")), @@ -3498,6 +3565,9 @@ async fn insert_bundle_route( 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(()) @@ -3844,6 +3914,7 @@ mod tests { method: Some("POST".into()), dispatch_mode: DispatchMode::Sync, enabled: true, + sealed: false, created_at: Utc::now(), }; let current = CurrentState { @@ -3864,6 +3935,7 @@ mod tests { path: "/p".into(), dispatch_mode: DispatchMode::Sync, enabled: true, + sealed: false, }], triggers: vec![], secrets: vec![], @@ -3910,6 +3982,7 @@ mod tests { name: "t".into(), kind, enabled: true, + sealed: false, dispatch_mode: TriggerDispatchMode::Async, retry_max_attempts: 3, retry_backoff: BackoffShape::Exponential, @@ -3989,6 +4062,7 @@ mod tests { ops: vec![], dispatch_mode: None, retry_max_attempts: None, + sealed: false, }) .is_err()); // A non-empty glob passes. @@ -3998,6 +4072,7 @@ mod tests { ops: vec![], dispatch_mode: None, retry_max_attempts: None, + sealed: false, }) .is_ok()); // A malformed pubsub topic_pattern (mid-pattern wildcard) is rejected; @@ -4007,6 +4082,7 @@ mod tests { topic_pattern: "user.*.created".into(), dispatch_mode: None, retry_max_attempts: None, + sealed: false, }) .is_err()); assert!(validate_trigger_shape(&BundleTrigger::Pubsub { @@ -4014,6 +4090,7 @@ mod tests { topic_pattern: "orders.created".into(), dispatch_mode: None, retry_max_attempts: None, + sealed: false, }) .is_ok()); // Queue visibility floor matches the interactive API (>= 30): a value @@ -4209,6 +4286,7 @@ mod tests { path: "/h".into(), dispatch_mode: DispatchMode::Sync, enabled: true, + sealed: false, }]; let w = disabled_target_warnings(&b); assert!( @@ -4241,6 +4319,7 @@ mod tests { 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. @@ -4311,6 +4390,7 @@ mod tests { ops: vec![KvEventOp::Insert, KvEventOp::Update], // reversed dispatch_mode: None, retry_max_attempts: None, + sealed: false, }]; let p = compute_diff(¤t, &b); assert!( @@ -4319,6 +4399,96 @@ mod tests { ); } + #[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, + }]; + let p = compute_diff(¤t, &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(¤t, &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 --git a/crates/manager-core/src/route_admin.rs b/crates/manager-core/src/route_admin.rs index 0107555..96f2433 100644 --- a/crates/manager-core/src/route_admin.rs +++ b/crates/manager-core/src/route_admin.rs @@ -241,6 +241,9 @@ async fn create_route( dispatch_mode: input.dispatch_mode, // Routes are created active; toggling is a dedicated path. enabled: true, + // Sealing is a group-template property (§11 tail); an interactively + // created app route is never sealed. + sealed: false, }) .await?; refresh_table(&state).await?; @@ -747,6 +750,7 @@ mod tests { method: None, dispatch_mode: DispatchMode::default(), enabled: true, + sealed: false, created_at: chrono::Utc::now(), } } diff --git a/crates/manager-core/src/route_repo.rs b/crates/manager-core/src/route_repo.rs index 14fb822..4865607 100644 --- a/crates/manager-core/src/route_repo.rs +++ b/crates/manager-core/src/route_repo.rs @@ -28,6 +28,10 @@ pub struct NewRoute { pub dispatch_mode: DispatchMode, /// Three-state lifecycle (§4.3). Create active by default. pub enabled: bool, + /// §11 tail: `true` for a sealed (non-suppressible) group route template. + /// Always `false` for an app-owned route (the reconcile rejects sealed on + /// an app owner before reaching here). + pub sealed: bool, } /// A route resolved for a specific app via the §11 tail expansion: the route @@ -153,7 +157,7 @@ impl RouteRepository for PostgresRouteRepository { async fn list_for_group(&self, group_id: GroupId) -> Result, ScriptRepositoryError> { let rows = sqlx::query_as::<_, RouteRow>( "SELECT id, app_id, group_id, script_id, host_kind, host, host_param_name, \ - path_kind, path, method, dispatch_mode, enabled, created_at \ + path_kind, path, method, dispatch_mode, enabled, sealed, created_at \ FROM routes WHERE group_id = $1 ORDER BY created_at", ) .bind(group_id.into_inner()) @@ -190,7 +194,7 @@ impl RouteRepository for PostgresRouteRepository { SELECT ac.effective_app_id, ac.depth, \ r.id, r.app_id, r.group_id, r.script_id, r.host_kind, r.host, \ r.host_param_name, r.path_kind, r.path, r.method, \ - r.dispatch_mode, r.enabled, r.created_at \ + r.dispatch_mode, r.enabled, r.sealed, r.created_at \ FROM app_chain ac \ JOIN routes r ON (r.app_id = ac.owner_app OR r.group_id = ac.owner_group) \ ORDER BY ac.effective_app_id, ac.depth", @@ -290,10 +294,10 @@ pub(crate) async fn insert_route_tx( let res = sqlx::query_as::<_, RouteRow>( "INSERT INTO routes ( \ app_id, group_id, script_id, host_kind, host, host_param_name, \ - path_kind, path, method, dispatch_mode, enabled \ - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \ + path_kind, path, method, dispatch_mode, enabled, sealed \ + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) \ RETURNING id, app_id, group_id, script_id, host_kind, host, host_param_name, \ - path_kind, path, method, dispatch_mode, enabled, created_at", + path_kind, path, method, dispatch_mode, enabled, sealed, created_at", ) .bind(owner_app_id) .bind(owner_group_id) @@ -306,6 +310,7 @@ pub(crate) async fn insert_route_tx( .bind(input.method.as_deref()) .bind(input.dispatch_mode.as_str()) .bind(input.enabled) + .bind(input.sealed) .fetch_one(&mut **tx) .await; match res { @@ -355,6 +360,12 @@ struct RouteRow { method: Option, dispatch_mode: String, enabled: bool, + // §11 tail: `default` so a SELECT/RETURNING that omits `sealed` still + // hydrates (→ false). The queries that must see a true value — `list_effective` + // (the rebuild gate) and the group-route load feeding the apply diff — list + // it explicitly. + #[sqlx(default)] + sealed: bool, created_at: chrono::DateTime, } @@ -391,6 +402,7 @@ impl From for Route { method: r.method, dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync), enabled: r.enabled, + sealed: r.sealed, created_at: r.created_at, } } diff --git a/crates/manager-core/src/trigger_repo.rs b/crates/manager-core/src/trigger_repo.rs index faf2433..f0966b7 100644 --- a/crates/manager-core/src/trigger_repo.rs +++ b/crates/manager-core/src/trigger_repo.rs @@ -56,6 +56,11 @@ pub struct Trigger { pub name: String, pub kind: TriggerKind, pub enabled: bool, + /// §11 tail: `true` for a sealed (non-suppressible) group trigger template. + /// Always `false` for an app-owned trigger. Part of the apply diff identity + /// so toggling it re-materializes the row. + #[serde(default)] + pub sealed: bool, pub dispatch_mode: TriggerDispatchMode, pub retry_max_attempts: u32, pub retry_backoff: BackoffShape, @@ -543,6 +548,9 @@ pub(crate) async fn insert_trigger_tx( retry_max_attempts: u32, retry_backoff: BackoffShape, retry_base_ms: u32, + // §11 tail: `true` for a sealed (non-suppressible) group trigger template. + // Always `false` for an app-owned trigger. + sealed: bool, details: &TriggerDetails, ) -> Result { let kind = match details { @@ -595,8 +603,8 @@ pub(crate) async fn insert_trigger_tx( "INSERT INTO triggers ( \ app_id, group_id, script_id, kind, enabled, dispatch_mode, \ retry_max_attempts, retry_backoff, retry_base_ms, \ - registered_by_principal \ - ) VALUES ($1, $2, $3, $4, TRUE, $5, $6, $7, $8, $9) RETURNING id", + registered_by_principal, sealed \ + ) VALUES ($1, $2, $3, $4, TRUE, $5, $6, $7, $8, $9, $10) RETURNING id", ) .bind(owner_app_id) .bind(owner_group_id) @@ -607,6 +615,7 @@ pub(crate) async fn insert_trigger_tx( .bind(retry_backoff.as_str()) .bind(i32::try_from(retry_base_ms).unwrap_or(1000)) .bind(registered_by.into_inner()) + .bind(sealed) .fetch_one(&mut **tx) .await?; let tid = row.0; @@ -806,6 +815,7 @@ impl TriggerRepo for PostgresTriggerRepo { name: parent.name.clone(), kind: TriggerKind::Kv, enabled: parent.enabled, + sealed: parent.sealed, dispatch_mode: dispatch_from_str(&parent.dispatch_mode), retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3), retry_backoff: BackoffShape::from_wire(&parent.retry_backoff) @@ -873,6 +883,7 @@ impl TriggerRepo for PostgresTriggerRepo { name: parent.name.clone(), kind: TriggerKind::Docs, enabled: parent.enabled, + sealed: parent.sealed, dispatch_mode: dispatch_from_str(&parent.dispatch_mode), retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3), retry_backoff: BackoffShape::from_wire(&parent.retry_backoff) @@ -935,6 +946,7 @@ impl TriggerRepo for PostgresTriggerRepo { name: parent.name.clone(), kind: TriggerKind::DeadLetter, enabled: parent.enabled, + sealed: parent.sealed, dispatch_mode: dispatch_from_str(&parent.dispatch_mode), retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(1), retry_backoff: BackoffShape::from_wire(&parent.retry_backoff) @@ -1003,6 +1015,7 @@ impl TriggerRepo for PostgresTriggerRepo { name: parent.name.clone(), kind: TriggerKind::Cron, enabled: parent.enabled, + sealed: parent.sealed, dispatch_mode: dispatch_from_str(&parent.dispatch_mode), retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3), retry_backoff: BackoffShape::from_wire(&parent.retry_backoff) @@ -1071,6 +1084,7 @@ impl TriggerRepo for PostgresTriggerRepo { name: parent.name.clone(), kind: TriggerKind::Files, enabled: parent.enabled, + sealed: parent.sealed, dispatch_mode: dispatch_from_str(&parent.dispatch_mode), retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3), retry_backoff: BackoffShape::from_wire(&parent.retry_backoff) @@ -1134,6 +1148,7 @@ impl TriggerRepo for PostgresTriggerRepo { name: parent.name.clone(), kind: TriggerKind::Pubsub, enabled: parent.enabled, + sealed: parent.sealed, dispatch_mode: dispatch_from_str(&parent.dispatch_mode), retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3), retry_backoff: BackoffShape::from_wire(&parent.retry_backoff) @@ -1195,6 +1210,7 @@ impl TriggerRepo for PostgresTriggerRepo { name: parent.name.clone(), kind: TriggerKind::Email, enabled: parent.enabled, + sealed: parent.sealed, dispatch_mode: dispatch_from_str(&parent.dispatch_mode), retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3), retry_backoff: BackoffShape::from_wire(&parent.retry_backoff) @@ -1235,7 +1251,7 @@ impl TriggerRepo for PostgresTriggerRepo { async fn list_for_app(&self, app_id: AppId) -> Result, TriggerRepoError> { let parents: Vec = sqlx::query_as( - "SELECT id, app_id, script_id, name, kind, enabled, dispatch_mode, \ + "SELECT id, app_id, script_id, name, kind, enabled, sealed, dispatch_mode, \ retry_max_attempts, retry_backoff, retry_base_ms, \ registered_by_principal, created_at, updated_at \ FROM triggers WHERE app_id = $1 ORDER BY created_at DESC", @@ -1263,7 +1279,7 @@ impl TriggerRepo for PostgresTriggerRepo { async fn list_for_group(&self, group_id: GroupId) -> Result, TriggerRepoError> { let parents: Vec = sqlx::query_as( - "SELECT id, app_id, group_id, script_id, name, kind, enabled, dispatch_mode, \ + "SELECT id, app_id, group_id, script_id, name, kind, enabled, sealed, dispatch_mode, \ retry_max_attempts, retry_backoff, retry_base_ms, \ registered_by_principal, created_at, updated_at \ FROM triggers WHERE group_id = $1 ORDER BY created_at DESC", @@ -1289,7 +1305,7 @@ impl TriggerRepo for PostgresTriggerRepo { // rows are all app-owned) — else a template would hydrate with both // owners None, breaking the exactly-one invariant in memory. let parent: Option = sqlx::query_as( - "SELECT id, app_id, group_id, script_id, name, kind, enabled, dispatch_mode, \ + "SELECT id, app_id, group_id, script_id, name, kind, enabled, sealed, dispatch_mode, \ retry_max_attempts, retry_backoff, retry_base_ms, \ registered_by_principal, created_at, updated_at \ FROM triggers WHERE id = $1", @@ -1580,6 +1596,7 @@ impl TriggerRepo for PostgresTriggerRepo { name: parent.name.clone(), kind: TriggerKind::Queue, enabled: parent.enabled, + sealed: parent.sealed, dispatch_mode: dispatch_from_str(&parent.dispatch_mode), retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3), retry_backoff: BackoffShape::from_wire(&parent.retry_backoff) @@ -1789,6 +1806,7 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result Manifest { path: "/hello".into(), dispatch_mode: DispatchMode::Sync, enabled: true, + sealed: false, }], triggers: crate::manifest::ManifestTriggers::default(), secrets: crate::manifest::ManifestSecrets::default(), diff --git a/crates/picloud-cli/src/cmds/pull.rs b/crates/picloud-cli/src/cmds/pull.rs index 5d187de..b856550 100644 --- a/crates/picloud-cli/src/cmds/pull.rs +++ b/crates/picloud-cli/src/cmds/pull.rs @@ -94,6 +94,9 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> path: r.path, dispatch_mode: r.dispatch_mode, enabled: r.enabled, + // `pull` reconciles an app; app-owned routes are never sealed + // (sealing is a group-template property, §11 tail). + sealed: false, }); } } @@ -158,6 +161,8 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> ops: d.ops, dispatch_mode, retry_max_attempts, + // app-owned triggers are never sealed (group-template only). + sealed: false, }); } "docs" => { @@ -168,6 +173,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> ops: d.ops, dispatch_mode, retry_max_attempts, + sealed: false, }); } "files" => { @@ -178,6 +184,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> ops: d.ops, dispatch_mode, retry_max_attempts, + sealed: false, }); } "cron" => { @@ -197,6 +204,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> topic_pattern: d.topic_pattern, dispatch_mode, retry_max_attempts, + sealed: false, }); } "queue" => { diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index 25e82a7..6aec5eb 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -370,6 +370,11 @@ pub struct ManifestRoute { skip_serializing_if = "is_true" )] pub enabled: bool, + /// §11 tail: `sealed = true` on a `[group]` route template makes it + /// non-suppressible — a descendant's `[suppress]` cannot decline it. + /// Meaningless (rejected at apply) on an `[app]` route. Omitted ⇒ unsealed. + #[serde(default, skip_serializing_if = "is_false")] + pub sealed: bool, } /// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …). @@ -414,6 +419,10 @@ pub struct KvTriggerSpec { pub dispatch_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub retry_max_attempts: Option, + /// §11 tail: `sealed = true` on a `[group]` template makes it + /// non-suppressible (event kinds only; group-only). Omitted ⇒ unsealed. + #[serde(default, skip_serializing_if = "is_false")] + pub sealed: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -426,6 +435,9 @@ pub struct DocsTriggerSpec { pub dispatch_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub retry_max_attempts: Option, + /// See [`KvTriggerSpec::sealed`]. + #[serde(default, skip_serializing_if = "is_false")] + pub sealed: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -438,6 +450,9 @@ pub struct FilesTriggerSpec { pub dispatch_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub retry_max_attempts: Option, + /// See [`KvTriggerSpec::sealed`]. + #[serde(default, skip_serializing_if = "is_false")] + pub sealed: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -461,6 +476,9 @@ pub struct PubsubTriggerSpec { pub dispatch_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub retry_max_attempts: Option, + /// See [`KvTriggerSpec::sealed`]. + #[serde(default, skip_serializing_if = "is_false")] + pub sealed: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -538,6 +556,11 @@ fn is_true(b: &bool) -> bool { *b } +/// Skip-serialize helper: `sealed` defaults false, so only emit it when true. +fn is_false(b: &bool) -> bool { + !*b +} + fn default_timezone() -> String { "UTC".to_string() } @@ -590,6 +613,7 @@ mod tests { path: "/posts".into(), dispatch_mode: DispatchMode::Sync, enabled: true, + sealed: false, }], triggers: ManifestTriggers { cron: vec![CronTriggerSpec { @@ -605,6 +629,7 @@ mod tests { ops: vec![KvEventOp::Insert, KvEventOp::Update], dispatch_mode: Some(DispatchMode::Async), retry_max_attempts: Some(5), + sealed: false, }], ..ManifestTriggers::default() }, @@ -810,6 +835,26 @@ mod tests { ); } + #[test] + fn sealed_parses_on_group_route_and_trigger_templates() { + // §11 tail: a [group] can mark a route/trigger template `sealed = true` + // (non-suppressible). The default is unsealed. + let m = Manifest::parse( + "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\ + [[routes]]\nscript = \"gate\"\npath = \"/health\"\npath_kind = \"exact\"\n\ + host_kind = \"any\"\nsealed = true\n\n\ + [[triggers.kv]]\nscript = \"audit\"\ncollection_glob = \"*\"\nsealed = true\n\n\ + [[triggers.docs]]\nscript = \"log\"\ncollection_glob = \"*\"\n", + ) + .expect("sealed templates parse"); + assert!(m.routes[0].sealed, "route sealed must parse"); + assert!(m.triggers.kv[0].sealed, "kv trigger sealed must parse"); + assert!( + !m.triggers.docs[0].sealed, + "an omitted sealed defaults to false" + ); + } + #[test] fn group_manifest_parses_and_rejects_app_only_blocks() { // A [group] node: scripts + vars, no [app]. diff --git a/crates/shared/src/route.rs b/crates/shared/src/route.rs index d1568f8..906bc6d 100644 --- a/crates/shared/src/route.rs +++ b/crates/shared/src/route.rs @@ -109,5 +109,13 @@ pub struct Route { #[serde(default = "crate::default_true")] pub enabled: bool, + /// §11 tail: `true` for a sealed group route TEMPLATE — a descendant app's + /// `[suppress]` cannot decline it, so the RouteTable rebuild keeps it in the + /// app's slice even at a suppressed path. Always `false` for an app-owned + /// route (sealing an un-inherited route is rejected at apply). Consulted + /// only at rebuild via `list_effective`; the request hot path never reads it. + #[serde(default)] + pub sealed: bool, + pub created_at: DateTime, }