docs(suppress): flag advisory-by-default trust model; test dangling-suppress warning (§11 tail review)
Two review findings. 1. Per-app opt-out changes the group-template guarantee from "runs on every descendant" to "runs unless the descendant declines" — a footgun for compliance hooks (an audit/security trigger a tenant can silently opt out of). There is no non-suppressible flag. Document this in design §4.5 + CLAUDE.md, and record the deferred mitigation: a `sealed`/`mandatory` group-template marker the trigger anti-join + route filter skip (cheap — both filters are centralized). 2. The dangling-suppress warning path had no coverage. The suppress journey now applies a `[suppress] triggers=["does-not-exist"]`, asserts the apply still succeeds and the report warns "no effect". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -161,6 +161,32 @@ fn app_suppresses_inherited_route_template() {
|
||||
"re-apply should not create a second suppression:\n{report}"
|
||||
);
|
||||
|
||||
// A suppress reference matching NO inherited template is a soft warning
|
||||
// (typo guard), not an error — the apply still succeeds.
|
||||
let dangling = format!(
|
||||
"[app]\nslug = \"{blog}\"\nname = \"Blog\"\n\n\
|
||||
[suppress]\nroutes = [\"/ghello\"]\ntriggers = [\"does-not-exist\"]\n"
|
||||
);
|
||||
fs::write(&apath, &dangling).unwrap();
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&apath)
|
||||
.output()
|
||||
.expect("apply dangling");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"a dangling suppress must not fail apply"
|
||||
);
|
||||
let combined = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(
|
||||
combined.contains("does-not-exist") && combined.to_lowercase().contains("no effect"),
|
||||
"a dangling suppress must warn that it matches no inherited template:\n{combined}"
|
||||
);
|
||||
|
||||
// Drop the [suppress] block + prune → blog re-inherits the route.
|
||||
let bare = format!("[app]\nslug = \"{blog}\"\nname = \"Blog\"\n");
|
||||
fs::write(&apath, &bare).unwrap();
|
||||
|
||||
@@ -436,8 +436,18 @@ Two distinct constraints:
|
||||
> is inheritance-only** — the `group_id IS NOT NULL` / `depth > 0` gates mean an app can only decline
|
||||
> what it inherits, never its own resource nor a sibling's (pinned by `tests/template_suppression.rs`).
|
||||
> A dangling suppress (matching no inherited template) is an apply-time **warning**, not an error;
|
||||
> read-only `pic suppress ls --app`. **Deferred:** group-level suppression (decline an ancestor's
|
||||
> template for a whole subtree).
|
||||
> read-only `pic suppress ls --app`.
|
||||
>
|
||||
> **Trust-model consequence — group templates are now advisory-by-default.** Before opt-out, a group
|
||||
> template was *guaranteed* to run on every descendant; with suppression the guarantee weakens to "runs
|
||||
> unless the descendant declines." This is fine for convenience templates (a shared webhook, a default
|
||||
> route) but is a **footgun for compliance** templates — an audit-logging or security trigger a
|
||||
> multi-tenant operator deploys at a group can be silently opted out of by a tenant app. There is
|
||||
> currently **no non-suppressible flag**; an operator who needs an unconditional hook must not rely on a
|
||||
> group template for it (deploy it per-app, or gate at the platform layer). **Deferred:** a `sealed` /
|
||||
> `mandatory` marker on a group template that the trigger anti-join + route filter skip (making it
|
||||
> non-declinable) — cheap to add since both filters are centralized; and group-level suppression
|
||||
> (decline an ancestor's template for a whole subtree).
|
||||
|
||||
### 4.6 Secrets & `pull`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user