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:
MechaCat02
2026-07-01 19:32:20 +02:00
parent 483e4cb116
commit 95f20b4add
10 changed files with 298 additions and 18 deletions

View File

@@ -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(),

View File

@@ -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" => {

View File

@@ -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].