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,