diff --git a/CLAUDE.md b/CLAUDE.md index 51fd6e4..bc6ab7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint **v1.1.x — SDK foundation + services — is complete.** The SDK shape (handle pattern, `::` namespaces, `Services`/`SdkCallCx`; see [docs/sdk-shape.md](docs/sdk-shape.md), stdlib at [docs/stdlib-reference.md](docs/stdlib-reference.md)) fixed in v1.1.0, then KV, docs, modules, HTTP, cron, files, pub/sub, email, users, and durable queues + `invoke()` filled it in through **v1.1.9** — blueprint §12 has the table. Earlier groundwork: blueprint Phase 3 (admin auth, multi-app scoping, Phase 3.5 capability gating — `manager-core::authz::{can, require, Capability}`, migration `0006_users_authz.sql`). -**Current focus: v1.2 _Hierarchies_ — groups + the declarative project tool** ([docs/design/groups-and-project-tool.md](docs/design/groups-and-project-tool.md)). That doc's §11 uses its own **Phase 1–6 numbering, distinct from the blueprint product-phase numbering above — do not conflate them** (its "Phase 3" = group-inherited config, not admin auth). Implemented on `feat/groups-*` branches: §11 Phase 1 (declarative `pic plan`/`apply`/`prune` + env overlays), Phase 2 (single-parent groups tree + hierarchy-aware RBAC), Phase 3 (group-inherited, env-scoped `vars` + secrets resolved **live** via a recursive CTE — no materialized cache), Phase 4-lite (group-owned **endpoint** scripts: `scripts` polymorphic owner in `0050_group_scripts.sql`, `get_by_name_inherited`/`is_invocable_by_app` chain resolution, inherited `invoke()` + declarative route/trigger binding — all **live**, no body materialization), Phase 5 (the **declarative project tool maps onto the group tree**: the reconcile engine generalized to `ApplyOwner{App|Group}`, a `[group]` manifest kind, and a single atomic **tree apply** — `pic plan/apply --dir` reconciles a whole directory tree of `picloud.toml` nodes in one Postgres transaction, groups-before-apps so an app route can bind a group script created in the same tx; the bound token folds in each group's `structure_version`. Multi-repo single-owner/attach-point and per-env approval gating are deferred; groups pre-exist), Phase 4b (group **modules** + the **lexical (sealed-by-default) import resolver**, §5.5: owner-polymorphic `ModuleScript`, origin-rooted `ModuleSource::resolve` walking the importing node's chain, `ExecRequest.script_owner` threaded from every dispatch + `invoke()` site, `_source`-driven lexical chaining in `PicloudModuleResolver` with the compiled-module cache re-keyed by `ScriptId`, group modules/imports allowed, single-node dangling-import `plan` check — an inherited group script's imports **seal to the group**, a leaf can't shadow them), §5.5 **extension points** (opt-in polymorphism — **§5.5 now complete**: marker table `0051_extension_points.sql` (owner-polymorphic, CASCADE — structurally a `secrets` name; default body = a co-located `kind=module` script), `ModuleSource::resolve_policy` with **nearest-declaration-kind-wins** — a concrete module resolves lexically, an EP marker resolves **dynamically against the inheriting app** (its override else the default body up-chain), `NoProvider` is a hard error; declarative-only authoring via the `[app]`/`[group]` manifest key `extension_points = [...]`, reconcile mirrors `secrets`, single-node no-provider `plan` check, read-only `pic extension-points ls` + `pull` round-trip — the app can **override** a group default, the deliberate inverse of the Phase 4b sealed import), §11.6 **group-level collections — KV + DOCS + FILES slices** (full cross-app shared read/write: a group declares a collection shared via the `[group]` manifest `collections = [...]` → owner-polymorphic marker `0052_group_collections.sql` with a `kind` discriminator + a per-kind group-keyed store: `0053_group_kv_entries.sql` (`kind='kv'`), `0054_group_docs.sql` (`kind='docs'`, the queryable-JSON store), and `0055_group_files.sql` (`kind='files'`, blob metadata in Postgres + bytes on disk under `/files/groups//...`, a `groups/` infix disjoint from the per-app `files//` subtree so the existing recursive orphan sweeper covers both with zero change) — no `app_id`, a shared row belongs to the group; CASCADE on group delete, an app delete leaves the data. Scripts use the **explicit** `kv::shared_collection("name")` / `docs::shared_collection("name")` / `files::shared_collection("name")` handles (`shared` alone is a Rhai reserved word); `GroupKv`/`GroupDocs`/`GroupFilesServiceImpl` resolve the owning group from `cx.app_id`'s ancestor chain **filtered by kind** (nearest-wins) — **that walk is the isolation boundary**, a foreign app gets `CollectionNotShared`; a `kv`, a `docs`, and a `files` collection of the same name are distinct stores. The docs slice reuses the `docs_filter` DSL — `build_find_query` generalized on its owner column (`docs`/`app_id` vs `group_docs`/`group_id`, both literals); the files slice likewise generalized the atomic-write + checksum-on-read path helpers on an owner-relative dir (one source for the security-sensitive disk mechanics). **Reads open** to any subtree script (anonymous incl. — the declaration is the grant), **writes require an authenticated editor+** on the owning group (`GroupKvRead/Write`, `GroupDocsRead/Write`, `GroupFilesRead/Write`, `script_gate_require_principal` fails closed on anon). Declarative authoring is the **string-or-table** form `collections = ["catalog", { name = "articles", kind = "docs" }, { name = "assets", kind = "files" }]` (bare string = kv); reconcile keys markers by `(name, kind)`; read-only `pic collections ls --group` shows a kind column. Deferred: shared-collection triggers (the "group trigger has no app to watch" problem), topics/queue shared collections (same trigger-centric gap), per-group quotas, an operator admin API for shared blobs (scripts use the SDK; `pic collections ls` shows the marker)), and **§4.5 group TRIGGER templates** (live, event kinds — a `[group]` declares a `[[triggers.kv|docs|files|pubsub]]` template binding a group-owned handler; `triggers` gained a polymorphic owner `0056_group_triggers.sql` mirroring `0050`; the dispatcher's `list_matching_kv/docs/files` + the pubsub publish fan-out prepend `CHAIN_LEVELS_CTE` + `JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner)` so a descendant app's event matches its own triggers **plus** ancestor-group templates in one query, the handler running under the firing `app_id` — **the chain walk is the isolation boundary**, a sibling-subtree app never matches; stateful kinds cron/queue/email rejected on a group (need materialization), per-app opt-out deferred; read-only `pic triggers ls --group`), and **§4.5 group ROUTE templates** (live, inherited — a `[group]` declares a `[[routes]]` template binding a group-owned endpoint; `routes` gained a polymorphic owner `0057_group_routes.sql` mirroring `0056`. Unlike triggers (per-event SQL), routes serve from the in-memory `RouteTable`, so the HTTP hot path can't resolve inheritance per request — instead the table **rebuild** expands templates into each descendant app's slice via `RouteRepository::list_effective` (all-apps generalization of `CHAIN_LEVELS_CTE`: every app × its ancestor chain ⋈ routes), and `compile_effective_routes` applies **nearest-owner-wins shadowing** (an app's own identical binding shadows the inherited template; non-identical bindings coexist under the matcher's existing precedence — a route picks one winner, unlike a fanning trigger). Because the table is a cache, inheritance is rebuilt **full-live** through the single `rebuild_route_table` chokepoint on every edge that changes it: route CRUD, apply, **and tree mutations** — app create/delete (`apps_api`) + group reparent (`groups_api`) — so a new app under a group serves its templates instantly. Host-claim validation is skipped for a group template (descendants serve it on their own host claim; templates use `host_kind = any`). **The chain expansion is the isolation boundary** — a sibling-subtree app never inherits (pinned by `tests/group_route_templates.rs` + the `group_routes` journey); read-only `pic routes ls --group`. Deferred: multi-node snapshot propagation), and **§11 tail per-app opt-out (template suppression)** (a descendant declines an inherited group template: an `[app]` declares `[suppress]` with `triggers = [...]` (handler script names) + `routes = [...]` (paths) — **coarse by reference**, not a full definition, since template row-ids churn on re-apply but a reference is stable (re-apply NoOp, may decline several templates bound to the same script/path). App-only marker `0058_template_suppressions.sql` (`app_id NOT NULL` CASCADE, a `target_kind` discriminator), reconciled with the extension-point marker pattern (prunable → re-inherits). Consumed at the two resolution points: the trigger dispatch queries gain a correlated `NOT EXISTS` anti-join (gated to `t.group_id IS NOT NULL`), and `compile_effective_routes` drops an inherited (`depth > 0`) route at a suppressed path (loaded via `RouteRepository::list_route_suppressions`). **Inheritance-only** — the `group_id IS NOT NULL` / `depth > 0` gates mean an app can only decline what it inherits, never its own or a sibling's (pinned by `tests/template_suppression.rs` + the `suppress` journey); a dangling suppress is an apply-time warning; read-only `pic suppress ls --app`. **Trust-model consequence:** group templates are advisory-by-default (run *unless* a descendant declines) — a footgun for compliance hooks (audit/security triggers a tenant can opt out of)), and **§11 tail `sealed` (mandatory) group templates** (closes that footgun: a `[group]` marks a route/event-trigger template `sealed = true` and the two suppression filters skip it, so a descendant's `[suppress]` is ignored — it fires/serves on every descendant. Column `sealed BOOLEAN` on `triggers` + `routes` (`0059_sealed_templates.sql`); the trigger anti-join gains `AND t.sealed = FALSE` (a sealed row is never excluded → fires through), and `compile_effective_routes` gates its suppression `continue` on `!er.route.sealed`. `sealed` lives on the shared `Route` DTO + manager-core `Trigger` (both pure data) so the apply diff sees the current value — part of the route Update comparison + the trigger identity, so toggling it re-applies. Authored per-template (`sealed = true` on a `[[routes]]`/`[[triggers.kv|docs|files|pubsub]]`), **group-only** — `validate_bundle_for` rejects it on an app owner (an app resource is never inherited). Sealing only *strengthens* the guarantee (a sealed template can't be declined; it never grants new reach — the chain walk is still the isolation boundary). The dangling-suppress warning also flags a suppression matching only sealed templates ("… is sealed — the suppression has no effect"), and `pic triggers/routes ls --group` show a `sealed` column; pinned by `tests/sealed_templates.rs` + the `sealed` journey. Deferred: group-level suppression, multi-node snapshot propagation). Next: multi-node cluster mode, or extend templates to the stateful kinds, or multi-repo ownership. +**Current focus: v1.2 _Hierarchies_ — groups + the declarative project tool** ([docs/design/groups-and-project-tool.md](docs/design/groups-and-project-tool.md)). That doc's §11 uses its own **Phase 1–6 numbering, distinct from the blueprint product-phase numbering above — do not conflate them** (its "Phase 3" = group-inherited config, not admin auth). Implemented on `feat/groups-*` branches: §11 Phase 1 (declarative `pic plan`/`apply`/`prune` + env overlays), Phase 2 (single-parent groups tree + hierarchy-aware RBAC), Phase 3 (group-inherited, env-scoped `vars` + secrets resolved **live** via a recursive CTE — no materialized cache), Phase 4-lite (group-owned **endpoint** scripts: `scripts` polymorphic owner in `0050_group_scripts.sql`, `get_by_name_inherited`/`is_invocable_by_app` chain resolution, inherited `invoke()` + declarative route/trigger binding — all **live**, no body materialization), Phase 5 (the **declarative project tool maps onto the group tree**: the reconcile engine generalized to `ApplyOwner{App|Group}`, a `[group]` manifest kind, and a single atomic **tree apply** — `pic plan/apply --dir` reconciles a whole directory tree of `picloud.toml` nodes in one Postgres transaction, groups-before-apps so an app route can bind a group script created in the same tx; the bound token folds in each group's `structure_version`. Multi-repo single-owner/attach-point and per-env approval gating are deferred; groups pre-exist), Phase 4b (group **modules** + the **lexical (sealed-by-default) import resolver**, §5.5: owner-polymorphic `ModuleScript`, origin-rooted `ModuleSource::resolve` walking the importing node's chain, `ExecRequest.script_owner` threaded from every dispatch + `invoke()` site, `_source`-driven lexical chaining in `PicloudModuleResolver` with the compiled-module cache re-keyed by `ScriptId`, group modules/imports allowed, single-node dangling-import `plan` check — an inherited group script's imports **seal to the group**, a leaf can't shadow them), §5.5 **extension points** (opt-in polymorphism — **§5.5 now complete**: marker table `0051_extension_points.sql` (owner-polymorphic, CASCADE — structurally a `secrets` name; default body = a co-located `kind=module` script), `ModuleSource::resolve_policy` with **nearest-declaration-kind-wins** — a concrete module resolves lexically, an EP marker resolves **dynamically against the inheriting app** (its override else the default body up-chain), `NoProvider` is a hard error; declarative-only authoring via the `[app]`/`[group]` manifest key `extension_points = [...]`, reconcile mirrors `secrets`, single-node no-provider `plan` check, read-only `pic extension-points ls` + `pull` round-trip — the app can **override** a group default, the deliberate inverse of the Phase 4b sealed import), §11.6 **group-level collections — KV + DOCS + FILES slices** (full cross-app shared read/write: a group declares a collection shared via the `[group]` manifest `collections = [...]` → owner-polymorphic marker `0052_group_collections.sql` with a `kind` discriminator + a per-kind group-keyed store: `0053_group_kv_entries.sql` (`kind='kv'`), `0054_group_docs.sql` (`kind='docs'`, the queryable-JSON store), and `0055_group_files.sql` (`kind='files'`, blob metadata in Postgres + bytes on disk under `/files/groups//...`, a `groups/` infix disjoint from the per-app `files//` subtree so the existing recursive orphan sweeper covers both with zero change) — no `app_id`, a shared row belongs to the group; CASCADE on group delete, an app delete leaves the data. Scripts use the **explicit** `kv::shared_collection("name")` / `docs::shared_collection("name")` / `files::shared_collection("name")` handles (`shared` alone is a Rhai reserved word); `GroupKv`/`GroupDocs`/`GroupFilesServiceImpl` resolve the owning group from `cx.app_id`'s ancestor chain **filtered by kind** (nearest-wins) — **that walk is the isolation boundary**, a foreign app gets `CollectionNotShared`; a `kv`, a `docs`, and a `files` collection of the same name are distinct stores. The docs slice reuses the `docs_filter` DSL — `build_find_query` generalized on its owner column (`docs`/`app_id` vs `group_docs`/`group_id`, both literals); the files slice likewise generalized the atomic-write + checksum-on-read path helpers on an owner-relative dir (one source for the security-sensitive disk mechanics). **Reads open** to any subtree script (anonymous incl. — the declaration is the grant), **writes require an authenticated editor+** on the owning group (`GroupKvRead/Write`, `GroupDocsRead/Write`, `GroupFilesRead/Write`, `script_gate_require_principal` fails closed on anon). Declarative authoring is the **string-or-table** form `collections = ["catalog", { name = "articles", kind = "docs" }, { name = "assets", kind = "files" }]` (bare string = kv); reconcile keys markers by `(name, kind)`; read-only `pic collections ls --group` shows a kind column. Deferred: shared-collection triggers (the "group trigger has no app to watch" problem), topics/queue shared collections (same trigger-centric gap), per-group quotas, an operator admin API for shared blobs (scripts use the SDK; `pic collections ls` shows the marker)), and **§4.5 group TRIGGER templates** (live, event kinds — a `[group]` declares a `[[triggers.kv|docs|files|pubsub]]` template binding a group-owned handler; `triggers` gained a polymorphic owner `0056_group_triggers.sql` mirroring `0050`; the dispatcher's `list_matching_kv/docs/files` + the pubsub publish fan-out prepend `CHAIN_LEVELS_CTE` + `JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner)` so a descendant app's event matches its own triggers **plus** ancestor-group templates in one query, the handler running under the firing `app_id` — **the chain walk is the isolation boundary**, a sibling-subtree app never matches; stateful kinds cron/queue/email rejected on a group (need materialization), per-app opt-out deferred; read-only `pic triggers ls --group`), and **§4.5 group ROUTE templates** (live, inherited — a `[group]` declares a `[[routes]]` template binding a group-owned endpoint; `routes` gained a polymorphic owner `0057_group_routes.sql` mirroring `0056`. Unlike triggers (per-event SQL), routes serve from the in-memory `RouteTable`, so the HTTP hot path can't resolve inheritance per request — instead the table **rebuild** expands templates into each descendant app's slice via `RouteRepository::list_effective` (all-apps generalization of `CHAIN_LEVELS_CTE`: every app × its ancestor chain ⋈ routes), and `compile_effective_routes` applies **nearest-owner-wins shadowing** (an app's own identical binding shadows the inherited template; non-identical bindings coexist under the matcher's existing precedence — a route picks one winner, unlike a fanning trigger). Because the table is a cache, inheritance is rebuilt **full-live** through the single `rebuild_route_table` chokepoint on every edge that changes it: route CRUD, apply, **and tree mutations** — app create/delete (`apps_api`) + group reparent (`groups_api`) — so a new app under a group serves its templates instantly. Host-claim validation is skipped for a group template (descendants serve it on their own host claim; templates use `host_kind = any`). **The chain expansion is the isolation boundary** — a sibling-subtree app never inherits (pinned by `tests/group_route_templates.rs` + the `group_routes` journey); read-only `pic routes ls --group`. Deferred: multi-node snapshot propagation), and **§11 tail per-app opt-out (template suppression)** (a descendant declines an inherited group template: an `[app]` declares `[suppress]` with `triggers = [...]` (handler script names) + `routes = [...]` (paths) — **coarse by reference**, not a full definition, since template row-ids churn on re-apply but a reference is stable (re-apply NoOp, may decline several templates bound to the same script/path). App-only marker `0058_template_suppressions.sql` (`app_id NOT NULL` CASCADE, a `target_kind` discriminator), reconciled with the extension-point marker pattern (prunable → re-inherits). Consumed at the two resolution points: the trigger dispatch queries gain a correlated `NOT EXISTS` anti-join (gated to `t.group_id IS NOT NULL`), and `compile_effective_routes` drops an inherited (`depth > 0`) route at a suppressed path (loaded via `RouteRepository::list_route_suppressions`). **Inheritance-only** — the `group_id IS NOT NULL` / `depth > 0` gates mean an app can only decline what it inherits, never its own or a sibling's (pinned by `tests/template_suppression.rs` + the `suppress` journey); a dangling suppress is an apply-time warning; read-only `pic suppress ls --app`. **Trust-model consequence:** group templates are advisory-by-default (run *unless* a descendant declines) — a footgun for compliance hooks (audit/security triggers a tenant can opt out of)), and **§11 tail `sealed` (mandatory) group templates** (closes that footgun: a `[group]` marks a route/event-trigger template `sealed = true` and the two suppression filters skip it, so a descendant's `[suppress]` is ignored — it fires/serves on every descendant. Column `sealed BOOLEAN` on `triggers` + `routes` (`0059_sealed_templates.sql`); the trigger anti-join gains `AND t.sealed = FALSE` (a sealed row is never excluded → fires through), and `compile_effective_routes` gates its suppression `continue` on `!er.route.sealed`. `sealed` lives on the shared `Route` DTO + manager-core `Trigger` (both pure data) so the apply diff sees the current value — part of the route Update comparison + the trigger identity, so toggling it re-applies. Authored per-template (`sealed = true` on a `[[routes]]`/`[[triggers.kv|docs|files|pubsub]]`), **group-only** — `validate_bundle_for` rejects it on an app owner (an app resource is never inherited). Sealing only *strengthens* the guarantee (a sealed template can't be declined; it never grants new reach — the chain walk is still the isolation boundary). The dangling-suppress warning also flags a suppression matching only sealed templates ("… is sealed — the suppression has no effect"), and `pic triggers/routes ls --group` show a `sealed` column; pinned by `tests/sealed_templates.rs` + the `sealed` journey. Deferred: multi-node snapshot propagation), and **§11 tail M1 group-level suppression** (`template_suppressions` gained a polymorphic owner `0060_group_suppressions.sql` — a `[group]` declares `[suppress]` to decline a template it inherits from a higher ancestor for its **whole subtree**. Both filters generalized to the chain: the trigger anti-join joins the `chain` CTE (`ts.app_id = sc.app_owner OR ts.group_id = sc.group_owner`), and `list_route_suppressions` expands group suppressions across descendants via the all-apps `app_chain` CTE (`compile_effective_routes` unchanged). Still inheritance-only + `sealed` overrides; owner-polymorphic `suppression_repo::list_for_owner`/`insert`/`delete`; read-only `pic suppress ls --group`; the ineffective-suppress warning walks `GROUP_CHAIN_LEVELS_CTE` for a group node. Pinned by `tests/group_suppression.rs` + the `suppress` journey). Next: multi-node cluster mode, or extend templates to the stateful kinds, or multi-repo ownership. **Data-model invariant:** app-owned data-plane tables (KV, docs, files, …) start with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE`; the group-inheritable tables — _config_ (`vars`, `secrets`) and now group-owned _code_ (`scripts`, `0050`) — instead carry a **polymorphic owner**: nullable `group_id` and `app_id` with an exactly-one CHECK and per-owner partial-unique indexes (config is `ON DELETE CASCADE`, scripts `RESTRICT` — code is not data). Inheritance resolves **live** down `apps.group_id → groups.parent_id` via `CHAIN_LEVELS_CTE` (no materialized view); nearest-owner-wins with an app's own row shadowing the inherited one (CoW). Every Rhai SDK call resolves its app from `cx.app_id`, never a script-passed arg, and a group script always runs under the *inheriting* app's `cx.app_id` (the cross-app isolation boundary). diff --git a/crates/manager-core/src/apply_api.rs b/crates/manager-core/src/apply_api.rs index bbde551..8b98a22 100644 --- a/crates/manager-core/src/apply_api.rs +++ b/crates/manager-core/src/apply_api.rs @@ -45,6 +45,7 @@ pub fn apply_router(service: ApplyService) -> Router { .route("/groups/{id}/triggers", get(group_triggers_handler)) .route("/groups/{id}/routes", get(group_routes_handler)) .route("/apps/{id}/suppressions", get(app_suppressions_handler)) + .route("/groups/{id}/suppressions", get(group_suppressions_handler)) .with_state(service) } @@ -64,6 +65,26 @@ async fn app_suppressions_handler( Ok(Json(report)) } +/// Read-only §11 tail M1 suppression report for a group: the inherited templates +/// it declines for its whole subtree (`target_kind`, `reference`). Viewer-tier +/// `GroupScriptsRead`. Backs `pic suppress ls --group`. +async fn group_suppressions_handler( + State(svc): State, + Extension(principal): Extension, + Path(id_or_slug): Path, +) -> Result>, ApplyError> { + let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?; + require( + svc.authz.as_ref(), + &principal, + Capability::GroupScriptsRead(group_id), + ) + .await + .map_err(map_authz)?; + let report = svc.suppression_report(ApplyOwner::Group(group_id)).await?; + Ok(Json(report)) +} + /// Read-only §11 tail route-template report for a group: its own declared route /// templates (method, host, path, handler script, dispatch, enabled). Viewer- /// tier read. Backs `pic routes ls --group`. diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 89fe9ca..009416a 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -1293,16 +1293,17 @@ impl ApplyService { // A group endpoint is reached by *inheritance* (a descendant app binds // it by name), never by its own route, so the "no route/trigger" // warning is noise for every group script — emit it for apps only. - if let ApplyOwner::App(app_id) = owner { + if let ApplyOwner::App(_) = owner { report .warnings .extend(unreachable_endpoint_warnings(bundle)); - // §11 tail: a suppress reference that matches no inherited template - // silently does nothing — warn (typo guard), don't fail. - report - .warnings - .extend(self.dangling_suppress_warnings(app_id, bundle).await?); } + // §11 tail: a suppress reference that matches no inherited template (or + // only sealed ones) silently does nothing — warn (typo guard), don't + // fail. M1: runs for a group node too (it walks the group's own chain). + report + .warnings + .extend(self.dangling_suppress_warnings(owner, bundle).await?); self.reconcile_node_tx( &mut tx, @@ -2151,39 +2152,52 @@ impl ApplyService { /// refs match an ancestor-group route's path. async fn dangling_suppress_warnings( &self, - app_id: AppId, + owner: ApplyOwner, bundle: &Bundle, ) -> Result, ApplyError> { if bundle.suppress_triggers.is_empty() && bundle.suppress_routes.is_empty() { return Ok(Vec::new()); } - // Inherited (ancestor-group) trigger handler names + route paths, each - // 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). + // §11 tail M1: the set of templates a suppression can decline lives on + // the OWNER's chain — an app walks its ancestor groups (`CHAIN_LEVELS_CTE` + // bound to the app), a group walks itself + its ancestors + // (`GROUP_CHAIN_LEVELS_CTE` bound to the group). Both CTEs expose a + // `chain` view with a `group_owner` column, so the queries below are + // identical modulo which CTE + bind. + let (chain_cte, owner_uuid) = match owner { + ApplyOwner::App(a) => (CHAIN_LEVELS_CTE, a.into_inner()), + ApplyOwner::Group(g) => ( + crate::config_resolver::GROUP_CHAIN_LEVELS_CTE, + g.into_inner(), + ), + }; + // Inherited trigger handler names + route paths, each 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_cte} \ SELECT LOWER(s.name), bool_or(NOT t.sealed) FROM triggers t \ JOIN scripts s ON s.id = t.script_id \ JOIN chain c ON t.group_id = c.group_owner \ WHERE t.group_id IS NOT NULL \ GROUP BY LOWER(s.name)", )) - .bind(app_id.into_inner()) + .bind(owner_uuid) .fetch_all(&self.pool) .await .map_err(|e| ApplyError::Backend(e.to_string()))?; let inherited_handlers: HashMap = trig_names.into_iter().collect(); let route_paths: Vec<(String, bool)> = sqlx::query_as(&format!( - "{CHAIN_LEVELS_CTE} \ + "{chain_cte} \ SELECT r.path, bool_or(NOT r.sealed) FROM routes r \ JOIN chain c ON r.group_id = c.group_owner \ WHERE r.group_id IS NOT NULL \ GROUP BY r.path", )) - .bind(app_id.into_inner()) + .bind(owner_uuid) .fetch_all(&self.pool) .await .map_err(|e| ApplyError::Backend(e.to_string()))?; diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index 02f2832..19e9b93 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -1385,6 +1385,19 @@ impl Client { .await?; decode(resp).await } + + /// §11 tail M1: a group's own suppressions (declined for its whole subtree). + pub async fn group_suppressions_list(&self, group_ident: &str) -> Result> { + let ident = seg(group_ident); + let resp = self + .request( + Method::GET, + &format!("/api/v1/admin/groups/{ident}/suppressions"), + ) + .send() + .await?; + decode(resp).await + } } /// One row of the §11 tail suppression report. diff --git a/crates/picloud-cli/src/cmds/suppress.rs b/crates/picloud-cli/src/cmds/suppress.rs index 8086e7d..2bef3a0 100644 --- a/crates/picloud-cli/src/cmds/suppress.rs +++ b/crates/picloud-cli/src/cmds/suppress.rs @@ -1,18 +1,23 @@ -//! `pic suppress ls --app ` — read-only view of an app's §11 tail -//! per-app opt-outs: the inherited group templates it declines. App-only; -//! authoring is declarative via the `[suppress]` block (`triggers = [...]` -//! handler script names, `routes = [...]` paths). +//! `pic suppress ls --app ` / `--group ` — read-only view of an owner's +//! §11 tail opt-outs: the inherited group templates it declines. An app declines +//! for itself; a group declines for its whole subtree (§11 tail M1). Authoring +//! is declarative via the `[suppress]` block (`triggers = [...]` handler script +//! names, `routes = [...]` paths). -use anyhow::Result; +use anyhow::{bail, Result}; use crate::client::Client; use crate::config; use crate::output::{OutputMode, Table}; -pub async fn ls(app: &str, mode: OutputMode) -> Result<()> { +pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; - let items = client.suppressions_list(app).await?; + let items = match (app, group) { + (Some(a), None) => client.suppressions_list(a).await?, + (None, Some(g)) => client.group_suppressions_list(g).await?, + _ => bail!("provide exactly one of --app or --group"), + }; let mut table = Table::new(["kind", "reference"]); for s in &items { diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index a415d7e..e7bc603 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -587,10 +587,14 @@ enum CollectionsCmd { #[derive(Subcommand)] enum SuppressCmd { - /// List the inherited templates an app declines (§11 tail). App-only. + /// List the inherited templates an owner declines (§11 tail). Exactly one + /// of `--app` (declines for itself) or `--group` (declines for its whole + /// subtree, §11 tail M1). Ls { + #[arg(long, conflicts_with = "group")] + app: Option, #[arg(long)] - app: String, + group: Option, }, } @@ -2042,8 +2046,8 @@ async fn main() -> ExitCode { cmd: CollectionsCmd::Ls { group }, } => cmds::collections::ls(&group, mode).await, Cmd::Suppress { - cmd: SuppressCmd::Ls { app }, - } => cmds::suppress::ls(&app, mode).await, + cmd: SuppressCmd::Ls { app, group }, + } => cmds::suppress::ls(app.as_deref(), group.as_deref(), mode).await, Cmd::Files { cmd: FilesCmd::Ls { diff --git a/crates/picloud-cli/tests/suppress.rs b/crates/picloud-cli/tests/suppress.rs index 2bfc33f..e087922 100644 --- a/crates/picloud-cli/tests/suppress.rs +++ b/crates/picloud-cli/tests/suppress.rs @@ -201,3 +201,100 @@ fn app_suppresses_inherited_route_template() { "pruning the suppression must re-inherit the route" ); } + +/// §11 tail M1: a CHILD group declares `[suppress]` for a template it inherits +/// from its PARENT → every app in the child's subtree declines it; a `pic +/// suppress ls --group` shows the marker. +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn group_suppresses_inherited_route_template_for_subtree() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let parent = common::unique_slug("gsup-p"); + let child = common::unique_slug("gsup-c"); + + let _gp = GroupGuard::new(&env.url, &env.token, &parent); + let _gc = GroupGuard::new(&env.url, &env.token, &child); + common::pic_as(&env) + .args(["groups", "create", &parent]) + .assert() + .success(); + common::pic_as(&env) + .args(["groups", "create", &child, "--parent", &parent]) + .assert() + .success(); + + // Parent handler + a route template binding it. + let dir = manifest_dir(); + fs::write( + dir.path().join("scripts/phello.rhai"), + r#"log::info("parent route"); "ok""#, + ) + .unwrap(); + common::pic_as(&env) + .args(["scripts", "deploy"]) + .arg(dir.path().join("scripts/phello.rhai")) + .args(["--group", &parent, "--name", "phello"]) + .assert() + .success(); + let _ps = ScriptGuard::new( + &env.url, + &env.token, + &group_script_id(&env, &parent, "phello"), + ); + let pmanifest = format!( + "[group]\nslug = \"{parent}\"\nname = \"P\"\n\n\ + [[routes]]\nscript = \"phello\"\npath = \"/phello\"\npath_kind = \"exact\"\n\ + host_kind = \"any\"\n" + ); + let ppath = dir.path().join("parent.toml"); + fs::write(&ppath, &pmanifest).unwrap(); + common::pic_as(&env) + .args(["apply", "--file"]) + .arg(&ppath) + .assert() + .success(); + + // App under the CHILD group inherits the parent's route template. + let app = common::unique_slug("gsup-app"); + let _a = AppGuard::new(&env.url, &env.token, &app); + common::pic_as(&env) + .args(["apps", "create", &app, "--group", &child]) + .assert() + .success(); + assert!(route_matches(&env, &app, "http://localhost/phello")); + + // The CHILD group applies a [suppress] declining /phello → the whole child + // subtree stops serving it. + let cmanifest = format!( + "[group]\nslug = \"{child}\"\nname = \"C\"\n\n\ + [suppress]\nroutes = [\"/phello\"]\n" + ); + let cpath = dir.path().join("child.toml"); + fs::write(&cpath, &cmanifest).unwrap(); + common::pic_as(&env) + .args(["apply", "--file"]) + .arg(&cpath) + .assert() + .success(); + assert!( + !route_matches(&env, &app, "http://localhost/phello"), + "an app under the suppressing group must stop serving the inherited route" + ); + + // `pic suppress ls --group` shows the child's marker. + let ls = String::from_utf8( + common::pic_as(&env) + .args(["suppress", "ls", "--group", &child]) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert!( + ls.contains("route") && ls.contains("/phello"), + "suppress ls --group should list the route suppression:\n{ls}" + ); +} diff --git a/docs/design/groups-and-project-tool.md b/docs/design/groups-and-project-tool.md index 1a6ff18..bc1e1ae 100644 --- a/docs/design/groups-and-project-tool.md +++ b/docs/design/groups-and-project-tool.md @@ -457,7 +457,18 @@ Two distinct constraints: > row, like any definitional change). A suppression that matches only sealed templates is an apply-time > **warning** ("… is sealed — the suppression has no effect"); `pic triggers/routes ls --group` show a > `sealed` column. Pinned by `manager-core/tests/sealed_templates.rs` + the `sealed` journey. -> **Deferred:** group-level suppression (decline an ancestor's template for a whole subtree). +> +> **Group-level suppression (✅, M1).** `template_suppressions` gained a polymorphic owner +> (`0060_group_suppressions.sql`, mirroring the triggers/routes reshape): a `[group]` may now declare +> `[suppress]` to decline a template it inherits from a **higher ancestor**, for its **whole subtree**. +> The two consumption filters generalized to the chain: the trigger anti-join joins the `chain` CTE +> (`ts.app_id = sc.app_owner OR ts.group_id = sc.group_owner`) so a suppression owned by the firing app OR +> any ancestor group applies; `list_route_suppressions` expands group-owned suppressions across +> descendants via the all-apps `app_chain` CTE, so `compile_effective_routes` is unchanged. Still +> inheritance-only (`t.group_id IS NOT NULL` / `depth > 0`) and `sealed` still overrides. Read-only +> `pic suppress ls --group`; the dangling/ineffective warning walks the group's own chain +> (`GROUP_CHAIN_LEVELS_CTE`). Pinned by `manager-core/tests/group_suppression.rs` + the `suppress` +> journey. **Deferred:** none for suppression. ### 4.6 Secrets & `pull`