diff --git a/CLAUDE.md b/CLAUDE.md index 6a5f2a1..d9a22c8 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). Next: §11.6 (group-level collections, v1.3) or 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 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 slice** (full cross-app shared read/write: a group declares a collection shared via the `[group]` manifest `collections = [...]` → owner-polymorphic marker `0052_group_collections.sql` (`kind='kv'`) + the group-keyed store `0053_group_kv_entries.sql` (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")` handle (`shared` alone is a Rhai reserved word); `GroupKvServiceImpl` resolves the owning group from `cx.app_id`'s ancestor chain (nearest-wins) — **that walk is the isolation boundary**, a foreign app gets `CollectionNotShared`. **Reads open** to any subtree script (anonymous incl. — the declaration is the grant), **writes require an authenticated editor+** on the owning group (`GroupKvRead`/`GroupKvWrite`, `script_gate_require_principal` fails closed on anon). Declarative reconcile mirrors `extension_points`; read-only `pic collections ls --group`. Deferred: shared-collection triggers (the "group trigger has no app to watch" problem), docs/files/topics/queue shared collections, per-group quotas). Next: extend §11.6 to docs/files/topics, or 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/executor-core/src/sdk/kv.rs b/crates/executor-core/src/sdk/kv.rs index 683d1f9..f5024e6 100644 --- a/crates/executor-core/src/sdk/kv.rs +++ b/crates/executor-core/src/sdk/kv.rs @@ -42,7 +42,7 @@ pub struct KvHandle { cx: Arc, } -/// §11.6 shared-collection handle, returned by `kv::shared(name)`. A distinct +/// §11.6 shared-collection handle, returned by `kv::shared_collection(name)`. A distinct /// Rhai type from `KvHandle` so the method set can diverge (e.g. shared /// collections never grow triggers) and a script's choice of private-vs-shared /// scope is explicit. Routes through the `GroupKvService`, which resolves the @@ -58,7 +58,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc Result> { if name.is_empty() { - return Err("kv::shared name must not be empty".into()); + return Err("kv::shared_collection name must not be empty".into()); } Ok(GroupKvHandle { collection: name.to_string(), diff --git a/crates/manager-core/migrations/0052_group_collections.sql b/crates/manager-core/migrations/0052_group_collections.sql index d6164ca..e3fe839 100644 --- a/crates/manager-core/migrations/0052_group_collections.sql +++ b/crates/manager-core/migrations/0052_group_collections.sql @@ -2,7 +2,7 @@ -- -- A row marks a collection NAME at a node as group-SHARED: every app in the -- owning group's subtree may read it (and, with an authenticated editor+, --- write it) via the explicit `kv::shared("name")` SDK handle. Resolution is +-- write it) via the explicit `kv::shared_collection("name")` SDK handle. Resolution is -- nearest-ancestor-group-wins, walking the reading app's chain — that ancestry -- walk is the isolation boundary (a foreign app's chain never contains the -- owning group, so the name simply does not resolve). See docs §11.6. diff --git a/crates/manager-core/src/apply_api.rs b/crates/manager-core/src/apply_api.rs index aa4c657..a377846 100644 --- a/crates/manager-core/src/apply_api.rs +++ b/crates/manager-core/src/apply_api.rs @@ -17,8 +17,8 @@ use serde_json::json; use crate::app_repo::AppRepository; use crate::apply_service::{ - ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, ExtensionPointInfo, - NodeKind, PlanResult, TreeBundle, TreePlanResult, + ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo, + ExtensionPointInfo, NodeKind, PlanResult, TreeBundle, TreePlanResult, }; use crate::authz::{require, AuthzDenied, Capability}; use crate::group_repo::GroupRepository; @@ -40,9 +40,29 @@ pub fn apply_router(service: ApplyService) -> Router { "/groups/{id}/extension-points", get(group_extension_points_handler), ) + .route("/groups/{id}/collections", get(group_collections_handler)) .with_state(service) } +/// Read-only §11.6 shared-collection report for a group: its own declared +/// shared KV collection names. Viewer-tier read. +async fn group_collections_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.collection_report(ApplyOwner::Group(group_id)).await?; + Ok(Json(report)) +} + /// Read-only §5.5 extension-point report for an app: every EP visible on its /// chain, each with `declared_here` + resolved `provider`. Viewer-tier read. async fn app_extension_points_handler( diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 81e3a33..e00665e 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -437,6 +437,12 @@ pub struct ExtensionPointInfo { pub provider: Option, } +/// One row of the read-only §11.6 shared-collection report. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CollectionInfo { + pub name: String, +} + // ---------------------------------------------------------------------------- // Errors // ---------------------------------------------------------------------------- @@ -1772,6 +1778,24 @@ impl ApplyService { } } + /// Read-only §11.6 shared-collection report for a node: the shared KV + /// collection names declared directly at it. Group-only in practice (the + /// CLI rejects app-declared collections); an app node returns its own + /// (degenerate) declarations, if any. Backs `pic collections ls`. + pub async fn collection_report( + &self, + owner: ApplyOwner, + ) -> Result, ApplyError> { + let names = + crate::group_collection_repo::list_for_owner(&self.pool, owner.as_script_owner()) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + Ok(names + .into_iter() + .map(|name| CollectionInfo { name }) + .collect()) + } + /// §5.5 no-provider plan check (app nodes only). Every extension point /// visible to the app — declared in this bundle or inherited from an /// ancestor — must have a provider: a module of that name the app provides diff --git a/crates/manager-core/src/group_kv_service.rs b/crates/manager-core/src/group_kv_service.rs index 0a3d861..be97a13 100644 --- a/crates/manager-core/src/group_kv_service.rs +++ b/crates/manager-core/src/group_kv_service.rs @@ -1,6 +1,6 @@ //! `GroupKvServiceImpl` — wires `GroupKvRepo` + the group-collection registry //! underneath the `picloud_shared::GroupKvService` trait that scripts reach via -//! the `kv::shared("name")` Rhai handle (§11.6). +//! the `kv::shared_collection("name")` Rhai handle (§11.6). //! //! Layers added over the raw repo: //! diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index b495b91..cabbb44 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -1332,6 +1332,26 @@ impl Client { .await?; decode(resp).await } + + /// `GET /api/v1/admin/groups/{ident}/collections` (§11.6). Group-only — + /// shared collections are declared on groups. + pub async fn collections_list(&self, group_ident: &str) -> Result> { + let ident = seg(group_ident); + let resp = self + .request( + Method::GET, + &format!("/api/v1/admin/groups/{ident}/collections"), + ) + .send() + .await?; + decode(resp).await + } +} + +/// One row of the §11.6 shared-collection report. +#[derive(Debug, Deserialize)] +pub struct CollectionInfoDto { + pub name: String, } // ---------- DTOs (CLI-local, wire-shape-matched) ---------- @@ -1351,6 +1371,8 @@ pub struct PlanDto { pub vars: Vec, #[serde(default)] pub extension_points: Vec, + #[serde(default)] + pub collections: Vec, /// Fingerprint of the live state this plan was computed against; carried /// in `.picloud/` and replayed to `apply` for the bound-plan check. #[serde(default)] @@ -1391,6 +1413,8 @@ pub struct NodePlanDto { pub vars: Vec, #[serde(default)] pub extension_points: Vec, + #[serde(default)] + pub collections: Vec, } /// Response of `POST .../apply`: counts of what changed. @@ -1423,6 +1447,10 @@ pub struct ApplyReportDto { #[serde(default)] pub extension_points_deleted: u32, #[serde(default)] + pub collections_created: u32, + #[serde(default)] + pub collections_deleted: u32, + #[serde(default)] pub warnings: Vec, } diff --git a/crates/picloud-cli/src/cmds/apply.rs b/crates/picloud-cli/src/cmds/apply.rs index 69c57da..64cfc5d 100644 --- a/crates/picloud-cli/src/cmds/apply.rs +++ b/crates/picloud-cli/src/cmds/apply.rs @@ -91,6 +91,13 @@ pub async fn run( "+{} -{}", report.extension_points_created, report.extension_points_deleted ), + ) + .field( + "collections", + format!( + "+{} -{}", + report.collections_created, report.collections_deleted + ), ); for w in &report.warnings { block.field("warning", w.clone()); @@ -165,6 +172,13 @@ pub async fn run_tree( "+{} -{}", report.extension_points_created, report.extension_points_deleted ), + ) + .field( + "collections", + format!( + "+{} -{}", + report.collections_created, report.collections_deleted + ), ); for w in &report.warnings { block.field("warning", w.clone()); diff --git a/crates/picloud-cli/src/cmds/collections.rs b/crates/picloud-cli/src/cmds/collections.rs new file mode 100644 index 0000000..23531fb --- /dev/null +++ b/crates/picloud-cli/src/cmds/collections.rs @@ -0,0 +1,23 @@ +//! `pic collections ls --group ` — read-only view of a group's §11.6 shared +//! KV collections. Group-only (shared collections are owned by groups); +//! authoring is declarative via the `[group]` manifest `collections = [...]`. +//! Scripts read/write them with `kv::shared_collection("name")`. + +use anyhow::Result; + +use crate::client::Client; +use crate::config; +use crate::output::{OutputMode, Table}; + +pub async fn ls(group: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let items = client.collections_list(group).await?; + + let mut table = Table::new(["name"]); + for c in &items { + table.row([c.name.clone()]); + } + table.print(mode); + Ok(()) +} diff --git a/crates/picloud-cli/src/cmds/mod.rs b/crates/picloud-cli/src/cmds/mod.rs index b11d72e..871e032 100644 --- a/crates/picloud-cli/src/cmds/mod.rs +++ b/crates/picloud-cli/src/cmds/mod.rs @@ -3,6 +3,7 @@ pub mod api_keys; pub mod apply; pub mod apps; pub mod apps_domains; +pub mod collections; pub mod config; pub mod dead_letters; pub mod extension_points; diff --git a/crates/picloud-cli/src/cmds/plan.rs b/crates/picloud-cli/src/cmds/plan.rs index 04facd3..e814e14 100644 --- a/crates/picloud-cli/src/cmds/plan.rs +++ b/crates/picloud-cli/src/cmds/plan.rs @@ -64,13 +64,14 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) { let mut table = Table::new(["node", "kind", "op", "resource", "detail"]); for n in &plan.nodes { let node = format!("{}:{}", n.kind, n.slug); - let groups: [(&str, &Vec); 6] = [ + let groups: [(&str, &Vec); 7] = [ ("script", &n.scripts), ("route", &n.routes), ("trigger", &n.triggers), ("secret", &n.secrets), ("var", &n.vars), ("extension_point", &n.extension_points), + ("collection", &n.collections), ]; for (rk, changes) in groups { for c in changes { @@ -162,6 +163,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result { "secrets": manifest.secrets.names, "vars": vars, "extension_points": manifest.extension_points(), + "collections": manifest.collections(), })) } @@ -176,13 +178,14 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result { fn render(plan: &PlanDto, mode: OutputMode) { let mut table = Table::new(["kind", "op", "resource", "detail"]); - let groups: [(&str, &Vec); 6] = [ + let groups: [(&str, &Vec); 7] = [ ("script", &plan.scripts), ("route", &plan.routes), ("trigger", &plan.triggers), ("secret", &plan.secrets), ("var", &plan.vars), ("extension_point", &plan.extension_points), + ("collection", &plan.collections), ]; for (kind, changes) in groups { for c in changes { diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 7253a80..df0ee79 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -162,6 +162,15 @@ enum Cmd { cmd: ExtensionPointsCmd, }, + /// Shared group collections (§11.6) — read-only view of the KV collection + /// names a group offers as cross-app-shared. Authored declaratively via the + /// `[group]` manifest `collections = [...]`; scripts read/write them with + /// `kv::shared_collection("name")`. + Collections { + #[command(subcommand)] + cmd: CollectionsCmd, + }, + /// Files inspection — list a collection's blobs, download bytes, or /// delete a file. Read + delete only; writes go through scripts. Files { @@ -558,6 +567,16 @@ enum ExtensionPointsCmd { }, } +#[derive(Subcommand)] +enum CollectionsCmd { + /// List the shared collections a group declares (§11.6). Group-only — + /// shared collections are owned by groups, not apps. + Ls { + #[arg(long)] + group: String, + }, +} + #[derive(Subcommand)] enum ScriptsCmd { /// List scripts. With `--app`, scoped to one app; with `--group`, a @@ -1985,6 +2004,9 @@ async fn main() -> ExitCode { Cmd::ExtensionPoints { cmd: ExtensionPointsCmd::Ls { app, group }, } => cmds::extension_points::ls(app.as_deref(), group.as_deref(), mode).await, + Cmd::Collections { + cmd: CollectionsCmd::Ls { group }, + } => cmds::collections::ls(&group, mode).await, Cmd::Files { cmd: FilesCmd::Ls { diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index 706a685..cece35d 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -111,6 +111,17 @@ impl Manifest { } } + /// This node's declared shared group-collection names (§11.6). Authored on + /// `[group]` nodes only — an `[app]` manifest carrying `collections` is a + /// hard parse error (`ManifestApp` has no such field + `deny_unknown_fields`). + #[must_use] + pub fn collections(&self) -> &[String] { + match &self.group { + Some(g) => &g.collections, + None => &[], + } + } + /// Load and parse the manifest at `path`. pub fn load(path: &Path) -> Result { let body = @@ -238,6 +249,13 @@ pub struct ManifestGroup { /// See [`ManifestApp::extension_points`]. A key of the `[group]` table. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub extension_points: Vec, + /// `collections = ["catalog", …]` (§11.6) — shared KV collection names this + /// group offers as cross-app-shared (read by any app in the subtree, + /// written by an authenticated editor+). Group-only: there is no + /// `collections` key on `[app]`. The data lives in `group_kv_entries`; + /// pruning a name removes the declaration, not the data. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub collections: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs index 9782905..2744d12 100644 --- a/crates/picloud-cli/tests/cli.rs +++ b/crates/picloud-cli/tests/cli.rs @@ -18,6 +18,7 @@ mod api_keys; mod apply; mod apps; mod auth; +mod collections; mod config; mod dead_letters; mod email_queue; diff --git a/crates/picloud-cli/tests/collections.rs b/crates/picloud-cli/tests/collections.rs new file mode 100644 index 0000000..e30bdc3 --- /dev/null +++ b/crates/picloud-cli/tests/collections.rs @@ -0,0 +1,187 @@ +//! §11.6 shared group collections, end to end via `pic`: +//! * a group declares a shared KV collection `catalog`; two apps under it +//! share one store — an authenticated write in app A is read back in app B +//! (cross-app data sharing, the deliberate inverse of per-app isolation), +//! * an app under a SIBLING group naming `catalog` gets CollectionNotShared +//! (the ancestry walk is the isolation boundary), +//! * `pic collections ls --group` lists the declared name; re-plan is NoOp. +//! +//! The anonymous-write-fails-closed half of the trust model is unit-tested in +//! `group_kv_service` (a journey invoke always carries the admin principal). + +use std::fs; + +use tempfile::TempDir; + +use crate::common; +use crate::common::cleanup::{AppGuard, GroupGuard}; + +fn manifest_dir() -> TempDir { + let dir = TempDir::new().expect("tempdir"); + fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir"); + dir +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn shared_collection_is_read_write_across_the_subtree() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let group = common::unique_slug("coll-grp"); + let sibling = common::unique_slug("coll-sib"); + let app_a = common::unique_slug("coll-a"); + let app_b = common::unique_slug("coll-b"); + let foreign = common::unique_slug("coll-f"); + + let _g = GroupGuard::new(&env.url, &env.token, &group); + let _gs = GroupGuard::new(&env.url, &env.token, &sibling); + common::pic_as(&env) + .args(["groups", "create", &group]) + .assert() + .success(); + common::pic_as(&env) + .args(["groups", "create", &sibling]) + .assert() + .success(); + + // The group declares `catalog` a shared collection (declarative apply). + let dir = manifest_dir(); + let gmanifest = + format!("[group]\nslug = \"{group}\"\nname = \"Coll\"\n\ncollections = [\"catalog\"]\n"); + let gpath = dir.path().join("group.toml"); + fs::write(&gpath, &gmanifest).unwrap(); + common::pic_as(&env) + .args(["apply", "--file"]) + .arg(&gpath) + .assert() + .success(); + + // `collections ls --group` shows the declared name. + let ls = String::from_utf8( + common::pic_as(&env) + .args(["collections", "ls", "--group", &group]) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert!( + ls.contains("catalog"), + "ls should list the collection:\n{ls}" + ); + + // Re-apply is a no-op (the marker already exists). + common::pic_as(&env) + .args(["apply", "--file"]) + .arg(&gpath) + .assert() + .success(); + + // Two apps under the group; one under a sibling group. + let _a = AppGuard::new(&env.url, &env.token, &app_a); + let _b = AppGuard::new(&env.url, &env.token, &app_b); + let _f = AppGuard::new(&env.url, &env.token, &foreign); + common::pic_as(&env) + .args(["apps", "create", &app_a, "--group", &group]) + .assert() + .success(); + common::pic_as(&env) + .args(["apps", "create", &app_b, "--group", &group]) + .assert() + .success(); + common::pic_as(&env) + .args(["apps", "create", &foreign, "--group", &sibling]) + .assert() + .success(); + + // App A writes to the shared collection (authenticated invoke → admin + // principal, so the editor+ write gate is satisfied). + fs::write( + dir.path().join("scripts/writer.rhai"), + r#"kv::shared_collection("catalog").set("sku-1", #{ price: 9 }); "ok""#, + ) + .unwrap(); + common::pic_as(&env) + .args(["scripts", "deploy"]) + .arg(dir.path().join("scripts/writer.rhai")) + .args(["--app", &app_a, "--name", "writer"]) + .assert() + .success(); + let writer_id = app_script_id(&env, &app_a, "writer"); + common::pic_as(&env) + .args(["scripts", "invoke", &writer_id]) + .assert() + .success(); + + // App B reads the SAME store back — cross-app sharing. + fs::write( + dir.path().join("scripts/reader.rhai"), + r#"kv::shared_collection("catalog").get("sku-1")"#, + ) + .unwrap(); + common::pic_as(&env) + .args(["scripts", "deploy"]) + .arg(dir.path().join("scripts/reader.rhai")) + .args(["--app", &app_b, "--name", "reader"]) + .assert() + .success(); + let reader_id = app_script_id(&env, &app_b, "reader"); + assert_eq!( + invoke_body(&env, &reader_id), + serde_json::json!({ "price": 9 }), + "an app in the subtree reads what another app wrote to the shared collection" + ); + + // The foreign app (sibling subtree) names `catalog` but it does not resolve + // on its chain — CollectionNotShared. The invoke fails. + fs::write( + dir.path().join("scripts/foreign.rhai"), + r#"kv::shared_collection("catalog").get("sku-1")"#, + ) + .unwrap(); + common::pic_as(&env) + .args(["scripts", "deploy"]) + .arg(dir.path().join("scripts/foreign.rhai")) + .args(["--app", &foreign, "--name", "peek"]) + .assert() + .success(); + let foreign_id = app_script_id(&env, &foreign, "peek"); + let out = common::pic_as(&env) + .args(["scripts", "invoke", &foreign_id]) + .output() + .expect("invoke"); + assert!( + !out.status.success(), + "a foreign app must not resolve another subtree's shared collection" + ); +} + +/// Id of the named script in an app (`pic scripts ls --app `). +fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String { + let ls = common::pic_as(env) + .args(["scripts", "ls", "--app", app]) + .output() + .expect("scripts ls"); + let table = String::from_utf8(ls.stdout).unwrap(); + table + .lines() + .map(common::cells) + .find(|c| c.get(2) == Some(&name)) + .and_then(|c| c.first().map(|s| (*s).to_string())) + .unwrap_or_else(|| panic!("script `{name}` not found:\n{table}")) +} + +fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value { + let out = common::pic_as(env) + .args(["scripts", "invoke", id]) + .output() + .expect("scripts invoke"); + assert!( + out.status.success(), + "invoke failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + serde_json::from_slice(&out.stdout).expect("invoke body is JSON") +} diff --git a/crates/shared/src/group_kv.rs b/crates/shared/src/group_kv.rs index 2f7c817..101fed7 100644 --- a/crates/shared/src/group_kv.rs +++ b/crates/shared/src/group_kv.rs @@ -1,7 +1,7 @@ //! `GroupKvService` — the §11.6 shared group-collection KV contract. //! //! The cross-app-sharing counterpart to [`crate::KvService`]. A script reaches -//! it via the explicit `kv::shared("name")` handle (distinct from the +//! it via the explicit `kv::shared_collection("name")` handle (distinct from the //! app-private `kv::collection("name")`). Unlike `KvService`, the *owner* of //! the data is not `cx.app_id`: the impl resolves the **owning group** by //! walking the calling app's ancestor chain for the nearest group that declares diff --git a/crates/shared/src/services.rs b/crates/shared/src/services.rs index cfbcdcc..5e29335 100644 --- a/crates/shared/src/services.rs +++ b/crates/shared/src/services.rs @@ -116,7 +116,7 @@ pub struct Services { pub vars: Arc, /// Shared cross-app group collections (§11.6). Scripts get - /// `kv::shared(name)` — read/write KV scoped to the nearest ancestor + /// `kv::shared_collection(name)` — read/write KV scoped to the nearest ancestor /// group that declares the collection shared. Backed by Postgres in the /// picloud binary (wired via [`Services::with_group_kv`]); defaults to /// `NoopGroupKvService` so test bundles that don't exercise sharing build diff --git a/docs/design/groups-and-project-tool.md b/docs/design/groups-and-project-tool.md index 0e43cbc..d5900e2 100644 --- a/docs/design/groups-and-project-tool.md +++ b/docs/design/groups-and-project-tool.md @@ -1044,6 +1044,25 @@ Resolved items now live inline next to their topic. What genuinely remains: real shared-scope authz model. Optionally, trigger/route **templates** (§4.5) if cardinality demands. + > **Shipped — §11.6 KV slice (full shared read/write).** A group declares a collection + > group-shared (`[group]` manifest `collections = ["catalog"]`, owner-polymorphic marker table + > `0052_group_collections`, `kind='kv'`); the data lives in `0053_group_kv_entries`, keyed by the + > owning `group_id` (NOT app — a shared row belongs to the group). Scripts read/write via the + > **explicit** `kv::shared_collection("catalog")` handle (a distinct `GroupKvHandle`; `shared` + > alone is a Rhai reserved word, hence `shared_collection`). The service resolves the owning group + > from `cx.app_id`'s ancestor chain (nearest-group-wins) — **that walk is the isolation boundary**: + > a foreign app's chain never contains the owning group, so the name returns `CollectionNotShared`. + > **Trust model:** reads are open to any subtree script (anonymous public HTTP included — the + > declaration *is* the grant); **writes require an authenticated editor+** on the owning group + > (`GroupKvWrite`, fails closed on an anonymous principal). CASCADE on group delete; an app delete + > leaves the shared data. Live- + journey-validated (app A writes, app B reads; a sibling-subtree + > app gets `CollectionNotShared`). + > + > **Deferred (documented gaps):** write-triggers/events on shared collections (the "group trigger + > has no app to watch" problem); docs/files/topics/queue shared collections (the `kind` column + > generalizes); per-group total-size quotas + write-rate limits; CAS/`set_if`; app-declared + > collections. Multi-node tree-apply leans on the runtime backstop for no-op edges, as elsewhere. + ### 11.1 Re-sequencing review (post-Phase-3) A review after Phase 3 shipped surfaced three adjustments to the 4→6 plan; carried here as decisions diff --git a/docs/sdk-shape.md b/docs/sdk-shape.md index a96dcb4..50582b6 100644 --- a/docs/sdk-shape.md +++ b/docs/sdk-shape.md @@ -74,6 +74,31 @@ collection, id)` for docs. Collections are **mandatory**, not optional — even single-collection apps name their collection. The service layer rejects requests with empty collection names. +### Shared group collections (§11.6) + +`kv::collection(name)` is **app-private** — scoped to `cx.app_id`, the +isolation boundary. A separate, explicit handle reaches a **group-shared** +KV collection that every app in a group's subtree reads (and an +authenticated editor+ writes): + +```rhai +let cat = kv::shared_collection("catalog"); // resolves the nearest +cat.set("sku-1", #{ price: 9 }); // ancestor group that +let p = cat.get("sku-1"); // declares "catalog" +``` + +The name is deliberately distinct from `kv::collection` — a script opts +into shared scope explicitly (implicit shadowing would silently redirect a +write across apps). (`kv::shared` would be ideal but `shared` is a Rhai +reserved word, hence `shared_collection`.) The owning group is resolved +from `cx.app_id`'s ancestor chain — that walk is the isolation boundary; +an app outside the owning group's subtree gets a "not a shared collection" +error, never another subtree's data. A collection is made shared +declaratively (`[group]` manifest `collections = [...]`), never from a +script. **Reads** are open to any subtree script (including anonymous +public endpoints — declaring the collection *is* the grant); **writes** +require an authenticated principal with editor+ on the owning group. + ## Error convention - **Throw on failure.** `widgets.set("k", "v")` throws a Rhai runtime