feat(cli): collections manifest + ls + journeys; rename to kv::shared_collection (§11.6 C5)

Finish the §11.6 KV slice: declarative authoring, read-only inspection, the
runtime journey, and docs — plus a forced SDK-name fix.

- SDK name: `shared` is a Rhai RESERVED keyword, so `kv::shared(...)` is a parse
  error. Renamed the handle constructor to `kv::shared_collection(...)`
  (keeps the user's "shared" intent; unambiguous). Updated everywhere.
- CLI manifest: `collections = [...]` on `[group]` only (ManifestApp has no such
  field + deny_unknown_fields → an app manifest carrying it is a hard error);
  Manifest::collections() accessor; build_bundle emits it.
- plan/apply: a `collection` row group in `pic plan`; created/deleted counts in
  the apply summary; collections threaded through PlanDto/NodePlanDto/
  ApplyReportDto.
- read-only `pic collections ls --group` → GET /admin/groups/{id}/collections
  (viewer+), backed by ApplyService::collection_report + CollectionInfo.
- journey: a group declares `catalog`; app A writes, app B (same subtree) reads
  it back; a sibling-subtree app gets CollectionNotShared; `collections ls` +
  re-apply NoOp. Full suite 114/114.
- docs: groups-and-project-tool §11.6 (KV-only MVP shipped + deferrals),
  sdk-shape.md (the shared-collection handle + trust model), CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-29 22:26:43 +02:00
parent b79d8ef47d
commit 0973344515
19 changed files with 398 additions and 13 deletions

View File

@@ -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

View File

@@ -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