From 9c6d690792d06f06be6c6efdb25d3a2f7f85a972 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 30 Jun 2026 19:56:47 +0200 Subject: [PATCH] =?UTF-8?q?test(cli):=20shared-files=20journey=20+=20.giti?= =?UTF-8?q?gnore=20+=20docs=20(=C2=A711.6=20files=20C5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - collections.rs: shared_files_collection_is_read_write_across_the_subtree — a group declares kv+docs+files in one string-or-table manifest; authed app A creates a blob via files::shared_collection("assets").create(...), app B lists + gets the SAME bytes back across the subtree, a sibling-subtree app gets CollectionNotShared, collections ls shows all three kinds. Live-verified the bytes land under files/groups//assets//. - .gitignore: ignore /crates/picloud-cli/data (the CLI journey harness spawns the server from that dir; the files journey is the first CLI test to write blobs). - docs: design §11.6 (KV+DOCS+FILES shipped; topics/queue still deferred), sdk-shape (files::shared_collection), CLAUDE.md current-focus. Full journey suite 117/117; schema blessed (55 migrations); workspace clippy -D clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 5 +- CLAUDE.md | 2 +- crates/picloud-cli/tests/collections.rs | 135 ++++++++++++++++++++++++ docs/design/groups-and-project-tool.md | 49 +++++---- docs/sdk-shape.md | 18 ++-- 5 files changed, 179 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index 9f13e01..fb3a80c 100644 --- a/.gitignore +++ b/.gitignore @@ -26,8 +26,11 @@ docker-compose.override.yml config.local.toml /data # Files-root blob storage created when integration tests run build_app -# from the picloud crate dir (PICLOUD_FILES_ROOT default ./data). +# from a crate dir (PICLOUD_FILES_ROOT default ./data). The CLI journey +# harness spawns the server from the picloud-cli dir, so it lands there too +# (the §11.6 group-files journey is the first CLI test to write blobs). /crates/picloud/data +/crates/picloud-cli/data /postgres-data # Dashboard diff --git a/CLAUDE.md b/CLAUDE.md index 3280440..56fba38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint **v1.1.x — SDK foundation + services — is complete.** The SDK shape (handle pattern, `::` namespaces, `Services`/`SdkCallCx`; see [docs/sdk-shape.md](docs/sdk-shape.md), stdlib at [docs/stdlib-reference.md](docs/stdlib-reference.md)) fixed in v1.1.0, then KV, docs, modules, HTTP, cron, files, pub/sub, email, users, and durable queues + `invoke()` filled it in through **v1.1.9** — blueprint §12 has the table. Earlier groundwork: blueprint Phase 3 (admin auth, multi-app scoping, Phase 3.5 capability gating — `manager-core::authz::{can, require, Capability}`, migration `0006_users_authz.sql`). -**Current focus: v1.2 _Hierarchies_ — groups + the declarative project tool** ([docs/design/groups-and-project-tool.md](docs/design/groups-and-project-tool.md)). That doc's §11 uses its own **Phase 1–6 numbering, distinct from the blueprint product-phase numbering above — do not conflate them** (its "Phase 3" = group-inherited config, not admin auth). Implemented on `feat/groups-*` branches: §11 Phase 1 (declarative `pic plan`/`apply`/`prune` + env overlays), Phase 2 (single-parent groups tree + hierarchy-aware RBAC), Phase 3 (group-inherited, env-scoped `vars` + secrets resolved **live** via a recursive CTE — no materialized cache), Phase 4-lite (group-owned **endpoint** scripts: `scripts` polymorphic owner in `0050_group_scripts.sql`, `get_by_name_inherited`/`is_invocable_by_app` chain resolution, inherited `invoke()` + declarative route/trigger binding — all **live**, no body materialization), Phase 5 (the **declarative project tool maps onto the group tree**: the reconcile engine generalized to `ApplyOwner{App|Group}`, a `[group]` manifest kind, and a single atomic **tree apply** — `pic plan/apply --dir` reconciles a whole directory tree of `picloud.toml` nodes in one Postgres transaction, groups-before-apps so an app route can bind a group script created in the same tx; the bound token folds in each group's `structure_version`. Multi-repo single-owner/attach-point and per-env approval gating are deferred; groups pre-exist), Phase 4b (group **modules** + the **lexical (sealed-by-default) import resolver**, §5.5: owner-polymorphic `ModuleScript`, origin-rooted `ModuleSource::resolve` walking the importing node's chain, `ExecRequest.script_owner` threaded from every dispatch + `invoke()` site, `_source`-driven lexical chaining in `PicloudModuleResolver` with the compiled-module cache re-keyed by `ScriptId`, group modules/imports allowed, single-node dangling-import `plan` check — an inherited group script's imports **seal to the group**, a leaf can't shadow them), §5.5 **extension points** (opt-in polymorphism — **§5.5 now complete**: marker table `0051_extension_points.sql` (owner-polymorphic, CASCADE — structurally a `secrets` name; default body = a co-located `kind=module` script), `ModuleSource::resolve_policy` with **nearest-declaration-kind-wins** — a concrete module resolves lexically, an EP marker resolves **dynamically against the inheriting app** (its override else the default body up-chain), `NoProvider` is a hard error; declarative-only authoring via the `[app]`/`[group]` manifest key `extension_points = [...]`, reconcile mirrors `secrets`, single-node no-provider `plan` check, read-only `pic extension-points ls` + `pull` round-trip — the app can **override** a group default, the deliberate inverse of the Phase 4b sealed import), §11.6 **group-level collections — KV + DOCS 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'`) and `0054_group_docs.sql` (`kind='docs'`, the queryable-JSON store) — 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")` handles (`shared` alone is a Rhai reserved word); `GroupKv`/`GroupDocsServiceImpl` 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` and a `docs` 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). **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`, `script_gate_require_principal` fails closed on anon). Declarative authoring is the **string-or-table** form `collections = ["catalog", { name = "articles", kind = "docs" }]` (bare string = kv); reconcile keys markers by `(name, kind)`; read-only `pic collections ls --group` shows a kind column. Deferred: shared-collection triggers (the "group trigger has no app to watch" problem), files/topics/queue shared collections (files needs group-keyed disk paths), per-group quotas). Next: extend §11.6 to files, 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 + DOCS + FILES slices** (full cross-app shared read/write: a group declares a collection shared via the `[group]` manifest `collections = [...]` → owner-polymorphic marker `0052_group_collections.sql` with a `kind` discriminator + a per-kind group-keyed store: `0053_group_kv_entries.sql` (`kind='kv'`), `0054_group_docs.sql` (`kind='docs'`, the queryable-JSON store), and `0055_group_files.sql` (`kind='files'`, blob metadata in Postgres + bytes on disk under `/files/groups//...`, a `groups/` infix disjoint from the per-app `files//` subtree so the existing recursive orphan sweeper covers both with zero change) — no `app_id`, a shared row belongs to the group; CASCADE on group delete, an app delete leaves the data. Scripts use the **explicit** `kv::shared_collection("name")` / `docs::shared_collection("name")` / `files::shared_collection("name")` handles (`shared` alone is a Rhai reserved word); `GroupKv`/`GroupDocs`/`GroupFilesServiceImpl` resolve the owning group from `cx.app_id`'s ancestor chain **filtered by kind** (nearest-wins) — **that walk is the isolation boundary**, a foreign app gets `CollectionNotShared`; a `kv`, a `docs`, and a `files` collection of the same name are distinct stores. The docs slice reuses the `docs_filter` DSL — `build_find_query` generalized on its owner column (`docs`/`app_id` vs `group_docs`/`group_id`, both literals); the files slice likewise generalized the atomic-write + checksum-on-read path helpers on an owner-relative dir (one source for the security-sensitive disk mechanics). **Reads open** to any subtree script (anonymous incl. — the declaration is the grant), **writes require an authenticated editor+** on the owning group (`GroupKvRead/Write`, `GroupDocsRead/Write`, `GroupFilesRead/Write`, `script_gate_require_principal` fails closed on anon). Declarative authoring is the **string-or-table** form `collections = ["catalog", { name = "articles", kind = "docs" }, { name = "assets", kind = "files" }]` (bare string = kv); reconcile keys markers by `(name, kind)`; read-only `pic collections ls --group` shows a kind column. Deferred: shared-collection triggers (the "group trigger has no app to watch" problem), topics/queue shared collections (same trigger-centric gap), per-group quotas, an operator admin API for shared blobs (scripts use the SDK; `pic collections ls` shows the marker)). Next: multi-node cluster mode, or the §11 tail (trigger/route templates, multi-repo ownership). **Data-model invariant:** app-owned data-plane tables (KV, docs, files, …) start with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE`; the group-inheritable tables — _config_ (`vars`, `secrets`) and now group-owned _code_ (`scripts`, `0050`) — instead carry a **polymorphic owner**: nullable `group_id` and `app_id` with an exactly-one CHECK and per-owner partial-unique indexes (config is `ON DELETE CASCADE`, scripts `RESTRICT` — code is not data). Inheritance resolves **live** down `apps.group_id → groups.parent_id` via `CHAIN_LEVELS_CTE` (no materialized view); nearest-owner-wins with an app's own row shadowing the inherited one (CoW). Every Rhai SDK call resolves its app from `cx.app_id`, never a script-passed arg, and a group script always runs under the *inheriting* app's `cx.app_id` (the cross-app isolation boundary). diff --git a/crates/picloud-cli/tests/collections.rs b/crates/picloud-cli/tests/collections.rs index 170012e..55e0301 100644 --- a/crates/picloud-cli/tests/collections.rs +++ b/crates/picloud-cli/tests/collections.rs @@ -394,6 +394,141 @@ fn shared_docs_collection_is_read_write_across_the_subtree() { ); } +/// §11.6 files slice: a group declares a `files`-kind shared collection (mixed +/// with kv + docs in one string-or-table manifest); an authenticated app A +/// `create`s a blob and app B `list`s + `get`s the SAME bytes back across the +/// subtree (the group-keyed disk path under `files/groups//...`); a +/// sibling-subtree app gets `CollectionNotShared`; `collections ls` shows all +/// three kinds. +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn shared_files_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("fcoll-grp"); + let sibling = common::unique_slug("fcoll-sib"); + let app_a = common::unique_slug("fcoll-a"); + let app_b = common::unique_slug("fcoll-b"); + let foreign = common::unique_slug("fcoll-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 kv + docs + files collections in one manifest. + let dir = manifest_dir(); + let gmanifest = format!( + "[group]\nslug = \"{group}\"\nname = \"FColl\"\n\n\ + collections = [\"catalog\", {{ name = \"articles\", kind = \"docs\" }}, \ + {{ name = \"assets\", kind = \"files\" }}]\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` shows all three kinds. + let ls = String::from_utf8( + common::pic_as(&env) + .args(["collections", "ls", "--group", &group]) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert!( + ls.contains("assets") && ls.contains("files"), + "ls should list the files collection:\n{ls}" + ); + + // Re-apply is a no-op (all three markers already exist). + common::pic_as(&env) + .args(["apply", "--file"]) + .arg(&gpath) + .assert() + .success(); + + 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); + for (app, grp) in [(&app_a, &group), (&app_b, &group), (&foreign, &sibling)] { + common::pic_as(&env) + .args(["apps", "create", app, "--group", grp]) + .assert() + .success(); + } + + // App A creates a blob in the shared files collection (authenticated). + // base64("hi") = "aGk=". + fs::write( + dir.path().join("scripts/fwriter.rhai"), + r#"files::shared_collection("assets").create(#{ name: "a.txt", content_type: "text/plain", data: base64::decode("aGk=") })"#, + ) + .unwrap(); + common::pic_as(&env) + .args(["scripts", "deploy"]) + .arg(dir.path().join("scripts/fwriter.rhai")) + .args(["--app", &app_a, "--name", "fwriter"]) + .assert() + .success(); + common::pic_as(&env) + .args(["scripts", "invoke", &app_script_id(&env, &app_a, "fwriter")]) + .assert() + .success(); + + // App B lists the shared collection and reads the SAME bytes back — + // cross-app blob sharing via the group-keyed disk path. + fs::write( + dir.path().join("scripts/freader.rhai"), + r#"let c = files::shared_collection("assets"); let p = c.list(); base64::encode(c.get(p.files[0].id))"#, + ) + .unwrap(); + common::pic_as(&env) + .args(["scripts", "deploy"]) + .arg(dir.path().join("scripts/freader.rhai")) + .args(["--app", &app_b, "--name", "freader"]) + .assert() + .success(); + assert_eq!( + invoke_body(&env, &app_script_id(&env, &app_b, "freader")), + serde_json::json!("aGk="), + "app B reads the blob app A wrote to the shared files collection" + ); + + // The foreign app (sibling subtree) → CollectionNotShared. + fs::write( + dir.path().join("scripts/fpeek.rhai"), + r#"files::shared_collection("assets").list()"#, + ) + .unwrap(); + common::pic_as(&env) + .args(["scripts", "deploy"]) + .arg(dir.path().join("scripts/fpeek.rhai")) + .args(["--app", &foreign, "--name", "fpeek"]) + .assert() + .success(); + let out = common::pic_as(&env) + .args(["scripts", "invoke", &app_script_id(&env, &foreign, "fpeek")]) + .output() + .expect("invoke"); + assert!( + !out.status.success(), + "a foreign app must not resolve another subtree's shared files 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) diff --git a/docs/design/groups-and-project-tool.md b/docs/design/groups-and-project-tool.md index 600a830..f3cfe4d 100644 --- a/docs/design/groups-and-project-tool.md +++ b/docs/design/groups-and-project-tool.md @@ -1044,31 +1044,38 @@ 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 + DOCS slices (full shared read/write).** A group declares a collection - > group-shared (`[group]` manifest `collections = [...]`, owner-polymorphic marker table + > **Shipped — §11.6 KV + DOCS + FILES slices (full shared read/write).** A group declares a + > collection group-shared (`[group]` manifest `collections = [...]`, owner-polymorphic marker table > `0052_group_collections` with a `kind` discriminator); the data lives in a per-kind store keyed by > the owning `group_id` (NOT app — a shared row belongs to the group): `0053_group_kv_entries` - > (`kind='kv'`) and `0054_group_docs` (`kind='docs'`, the queryable-JSON store). Scripts read/write - > via the **explicit** `kv::shared_collection("catalog")` / `docs::shared_collection("articles")` - > handles (distinct `GroupKvHandle`/`GroupDocsHandle`; `shared` alone is a Rhai reserved word, hence - > `shared_collection`). The service resolves the owning group from `cx.app_id`'s ancestor chain - > **filtered by kind**, nearest-group-wins — **that walk is the isolation boundary**: a foreign - > app's chain never contains the owning group, so the name returns `CollectionNotShared`; a `kv` and - > a `docs` collection of the same name are distinct stores. **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`/`GroupDocsWrite`, fail closed on an - > anonymous principal). The docs slice reuses the `docs_filter` DSL — `build_find_query` was - > generalized on its owner column (`docs`/`app_id` vs `group_docs`/`group_id`), both compile-time - > literals, so the find SQL has one source. Declarative authoring uses the **string-or-table** form: - > `collections = ["catalog", { name = "articles", kind = "docs" }]` (bare string = `kv`). CASCADE on - > group delete; an app delete leaves the shared data. Live- + journey-validated for both kinds (app - > A writes, app B reads/finds; a sibling-subtree app gets `CollectionNotShared`). + > (`kind='kv'`), `0054_group_docs` (`kind='docs'`, the queryable-JSON store), and `0055_group_files` + > (`kind='files'`, the blob store — 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). Scripts read/write via the + > **explicit** `kv::shared_collection("catalog")` / `docs::shared_collection("articles")` / + > `files::shared_collection("assets")` handles (distinct `GroupKvHandle`/`GroupDocsHandle`/ + > `GroupFilesHandle`; `shared` alone is a Rhai reserved word, hence `shared_collection`). The service + > resolves the owning group from `cx.app_id`'s ancestor chain **filtered by kind**, nearest-group-wins + > — **that walk is the isolation boundary**: a foreign app's chain never contains the owning group, so + > the name returns `CollectionNotShared`; a `kv`, a `docs`, and a `files` collection of the same name + > are distinct stores. **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`/`GroupDocsWrite`/`GroupFilesWrite`, fail closed on an anonymous + > principal). The docs slice reuses the `docs_filter` DSL — `build_find_query` was generalized on its + > owner column (`docs`/`app_id` vs `group_docs`/`group_id`), both compile-time literals, so the find + > SQL has one source; the files slice likewise generalized the atomic-write + checksum-on-read path + > helpers on an owner-relative dir, so the security-sensitive disk mechanics have one source. + > Declarative authoring uses the **string-or-table** form: `collections = ["catalog", { name = + > "articles", kind = "docs" }, { name = "assets", kind = "files" }]` (bare string = `kv`). CASCADE on + > group delete; an app delete leaves the shared data. Live- + journey-validated for all three kinds + > (app A writes, app B reads/finds/gets the same bytes; a sibling-subtree app gets + > `CollectionNotShared`). > > **Deferred (documented gaps):** write-triggers/events on shared collections (the "group trigger - > has no app to watch" problem); **files/topics/queue** shared collections (the `kind` column - > generalizes — files needs group-keyed disk paths); 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. + > has no app to watch" problem); **topics/queue** shared collections (trigger-centric, same gap); + > per-group total-size quotas + write-rate limits; CAS/`set_if`; an operator admin API for shared + > blobs (scripts use the SDK; `pic collections ls` shows the marker — matches KV/docs); 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) diff --git a/docs/sdk-shape.md b/docs/sdk-shape.md index 9ccd6d3..225afa3 100644 --- a/docs/sdk-shape.md +++ b/docs/sdk-shape.md @@ -99,14 +99,18 @@ 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. -**Docs too.** The same handle exists for the queryable-JSON store: -`docs::shared_collection("articles")` returns a shared-docs handle with the -full `create`/`get`/`find`/`find_one`/`update`/`delete`/`list` surface (same -trust model + isolation boundary as the KV one). A `[group]` declares a +**Docs and files too.** The same handle exists for the queryable-JSON store +and the blob store: `docs::shared_collection("articles")` returns a shared-docs +handle with the full `create`/`get`/`find`/`find_one`/`update`/`delete`/`list` +surface, and `files::shared_collection("assets")` returns a shared-files handle +with `create`/`head`/`get`/`update`/`delete`/`list` (Blob in/out) — both with +the same trust model + isolation boundary as the KV one. A `[group]` declares a collection's store with a kind: `collections = ["catalog", { name = -"articles", kind = "docs" }]` — a bare string is `kv`, a `{ name, kind }` -table sets it. A `kv` and a `docs` collection of the same name are distinct -stores. (Files/topics/queue shared collections are not yet implemented.) +"articles", kind = "docs" }, { name = "assets", kind = "files" }]` — a bare +string is `kv`, a `{ name, kind }` table sets it (`kv`/`docs`/`files`). A `kv`, +a `docs`, and a `files` collection of the same name are distinct stores; group +blobs live on disk under `/files/groups//...`. (Topics/queue +shared collections are not yet implemented.) ## Error convention