feat(sealed): author + persist sealed group templates (§11 tail M2)
Thread `sealed` from the manifest through to the DB column: - manifest: `sealed` on ManifestRoute + the four event-kind trigger specs (Kv/Docs/Files/Pubsub), flowing to Bundle via serde. - persist: NewRoute.sealed + insert_route_tx; insert_trigger_tx `sealed` param; the reconcile passes br.sealed / bt.sealed(). - validate_bundle_for rejects `sealed` on an app owner (meaningless — an app route/trigger is never inherited). - diff: `sealed` joins the route Update comparison and the trigger identity (both bundle + current sides), so toggling it re-applies rather than NoOp. `sealed` lives on shared Route + manager-core Trigger (both pure DTOs) so the diff can see the current value; RouteRow/TriggerRow default it so RETURNING clauses that omit it still hydrate. No runtime read effect yet — the two suppression gates land in M3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -136,6 +136,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
path: "/hello".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
}],
|
||||
triggers: crate::manifest::ManifestTriggers::default(),
|
||||
secrets: crate::manifest::ManifestSecrets::default(),
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
/// §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<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
/// 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<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
/// 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<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
/// 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].
|
||||
|
||||
Reference in New Issue
Block a user