diff --git a/CLAUDE.md b/CLAUDE.md index 2332eb0..1cd367e 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 attach-point ceiling + blast-radius and per-env approval gating are deferred; the single-owner ownership **claim** shipped as §7 M1 (see the tail); 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. Topic shared collections shipped as D2 (storeless), queue as D3 — see below), 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 need materialization — see M5 below; 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), and **§11.6 M2 shared-collection triggers** (a write to a group SHARED collection now fires a trigger — closes the "group trigger has no app to watch" gap. A group-owned trigger marked `shared = true` (`shared BOOLEAN` on `triggers`, `0061_shared_triggers.sql`) watches the group's shared collection; the per-app `list_matching_kv/docs/files` add `AND t.shared = FALSE`, new `list_matching_shared_{kv,docs,files}(owning_group,…)` select `shared = TRUE` triggers on the owning group — the `shared` flag is the namespace boundary. `ServiceEventEmitter` gained `emit_shared(cx, owning_group, event)`; `GroupKv/Docs/FilesServiceImpl` (via a `with_events` builder) emit on write, and the outbox emitter stamps the WRITER app_id so the handler runs under the writer. `shared` is authored on a group `[[triggers.kv|docs|files]]`, is part of the diff identity, and `validate_bundle_for` rejects it on an app owner / a non-collection kind / an undeclared collection. Read-only `shared` column in `pic triggers ls --group`; pinned by `tests/shared_triggers.rs` + the `shared_triggers` journey. Shared pubsub triggers shipped as D2 — see below), and **§11.6 M3 per-group quotas** (global env-var ceilings enforced in the group write path: `PICLOUD_GROUP_KV_MAX_ROWS`/`_DOCS_MAX_ROWS` (per-group row count, new-key only) + `_FILES_MAX_TOTAL_BYTES` (per-group total bytes); `group_quota` helpers + `count_rows`/`total_bytes` repo methods + `QuotaExceeded` errors), and **§11.6 M4 read-only operator admin API** (`group_blobs_api` mirrors the per-app kv/files admin surface for a group's shared collections: `GET /groups/{id}/{kv,docs,files}[/{collection}/{key|id}]`, authz `GroupKvRead`/`GroupDocsRead`/`GroupFilesRead`; the host hoists the group repos to share one instance; `pic kv ls/get --group`; reads-only, writes stay script-only), and **§4.5 M5 stateful group trigger templates via materialization** (cron + queue + email — the kinds that can't resolve live because each needs a per-app row: cron `last_fired_at`, the queue one-consumer advisory lock, the email sealed inbound secret. A group `[[triggers.cron|queue|email]]` template is **materialized** into an app-owned copy per descendant (`materialized_from` col, `0062_materialized_triggers.sql`) by `materialize::rematerialize_stateful_templates` — all-apps `app_chain` CTE ⋈ group-owned stateful templates, a precise create/delete diff preserving cron state — run full-live at the route-rebuild chokepoints (apply single+tree, app create/delete in `apps_api`, group reparent in `groups_api`; `AppsState`/`GroupsState` gained a `pool`). The scheduler + `list_active_queue_consumers` + `email_inbound_target` gained `AND t.app_id IS NOT NULL` so a group TEMPLATE is never dispatched directly (nor invocable via its own webhook URL) — only its per-app copies are; a queue copy is skipped-with-warning when the app already fills that queue's slot. **M5.5 email uses the shared-group-secret model:** the template resolves its `inbound_secret_ref` against the **group's own** secret store once at apply and seals it (`resolve_and_seal`/`insert_email_trigger_tx` generalized to a `SecretOwner`/`ScriptOwner`); email secrets are v0/no-AAD so the ciphertext is portable across rows — materialization copies the sealed bytes **verbatim** (no master key, no reseal, no new schema column, no threading into the CRUD hooks). All descendant webhooks share the one group HMAC secret; an unset group secret fails apply hard. Loading a group's own set-secret names into `CurrentState` (was hardcoded empty) lets the plan-time email-secret check resolve against the group. Pinned by `tests/stateful_templates.rs` + the `stateful_templates` journey. **Deferred:** the per-app (non-shared) email secret model), and **D1 the `materialized` column** (`pic triggers ls --app` now shows a read-only `materialized` column — a copy of an M5 group stateful template reads `true`, distinct from a hand-authored trigger; a derived `materialized` bool = `materialized_from IS NOT NULL` threaded row→domain→API→CLI mirroring `sealed`/`shared`), and **§11.6 D2 shared TOPICS + shared pub/sub triggers** (a group declares a storeless `topic` shared collection (`group_collections.kind` widened to `topic`+`queue` in `0064`); a `[[triggers.pubsub]] shared = true` handler watches it. `BundleTrigger::Pubsub` gained `shared` (part of the identity); `validate_bundle_for` requires the topic pattern's ROOT segment (`events.*` → `events`) be a declared `kind='topic'` collection, group-only. Scripts publish via the explicit `pubsub::shared_topic("events").publish("created", msg)` handle → `GroupPubsubServiceImpl` resolves the owning group (kind `topic`) from `cx.app_id`'s chain, requires editor+ (`GroupPubsubPublish`, fails closed on anon), and fans out via `PubsubRepo::fan_out_shared_publish` to `shared = true` pubsub triggers on that group, each outbox row stamped the WRITER app_id (M2 model). The per-app `fan_out_publish` gained `AND t.shared = FALSE` — a shared trigger never fires on a per-app publish and vice-versa (the `shared` flag is the namespace boundary); the owning-group chain walk is the isolation boundary. Pinned by `tests/shared_topics.rs` + the `shared_topics` journey. **Deferred:** shared-topic external SSE subscription), and **§11.6 D3 shared durable QUEUES** (a group declares a `queue` shared collection; any subtree app enqueues into ONE group-keyed store (`group_queue_messages`, `0065`, mirrors `queue_messages` keyed by `(group_id, collection)`; CASCADE on group delete) via `queue::shared_collection("name").enqueue(...)` → `GroupQueueServiceImpl` resolves the owning group (kind `queue`), requires editor+ (`GroupQueueEnqueue`, fails closed on anon). **Consumption is by COMPETING CONSUMERS:** a group `[[triggers.queue]] shared = true` consumer (shared threaded through `BundleTrigger::Queue` + identity) **materializes** a consumer copy per descendant app (`materialize` skips the M5 one-consumer-slot check for shared — each descendant intentionally gets a consumer), and all copies claim the SHARED store with `FOR UPDATE SKIP LOCKED` — each message delivered at-most-once across the subtree, scaling horizontally, each handler under its own `cx.app_id`. The dispatcher's queue arm gained a `shared_group` on `ActiveQueueConsumer` (recovered via a LEFT JOIN to the materialized copy's source template) and routes claim/ack/nack/terminal to the group store when set (`q_claim/q_ack/q_nack/q_terminal` helpers; a group claim normalizes to a `ClaimedMessage` under the consuming app); the reclaim task drains both stores. `validate_bundle_for` requires a shared queue on a group to name a declared `kind='queue'` collection; a shared queue on an app is rejected by the app-owner shared guard. Pinned by `tests/group_queue.rs` (competing-consumer at-most-once) + `stateful_templates.rs` + the `shared_queues` journey. **Deferred:** a group dead-letter store (an exhausted shared-queue message is dropped-with-warning, never silently lost)), and **§7 multi-repo ownership M1 — the single-owner claim** (the `owner_project` seam (0047) is now live behind a first-class `projects` table (`0066_projects.sql`, UUID pk + unique slug; `owner_project` FKs it `ON DELETE SET NULL` — un-claim, never cascade-destroy a tree). A `[project]` block (slug + optional name) in the repo's ROOT manifest declares identity (independent of the `[app]`/`[group]` XOR); the first apply with a new slug registers the project and **claims** each group node it touches. The claim runs inside the apply tx under the per-node advisory lock **before the diff**, so a conflict short-circuits with a **409** before any write. Pure policy `decide_group_claim`/`decide_app_owner` in `apply_service` (unit-tested, DB-free): unclaimed→claim, owner→no-op, foreign→conflict unless `--takeover` (which additionally requires `GroupAdmin` — ownership ⟂ RBAC, mapped to 403 vs the 409 conflict), no-project-into-a-claimed-subtree→conflict. **Apps carry no owner** — an app inherits ownership from its **nearest claimed ancestor group** (the ancestor walk, via `groups.ancestors` now carrying `owner_project` through its recursive CTE, is the isolation boundary); an unclaimed subtree stays open, so nothing changes until a repo first declares `[project]` (backward-compatible). `ProjectRepository` (read side) + tx free-fns `upsert_project_tx`/`read_group_owner_tx`/`write_group_owner_tx`; the claim deliberately does **not** bump `structure_version` (not a diff change → won't churn a pending bound plan). Wire: `project`/`takeover` on the apply request (both `#[serde(default)]` → the pre-M1 CLI stays compatible); the CLI surfaces the server's 409 message verbatim (covers `StateMoved` + `OwnershipConflict`). Visibility: `pic groups ls` `owner` column (server `list_with_owner` LEFT JOIN; the shared `Group` deserialize ignores the extra field so `pic groups tree`/dashboard are unaffected) + `pic apply --takeover`. Pinned by `apply_service` unit tests, `tests/projects_repo.rs`, and the `apply_ownership` journey. **M2 shipped — the attach-point ceiling:** a `[project] parent_group = ""` binds the repo under a pre-existing group; `check_within_attach` refuses (422 `OutsideAttachPoint`) any node not strictly within that subtree (a group node must be a *proper* descendant — you can't apply the attach point itself; an app node's group must be at-or-below it), resolved via `groups.ancestors` and enforced read-only before the claim in `apply_owner`/`apply_tree`; absent = instance root = no ceiling. **Deferred:** M3 the plan-time cross-repo **blast-radius** preview + `pic projects ls`; per-env approval gating; structural-divergence detection + declarative group create/reparent (lifting "groups pre-exist")). Next: multi-repo ownership M3 (blast-radius + `pic projects ls`), then multi-node cluster mode. +**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 attach-point ceiling + blast-radius and per-env approval gating are deferred; the single-owner ownership **claim** shipped as §7 M1 (see the tail); 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. Topic shared collections shipped as D2 (storeless), queue as D3 — see below), 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 need materialization — see M5 below; 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), and **§11.6 M2 shared-collection triggers** (a write to a group SHARED collection now fires a trigger — closes the "group trigger has no app to watch" gap. A group-owned trigger marked `shared = true` (`shared BOOLEAN` on `triggers`, `0061_shared_triggers.sql`) watches the group's shared collection; the per-app `list_matching_kv/docs/files` add `AND t.shared = FALSE`, new `list_matching_shared_{kv,docs,files}(owning_group,…)` select `shared = TRUE` triggers on the owning group — the `shared` flag is the namespace boundary. `ServiceEventEmitter` gained `emit_shared(cx, owning_group, event)`; `GroupKv/Docs/FilesServiceImpl` (via a `with_events` builder) emit on write, and the outbox emitter stamps the WRITER app_id so the handler runs under the writer. `shared` is authored on a group `[[triggers.kv|docs|files]]`, is part of the diff identity, and `validate_bundle_for` rejects it on an app owner / a non-collection kind / an undeclared collection. Read-only `shared` column in `pic triggers ls --group`; pinned by `tests/shared_triggers.rs` + the `shared_triggers` journey. Shared pubsub triggers shipped as D2 — see below), and **§11.6 M3 per-group quotas** (global env-var ceilings enforced in the group write path: `PICLOUD_GROUP_KV_MAX_ROWS`/`_DOCS_MAX_ROWS` (per-group row count, new-key only) + `_FILES_MAX_TOTAL_BYTES` (per-group total bytes); `group_quota` helpers + `count_rows`/`total_bytes` repo methods + `QuotaExceeded` errors), and **§11.6 M4 read-only operator admin API** (`group_blobs_api` mirrors the per-app kv/files admin surface for a group's shared collections: `GET /groups/{id}/{kv,docs,files}[/{collection}/{key|id}]`, authz `GroupKvRead`/`GroupDocsRead`/`GroupFilesRead`; the host hoists the group repos to share one instance; `pic kv ls/get --group`; reads-only, writes stay script-only), and **§4.5 M5 stateful group trigger templates via materialization** (cron + queue + email — the kinds that can't resolve live because each needs a per-app row: cron `last_fired_at`, the queue one-consumer advisory lock, the email sealed inbound secret. A group `[[triggers.cron|queue|email]]` template is **materialized** into an app-owned copy per descendant (`materialized_from` col, `0062_materialized_triggers.sql`) by `materialize::rematerialize_stateful_templates` — all-apps `app_chain` CTE ⋈ group-owned stateful templates, a precise create/delete diff preserving cron state — run full-live at the route-rebuild chokepoints (apply single+tree, app create/delete in `apps_api`, group reparent in `groups_api`; `AppsState`/`GroupsState` gained a `pool`). The scheduler + `list_active_queue_consumers` + `email_inbound_target` gained `AND t.app_id IS NOT NULL` so a group TEMPLATE is never dispatched directly (nor invocable via its own webhook URL) — only its per-app copies are; a queue copy is skipped-with-warning when the app already fills that queue's slot. **M5.5 email uses the shared-group-secret model:** the template resolves its `inbound_secret_ref` against the **group's own** secret store once at apply and seals it (`resolve_and_seal`/`insert_email_trigger_tx` generalized to a `SecretOwner`/`ScriptOwner`); email secrets are v0/no-AAD so the ciphertext is portable across rows — materialization copies the sealed bytes **verbatim** (no master key, no reseal, no new schema column, no threading into the CRUD hooks). All descendant webhooks share the one group HMAC secret; an unset group secret fails apply hard. Loading a group's own set-secret names into `CurrentState` (was hardcoded empty) lets the plan-time email-secret check resolve against the group. Pinned by `tests/stateful_templates.rs` + the `stateful_templates` journey. **Deferred:** the per-app (non-shared) email secret model), and **D1 the `materialized` column** (`pic triggers ls --app` now shows a read-only `materialized` column — a copy of an M5 group stateful template reads `true`, distinct from a hand-authored trigger; a derived `materialized` bool = `materialized_from IS NOT NULL` threaded row→domain→API→CLI mirroring `sealed`/`shared`), and **§11.6 D2 shared TOPICS + shared pub/sub triggers** (a group declares a storeless `topic` shared collection (`group_collections.kind` widened to `topic`+`queue` in `0064`); a `[[triggers.pubsub]] shared = true` handler watches it. `BundleTrigger::Pubsub` gained `shared` (part of the identity); `validate_bundle_for` requires the topic pattern's ROOT segment (`events.*` → `events`) be a declared `kind='topic'` collection, group-only. Scripts publish via the explicit `pubsub::shared_topic("events").publish("created", msg)` handle → `GroupPubsubServiceImpl` resolves the owning group (kind `topic`) from `cx.app_id`'s chain, requires editor+ (`GroupPubsubPublish`, fails closed on anon), and fans out via `PubsubRepo::fan_out_shared_publish` to `shared = true` pubsub triggers on that group, each outbox row stamped the WRITER app_id (M2 model). The per-app `fan_out_publish` gained `AND t.shared = FALSE` — a shared trigger never fires on a per-app publish and vice-versa (the `shared` flag is the namespace boundary); the owning-group chain walk is the isolation boundary. Pinned by `tests/shared_topics.rs` + the `shared_topics` journey. **Deferred:** shared-topic external SSE subscription), and **§11.6 D3 shared durable QUEUES** (a group declares a `queue` shared collection; any subtree app enqueues into ONE group-keyed store (`group_queue_messages`, `0065`, mirrors `queue_messages` keyed by `(group_id, collection)`; CASCADE on group delete) via `queue::shared_collection("name").enqueue(...)` → `GroupQueueServiceImpl` resolves the owning group (kind `queue`), requires editor+ (`GroupQueueEnqueue`, fails closed on anon). **Consumption is by COMPETING CONSUMERS:** a group `[[triggers.queue]] shared = true` consumer (shared threaded through `BundleTrigger::Queue` + identity) **materializes** a consumer copy per descendant app (`materialize` skips the M5 one-consumer-slot check for shared — each descendant intentionally gets a consumer), and all copies claim the SHARED store with `FOR UPDATE SKIP LOCKED` — each message delivered at-most-once across the subtree, scaling horizontally, each handler under its own `cx.app_id`. The dispatcher's queue arm gained a `shared_group` on `ActiveQueueConsumer` (recovered via a LEFT JOIN to the materialized copy's source template) and routes claim/ack/nack/terminal to the group store when set (`q_claim/q_ack/q_nack/q_terminal` helpers; a group claim normalizes to a `ClaimedMessage` under the consuming app); the reclaim task drains both stores. `validate_bundle_for` requires a shared queue on a group to name a declared `kind='queue'` collection; a shared queue on an app is rejected by the app-owner shared guard. Pinned by `tests/group_queue.rs` (competing-consumer at-most-once) + `stateful_templates.rs` + the `shared_queues` journey. **Deferred:** a group dead-letter store (an exhausted shared-queue message is dropped-with-warning, never silently lost)), and **§7 multi-repo ownership M1 — the single-owner claim** (the `owner_project` seam (0047) is now live behind a first-class `projects` table (`0066_projects.sql`, UUID pk + unique slug; `owner_project` FKs it `ON DELETE SET NULL` — un-claim, never cascade-destroy a tree). A `[project]` block (slug + optional name) in the repo's ROOT manifest declares identity (independent of the `[app]`/`[group]` XOR); the first apply with a new slug registers the project and **claims** each group node it touches. The claim runs inside the apply tx under the per-node advisory lock **before the diff**, so a conflict short-circuits with a **409** before any write. Pure policy `decide_group_claim`/`decide_app_owner` in `apply_service` (unit-tested, DB-free): unclaimed→claim, owner→no-op, foreign→conflict unless `--takeover` (which additionally requires `GroupAdmin` — ownership ⟂ RBAC, mapped to 403 vs the 409 conflict), no-project-into-a-claimed-subtree→conflict. **Apps carry no owner** — an app inherits ownership from its **nearest claimed ancestor group** (the ancestor walk, via `groups.ancestors` now carrying `owner_project` through its recursive CTE, is the isolation boundary); an unclaimed subtree stays open, so nothing changes until a repo first declares `[project]` (backward-compatible). `ProjectRepository` (read side) + tx free-fns `upsert_project_tx`/`read_group_owner_tx`/`write_group_owner_tx`; the claim deliberately does **not** bump `structure_version` (not a diff change → won't churn a pending bound plan). Wire: `project`/`takeover` on the apply request (both `#[serde(default)]` → the pre-M1 CLI stays compatible); the CLI surfaces the server's 409 message verbatim (covers `StateMoved` + `OwnershipConflict`). Visibility: `pic groups ls` `owner` column (server `list_with_owner` LEFT JOIN; the shared `Group` deserialize ignores the extra field so `pic groups tree`/dashboard are unaffected) + `pic apply --takeover`. Pinned by `apply_service` unit tests, `tests/projects_repo.rs`, and the `apply_ownership` journey. **M2 shipped — the attach-point ceiling:** a `[project] parent_group = ""` binds the repo under a pre-existing group; `check_within_attach` refuses (422 `OutsideAttachPoint`) any node not strictly within that subtree (a group node must be a *proper* descendant — you can't apply the attach point itself; an app node's group must be at-or-below it), resolved via `groups.ancestors` and enforced read-only before the claim in `apply_owner`/`apply_tree`; absent = instance root = no ceiling. **M3 shipped — plan preview + `pic projects ls`:** `pic plan` now carries the `[project]` and returns an `ownership` preview per node (`claim`/`owned`/`conflict`-owner-named/`unclaimed`, pure `preview_ownership`) plus, for a group node, the cross-repo **blast radius** (descendant apps owned by OTHER projects the change reaches, via `group_blast_radius` — a subtree CTE with per-group memoized nearest-claimed resolution); the attach ceiling is previewed at plan too. Read-only `pic projects ls` (`GET /api/v1/admin/projects`, `list_with_counts`) lists projects + owned-group counts. Pinned by the `apply_ownership` plan-preview case. **With M3 the §7 multi-repo ownership track (M1 claim · M2 attach ceiling · M3 preview + `pic projects ls`) is COMPLETE. Deferred (separate later work):** §6 structural-divergence detection + declarative group create/reparent (lifting "groups pre-exist"); per-env approval gating (`[project.environments]`)). Next: multi-node cluster mode. **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/lib.rs b/crates/manager-core/src/lib.rs index cf42a35..005b379 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -80,6 +80,7 @@ pub mod outbox_event_emitter; pub mod outbox_repo; pub mod principal_resolver; pub mod project_repo; +pub mod projects_api; pub mod pubsub_repo; pub mod pubsub_service; pub mod queue_repo; @@ -225,6 +226,7 @@ pub use outbox_repo::{ }; pub use principal_resolver::{AdminPrincipalResolver, PrincipalResolver, PrincipalResolverError}; pub use project_repo::{PostgresProjectRepository, ProjectRepository, ProjectRepositoryError}; +pub use projects_api::{projects_router, ProjectsApiError, ProjectsState}; pub use pubsub_repo::{PostgresPubsubRepo, PublishCtx, PubsubRepo, PubsubRepoError}; pub use pubsub_service::{PubsubServiceImpl, SubscriberTokenConfig}; pub use realtime_authority::RealtimeAuthorityImpl; diff --git a/crates/manager-core/src/project_repo.rs b/crates/manager-core/src/project_repo.rs index beabd5b..803ff1a 100644 --- a/crates/manager-core/src/project_repo.rs +++ b/crates/manager-core/src/project_repo.rs @@ -21,6 +21,9 @@ pub enum ProjectRepositoryError { pub trait ProjectRepository: Send + Sync { /// Every project on the instance, ordered by slug. async fn list(&self) -> Result, ProjectRepositoryError>; + /// Every project paired with how many group nodes it owns — backs + /// `pic projects ls`. One GROUP BY instead of N per-project counts. + async fn list_with_counts(&self) -> Result, ProjectRepositoryError>; async fn get_by_id(&self, id: ProjectId) -> Result, ProjectRepositoryError>; async fn get_by_slug(&self, slug: &str) -> Result, ProjectRepositoryError>; } @@ -40,6 +43,21 @@ const PROJECT_COLS: &str = "id, slug, name, created_by, created_at"; #[async_trait] impl ProjectRepository for PostgresProjectRepository { + async fn list_with_counts(&self) -> Result, ProjectRepositoryError> { + let rows = sqlx::query_as::<_, ProjectCountRow>( + "SELECT p.id, p.slug, p.name, p.created_by, p.created_at, \ + COUNT(g.id) AS owned_groups \ + FROM projects p LEFT JOIN groups g ON g.owner_project = p.id \ + GROUP BY p.id ORDER BY p.slug", + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| (r.project.into(), r.owned_groups)) + .collect()) + } + async fn list(&self) -> Result, ProjectRepositoryError> { let rows = sqlx::query_as::<_, ProjectRow>(&format!( "SELECT {PROJECT_COLS} FROM projects ORDER BY slug" @@ -79,6 +97,14 @@ struct ProjectRow { created_at: chrono::DateTime, } +/// `list_with_counts` row: a project flattened with its owned-group count. +#[derive(sqlx::FromRow)] +struct ProjectCountRow { + #[sqlx(flatten)] + project: ProjectRow, + owned_groups: i64, +} + impl From for Project { fn from(r: ProjectRow) -> Self { Self { diff --git a/crates/manager-core/src/projects_api.rs b/crates/manager-core/src/projects_api.rs new file mode 100644 index 0000000..0fb9a10 --- /dev/null +++ b/crates/manager-core/src/projects_api.rs @@ -0,0 +1,81 @@ +//! §7 M3 read-only projects surface: `GET /api/v1/admin/projects` — every +//! project (repo-root) with the count of group nodes it owns. Backs +//! `pic projects ls`. Behind the `/admin` middleware, so any authenticated +//! admin can list them (a project reveals only org structure, like the groups +//! list); ownership WRITES stay in the apply path. + +use std::sync::Arc; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Json, Response}; +use axum::routing::get; +use axum::{Extension, Router}; +use chrono::{DateTime, Utc}; +use picloud_shared::Principal; +use serde::Serialize; +use serde_json::json; + +use crate::project_repo::{ProjectRepository, ProjectRepositoryError}; + +#[derive(Clone)] +pub struct ProjectsState { + pub projects: Arc, +} + +/// Mounted under `/api/v1/admin`. +pub fn projects_router(state: ProjectsState) -> Router { + Router::new() + .route("/projects", get(list_projects)) + .with_state(state) +} + +#[derive(Serialize)] +struct ProjectListItem { + slug: String, + name: String, + owned_groups: i64, + created_at: DateTime, +} + +async fn list_projects( + State(s): State, + Extension(_principal): Extension, +) -> Result>, ProjectsApiError> { + let items = s + .projects + .list_with_counts() + .await? + .into_iter() + .map(|(p, owned_groups)| ProjectListItem { + slug: p.slug, + name: p.name, + owned_groups, + created_at: p.created_at, + }) + .collect(); + Ok(Json(items)) +} + +#[derive(Debug, thiserror::Error)] +pub enum ProjectsApiError { + #[error("backend: {0}")] + Backend(String), +} + +impl From for ProjectsApiError { + fn from(e: ProjectRepositoryError) -> Self { + Self::Backend(e.to_string()) + } +} + +impl IntoResponse for ProjectsApiError { + fn into_response(self) -> Response { + tracing::error!(error = %self, "projects api error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "internal error" })), + ) + .into_response() + } +} diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index fb98f6e..079159b 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -174,6 +174,16 @@ impl Client { decode(resp).await } + /// `GET /api/v1/admin/projects` — every §7 project + its owned-group count. + /// Backs `pic projects ls`. + pub async fn projects_list(&self) -> Result> { + let resp = self + .request(Method::GET, "/api/v1/admin/projects") + .send() + .await?; + decode(resp).await + } + /// `GET /api/v1/admin/groups/{id_or_slug}` — group + path + children. pub async fn groups_get(&self, ident: &str) -> Result { let ident = seg(ident); @@ -1708,6 +1718,17 @@ pub struct GroupListItem { pub owner: Option, } +/// One row of `pic projects ls` (§7 M3): a project + how many group nodes it +/// owns. +#[derive(Debug, Deserialize)] +pub struct ProjectDto { + pub slug: String, + pub name: String, + #[serde(default)] + pub owned_groups: i64, + pub created_at: DateTime, +} + #[derive(Debug, Serialize)] pub struct CreateRouteBody<'a> { pub host_kind: HostKind, diff --git a/crates/picloud-cli/src/cmds/mod.rs b/crates/picloud-cli/src/cmds/mod.rs index b9993f0..e42e176 100644 --- a/crates/picloud-cli/src/cmds/mod.rs +++ b/crates/picloud-cli/src/cmds/mod.rs @@ -16,6 +16,7 @@ pub mod logout; pub mod logs; pub mod members; pub mod plan; +pub mod projects; pub mod pull; pub mod queues; pub mod routes; diff --git a/crates/picloud-cli/src/cmds/projects.rs b/crates/picloud-cli/src/cmds/projects.rs new file mode 100644 index 0000000..d051f7a --- /dev/null +++ b/crates/picloud-cli/src/cmds/projects.rs @@ -0,0 +1,29 @@ +//! `pic projects` — read-only view of §7 multi-repo ownership projects. +//! +//! A project is the repo-root that declaratively owns a slice of the group +//! tree. Ownership is CLAIMED by `pic apply` (first apply with a `[project]` +//! slug registers it); this command only lists what exists. + +use anyhow::Result; + +use crate::client::Client; +use crate::config; +use crate::output::{OutputMode, Table}; + +/// `pic projects ls` — every project + how many group nodes it owns. +pub async fn ls(mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let projects = client.projects_list().await?; + let mut table = Table::new(["slug", "name", "owned_groups", "created_at"]); + for p in &projects { + table.row([ + p.slug.clone(), + p.name.clone(), + p.owned_groups.to_string(), + p.created_at.to_rfc3339(), + ]); + } + table.print(mode); + Ok(()) +} diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index ae7dea5..557829b 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -58,6 +58,12 @@ enum Cmd { cmd: GroupsCmd, }, + /// §7 multi-repo ownership projects (read-only). + Projects { + #[command(subcommand)] + cmd: ProjectsCmd, + }, + /// Script management. Scripts { #[command(subcommand)] @@ -490,6 +496,12 @@ enum AppsCmd { }, } +#[derive(Subcommand)] +enum ProjectsCmd { + /// List all projects + how many group nodes each owns. + Ls, +} + #[derive(Subcommand)] enum GroupsCmd { /// List all groups (flat). @@ -1509,6 +1521,9 @@ async fn main() -> ExitCode { cmd: DomainsCmd::Rm { app, domain_id }, }, } => cmds::apps_domains::rm(&app, &domain_id).await, + Cmd::Projects { + cmd: ProjectsCmd::Ls, + } => cmds::projects::ls(mode).await, Cmd::Groups { cmd: GroupsCmd::Ls } => cmds::groups::ls(mode).await, Cmd::Groups { cmd: GroupsCmd::Tree, diff --git a/crates/picloud-cli/tests/apply_ownership.rs b/crates/picloud-cli/tests/apply_ownership.rs index 7806ceb..fcefc30 100644 --- a/crates/picloud-cli/tests/apply_ownership.rs +++ b/crates/picloud-cli/tests/apply_ownership.rs @@ -354,6 +354,31 @@ fn plan_previews_ownership_and_blast_radius() { claim(&plat, &team_a); claim(&teamb, &team_b); + // `pic projects ls` lists both registered projects with their owned-group + // counts (plat owns exactly team_a). + let projects = String::from_utf8( + common::pic_as(&env) + .args(["projects", "ls"]) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + let plat_row = projects + .lines() + .map(common::cells) + .find(|c| c.first() == Some(&plat.as_str())) + .unwrap_or_else(|| panic!("plat not in projects ls:\n{projects}")); + assert_eq!( + plat_row.get(2).copied(), + Some("1"), + "plat owns exactly one group:\n{projects}" + ); + assert!( + projects.contains(teamb.as_str()), + "teamb must be listed:\n{projects}" + ); + // Plan acme with project `platform`: acme is unclaimed → action `claim`; the // blast radius lists the OTHER projects' descendant apps (plat + teamb). fs::write( diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index fd6f8a5..0f4cd53 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -13,31 +13,31 @@ use picloud_manager_core::{ admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api, apps_router, attach_principal_if_present, auth_router, dead_letters_router, dev_emails_router, email_inbound_router, files_admin_router, groups_router, kv_admin_router, migrations, - rebuild_route_table, require_authenticated, route_admin_router, secrets_router, topics_router, - triggers_router, vars_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, - AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, - AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService, - AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher, - DocsServiceImpl, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig, - FilesServiceImpl, FsFilesRepo, FsGroupFilesRepo, GroupDocsServiceImpl, GroupFilesServiceImpl, - GroupKvServiceImpl, GroupMembersRepository, GroupPubsubServiceImpl, GroupQueueServiceImpl, - GroupRepository, GroupsState, HttpConfig, HttpServiceImpl, InboundNonceDedup, KvAdminState, - KvServiceImpl, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, - PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository, - PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository, - PostgresAppSecretsRepo, PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, - PostgresAppUserRepository, PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, - PostgresAppUserVerificationRepo, PostgresDeadLetterRepo, PostgresDeadLetterService, - PostgresDocsRepo, PostgresExecutionLogRepository, PostgresExecutionLogSink, - PostgresGroupCollectionResolver, PostgresGroupDocsRepo, PostgresGroupKvRepo, - PostgresGroupMembersRepository, PostgresGroupQueueRepo, PostgresGroupRepository, - PostgresKvRepo, PostgresOutboxRepo, PostgresProjectRepository, PostgresPubsubRepo, - PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, - PostgresTriggerRepo, PostgresVarsRepo, PrincipalResolver, PubsubServiceImpl, - RealtimeAuthorityImpl, RepoResolver, RouteAdminState, SandboxCeiling, ScriptRepository, - SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, - TriggerConfig, TriggerRepo, TriggersState, UsersServiceConfig, UsersServiceImpl, VarsApiState, - VarsServiceImpl, + projects_router, rebuild_route_table, require_authenticated, route_admin_router, + secrets_router, topics_router, triggers_router, vars_router, AbandonedRepo, + AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, + ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, + AppRepository, ApplyService, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, + DevEmailState, Dispatcher, DocsServiceImpl, EmailInboundState, EmailServiceImpl, + FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, FsGroupFilesRepo, + GroupDocsServiceImpl, GroupFilesServiceImpl, GroupKvServiceImpl, GroupMembersRepository, + GroupPubsubServiceImpl, GroupQueueServiceImpl, GroupRepository, GroupsState, HttpConfig, + HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, + OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, + PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, + PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo, + PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo, + PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo, + PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository, + PostgresExecutionLogSink, PostgresGroupCollectionResolver, PostgresGroupDocsRepo, + PostgresGroupKvRepo, PostgresGroupMembersRepository, PostgresGroupQueueRepo, + PostgresGroupRepository, PostgresKvRepo, PostgresOutboxRepo, PostgresProjectRepository, + PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, + PostgresTopicRepo, PostgresTriggerRepo, PostgresVarsRepo, PrincipalResolver, ProjectRepository, + ProjectsState, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState, + SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, + SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState, + UsersServiceConfig, UsersServiceImpl, VarsApiState, VarsServiceImpl, }; use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; @@ -117,6 +117,8 @@ pub async fn build_app( let apps_repo: Arc = Arc::new(PostgresAppRepository::new(pool.clone())); let groups_repo: Arc = Arc::new(PostgresGroupRepository::new(pool.clone())); + let projects_repo: Arc = + Arc::new(PostgresProjectRepository::new(pool.clone())); let group_members_repo: Arc = Arc::new(PostgresGroupMembersRepository::new(pool.clone())); let domains_repo: Arc = @@ -538,7 +540,7 @@ pub async fn build_app( vars: Arc::new(PostgresVarsRepo::new(pool.clone())), apps: apps_repo.clone(), groups: groups_repo.clone(), - projects: Arc::new(PostgresProjectRepository::new(pool.clone())), + projects: projects_repo.clone(), modules: modules.clone(), domains: domains_repo.clone(), authz: authz.clone(), @@ -696,6 +698,9 @@ pub async fn build_app( .merge(api_keys_router(api_keys_state)) .merge(triggers_router(triggers_state)) .merge(apply_router(apply_service)) + .merge(projects_router(ProjectsState { + projects: projects_repo.clone(), + })) .merge(picloud_manager_core::queues_api::queues_router( picloud_manager_core::queues_api::QueuesState { queues: queue_repo.clone(), diff --git a/docs/design/groups-and-project-tool.md b/docs/design/groups-and-project-tool.md index 939394c..1ef720b 100644 --- a/docs/design/groups-and-project-tool.md +++ b/docs/design/groups-and-project-tool.md @@ -783,8 +783,18 @@ within that subtree. `check_within_attach` requires the attach group be a **prop (so you can't apply the attach point itself, only its descendants) or an ancestor (inclusive) of an app node's group — resolved via `groups.ancestors`, enforced read-only before the claim in both `apply_owner` and `apply_tree`. Absent = instance root = no ceiling (default). Pinned by the `apply_ownership` journey's -attach-ceiling case. **Deferred:** the plan-time cross-repo blast-radius preview + `pic projects ls` (M3); the -**structural-divergence** detection of §6; declarative group create/reparent (lifting "groups pre-exist"). +attach-ceiling case. + +**Status — M3 shipped (plan preview + `pic projects ls`).** `pic plan` now carries the declaring `[project]` +and returns an `ownership` preview per node — `claim` / `owned` / `conflict` (owner named) / `unclaimed` +(pure `preview_ownership`, unit-tested) — plus, for a group node, the cross-repo **blast radius**: descendant +apps owned by OTHER projects the change fans out to (`group_blast_radius`, a subtree CTE with per-group +memoized nearest-claimed resolution). The attach ceiling is previewed at plan too (consistent with apply). +Read-only `pic projects ls` lists every project + its owned-group count (`GET /api/v1/admin/projects`, +`list_with_counts`). Pinned by the `apply_ownership` journey's plan-preview case. **With M3, the multi-repo +ownership track (§7 M1–M3) is complete. Deferred (separate later work):** §6 structural-divergence detection +(`--adopt-server-structure` / `--force-local-structure`); declarative group create/reparent (lifting "groups +pre-exist"); per-env approval gating (`[project.environments]`). ---