feat(sealed): sealed visibility + ineffective-suppress warning (§11 tail M4)

- `pic triggers ls --group` / `routes ls --group` gain a `sealed` column
  (threaded through TriggerTemplateInfo/RouteTemplateInfo + the two DTOs).
- dangling_suppress_warnings now also warns when a suppress reference matches
  only SEALED inherited templates ("... is sealed — the suppression has no
  effect") via `bool_or(NOT sealed)` per handler/path — making the mandatory
  guarantee observable at plan/apply time, alongside the existing typo guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-01 19:38:18 +02:00
parent 247e2836b8
commit fa0702870a
4 changed files with 53 additions and 17 deletions

View File

@@ -530,6 +530,8 @@ pub struct TriggerTemplateInfo {
pub target: String, pub target: String,
pub script: String, pub script: String,
pub enabled: bool, pub enabled: bool,
/// §11 tail: `true` for a sealed (non-suppressible) template.
pub sealed: bool,
} }
/// One row of the read-only §11 tail route-template report (`pic routes ls /// One row of the read-only §11 tail route-template report (`pic routes ls
@@ -545,6 +547,8 @@ pub struct RouteTemplateInfo {
pub script: String, pub script: String,
pub dispatch: String, pub dispatch: String,
pub enabled: bool, pub enabled: bool,
/// §11 tail: `true` for a sealed (non-suppressible) template.
pub sealed: bool,
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -2081,6 +2085,7 @@ impl ApplyService {
target, target,
script: name_by_id.get(&t.script_id).cloned().unwrap_or_default(), script: name_by_id.get(&t.script_id).cloned().unwrap_or_default(),
enabled: t.enabled, enabled: t.enabled,
sealed: t.sealed,
} }
}) })
.collect()) .collect())
@@ -2122,6 +2127,7 @@ impl ApplyService {
script: name_by_id.get(&r.script_id).cloned().unwrap_or_default(), script: name_by_id.get(&r.script_id).cloned().unwrap_or_default(),
dispatch: r.dispatch_mode.as_str().to_string(), dispatch: r.dispatch_mode.as_str().to_string(),
enabled: r.enabled, enabled: r.enabled,
sealed: r.sealed,
}) })
.collect()) .collect())
} }
@@ -2153,47 +2159,63 @@ impl ApplyService {
if bundle.suppress_triggers.is_empty() && bundle.suppress_routes.is_empty() { if bundle.suppress_triggers.is_empty() && bundle.suppress_routes.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
// Inherited (ancestor-group) trigger handler names + route paths. // Inherited (ancestor-group) trigger handler names + route paths, each
let trig_names: Vec<(String,)> = sqlx::query_as(&format!( // tagged with whether ANY matching template is un-sealed (i.e. actually
// suppressible). `bool_or(NOT sealed)` = true iff at least one match can
// be declined; false = every match is `sealed` (§11 tail), so the
// suppression is inert; absent = no inherited template at all (a typo).
let trig_names: Vec<(String, bool)> = sqlx::query_as(&format!(
"{CHAIN_LEVELS_CTE} \ "{CHAIN_LEVELS_CTE} \
SELECT DISTINCT LOWER(s.name) FROM triggers t \ SELECT LOWER(s.name), bool_or(NOT t.sealed) FROM triggers t \
JOIN scripts s ON s.id = t.script_id \ JOIN scripts s ON s.id = t.script_id \
JOIN chain c ON t.group_id = c.group_owner \ JOIN chain c ON t.group_id = c.group_owner \
WHERE t.group_id IS NOT NULL", WHERE t.group_id IS NOT NULL \
GROUP BY LOWER(s.name)",
)) ))
.bind(app_id.into_inner()) .bind(app_id.into_inner())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| ApplyError::Backend(e.to_string()))?; .map_err(|e| ApplyError::Backend(e.to_string()))?;
let inherited_handlers: HashSet<String> = trig_names.into_iter().map(|(n,)| n).collect(); let inherited_handlers: HashMap<String, bool> = trig_names.into_iter().collect();
let route_paths: Vec<(String,)> = sqlx::query_as(&format!( let route_paths: Vec<(String, bool)> = sqlx::query_as(&format!(
"{CHAIN_LEVELS_CTE} \ "{CHAIN_LEVELS_CTE} \
SELECT DISTINCT r.path FROM routes r \ SELECT r.path, bool_or(NOT r.sealed) FROM routes r \
JOIN chain c ON r.group_id = c.group_owner \ JOIN chain c ON r.group_id = c.group_owner \
WHERE r.group_id IS NOT NULL", WHERE r.group_id IS NOT NULL \
GROUP BY r.path",
)) ))
.bind(app_id.into_inner()) .bind(app_id.into_inner())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| ApplyError::Backend(e.to_string()))?; .map_err(|e| ApplyError::Backend(e.to_string()))?;
let inherited_paths: HashSet<String> = route_paths.into_iter().map(|(p,)| p).collect(); let inherited_paths: HashMap<String, bool> = route_paths.into_iter().collect();
let mut out = Vec::new(); let mut out = Vec::new();
for r in &bundle.suppress_triggers { for r in &bundle.suppress_triggers {
if !inherited_handlers.contains(&r.to_lowercase()) { match inherited_handlers.get(&r.to_lowercase()) {
out.push(format!( None => out.push(format!(
"suppress: no inherited trigger bound to script `{r}` — the \ "suppress: no inherited trigger bound to script `{r}` — the \
suppression has no effect" suppression has no effect"
)); )),
Some(false) => out.push(format!(
"suppress: the inherited trigger bound to script `{r}` is \
sealed — the suppression has no effect"
)),
Some(true) => {}
} }
} }
for r in &bundle.suppress_routes { for r in &bundle.suppress_routes {
if !inherited_paths.contains(r) { match inherited_paths.get(r) {
out.push(format!( None => out.push(format!(
"suppress: no inherited route at path `{r}` — the \ "suppress: no inherited route at path `{r}` — the \
suppression has no effect" suppression has no effect"
)); )),
Some(false) => out.push(format!(
"suppress: the inherited route at path `{r}` is sealed — the \
suppression has no effect"
)),
Some(true) => {}
} }
} }
Ok(out) Ok(out)

