feat(suppress): owner-polymorphic suppression repo + reconcile (M1.2)

suppression_repo takes a ScriptOwner (app or group), binding the matching
owner column. apply_service load_current / create / prune / suppression_report
all thread owner.as_script_owner(); the validate_bundle_for group-suppress
rejection and the manifest parse guard are dropped — a [group] may now declare
[suppress] to decline a template it inherits from a higher ancestor. No
runtime consumption effect yet (the chain filters land in M1.3/M1.4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-01 20:04:32 +02:00
parent cdef97a634
commit 86c57e2e43
3 changed files with 110 additions and 86 deletions

View File

@@ -767,20 +767,11 @@ impl ApplyService {
));
}
}
// §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
// Bundle — the reconcile's `owner.app_id().expect(...)` would otherwise
// panic on a group).
if matches!(owner, ApplyOwner::Group(_))
&& (!bundle.suppress_triggers.is_empty() || !bundle.suppress_routes.is_empty())
{
return Err(ApplyError::Invalid(
"a group cannot declare suppressions — only an app opts out of \
inherited templates"
.into(),
));
}
// §11 tail M1: both an app and a group may declare suppressions — an
// app declines an inherited template for itself, a group for its whole
// subtree. Both are inheritance-only (the dispatch filters gate to
// group-owned templates on the suppressing owner's chain), so no
// owner-kind guard here.
self.validate_bundle(bundle, inherited_endpoints)
}
@@ -1065,17 +1056,22 @@ impl ApplyService {
}
// 3e. Per-app suppression markers (§11 tail) — insert each Create
// (idempotent). App-only; deletes happen in prune → the template
// re-inherits. Key is `"{target_kind}:{reference}"`; split on the FIRST
// `:` so a route reference containing `:` (a param path `/users/:id`)
// survives.
// (idempotent). Owner-polymorphic (§11 tail M1): an app declines for
// itself, a group for its whole subtree. Deletes happen in prune → the
// template re-inherits. Key is `"{target_kind}:{reference}"`; split on
// the FIRST `:` so a route reference containing `:` (a param path
// `/users/:id`) survives.
for ch in &plan.suppressions {
if ch.op == Op::Create {
let app_id = owner.app_id().expect("suppressions are app-only");
let (kind, reference) = ch.key.split_once(':').expect("suppression key has ':'");
crate::suppression_repo::insert_suppression_tx(&mut *tx, app_id, kind, reference)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
crate::suppression_repo::insert_suppression_tx(
&mut *tx,
owner.as_script_owner(),
kind,
reference,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
report.suppressions_created += 1;
}
}
@@ -1188,15 +1184,17 @@ impl ApplyService {
}
}
// Per-app suppression markers are prunable config too (§11 tail).
// Suppression markers are prunable config too (§11 tail).
// Removing a marker re-inherits the template.
for ch in &plan.suppressions {
if ch.op == Op::Delete {
let app_id = owner.app_id().expect("suppressions are app-only");
let (kind, reference) =
ch.key.split_once(':').expect("suppression key has ':'");
crate::suppression_repo::delete_suppression_tx(
&mut *tx, app_id, kind, reference,
&mut *tx,
owner.as_script_owner(),
kind,
reference,
)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
@@ -2221,17 +2219,14 @@ impl ApplyService {
Ok(out)
}
/// Read-only §11 tail report: an app's own suppression markers (the
/// inherited templates it declines). Group owner → empty (groups don't
/// suppress). Backs `pic suppress ls --app`.
/// Read-only §11 tail report: an owner's own suppression markers (the
/// inherited templates it declines). An app declines for itself, a group
/// for its subtree. Backs `pic suppress ls --app` / `--group`.
pub async fn suppression_report(
&self,
owner: ApplyOwner,
) -> Result<Vec<SuppressionInfo>, ApplyError> {
let ApplyOwner::App(app_id) = owner else {
return Ok(Vec::new());
};
let rows = crate::suppression_repo::list_for_app(&self.pool, app_id)
let rows = crate::suppression_repo::list_for_owner(&self.pool, owner.as_script_owner())
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
Ok(rows
@@ -2529,14 +2524,13 @@ impl ApplyService {
crate::group_collection_repo::list_all_for_owner(&self.pool, owner.as_script_owner())
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
// §11 tail per-app suppression markers — app-only (a group never
// suppresses; it just wouldn't declare the template).
let suppressions = match owner {
ApplyOwner::App(app_id) => crate::suppression_repo::list_for_app(&self.pool, app_id)
// §11 tail suppression markers declared directly at this node
// (§11 tail M1: owner-polymorphic — an app declines for itself, a group
// for its subtree).
let suppressions =
crate::suppression_repo::list_for_owner(&self.pool, owner.as_script_owner())
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?,
ApplyOwner::Group(_) => Vec::new(),
};
.map_err(|e| ApplyError::Backend(e.to_string()))?;
Ok(CurrentState {
scripts,
routes,

View File

@@ -1,52 +1,62 @@
//! Template-suppression markers (§11 tail) — the `template_suppressions` table
//! (0058). A marker `(app_id, target_kind, reference)` records that an app opts
//! OUT of an inherited group template: a handler script name it declines
//! (`target_kind='trigger'`) or a path it declines (`target_kind='route'`).
//! (0058, made owner-polymorphic in 0060). A marker `(owner, target_kind,
//! reference)` records that an OWNER opts OUT of an inherited group template: a
//! handler script name it declines (`target_kind='trigger'`) or a path it
//! declines (`target_kind='route'`).
//!
//! App-only (a group would just not declare the template), so — unlike the
//! owner-polymorphic `extension_points` — these free functions take a plain
//! `AppId`. Same tx-function style: read over `&PgPool`, write over a
//! `&mut Transaction`.
//! Owner-polymorphic (like `extension_points`): an **app** declines for itself;
//! a **group** declines for its whole subtree. The functions take a
//! `ScriptOwner` and bind the matching owner column. Same tx-function style:
//! read over `&PgPool`, write over a `&mut Transaction`.
use picloud_shared::AppId;
use picloud_shared::ScriptOwner;
use sqlx::{PgPool, Postgres, Transaction};
/// The two suppressible template kinds, as stored in `target_kind`.
pub const TARGET_TRIGGER: &str = "trigger";
pub const TARGET_ROUTE: &str = "route";
/// List the suppression markers declared at `app_id`, as `(target_kind,
/// List the suppression markers declared at `owner`, as `(target_kind,
/// reference)` pairs, stably ordered. Backs `load_current` (the apply diff) and
/// the read-only `suppress ls`.
pub async fn list_for_app(
pub async fn list_for_owner(
pool: &PgPool,
app_id: AppId,
owner: ScriptOwner,
) -> Result<Vec<(String, String)>, sqlx::Error> {
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT target_kind, reference FROM template_suppressions \
WHERE app_id = $1 ORDER BY target_kind, LOWER(reference)",
)
.bind(app_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows)
let sql = match owner {
ScriptOwner::App(_) => {
"SELECT target_kind, reference FROM template_suppressions \
WHERE app_id = $1 ORDER BY target_kind, LOWER(reference)"
}
ScriptOwner::Group(_) => {
"SELECT target_kind, reference FROM template_suppressions \
WHERE group_id = $1 ORDER BY target_kind, LOWER(reference)"
}
};
sqlx::query_as(sql)
.bind(owner_uuid(owner))
.fetch_all(pool)
.await
}
/// Insert a suppression marker in the apply transaction. Idempotent: re-apply
/// of an already-declared `(kind, reference)` is a no-op (`ON CONFLICT DO
/// NOTHING`), so the marker survives without a spurious version bump.
/// NOTHING` against the per-owner partial unique index), so the marker survives
/// without a spurious version bump.
pub async fn insert_suppression_tx(
tx: &mut Transaction<'_, Postgres>,
app_id: AppId,
owner: ScriptOwner,
target_kind: &str,
reference: &str,
) -> Result<(), sqlx::Error> {
let (app_id, group_id) = owner_columns(owner);
sqlx::query(
"INSERT INTO template_suppressions (app_id, target_kind, reference) \
VALUES ($1, $2, $3) \
ON CONFLICT (app_id, target_kind, reference) DO NOTHING",
"INSERT INTO template_suppressions (app_id, group_id, target_kind, reference) \
VALUES ($1, $2, $3, $4) \
ON CONFLICT DO NOTHING",
)
.bind(app_id.into_inner())
.bind(app_id)
.bind(group_id)
.bind(target_kind)
.bind(reference)
.execute(&mut **tx)
@@ -58,18 +68,41 @@ pub async fn insert_suppression_tx(
/// the manifest stops declaring the reference → the template re-inherits.
pub async fn delete_suppression_tx(
tx: &mut Transaction<'_, Postgres>,
app_id: AppId,
owner: ScriptOwner,
target_kind: &str,
reference: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
"DELETE FROM template_suppressions \
WHERE app_id = $1 AND target_kind = $2 AND reference = $3",
)
.bind(app_id.into_inner())
.bind(target_kind)
.bind(reference)
.execute(&mut **tx)
.await?;
let sql = match owner {
ScriptOwner::App(_) => {
"DELETE FROM template_suppressions \
WHERE app_id = $1 AND target_kind = $2 AND reference = $3"
}
ScriptOwner::Group(_) => {
"DELETE FROM template_suppressions \
WHERE group_id = $1 AND target_kind = $2 AND reference = $3"
}
};
sqlx::query(sql)
.bind(owner_uuid(owner))
.bind(target_kind)
.bind(reference)
.execute(&mut **tx)
.await?;
Ok(())
}
/// `(app_id, group_id)` bind pair for the polymorphic owner — exactly one set.
fn owner_columns(owner: ScriptOwner) -> (Option<uuid::Uuid>, Option<uuid::Uuid>) {
match owner {
ScriptOwner::App(a) => (Some(a.into_inner()), None),
ScriptOwner::Group(g) => (None, Some(g.into_inner())),
}
}
/// The single owner UUID (app or group), for the WHERE-on-one-column queries.
fn owner_uuid(owner: ScriptOwner) -> uuid::Uuid {
match owner {
ScriptOwner::App(a) => a.into_inner(),
ScriptOwner::Group(g) => g.into_inner(),
}
}

View File

@@ -73,16 +73,12 @@ impl Manifest {
}
// A group node owns scripts + vars (+ secret names) and, as of §11 tail,
// ROUTE TEMPLATES + EVENT trigger TEMPLATES (kv/docs/files/pubsub) that
// fan out live to descendant apps. The stateful trigger kinds
// fan out live to descendant apps. As of §11 tail M1 a group may also
// declare `[suppress]` to decline a template it inherits from a higher
// ancestor, for its whole subtree. The stateful trigger kinds
// (cron/queue/email) stay app-only — they need per-app state/secrets →
// materialization.
if m.group.is_some() {
if !m.suppress.is_empty() {
anyhow::bail!(
"a [group] cannot declare [suppress] — only an app opts out of \
inherited templates; a group simply doesn't declare the template"
);
}
let t = &m.triggers;
if !t.cron.is_empty() || !t.queue.is_empty() || !t.email.is_empty() {
anyhow::bail!(
@@ -808,7 +804,7 @@ mod tests {
}
#[test]
fn app_suppress_parses_group_suppress_rejected() {
fn app_and_group_suppress_parse() {
// §11 tail: an [app] declares [suppress] to opt out of inherited
// templates — script names (triggers) + paths (routes).
let m = Manifest::parse(
@@ -819,13 +815,14 @@ mod tests {
assert_eq!(m.suppress.triggers, ["audit"]);
assert_eq!(m.suppress.routes, ["/hello"]);
// A [group] cannot suppress — it simply wouldn't declare the template.
let err = Manifest::parse(
// §11 tail M1: a [group] MAY suppress — it declines a template it
// inherits from a higher ancestor, for its whole subtree.
let g = Manifest::parse(
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
[suppress]\ntriggers = [\"audit\"]\n",
)
.expect_err("group [suppress] is rejected");
assert!(err.to_string().contains("suppress"), "got: {err}");
.expect("group [suppress] must parse");
assert_eq!(g.suppress.triggers, ["audit"]);
// An unknown key inside [suppress] is a hard error (deny_unknown_fields).
let typo = "[app]\nslug = \"x\"\nname = \"X\"\n\n[suppress]\ntrigger = [\"a\"]\n";