feat(sealed): consume sealed at the two suppression gates (§11 tail M3)

The runtime effect: a sealed group template ignores a descendant's opt-out.
- Trigger dispatch: `AND t.sealed = FALSE` inside TRIGGER_SUPPRESSION_ANTIJOIN
  — a sealed row is never excluded, so it fires through the suppression (one
  edit covers list_matching_kv/docs/files + pubsub fan-out).
- Route rebuild: compile_effective_routes gates its suppression `continue` on
  `!er.route.sealed`, so a sealed inherited route stays in the app's slice.

Pinned by tests/sealed_templates.rs (live-DB): a sealed trigger + route
survive an app's suppression of both; an unsealed sibling with the same
suppression is still declined — the gate is exactly `sealed`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-01 19:34:22 +02:00
parent 95f20b4add
commit 247e2836b8
3 changed files with 244 additions and 2 deletions

View File

@@ -466,7 +466,11 @@ pub fn compile_effective_routes(
}
// §11 tail: an inherited route whose path this app suppresses is
// dropped — the binding 404s. Gated to inherited rows (`depth > 0`).
if er.depth > 0 && suppressed_paths.contains(&(er.effective_app_id, er.route.path.clone()))
// A `sealed` template is non-suppressible: the descendant's opt-out is
// ignored, so it stays in the slice regardless of the suppression.
if er.depth > 0
&& !er.route.sealed
&& suppressed_paths.contains(&(er.effective_app_id, er.route.path.clone()))
{
continue;
}

View File

@@ -28,7 +28,8 @@ pub(crate) const TRIGGER_SUPPRESSION_ANTIJOIN: &str = " \
JOIN scripts s ON s.id = t.script_id \
WHERE ts.app_id = $1 AND ts.target_kind = 'trigger' \
AND LOWER(ts.reference) = LOWER(s.name) \
AND t.group_id IS NOT NULL)";
AND t.group_id IS NOT NULL \
AND t.sealed = FALSE)";
#[derive(Debug, thiserror::Error)]
pub enum TriggerRepoError {