View File

@@ -1405,6 +1405,9 @@ pub struct TriggerTemplateDto {
#[serde(default)] #[serde(default)]
pub script: String, pub script: String,
pub enabled: bool, pub enabled: bool,
/// §11 tail: `true` for a sealed (non-suppressible) template.
#[serde(default)]
pub sealed: bool,
} }
/// One row of the §11 tail route-template report. /// One row of the §11 tail route-template report.
@@ -1423,6 +1426,9 @@ pub struct RouteTemplateDto {
#[serde(default)] #[serde(default)]
pub dispatch: String, pub dispatch: String,
pub enabled: bool, pub enabled: bool,
/// §11 tail: `true` for a sealed (non-suppressible) template.
#[serde(default)]
pub sealed: bool,
} }
/// One row of the §11.6 shared-collection report. /// One row of the §11.6 shared-collection report.

View File

@@ -50,6 +50,7 @@ pub async fn ls_group(group: &str, mode: OutputMode) -> Result<()> {
"script", "script",
"dispatch", "dispatch",
"enabled", "enabled",
"sealed",
]); ]);
for r in rows { for r in rows {
table.row([ table.row([
@@ -60,6 +61,7 @@ pub async fn ls_group(group: &str, mode: OutputMode) -> Result<()> {
r.script, r.script,
r.dispatch, r.dispatch,
r.enabled.to_string(), r.enabled.to_string(),
r.sealed.to_string(),
]); ]);
} }
table.print(mode); table.print(mode);

View File

@@ -47,9 +47,15 @@ pub async fn ls_group(group: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?; let creds = config::resolve()?;
let client = Client::from_creds(&creds)?; let client = Client::from_creds(&creds)?;
let rows = client.group_triggers_list(group).await?; let rows = client.group_triggers_list(group).await?;
let mut table = Table::new(["kind", "target", "script", "enabled"]); let mut table = Table::new(["kind", "target", "script", "enabled", "sealed"]);
for t in rows { for t in rows {
table.row([t.kind, t.target, t.script, t.enabled.to_string()]); table.row([
t.kind,
t.target,
t.script,
t.enabled.to_string(),
t.sealed.to_string(),
]);
} }
table.print(mode); table.print(mode);
Ok(()) Ok(())