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:
@@ -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<TriggerDispatchMode>,
|
||||
#[serde(default)]
|
||||
retry_max_attempts: Option<u32>,
|
||||
/// §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<TriggerDispatchMode>,
|
||||
#[serde(default)]
|
||||
retry_max_attempts: Option<u32>,
|
||||
#[serde(default)]
|
||||
sealed: bool,
|
||||
},
|
||||
Files {
|
||||
script: String,
|
||||
@@ -194,6 +205,8 @@ pub enum BundleTrigger {
|
||||
dispatch_mode: Option<TriggerDispatchMode>,
|
||||
#[serde(default)]
|
||||
retry_max_attempts: Option<u32>,
|
||||
#[serde(default)]
|
||||
sealed: bool,
|
||||
},
|
||||
Cron {
|
||||
script: String,
|
||||
@@ -212,6 +225,8 @@ pub enum BundleTrigger {
|
||||
dispatch_mode: Option<TriggerDispatchMode>,
|
||||
#[serde(default)]
|
||||
retry_max_attempts: Option<u32>,
|
||||
#[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<ScriptId, String>) -> Option<String> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user