Files
PiCloud/docs/design/groups-and-project-tool.md
MechaCat02 4b4b3148b7 feat(apply): server-side enforcement of per-env approval gating (§3 M3 / §4.2)
The per-env approval gate (`[project.environments]`, `confirm = true`) was
client-side only — the policy was `#[serde(skip_serializing)]`, so a patched or
older CLI applying to a confirm-required env bypassed it entirely. This makes
the gate server-enforced, admin-gated, and audited (the deferred §4.2 piece).

- Wire: `ProjectDecl` gains `environments` (the CLI stops skip_serializing it and
  sends the selected `env` + the `--approve` set on the apply request). The
  server RE-DERIVES the gate from the request (`env_gate_check`): a
  confirm-required env not in `approved_envs` → 409 `ApprovalRequired`.
- Admin gate: an approved override additionally requires `AppAdmin`/`GroupAdmin`
  on EVERY declared node — a step up from the editor write caps an ordinary
  apply needs (reusing the admin caps per §4.2) — and emits a `tracing::info!`
  audit line. Enforced on all three handlers (single app, single group, tree);
  the check runs before any tx, so a denial (403) precedes any mutation.
- Token: the env policy folds into the TREE bound-plan token (a policy edit
  between plan and apply trips StateMoved). The single-node token is left
  unchanged (a bare live-state hash), so plan/apply still match without threading
  a project into it.
- CLI: single-node `apply`/`plan` now resolve the GOVERNING project (own block
  else nearest ancestor's) so a leaf apply carries the root's policy; `plan`
  surfaces `approvals_required`. Client-side `require_env_approval` fast-fail
  kept as UX.

Adapts the earlier feat/ownership-and-approval M5 (654e387) to main's DTOs; main
addresses tree group nodes by slug only, so the admin loop mirrors authz_tree
(app: resolve_app_id fail-closed; group: get_by_slug, skip to-create).

Threat-model note (honest scope, in the doc + code comments): this closes the
patched/older-CLI bypass and admin-gates + audits the override, but is NOT a
hermetic boundary against a hand-crafted request that OMITS the policy — the
policy is applier-supplied, not persisted server state. Full closure (persist a
`project_environments` source of truth + a server-determined env) is deferred.

Review (adversarial pass): fixed a MEDIUM (single-node `plan` didn't resolve the
governing project, so a leaf plan under-reported the gate `apply` enforces) and a
LOW (duplicate clippy attr); corrected overstated "can't bypass" wording.

Tests: ProjectDecl gate unit test; env_approval journeys
`server_enforces_the_gate_against_a_non_stock_client` (raw request → 409 without
approval, admin-approved → 200) and `approving_a_gated_apply_requires_admin`
(editor + --approve → 403). 417 lib tests + 33 CLI journeys + workspace clippy
-D warnings + fmt clean. No migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:01:04 +02:00

98 KiB
Raw Blame History

Groups, Projects & the Declarative Project Tool

Status: Active — design discussion captured 2026-06-18, revised 2026-06-20 with resolutions grounded in precedent (GitLab, Kustomize, Helm, Terraform, Kubernetes Server-Side Apply, Pulumi) and corrected against the codebase after three independent review passes (consistency, gaps, feasibility). Scheduled as the Hierarchies track of v1.2 (blueprint §12 Phase 5). §11 Phases 13 (project tool, groups + hierarchy RBAC, group-inherited config) are shipped; Phases 46 remain. NB: §11's own Phase 16 numbering is local to this initiative and is distinct from the blueprint product-phase numbering — "Phase 3" here = group-inherited config, not the blueprint's admin-auth phase.

Scope: turns the imperative pic CLI into a declarative, file-based project tool, and introduces a server-side groups hierarchy so apps can share and inherit config/scripts without duplication.

Blueprint impact: this reverses the §11.5 snapshot-copy, not live-link stance — top-down hierarchical inheritance (group → app) is now in scope. Originally designed as a materialized, auto-invalidated view (§5.1); Phase 3 shipped it as live, resolve-on-read inheritance (one recursive CTE per call, no cache), with materialization deferred to a future optimization (see the Phase 3 status note in §11.3). Fold the outcome back into the blueprint before it drifts.


1. Motivation

Today pic is imperative: every command (pic deploy, pic routes create, pic triggers create-cron, …) maps 1:1 to an admin API call. There is no memory of desired state, no project file, and no way to express "these N apps are the staging/prod/tenant variants of one thing."

Two gaps follow:

  1. No declarative project layer. Developers re-run commands by hand; nothing reconciles a repo to an instance.
  2. No server-side notion of a project/group. If we model environments as separate apps, the shared base (config, scripts) is duplicated across every deployed app. The server sees N unrelated apps.

This document designs both halves: a declarative manifest + project tool, and a groups hierarchy on the server that the project tool projects onto the filesystem.

manager-core is the single writer to Postgres. We lean on that for two concrete things — an atomic desired-state write (one DB transaction per apply, §4.2) and a server-computed diff (§4.2) — not for continuous reconciliation, which we deliberately decline (drift handling is detect-and-surface, §4.2). This is a narrower and more honest claim than "we can reconcile live state": most comparable tools cannot do an all-or-nothing apply because their effects are un-undoable cloud resources; ours are Postgres rows, so we can — within the boundary described in §4.2.


2. Vocabulary

Term Meaning
Group Server-side hierarchy node. Single-parent tree. Owns shared code/config definitions and members/roles. Nestable.
App A deployable unit. The data-isolation boundary (app_id). Lives under a group.
Environment A deployment variant (staging/production/…). An environment is an app. Also a scope dimension on config (§3).
Tenant Modeled like an environment — a scope dimension / overlay axis, not a second parent (§5.4).
Project Not a server entity. A CLI view over a repo-managed subtree of groups.
Definition Entities that can be group-owned: scripts/modules, vars, secret-refs only. Routes, triggers, collections, topics are app-scoped (§3, §5.1).
Data KV/docs/files collections + pub/sub topics. Always app-owned (app_id). The isolation boundary never moves.
Manifest TOML file describing desired state for one node (group base) + per-env overlays.
Effective view The materialized, per-app resolved set of definitions, served to the runtime and recomputed on any ancestor change (§5.1).
Attach point Where a local working tree's root binds into the server group tree. Its ceiling — you cannot apply above it.

Relationship: the base + per-env-overlay "project" is the degenerate one-group subtree of the general groups model. Nesting generalizes it to multi-group subtrees. Nothing about the single-project model is lost.


3. Configuration resolution

This is the single rule the whole design hangs off; everything else (env vars, secrets, enabled, overrides) resolves through it. All three precedent systems converge on the same shape, so we adopt it wholesale:

⚠️ Net-new, not an extension (verified against the codebase). PiCloud has no env-agnostic config / vars layer today — this resolution engine and the vars table are greenfield. Only secrets exists, and as a value-bearing table, not a ref. Read §3 as new infrastructure, not a modification of something present.

Resolution = sparse, per-field merge; environment is a pre-filter, not a precedence tier; proximity wins across levels.

To resolve a key for an app in environment E:

  1. Filter by environment first, per level. A value scoped @E is eligible; a value scoped * (env-agnostic) is the fallback. At a single level, @E beats *. Environment scope decides eligibility, it is not a competing precedence rank. Evidence: GitLab CI/CD variables use environment_scope exactly this way — a production-scoped row simply isn't visible to a staging job (GitLab CI/CD variables).
  2. Then nearest level wins. Walk up acme → team-a → blog → app; the closest level that defines the (filtered) key wins. GitLab states this verbatim: "if the same variable name exists in a group and its subgroups, the job uses the value from the closest subgroup."
  3. Merge granularity: maps/vars deep-merge per key (set title, still inherit region); entities (scripts) replace by identity (name); deletion is an explicit tombstone. Evidence: Kustomize strategic-merge patches are sparse and deep-merge maps but replace lists unless keyed (Kustomize patches); Helm deep-merges nested maps and uses null to delete a defaulted key (Helm values).

This one rule resolves several issues at once: it makes group config environment-scopable (a group sets db_url@production and db_url@staging; descendants inherit the right one — no per-leaf duplication), it defines merge granularity (maps per-key, entities per-identity), and it makes enabled just another sparse field (§4.3).

3.1 The env-scope manifest syntax

# group manifest (e.g. team-a/picloud.toml)
[vars]
region = "eu"                 # env-agnostic (scope *)

[vars.production]             # scope @production
db_url = "postgres://prod/..."
[vars.staging]
db_url = "postgres://staging/..."

[secrets]
names = ["stripe_key"]        # name-only; values pushed per env via CLI (§4.6)

3.2 The one deliberately-novel precedence call

With db_url@production at the root and a plain db_url at the leaf, the leaf's env-agnostic value wins for a production app — proximity beats farther-level env-specificity. The research was explicit that no major system lets env-specificity override proximity across levels; GitLab structurally avoids the question by treating env as a per-level filter (above). So proximity-first is the evidence-backed default, and we document it as a chosen rule: inner scope shadows outer, like lexical scoping. To avoid this becoming a debugging nightmare at depth, pic config --effective --explain must show why a value resolved (which level/scope it came from).

Residual risk (carried, not solved): multi-level + environment scopes is genuinely novel territory — GitLab is two-level (group→project). Proximity-first at arbitrary depth is consistent and defensible but not battle-tested. The --explain tooling is a hard requirement, not a nicety.


4. Manifest & apply

4.1 Manifest & layering

  • TOML, data not code. PiCloud runs untrusted scripts; the deployment descriptor stays inert, diffable, server-validatable, and dashboard-renderable. Turing-completeness belongs in the Rhai script, never the manifest.
  • Base + overlays. A node's picloud.toml holds the shared base; picloud.<env>.toml overlays add per-env vars/secrets/slug. Shared config is written once; overlays only carry deltas (the Kustomize base+overlay model — overlays are sparse patches, not replacements).
  • Routes are top-level, referencing scripts by name (one place to see all routing).
  • pull is first-class — see §4.6 for its inheritance/masking semantics.

4.2 The apply engine

The IaC research reshaped this section materially; the relevant precedents are cited inline.

Plan/apply split with a bound plan artifact. pic plan computes a reviewable diff (create / update / delete / replace), the server persists it as a row, and apply executes exactly that stored plandetecting and refusing if state moved underneath it (state version/serial check, cheap given the single writer). Evidence: Terraform's saved plan "reliably perform[s] an exact set of pre-approved changes, even if the configuration or state has changed in the minutes since" (Terraform run). The "state moved" check covers both the per-node content version and the tree-structure version (§6): a reparent or a new app created under the subtree between plan and apply changes the true blast radius, so the bound plan must refuse if either counter moved. Both are net-new — the existing scripts.version column is an unconditional write counter, not a compare-and-set guard, and must not be mistaken for one.

Atomicity — the corrected position. No major IaC tool does transactional apply, because their effects are un-undoable cloud resources (you cannot un-create a VM). That constraint does not bind us: a PiCloud manifest apply is almost entirely Postgres row writes (script source, route/trigger defs, config). Therefore:

  • The desired-state write is a single DB transaction — all-or-nothing across the subtree. This is achievable because our substrate is one transactional store (unlike Terraform's un-undoable cloud resources). It removes the downstream-breakage hazard of an earlier draft (which proposed per-node, stop-on-error apply — now superseded): there is no intermediate state where a parent committed and a child did not.
  • Propagation is forward-convergent, not transactional. The post-commit effective-view refresh + cache invalidation (§5.1) live outside the transaction. So "atomic" means atomic-for-desired-state, eventually-consistent-for-effect, with a bounded window where the DB says new and a cache says old.

Invariant to enforce — and it does NOT hold today (verified). The single-transaction model requires apply to write only Postgres, but the current admin write paths violate that: route and domain writes prime in-process caches synchronously, interleaved with the write (route_admin.rs:234, apps_api.rs:396), and files fsync to disk. So moving to "commit the transaction, then refresh views" is a deliberate restructuring of manager-core, not a description of the status quo. Additionally, domains are a non-DB effect (Caddy/filesystem, out of band) — they must be excluded from the transactional core and handled by the convergence model below. Treat "apply writes only Postgres" as an invariant to establish and guard, not one we already have.

Idempotent, convergent recovery (for the non-transactional propagation and any future side effects). Operations are idempotent upserts keyed by stable identity; "re-apply converges" is the recovery story; "rollback" means revert the declaration and re-apply forward. Per-node status (applied/pending/failed/drifted) is recorded so a partial propagation failure is observable and re-runnable. Evidence: every surveyed tool (Terraform, ArgoCD, Flux, Pulumi) chose idempotent forward-only convergence over distributed rollback.

Drift model — upgraded from pure last-write-wins. An earlier draft chose model "(c)": last-write-wins, just log. That is exactly the pre-SSA kubectl apply footgun — KEP-555 lists the scenario verbatim: "User does an apply, then kubectl edit, then applies again: surprise!" Kubernetes Server-Side Apply exists to convert that silent clobber into an explicit conflict (K8s SSA). Concrete risk for us: CI runs pic apply and silently re-enables a route an operator killed in an emergency. Resolution:

  • Keep (c)'s simplicity for ordinary fields (last-write-wins, change reported).
  • For the security-relevant subset onlyenabled and a secret's reference/existence in desired state — track a "changed out-of-band" bit. If that subset was changed outside the manifest (e.g. an operator disabled a route in the dashboard), pic plan surfaces it as a labeled conflict and apply refuses without --force. This is a scoped version of SSA's field ownership; we deliberately avoid full per-field managers (object bloat, complexity).
  • Secret values are explicitly out of scope of the conflict/state-version machinery. Values are never in the manifest or the plan (§4.6), so a routine pic secret set between plan and apply is the supported workflow and does not trip the state-version refusal — the state-version check covers manifest-managed desired state (definitions, including secret references and enabled), not value rotation.
  • Out-of-band changes render as a distinct labeled diff in plan, separate from intended changes. Evidence: Terraform/Pulumi both make read-only drift detection default and label drift distinctly (Pulumi drift).

Residual risk: the conflict bit reintroduces some of the complexity (c) was chosen to avoid. The cruder fallback, if even that is too much: keep pure (c) but add a non-revertible operational lock flag an operator sets in an emergency that apply won't touch. Either closes the silent-revert hole; neither is free.

Gating high-stakes applies: trigger ≠ authorization. Any CI trigger may plan, but applying to a confirm-required env is a separate, default-off, explicitly-authorized step. A blanket --yes covers ordinary confirms; confirm-required envs require an explicit per-env --approve <env>, so CI must opt in per environment. "Override a gate" is its own audited capability (maps onto manager-core::authz::can). Evidence: Terraform Cloud parks runs in Needs Confirmation and separates the apply runs permission from manage policy overrides (TFC run states).

Concurrency. Start with a coarse per-instance (or per-root-group) apply lock — one apply at a time — which is trivially correct for the single-node MVP and makes "last-commit-wins" hold. Refine later to per-blast-radius advisory locks (lock the affected apps in app_id order so overlapping radii serialize while disjoint ones proceed; queue triggers already use this advisory-lock primitive) only if contention appears. Don't build the fine-grained version speculatively.

Blast radius — defined and bounded. The blast radius is the set of descendant apps whose materialized effective view would actually change — a diff, not "all descendants." Per changed definition at node N it is subtree(N) minus apps that override that key nearer. pic plan enumerates it for small radii; for large ones it summarizes (count + sample) and a threshold triggers extra confirmation ("this changes 4,213 apps — confirm"). Because only scripts/vars/secret- refs inherit (§5.1), blast radius applies to those changes; an app-scoped change (a route or trigger) has blast radius = that single app. Root-level changes are accepted as expensive, rare, and high-confirmation; the computation is bounded by subtree(N) size, and the confirmed radius is re-validated at apply against the tree-structure version (above) so it can't go stale.

In-flight executions. An apply that disables or replaces a script affects new invocations immediately (via the enabled re-check + view invalidation; pending trigger outbox rows are dropped at fire-time, §4.3 — so no separate outbox purge is needed). In-flight running executions run to completion, and note (verified) the executor has no external-cancel path today: executions are spawn_blocking Rhai calls interruptible only by their operation budget or a pre-set wall-clock deadline self-checked in engine.on_progress. A true must-stop-now kill-switch is therefore a net-new capability — buildable by having that same on_progress hook also poll a per-execution cancel flag — gated by an admin capability (authz::can) and audited, scheduled as a later item, not phase 1. Killing mid-run also risks partial side effects, so it stays the explicit extreme, never default apply behavior.

Apply flow (end to end):

CLI builds bundle (manifests + script sources) for the subtree
  → server computes plan + blast radius (incl. descendant apps in OTHER repos)
  → server persists plan artifact; checks state version
  → dev reviews; confirm/approve per env policy
  → server applies desired state in ONE DB transaction (all-or-nothing)
  → server refreshes effective views + bumps generation + invalidates caches (convergent)
  → server returns change report; CLI logs created / updated / re-enabled / pruned / conflicts

4.3 The three-state enabled lifecycle

enabled is a real platform feature (DB + server runtime + UI badge/toggle), not CLI sugar, and — by §3 — it is just another sparse, proximity-resolved field.

State Meaning Pruned?
declared, enabled = true (or omitted) deployed, active kept
declared, enabled = false deployed but inert (route short-circuits, trigger doesn't fire, script not invocable) kept — still desired state
absent from merged manifest stale deleted by prune / --prune
  • Default true; last-write-wins on merge; a base enabled = false is inherited until an overlay (env or a nearer group/app) explicitly sets enabled = true. Overriding a different field does not implicitly re-enable.
  • Across the group axis (resolved): a descendant disables an inherited (group-owned) script via a sparse override — an override row that sets only enabled = false, inheriting the source. A descendant can re-enable a parent-disabled entity because nearest-level wins. This is the same sparse-field mechanism as everything else (Kustomize patches are sparse — you specify only what changes).
  • Base = the superset across envs. Per-env you may toggle enabled in either direction (disable a base-active entity, or re-enable a base-disabled one — proximity wins, §3), but you cannot remove a base entity in one env; true removal requires the entity not be in the base. An entity unique to one env goes in that overlay; an entity present everywhere but off in staging goes in base with enabled = false in the staging overlay.
  • Disabled = invisible. External callers hitting a disabled route get 404 (indistinguishable from absent — no info leak).
  • Schema note: the triggers table already has enabled (+ dispatch_mode, retry columns) and it is honored at match/schedule time (trigger_repo.rs, cron_scheduler.rs — verified). New work is enabled on scripts and routes only, plus runtime honoring in the matcher / invoker.
  • Fire-time re-check (shipped, test-backed): the dispatcher re-checks enabled at fire time on every async path, so a pending event for a now-disabled trigger/script is dropped (not executed) when it comes up — the §5.1 security-disable guarantee on the trigger path. Outbox arm: resolve_trigger sets active = trigger.enabled && script.enabled and the async-HTTP/invoke builders set active = script.enabled; dispatch_one drops the row via a single unified if !resolved.active gate. Queue arm: dispatch_one_queue runs a separate path (it claims from queue_messages, not the outbox), so in addition to list_active_queue_consumers filtering s.enabled/t.enabled at list time, it re-checks the freshly-read script.enabled before executing and releases the claim (nack) when disabled — closing the per-tick TOCTOU window (a script disabled after the list snapshot but before its in-flight message runs). Both arms are regression-locked: dispatcher::tests::outbox_enabled_gate::disabled_http_outbox_row_is_dropped_without_executing covers the unified !resolved.active outbox gate, and dispatcher::tests::queue_enabled_gate::disabled_queue_consumer_releases_claim_without_executing covers the queue arm. Same-app backstop: every async build path (resolve_trigger, build_http_request, build_invoke_request, dispatch_one_queue) rejects a row whose script's app_id ≠ the row's app_id, so a hand-edited/restored row can't run one app's script under another's SdkCallCx — the cross-app isolation boundary.
  • Provenance caveat (accepted): a single boolean carries no "manual vs manifest" provenance, so a disabled entity looks the same however it got there — which is why the §4.2 conflict bit on the enabled/secrets subset exists, to stop the next apply silently reverting an operational disable.

4.4 Identity & naming

  • Kebab everywhere. One canonical identifier regex: ^[a-z0-9][a-z0-9-]{0,62}$ for project names, env names, script names/slugs, trigger names — unified with the existing app-slug rule.
  • Scripts: unique name/slug per app = merge/upsert key.
  • Routes: identity = the triple <method> <host> <path>, e.g. ANY *.beta.example.com /hello/:name. dispatch_mode, host_param_name, etc. are attributes overridable without changing identity. The CLI infers host_kind/path_kind from the pattern syntax (*, {name}, :name, exact), with an explicit kind key as override (mirrors the UI). Needs a normalization rule (default method ANY, default host *, case-folding) so manifest ↔ server match exactly.
  • Slugs are instance-global, derived from the path. Two identifiers coexist:
    • path = acme/team-a/blog — hierarchical, group-scoped, display/organization/RBAC.
    • slug = flat, instance-global, the deployment key. The derived default is {flattened-path}-{env} (e.g. team-a-blog-staging) — unique by construction in the multi-group world, unlike a bare {leaf}-{env} which collides whenever two groups reuse a leaf name. On >63-char overflow, truncate + short hash suffix. Explicit override allowed; the path never is the slug, it only seeds it.

4.5 Triggers

Triggers are app-scoped, not group-inherited (§5.1 explains why). Two senses of "app" are in play and must not be conflated: a trigger is declared once in the leaf (logical app) base — an authoring convenience — and materializes into per-app_id rows, one per env-app, where app_id is the server-app isolation boundary (§5.2). It is never group-owned.

Two distinct constraints:

  • name (explicit, kebab) = the merge/identity key + upsert target. Unique per app. (Triggers have no name column today — new.)
  • Semantic uniqueness = a post-merge validation: no two triggers may share their kind-specific semantic key. Checked after merging, so overriding a base trigger per-env (reuse name, change a field) is fine; two differently-named triggers with identical effect is an error.
Kind Semantic key Note
kv / docs / files (script, collection_glob, ops) canonicalize ops (sort + dedupe)
cron (script, schedule, timezone) exact TEXT match
pubsub (script, topic_pattern)
dead-letter (script, source_filter, trigger_id_filter, script_id_filter) NULL = literal value (not wildcard) for equality
email (script) no filters — one per script
queue (queue_name)not script-scoped already server-enforced (advisory lock: one consumer per (app_id, queue_name))
  • Matching vs identity: []/NULL/globs mean "any/wildcard" at dispatch time (unchanged runtime matching) but are compared as literal structural values for dedup. We dedup on structural identity, never on overlap/subsumptionops = [] and ops = ["insert"] are not duplicates. Overlapping triggers coexist (multiple triggers firing on one event is already how PiCloud works).
  • Name backfill for existing nameless rows: {kind}-{entity}-{n}, where {entity} is the kind's identity token (collection / queue / topic; for cron/email/dead-letter fall back to {kind}-{n}), sanitized to the kebab regex, with -{n} (discovery order) guaranteeing per-app uniqueness.

Ergonomic debt (accepted, watch it): because triggers don't inherit, 100 tenant apps each needing the same 5 triggers = 500 declarations. The fix is group trigger/route templates that fan out per descendant — deferred, but it bites early if tenant cardinality is high. Pressure-test against real tenant counts before committing to the narrow-inheritance choice (§5.1).

Shipped — group TRIGGER templates (live, event kinds). Implemented as live inheritance via the ancestor-chain CTE, not the materialized "instantiation" this note first sketched — consistent with how vars/secrets/scripts/§11.6 collections all resolve. A [group] declares an EVENT trigger (kv/docs/files/pubsub) binding a group-owned handler; triggers gained a polymorphic owner (0056_group_triggers.sql, nullable group_id, mirrors 0050), and the dispatcher's list_matching_kv/docs/files + the pubsub publish fan-out prepend CHAIN_LEVELS_CTE and 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's app_id (the Phase-4 boundary). That walk is the isolation boundary: a sibling-subtree app never sees the template (pinned by tests/group_trigger_templates.rs). Authoring is [[triggers.kv]] (etc.) on a [group]; read-only pic triggers ls --group.

Stateful templates via MATERIALIZATION (, M5 — cron + queue + email). The stateful kinds can't resolve live (each needs a per-app row: cron last_fired_at, the queue one-consumer advisory lock, the email sealed inbound secret), so a group [[triggers.cron|queue|email]] template is materialized into an app-owned copy per descendant — materialized_from = template.id (0062) — by materialize::rematerialize_stateful_templates. That reconciler (all-apps app_chain CTE ⋈ group-owned stateful templates, a precise create/delete diff that preserves last_fired_at) runs full-live at the route-rebuild chokepoints: apply (single + tree), app create/delete, group reparent. The dispatch paths are unchanged — the scheduler + queue consumer + 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 consumer slot (the one-consumer invariant).

Email uses the shared-group-secret model (M5.5). Rather than a per-descendant reseal (the original deferral, which would have needed the master key threaded into every CRUD hook), the email template resolves its inbound_secret_ref against the group's own secret store once at apply and seals it onto the template row. Email secrets seal v0/no-AAD (seal_legacy), so the ciphertext is portable across rows — materialization copies the sealed bytes verbatim into each descendant (no master key, no reseal, no new schema column). All descendant email webhooks validate against the one group HMAC secret; each copy has its own trigger_id / inbound URL. A group email template whose group secret is unset fails apply hard (same as the app path). Pinned by tests/stateful_templates.rs + the stateful_templates journey. Deferred: a per-app materialized column in pic triggers ls --app; cross-owner dedup (a descendant re-declaring an identical trigger double-fires — "overlapping triggers coexist"); the per-app (non-shared) email secret model.

Shipped — group ROUTE templates (live, inherited). The same live model, adapted to routes. A [group] declares a [[routes]] template binding a group-owned endpoint; routes gained a polymorphic owner (0057_group_routes.sql, nullable group_id, mirrors 0056). Unlike triggers (dispatched by a per-event SQL query), routes are served 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 (the all-apps generalization of CHAIN_LEVELS_CTE: every app × its ancestor chain ⋈ routes, tagged with the effective app + owner depth). compile_effective_routes applies nearest-owner-wins shadowing — for one app, an identical binding tuple (method+host+path) owned at multiple chain levels collapses to the nearest (an app's own route shadows an ancestor-group template; a route picks one winner, unlike a fanning trigger), while non-identical bindings coexist under the existing matcher precedence. The match + dispatch paths are unchanged; the group handler runs under the firing app's app_id. The chain expansion is the isolation boundary — a template lands only in the slices of apps whose ancestor chain contains the owning group (pinned by tests/group_route_templates.rs + the group_routes journey). Because the table is a cache, inheritance is rebuilt full-live on every edge that changes it: route CRUD, apply, and tree mutations — app create/delete (apps_api) and group reparent (groups_api) all route through the single rebuild_route_table chokepoint, so a new app under a group serves its templates instantly. Host-claim validation is skipped for a group template (no single app; descendants serve it on whatever host they claim, so templates use host_kind = any). Authoring is [[routes]] on a [group]; read-only pic routes ls --group. Disabled semantic (§4.3): the shadow check runs after the enabled filter, so a disabled own-route does not claim its binding — an enabled ancestor template at the same path then falls through and serves ("disabled = absent"). To actually 404 an inherited route, an app SUPPRESSES its path (below). Deferred: multi-node route-snapshot propagation (cluster mode).

Shipped — per-app opt-out of inherited templates (suppression). A descendant could shadow a template but not decline one; for triggers there was no decline path at all (an app's own identical trigger double-fires). An app now declares [suppress]triggers = [...] (handler SCRIPT names) and routes = [...] (PATHS) — to opt out of inherited group templates. Coarse by reference, not a full definition (template row-ids churn on re-apply; a reference is stable, so re-apply is a NoOp and a reference may decline several templates bound to the same script/path). Marker table 0058_template_suppressions.sql — app-only (app_id NOT NULL, ON DELETE CASCADE; a group just wouldn't declare the template), a target_kind discriminator (trigger|route), reconciled with the extension-point marker pattern (idempotent create, prunable delete → the template re-inherits). Consumed at the two existing resolution points: the trigger dispatch queries gain a correlated NOT EXISTS anti-join (excluding a group-owned trigger whose handler the app suppresses, gated to t.group_id IS NOT NULL), and compile_effective_routes drops an inherited (depth > 0) route at a suppressed path (loaded once per rebuild via RouteRepository::list_route_suppressions). Suppression is inheritance-only — the group_id IS NOT NULL / depth > 0 gates mean an app can only decline what it inherits, never its own resource nor a sibling's (pinned by tests/template_suppression.rs). A dangling suppress (matching no inherited template) is an apply-time warning, not an error; read-only pic suppress ls --app.

Trust-model consequence — group templates are advisory-by-default unless sealed. Before opt-out, a group template was guaranteed to run on every descendant; with suppression the guarantee weakens to "runs unless the descendant declines." This is fine for convenience templates (a shared webhook, a default route) but was a footgun for compliance templates — an audit-logging or security trigger a multi-tenant operator deploys at a group could be silently opted out of by a tenant app.

Resolved — sealed (mandatory) templates (). A [group] marks a route or event-trigger template sealed = true; the two suppression filters skip a sealed row, so a descendant's [suppress] is ignored and it fires/serves on every descendant. Backing: a sealed BOOLEAN column on triggers + routes (0059_sealed_templates.sql); the trigger anti-join gains AND t.sealed = FALSE (a sealed row is never excluded → fires through the opt-out), and compile_effective_routes gates its suppression continue on !er.route.sealed. sealed is authored per-template and is group-onlyvalidate_bundle_for rejects it on an app owner (an app route/trigger is never inherited, so sealing it is meaningless). It only strengthens the guarantee (a sealed template can't be declined; it never grants new reach — the chain walk stays the isolation boundary). It joins the route Update comparison + the trigger identity, so toggling it re-applies (a trigger toggle needs --prune to drop the stale row, like any definitional change). A suppression that matches only sealed templates is an apply-time warning ("… is sealed — the suppression has no effect"); pic triggers/routes ls --group show a sealed column. Pinned by manager-core/tests/sealed_templates.rs + the sealed journey.

Group-level suppression (, M1). template_suppressions gained a polymorphic owner (0060_group_suppressions.sql, mirroring the triggers/routes reshape): a [group] may now declare [suppress] to decline a template it inherits from a higher ancestor, for its whole subtree. The two consumption filters generalized to the chain: the trigger anti-join joins the chain CTE (ts.app_id = sc.app_owner OR ts.group_id = sc.group_owner) so a suppression owned by the firing app OR any ancestor group applies; list_route_suppressions expands group-owned suppressions across descendants via the all-apps app_chain CTE, so compile_effective_routes is unchanged. Still inheritance-only (t.group_id IS NOT NULL / depth > 0) and sealed still overrides. Read-only pic suppress ls --group; the dangling/ineffective warning walks the group's own chain (GROUP_CHAIN_LEVELS_CTE). Pinned by manager-core/tests/group_suppression.rs + the suppress journey. Deferred: none for suppression.

4.6 Secrets & pull

  • Name-only in the manifest; value pushed via CLI (pic secret set, reads stdin). Unanimous across every comparable tool — never commit secret values.
  • Env-scoped like any var ([secrets] names declared once; values set per env).
  • Warn (don't block) if a referenced secret is not set. This requires an app dev to see that a group secret exists / is set (a boolean) without reading its value — an accepted, explicit authz boundary. GitLab surfaces inherited masked variable keys the same way.
  • Email's inbound_secret is a reference, not inline — same rule; the server already encrypts it at rest.
  • pull exports own-rows only (this node's overrides), never effective/inherited state. A separate read-only pic config --effective shows the inherited result with masked secrets rendered as <set> / ***, never plaintext. This makes pull-under-masking safe by construction — you cannot pull a secret you cannot read, and you cannot accidentally duplicate inherited config into a leaf. Evidence: GitLab shows inherited group variables in a project read-only and separate from project-own variables.
  • Flat pull for a new project; "smart" delta-pull (own-vs-effective diff) is server-computed since an app dev's checkout lacks ancestor manifests.

4.7 Apply-time warnings

  • Enabled route/trigger pointing at a disabled script.
  • An endpoint script deployed with no route and no trigger (unreachable). Modules are exempt.
  • Sandbox override exceeding the admin ceiling.
  • Referenced secret not set.
  • An out-of-band change to the enabled/secrets subset (surfaced as a conflict, §4.2).

5. Groups & inheritance

5.1 What inherits, and the runtime model

  • GitLab-like, nested, single-parent tree. Single parent keeps inheritance acyclic and resolution deterministic (no diamond precedence).

  • Inherit code/config — narrowly. Inherit data — no. Group-inheritable = scripts/modules, vars, secret-refs only. Routes, triggers, collections, topics, files are app-scoped.

    • Why narrow: routes and triggers are bindings, not pure code/config. A group-owned trigger has no app data to watch (triggers fire on app-owned collections/topics/queues); a group-owned route has no host (routing is Host→app first). Inheriting them is incoherent. There are two distinct sharing mechanisms and an earlier draft conflated them: the leaf base+overlay shares routes/triggers across one app's environments; group inheritance shares code/config across many apps. Evidence: Serverless/SAM keep events: per-function and share logic via layers — bindings local, code shared.
    • Why data stays app-owned: a group script executes in the inheriting app's context, so its cx.app_id still scopes data to that app. Group-level collections/topics would break app_id as the isolation boundary — that is the v1.3 cross-app data-sharing problem and stays out.
  • Runtime model: a materialized effective view + versioned cache (not per-request live-resolve). An earlier draft said "live-resolve," which is underspecified and would fight the existing cache. The real model:

    ⚠️ Superseded for config by what Phase 3 actually shipped (§11.3): live resolution. For vars + secrets, PiCloud resolves on-demand via one recursive CTE per call — no materialized view, generation counter, or invalidation fan-out. There was no pre-existing config cache to "fight" (config is net-new, §3), and the org tree is small, so resolve-on-read is cheap and sidesteps the invalidation SLA below. The materialized model in this bullet stands only as the deferred plan for script-body inheritance (Phase 4) — where a hot per-request path may justify a cache — and as a future optimization for config if a deep tree ever demands it. Read the rest of §5.1 as "the materialization design, if/when we need it," not as Phase 3's runtime. Update — Phase 4-lite (§11.4 status) also shipped live, not materialized: group scripts are referenced by id on the runtime hot path and resolved by name only at bind/invoke time, so even script inheritance needed no materialized view or invalidation fan-out. The materialization design now stands purely as a future optimization, gated on a measured cost.

    • manager-core resolves-at-write into a materialized per-app effective view (§3 rule applied: sparse merge, env filter, proximity, CoW, enabled).
    • The orchestrator/executor serve from that view, keyed by app_id + a generation/version. The app_id-keyed shape survives (today's route cache is already an app_id→routes map), but the substance is net-new (verified): there is no generation counter anywhere today, the route cache is rebuilt whole-table on every write rather than per-app, and script bodies are live-resolved per request. Read "serve from it" as extend the keying, not reuse the mechanism — and note that fanning out today's full-rescan invalidation to thousands of descendants would be a regression, so per-app incremental invalidation is part of the build.
    • On any write to a node, manager-core (single writer, knows the tree) recomputes descendants' views + bumps the generation + invalidates caches.
    • The materialized view is a derived cache, not a second source of truth — canonical config still lives once at the owning node, so this does not reintroduce duplication. This dissolves the long-running "snapshot vs. live" tension: no duplication and propagation and a fast hot path.

Residual risk (relocated, not solved): cache invalidation is now a correctness/security requirement — disabling a script for a security reason must stop it running everywhere within bounded time. This is the same class as the existing PrincipalCache revocation lag. Require synchronous invalidation for the security-relevant subset (enabled=false, secret rotation) and accept bounded eventual staleness elsewhere; the hard SLA at fan-out to thousands of descendants is genuinely unsolved.

5.2 Schema impact

  • Inheritable definition kinds get a polymorphic owner (owner_kind ∈ {group, app} + owner_id): scripts (modify the existing table — and note its app_id FK is ON DELETE RESTRICT, not CASCADE, so a pruning apply needs explicit ordering), plus vars and secret-refs, which are net-new tables (they do not exist today — §3). So this is one table modified + two invented, not "three tables touched." Routes, triggers, collections, topics, files stay strictly app_id-owned, so the runtime isolation boundary stays fixed. (The CLAUDE.md app_id NOT NULL … CASCADE rule is itself not universal today — scripts already use RESTRICT.)
  • New: a groups table (single-parent, parent_id), group membership/roles, an owner_project column on group nodes (§7), and the materialized effective-view store keyed by app_id + generation (§5.1). Env-scoped values carry an environment_scope column (* or a specific env).

5.3 RBAC

  • Hierarchy-aware capabilities. authz::can(principal, cap, on=node) resolves by walking ancestors and taking the highest effective role. Instance → group(s) → app.

  • Inherited membership (GitLab-style): a group admin is implicitly admin of every subgroup/app beneath it.

  • Masked group secrets: a group secret is used by an app at runtime but not human-readable by the app's developers. Two orthogonal gates: runtime resolution (engine injects plaintext) vs human-read authz (admin API returns a value only to a principal with rights at the owning group). An app-scoped admin call never returns group secrets; runtime injection bypasses the human gate. App devs may see a group secret exists (for the unset-warning, §4.6) but not its value. An app can run with config its own developers cannot see.

    Crypto caveat (verified): secrets today are AES-GCM sealed with AAD secret:{app_id}:{name}, and the decrypt path hard-codes cx.app_id (migration 0042). A group-owned secret is not expressible under this scheme — there is no group identity in the AAD. It needs a new AAD identity (e.g. secret:group:{group_id}:{name}) and an owner-aware decrypt path that resolves whether the inherited secret is group- or app-owned. This is the single hardest correctness detail of group secrets and gates phasing step 3.

5.4 Tenants & single-parent

Single-parent forbids an app combining two sibling groups' configs — which seems to threaten the multi-tenant use case (shared-platform base + per-tenant overlay). It does not, because the shared base is an ancestor, not a sibling: model tenants as leaf apps under a tenants/ subgroup that inherits the platform base up the chain (tenant leaf → tenants group → platform group). The "two parents" intuition is satisfied by the chain. Better still, a tenant is a scope dimension like environment (§3) — the same env-scope machinery generalizes to a tenant scope, so multi-tenant needs no new hierarchy primitive. Evidence: GitLab is single-parent and serves multi-tenant teams via subgroups; "combine two siblings" is handled by promoting shared config to a common ancestor.


5.5 Module & import resolution under inheritance

A group-owned script may import modules, and inheritance makes "which module?" ambiguous (an inherited script importing a module a leaf has shadowed could bind to app-dev code it never saw — a trust inversion). The rule:

  • Lexical by default. An inherited script's imports resolve against the module set visible at the script's own defining node (walking up from there), not the inheriting app's effective view. A leaf cannot shadow a module an inherited group script depends on, and a group script behaves identically across every app that inherits it — preserving both determinism and the trust boundary (a security-authored shared script is tamper-proof from below). Ordinary lexical scoping; "sealed by default", like non-open classes in Kotlin / final in Java.
  • "Defining node" of a CoW-overridden script = the node that authored that body. An inherited-unchanged script's defining node is the ancestor that owns it (imports sealed to the ancestor's modules). A script a leaf overrides (CoW, §3) has the leaf as its defining node, so the override's imports resolve from the leaf — which is not a trust inversion, because the app owner wrote that body and is trusted within their own app. Consequence (intended): the same module name can resolve to two different bodies in one app, depending on the importing script's defining node.
  • Explicit extension points for opt-in polymorphism. A module is marked an extension point in the manifest (an [extension_points] declaration — not in Rhai source, keeping code inert and giving the plan checker something to read). Such a module is one descendants are expected to provide or override; only these resolve against the inheriting app's effective view. Controlled template-method customization (a shared render whose theme module each tenant supplies) without the blanket trust inversion of dynamic resolution. When a name is declared at more than one level — or is concrete on one path and an extension point on another — the nearest declaration's kind wins (proximity, §3); its default body (if any) is the inherited fallback.
  • Apply-time checks: a dangling import (inherited script → missing module) is a plan error; an extension point with no provider in a given app is an error for that app (a hard failure, joining §4.7).

Resolved (Phase 4b ). The lexical (sealed-by-default) core shipped: ModuleScript carries a polymorphic owner; ModuleSource::resolve(origin, name) walks the chain rooted at the importing node (app-rooted CHAIN_LEVELS_CTE or the new group-rooted CTE); the resolved script's owner threads through ExecRequest.script_owner (the executor's default_origin) and every dispatch + invoke() site; the PicloudModuleResolver reads the importing node from Rhai's _source (set to each module's owner via AST::set_source(encode(owner)) before eval_ast_as_new — the lexical chaining), and the cache is re-keyed by resolved ScriptId. Group modules + group-script imports are now allowed (group_scripts_api), and the single-node apply runs the §5.5 dangling-import plan check.

Resolved (extension points ) — §5.5 is complete. Opt-in polymorphism shipped: an extension point is a pure marker (owner, name) (migration 0051 extension_points, owner-polymorphic + CASCADE — structurally a secrets name), authored declaratively as an [app]/[group] manifest key extension_points = [...] (a node key, so TOML can't mis-nest it); the optional default body is a co-located kind=module script. The resolver's ModuleSource::resolve_policy(origin, app, name) applies nearest-declaration-kind-wins over the importing node's chain (one query: min EP depth vs min module depth, EP wins a tie): a concrete module → lexical; an EP marker → dynamic, resolved against the inheriting app (its override, else the default body up-chain) — NoProvider is a hard error. Reconcile mirrors secrets (name-only diff/prune/state-token); the single-node apply adds the no-provider plan check (every EP visible to an app must have a provider); read-only pic extension-points ls + pull round-trip complete the surface. Authoring is declarative only (no imperative add/rm). Verified e2e: a group default resolves for an inheriting app, and the app can override it — the deliberate inverse of the Phase 4b sealed/lexical import.

5.6 Tree lifecycle: delete, reparent, rename

Structural mutations of the group tree, which the rest of the design depends on staying acyclic and non-orphaning:

  • Delete = RESTRICT, never implicit CASCADE. Deleting a non-empty group is refused — an implicit cascade would destroy descendant apps and their isolated data. CLI --recursive expands a delete into ordered, explicit, confirmed child deletions; the DB FK stays RESTRICT. Corollary: a referenced ancestor cannot vanish while it has descendants, so cross-repo read-only references (§7) can't be orphaned by deletion — the RESTRICT protects them automatically. App data is destroyed only on explicit opt-in: an app delete refuses unless --purge-data, which then removes its KV/docs rows and its files blob tree under PICLOUD_FILES_ROOT/<app_id>/ — a non-DB, non-undoable effect run outside the transaction and logged. So --recursive group delete requires --purge-data to touch any descendant app's data; without it, a non-empty app blocks the delete.
  • Reparent / rename: the slug is frozen at creation. The path only seeds the derived slug (§4.4); a move or rename updates the display path but never rewrites the instance-global slug — the deployment key stays stable, external references don't break. After a move the slug no longer mirrors the path (cosmetic, accepted).
  • Reparent recomputes descendant effective views (it changes the resolution chain — the same fan-out invalidation as a node write, §5.1) and is doubly capability-gated: group-admin at both the source and destination parent (you remove from one ancestor's domain and add to another's). Because it changes the resolution chain, a reparent is validated like a plan and refused (unless forced) if the recompute would orphan a sparse enabled-override (now shadowing nothing) or leave an extension point with no provider (§5.5) — a structural move must not silently produce a state apply would have rejected.
  • Cycle guard, under the apply lock. Reparent runs an ancestor-walk check in manager-core (walk from the destination up to root; reject if it reaches the node being moved). A Postgres CHECK can't express this; the guard is what guarantees §9's "resolution always terminates." Single-parent + this guard = acyclic. All structural mutations (reparent/rename/delete) take the same coarse apply-lock (§4.2), so the ancestor-walk + parent_id write run serialized — two concurrent reparents can't race into a cycle, and a reparent's view-recompute can't collide with an overlapping apply on the materialized-view store.

6. The CLI ↔ server projection

  • Directories = groups (the hierarchy axis). Single-parent falls out of the filesystem for free.
  • Overlay files = environments/apps (the deployment-variant axis) — not subdirectories, because envs share scripts/structure and only diverge on vars/secrets/slug; files structurally prevent per-env script drift.
  • scripts/ at every level cascades up the tree; nearer overrides farther by name (CoW).
  • A leaf group = one logical app; its environments = the actual server apps. Multiple distinct apps = multiple sibling leaf groups. Intermediate groups may also bear apps (a dir may have both subdirs and overlay files) — allowed, no special-casing.
  • Environment registry lives at the app-bearing node, but confirm-policy is inheritable (set "production always confirms" once at root; it flows down via the §3 mechanism). Leaves may declare independent environment sets; a tree-wide pic apply --env production simply skips a leaf that has no production.
  • Attach point: the local root manifest declares where it binds into the server tree (parent_group = "acme" or instance root). Ancestors above it are inherited/referenced but not present locally — which makes the sparse checkout enforce the RBAC masking for free; effective config needs a server round-trip (pic config --effective).
  • Stable IDs in gitignored .picloud/ (group IDs, instance URL, token ref) so a directory rename/move maps to a server reparent, not delete+create.
  • Local/server structural divergence is detected, not silently fought. Alongside the per-node content version (§4.2), each node carries a per-subtree structure version (covering its own parentage/subtree — not one global counter, so a structural edit in one team's subtree never force-refuses an unrelated repo's plan). pic plan compares the local parent-by-ID (from .picloud/) against the server's; on a structural mismatch (someone reparented server-side, or a dir moved locally) it refuses, requiring an explicit --adopt-server-structure or --force-local-structure (Terraform's detect-and-refuse on stale state). The content-version check alone would miss a pure structural move; reparent/rename/delete each bump the affected subtree's structure version.
  • Mono-repo = attach at instance root. Per-team repo = attach at a subgroup, contain only its slice. Same model, different attach depth.

7. Ownership of shared nodes

Single-owner-per-node, ceilinged by the attach point.

  1. Each group node is owned by exactly one project-root (the repo that manages it — contains its manifest as something it applies, not merely references). The server records owner_project.
  2. Your attach point is your ceiling — you cannot apply to anything above your local root.
  3. First apply claims; transfer is explicit and capability-gated. A second repo applying to an owned node is rejected (owned by project X; use --takeover); takeover needs group-admin capability. This stops one team silently clobbering org-wide config — whose blast radius (via the effective-view fan-out) is the whole subtree.
  4. Ownership ⟂ RBAC. Ownership = which manifest is authoritative; RBAC = whether this principal may. The owner still needs group-admin capability to apply.
  5. A node with no project claim is UI/API-owned — the dashboard is its source of truth and no manifest fights it. Every node is either manifest-owned (one repo) or UI-owned.

Corollary: don't co-own a node — split config downward. Shared config lives higher (owned by a platform/shared repo attaching at root); team-specific bits go into subgroups each team owns.

Status — M1 shipped (the ownership claim). The owner_project seam (0047) is now live, backed by a first-class projects table (0066, UUID pk + unique slug; owner_project FKs it ON DELETE SET NULL). A [project] block (slug + optional name) in the repo's root manifest declares identity; the first apply with a new slug registers the project and claims each group node it touches. The claim is a gate run inside the apply transaction, 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): unclaimed→claim, owner→no-op, foreign→conflict unless --takeover (which additionally requires GroupAdmin — ownership ⟂ RBAC), no-project-into-a-claimed-subtree→conflict. Apps carry no owner_project; an app inherits ownership from its nearest claimed ancestor group (the ancestor walk is the boundary), and an unclaimed subtree stays open (backward-compatible — nothing changes until a repo first declares [project]). Visibility: pic groups ls shows an owner column; --takeover on pic apply. Pinned by apply_service unit tests + the apply_ownership journey.

Status — M2 shipped (the attach-point ceiling, point 2). A [project] parent_group = "<slug>" binds the repo UNDER a pre-existing group; applies are refused (422 OutsideAttachPoint) for any node not strictly within that subtree. check_within_attach requires the attach group be a proper ancestor of a group node (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.

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 M1M3) 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]).

§6 group-tree M1 shipped — declarative group CREATE. A [group] node's PARENT is now inferred from directory nesting (the nearest ancestor directory holding a [group]; the topmost group binds to the repo's [project] parent_group attach point, else the instance root). discover::build_tree emits parent (plus the group's name/description) per group node, sorted parents-first by directory depth; TreeNode gained those fields (#[serde(default)], so a pre-§6 CLI still reconciles existing groups). Server-side, apply_tree runs a Phase 0 (create_missing_groups_tx) that resolves-or-creates every group node IN the apply tx (parents-first, a same-tree parent resolved before its child via read_group_id_by_slug_tx), under the coarse GROUP_STRUCTURAL_LOCK_KEY (taken only when it creates), enforcing the attach-point ceiling and RBAC (InstanceCreateGroup at the root / GroupAdmin(parent) for a subgroup) per create; the group is then CLAIMED in Phase A alongside existing groups (a fresh group is decide_group_claimClaim). prepare_tree gained a caller-supplied resolved_ids map (existing + just-created) and returns a to_create list the plan path previews (empty current → a full-create plan; ownership → claim) — a to-create group's token part is identical to its post-create part, so plan/apply tokens match across a create. authz_tree skips a to-create group (its authorization is the service-side create gate). Reparent of an EXISTING diverged group + the detect-and-refuse flags are M2. Pinned by tests/group_create.rs.

§6 group-tree M2 shipped — structural-divergence detection + declarative reparent. With the declared parent now on the wire, apply_tree compares each EXISTING group node's server parent against the manifest's (directory- nesting) parent. A divergence is resolved by a request StructureMode (default Refuse, so a pre-M2 CLI never reshapes the tree): RefuseApplyError::StructuralDivergence (422, naming the resolution flags); ForceLocal → reparent to the declared parent IN the apply tx via the extracted group_repo::reparent_group_tx (cycle guard + structure_version bump), §5.6-gated (GroupAdmin on the node + source + destination) and attach-ceilinged; AdoptServer → keep the server shape, reconcile content in place. M1's create_missing_groups_tx generalized to reconcile_group_structure_tx (create + reparent share the coarse-lock / attach / RBAC path; both are recorded as structurally_changed so the pool-based attach re-check skips their uncommitted rows). pic plan previews the divergence (divergence_preview → a structure row: diverged, server + manifest parents). CLI: --force-local-structure / --adopt-server-structure (mutually exclusive) → the structure_mode wire field; the 422 surfaces verbatim. A concurrent pic groups reparent between plan and apply still trips StateMoved (the tree token folds structure_version). Pinned by tests/structural_divergence.rs.

§3 M3 shipped — per-env approval gating ([project.environments]). A [project.environments] block on the root [project] maps an env name to { confirm = bool }. pic apply --env <e> is REFUSED (client-side fast-fail, before any request) when <e> is confirm-required unless it is explicitly --approve <e>d; a blanket --yes does NOT cover a gated env (§4.2 "CI must opt in per environment"); an unlisted / confirm = false env applies freely. require_env_approval gates both the single (run) and tree (run_tree) apply paths; --approve <env> is a repeatable flag. Value overlays (picloud.<env>.toml) already merge client-side, so this milestone is the confirm-policy gate only. Pinned by tests/env_approval.rs.

Server-side enforcement shipped (2026-07-11). The policy is no longer skip_serializing — the CLI SENDS [project.environments] (on ProjectDecl.environments) plus the selected --env + the --approve set, and the server RE-DERIVES the gate authoritatively (env_gate_check in apply_api.rs): a confirm-required env not in approved_envs409 ApprovalRequired; an approved override additionally requires AppAdmin/GroupAdmin on every declared node (a step up from the editor write caps an ordinary apply needs — §4.2 "override a gate is its own capability", reusing the admin caps) and emits a tracing::info! audit line. Enforced on all three handlers (single app, single group, tree); the env policy folds into the tree bound-plan token (a policy edit between plan and apply trips StateMoved); single-node apply --file now sends the GOVERNING project (own block else nearest ancestor's), so a leaf apply carries the root's policy; plan surfaces approvals_required. Pinned by tests/env_approval.rs::{server_enforces_the_gate_against_a_non_stock_client, approving_a_gated_apply_requires_admin}. Threat-model note (honest scope): this closes the "patched/older CLI that still declares the project but skips the local check" bypass, and admin-gates + audits the override. It is NOT a hermetic authorization boundary against a hand-crafted request that OMITS the policy (or the env label) — the policy is applier-supplied, not persisted server state, so an omitted policy is inherently ungated. Full closure (deferred): persist the env policy server-side (a project_environments source of truth) + a server-determined env, so enforcement is independent of what the request declares. Also deferred: env-as-app mapping + declarative server-side @E config scoping.

§6/§3 group-tree Tier 1 (M1 create · M2 divergence/reparent · M3 per-env approval) is COMPLETE.

Review follow-ups (post-Tier-1 hardening). A code review of the Tier-1 branch surfaced four fixes: (1) M1's authz_tree skip for a to-create group opened a race window (a group created by a racer between the API-layer authz check and the in-tx reconcile could have its content written unauthorized) — closed by an in-tx content-write re-check in apply_tree for every group NOT structurally created/reparented by that apply (require_group_node_writes moved from apply_api onto ApplyService; a group WE create is covered by create-RBAC and its uncommitted row is invisible to the authz cascade, so it's skipped). (2) The M3 approval gate could silently fail open — now EnvPolicy.confirm is a REQUIRED field (an empty prod = {} is a load error, not a silent no-gate), a single-file pic apply --file <leaf> discovers the governing [project] by walking up the directory tree, and a non-root [project].environments is dropped with an explicit warning. (3) Divergence detection is unified — the plan-path divergence_preview reuses the already-loaded group rows (no per-node re-fetch) and compares parent slugs case-insensitively so it can't disagree with the apply path's id-based check. (4) reconcile_group_structure_tx's create/reparent branches are extracted into helpers. Pinned by new cases in tests/group_create.rs + tests/env_approval.rs.


8. Diagrams

8.1 Server ownership & containment

Only scripts/vars/secret-refs are group-ownable (polymorphic owner); routes/triggers/data are always app-owned via app_id.

graph TD
    INST([Instance])
    INST --> RG["Group: acme (root)"]
    RG --> SGA["Group: team-a"]
    RG --> SGB["Group: team-b"]
    SGA --> LGA["Group: blog (leaf)"]
    SGA --> LGB["Group: shop (leaf)"]
    LGA --> APP1["App: blog-staging"]
    LGA --> APP2["App: blog-production"]

    RG -.->|"owns (shared)"| D0["scripts, vars, secret-refs"]
    SGA -.->|owns| D1["team-a scripts, vars"]
    APP2 -.->|"owns / overrides"| D2["app scripts, vars, secrets"]

    APP1 ==>|app_id| C1[("routes, triggers, KV/Docs/Files, Topics")]
    APP2 ==>|app_id| C2[("routes, triggers, KV/Docs/Files, Topics")]

8.2 Entity identity & cardinalities

erDiagram
    GROUP ||--o{ GROUP : "parent-of (single parent)"
    GROUP ||--o{ APP : contains
    GROUP ||--o{ DEFINITION : "owns (scripts/vars/secret-refs)"
    APP ||--o{ DEFINITION : "owns (override)"
    APP ||--o{ APPSCOPED : "owns (routes/triggers/data, app_id)"
    GROUP ||--o{ MEMBERSHIP : "role (inherited down)"
    APP ||--o{ MEMBERSHIP : "role"

8.3 Config resolution (sparse merge, env filter, proximity-first)

Effective value for one app+env, resolved by §3. Env-scope filters per level; nearest level wins; maps merge per key.

graph TB
    I["Instance defaults"] --> RG["Group acme<br/>secret stripe_key (ref)<br/>script auth.rhai<br/>db_url@production"]
    RG --> SG["Group team-a<br/>var region = eu"]
    SG --> LG["Group blog<br/>script render.rhai<br/>var title = Blog"]
    LG --> EV["Env overlay: production<br/>var title = Blog PROD (override)<br/>secret stripe_key = prod value"]
    EV --> EFF[["Materialized effective view (app=blog-production):<br/>auth.rhai (acme), render.rhai (blog)<br/>title = Blog PROD (leaf overlay)<br/>region = eu (team-a)<br/>db_url = prod (acme @production filter)<br/>stripe_key = prod"]]

8.4 Filesystem ↔ server mapping

graph LR
    subgraph FS["Git working tree"]
      direction TB
      R["acme/<br/>picloud.toml<br/>scripts/"]
      R --> TA["team-a/<br/>picloud.toml<br/>scripts/"]
      TA --> BL["blog/<br/>picloud.toml (base)<br/>picloud.staging.toml<br/>picloud.production.toml<br/>scripts/render.rhai"]
      LINK[".picloud/ (gitignored)<br/>group IDs, instance URL, token ref"]
    end
    subgraph SRV["PiCloud server"]
      direction TB
      G0["Group acme"] --> G1["Group team-a"]
      G1 --> G2["Group blog"]
      G2 --> A1["App blog-staging"]
      G2 --> A2["App blog-production"]
    end
    R -.->|defines| G0
    TA -.->|defines| G1
    BL -.->|"base defines"| G2
    BL -.->|"staging.toml"| A1
    BL -.->|"production.toml"| A2
    LINK -.->|"stable IDs"| SRV

8.5 Multi-repo subtree views & single-owner ownership

graph TD
    subgraph Server["Server group tree (authoritative)"]
      acme["acme (root)"]
      acme --> ta["team-a"]
      acme --> tb["team-b"]
      ta --> blog["blog"]
      tb --> shop["shop"]
    end
    subgraph PR["Repo: platform"]
      pr["manages acme"]
    end
    subgraph AR["Repo: team-a"]
      ar["attaches at acme<br/>manages team-a + blog"]
    end
    pr ==>|owns| acme
    ar ==>|owns| ta
    ar ==>|owns| blog
    ar -.->|"reference, read-only"| acme

8.6 Apply pipeline (bound plan → DB-atomic write → convergent propagation)

sequenceDiagram
    actor Dev
    participant CLI as pic CLI
    participant Mgr as manager-core (single writer)
    participant DB as Postgres
    participant View as effective views + caches
    Dev->>CLI: pic plan --env production
    CLI->>CLI: read manifests + .rhai sources, build subtree bundle
    CLI->>Mgr: send bundle (whole subtree)
    Mgr->>DB: read state + version of affected nodes
    Mgr->>Mgr: diff + blast radius + persist plan artifact
    Mgr-->>CLI: PLAN (changes, N descendant apps incl. other repos, conflicts)
    CLI-->>Dev: show plan; confirm/approve per env policy
    Dev->>CLI: pic apply (executes stored plan)
    CLI->>Mgr: apply (plan id)
    Mgr->>DB: refuse if content or structure version moved; else ONE transaction (all-or-nothing)
    Mgr->>View: recompute effective views + bump generation + invalidate
    Mgr-->>CLI: change report (created/updated/re-enabled/pruned/conflicts)
    CLI-->>Dev: log changes

8.7 RBAC: masked group secrets

graph TD
    GA["Group admin (team-a)"] -->|"sets + can read"| GS["Group secret: stripe_key<br/>owned by team-a, encrypted at rest"]
    AD["App developer (blog)"] -- "cannot read value (sees exists)" --x GS
    AD -->|"can edit"| AS["App script source + app vars"]
    GS ==>|"runtime injects plaintext"| EX["Executor: running app script"]
    AS --> EX
    GATE["Two gates:<br/>human-read authz  vs  runtime resolution"]
    GATE -.-> GS
    GATE -.-> EX

8.8 The three-state enabled lifecycle

stateDiagram-v2
    [*] --> Active: declared (enabled=true/omitted)
    Active --> Disabled: set enabled=false (manifest or UI toggle)
    Disabled --> Active: set enabled=true (manifest or UI toggle)
    Active --> Pruned: removed from manifest + prune/--prune
    Disabled --> Pruned: removed from manifest + prune/--prune
    Pruned --> [*]
    note right of Disabled
      Still desired state, NOT pruned.
      Route 404s, trigger inert, script not invocable.
      Out-of-band toggle on this field is conflict-guarded (4.2).
    end note

9. Adoption & backfill

Groups land onto a live instance with existing flat apps, so a migration is a prerequisite, not an afterthought:

  • Create a root group (and/or a per-owner personal namespace, GitLab-style) and reparent every existing app under it. Every app must have a parent from day one so resolution always terminates.
  • Existing apps have no group-owned definitions, so their effective view = their own rows — the materialized-view store can be backfilled trivially (identity resolution).
  • The trigger name backfill (§4.5) runs in the same migration window.
  • Existing app slugs are already instance-global, so no slug rewrite is needed; the path is new metadata layered on top.

10. Open questions & residual risks

Resolved items now live inline next to their topic. What genuinely remains:

  • Effective-view invalidation SLA (§5.1) — the security-staleness guarantee at fan-out to many descendants is unsolved; synchronous-for-security + eventual-elsewhere is the proposed shape, not a proven one. (Was the highest-risk open item.) Moot for config as of Phase 3: vars/secrets ship with live resolution (no cache → no staleness window; a disable/rotation is visible on the next call). The SLA only re-arises if script-body materialization is built (Phase 4) or if config is ever materialized for performance; until then there is nothing to invalidate.
  • Conflict bit vs. operational lock (§4.2)decided: phase 1 ships the enabled / secret- reference conflict bit; the operational-lock flag is the documented fallback if the bit proves too heavy. (Was listed as undecided; resolved here to match §11 phase 1.)
  • Multi-level env-scope precedence (§3.2)decided default: proximity-first with env as a per-level filter. The open part is only validation at depth, which is why pic config --effective --explain is a phase-3 hard requirement (when multi-level resolution first ships), not a precondition to adopting the rule.
  • Inherited-membership revocation lag (§5.3) — revoking a group admin must drop implicit admin on every descendant app, but §5.1's synchronous-invalidation subset covers only enabled/secrets, not role revocation — leaving an unbounded window. New residual risk; should get the same synchronous-for-security bound, lands with phase 3's authz. Resolved — never materialized: Phase 2 RBAC and Phase 3 config both resolve roles/config live (no inherited-role or effective-view cache), so a revoked group grant is gone on the next request. The lag only exists if a cache is introduced.
  • External execution cancel (§4.2 kill-switch) — the executor has no external-cancel path today (spawn_blocking + self-checked deadline, verified); the kill-switch is a net-new capability (a cancel flag polled in on_progress), deferred past phase 1. Until it exists, the strongest stop is op-budget/deadline + the dispatcher fire-time enabled re-check (§4.3) for the trigger path.
  • Narrow-inheritance vs. trigger/route templates (§4.5, §5.1) — the per-app binding tax bites early at high tenant cardinality. Decide whether templates are truly deferrable for your target.
  • pull --factor — auto-extract a shared base by diffing two pulled envs (later nicety).

11. Suggested phasing

Status (Phase 1): shipped. init, pull, config --effective, manifest parse/validate, plan + apply (single-transaction desired-state write, post-commit route refresh, domains/files outside the tx), prune, secrets push, .picloud/ link state, env-scoped overlays (picloud.<env>.toml base+overlay), the three-state enabled lifecycle on scripts/routes (runtime 404 + dispatcher fire-time re-check), the trigger name column + backfill, a coarse per-app apply lock, and in-flight-finish-no-kill all landed.

The bound-plan staleness check is the content-fingerprint form (a state_token hash of the live state; apply 409s if it moved) rather than a persisted plan-artifact + serial — it delivers the §4.2 guarantee without a migration or interactive-write-path changes, and the token covers enabled/secret names so the enabled/secrets conflict is enforced on the plan→apply path.

Deferred, with rationale: the tree-structure version counter is a no-op seam until groups exist (Phase 2); vars + multi-level/--explain resolution and the richer per-env overlay are Phase 3; trigger upsert-by-name (in-place Update; the diff still Create/Deletes by semantic identity) and the dashboard enabled toggle are tracked follow-ups beyond the §11 bullets.

  1. Declarative project tool, single-app (no groups yet). init, pull/config --effective, manifest parse/validate, plan (bound artifact), apply (atomic desired-state write — requires the manager-core post-commit-refresh restructuring of §4.2, domains/files excluded from the transactional core), prune, secrets push, link state, env-scoped config. Adds enabled to scripts/routes + the three-state runtime + the dispatcher fire-time enabled re-check (§4.3) + trigger name column/backfill + the enabled/secrets conflict bit + the net-new content + tree-structure version counters + a coarse per-instance apply lock; in-flight executions finish (no kill).

  2. Groups as pure org/RBAC/UI container. Nested groups (single-parent, parent_id, delete = RESTRICT, reparent/rename with the ancestor-walk cycle guard + slug-freeze + tree-structure version, §5.6), inherited membership, hierarchy-aware can, UI grouping, the §9 backfill. No shared resources yet — cheap, no data-plane schema change.

    Status (Phase 2): shipped. Migration 0047_groups.sql adds groups (self-FK parent_id ON DELETE RESTRICT, instance-global frozen slug, structure_version, inert owner_project §7 seam) + group_members (same app_admin|editor|viewer literals as app_members) + apps.group_id (NOT NULL, RESTRICT) with the §9 single-root backfill. The tree lives in group_repo (reparent: ancestor-walk cycle guard under a coarse instance-wide structural advisory lock, slug-freeze, structure_version bump; delete=RESTRICT) and group_members_repo. Hierarchy-aware RBAC (§5.3): AuthzRepo::effective_app_role / effective_group_role resolve the highest role across the app's own membership + every ancestor group via one depth-bounded recursive CTE; authz::can's Member path folds inherited group roles, so a group_admin on any ancestor is implicitly app_admin beneath it — resolved live per request (revocation is immediate). New caps InstanceCreateGroup / Group{Read,Write,Admin} (no app_id ⇒ bound API keys can't manage groups). Surfaced via groups_api, pic groups …, and a dashboard group tree + detail/members. structure_version is bumped but not yet a consumer beyond the reparent path (CLI structural-drift detection is Phase 5).

    Deferred to Phase 3+ (unchanged): group-owned scripts/vars/secret-refs, the materialized effective-view resolver, masked group secrets + the group-secret AAD scheme, module/import resolution under inheritance, config --effective --explain, and personal-namespace root groups (the backfill seeds a single instance root). The §10 inherited-membership revocation-lag risk does not bite in Phase 2 because resolution is live (no inherited-role cache); it returns only when Phase 3's materialized views land.

  3. Group-inherited config (vars, secret-refs, env-scoped). The net-new vars/secret-refs tables + polymorphic owner; the group-secret AAD scheme (§5.3 caveat); masked group secrets; the effective-view resolver + materialization + invalidation; config --effective --explain (hard requirement, since multi-level resolution first ships here).

    Status (Phase 3): shipped — with one deliberate architecture change: LIVE resolution, not materialization. §5.1 / this bullet / §12 prescribed a materialized per-app effective view with a generation counter and fan-out invalidation. We shipped live, on-demand resolution instead: one depth-bounded recursive CTE (config_resolver::CHAIN_LEVELS_CTE, walking apps.group_id → groups.parent_id → root) runs per vars::get/secrets::get/config/effective, and the §3 merge/proximity/tombstone semantics run in a pure, unit-tested resolve() pass on the fetched rows. No materialized store, no generation, no invalidation protocol. Rationale: the group tree is small org structure, resolve-on-read is cheap, and it sidesteps §10's unsolved fan-out-invalidation SLA entirely. Consequence: §10's "effective-view invalidation SLA" and "inherited-membership revocation lag" are moot for config — with no cache, a security disable or secret rotation propagates immediately (same posture as Phase 2's live RBAC). Materialization (and a per-execution memo keyed by cx.execution_id) is re-scoped to a future optimization, to revisit only if a deep tree + hot path ever makes live resolution measurably costly.

    Shipped surface:

    • Schema: 0048_vars.sql (vars polymorphic owner [group_id XOR app_id], environment_scope, is_tombstone, partial-unique indexes) + apps.environment TEXT NULL (the explicit realization of "an environment is an app", §2 — set at app-create, read by the resolver, never entering SdkCallCx). 0049_group_secrets.sql reshapes the live secrets table to the same polymorphic-owner + environment_scope contract (PK → two partial-unique indexes); existing app rows backfill to app_id + scope '*' and decrypt byte-for-byte (the v1 AAD excludes the scope).
    • §3 resolver: env-filter in SQL (scope IN ('*', app_env)), then proximity-first + per-key deep-merge / replace / tombstone in Rust. A same-level @E value suppresses the same-level * (env is eligibility, not a merge tier — §3.1), incl. the map-vs-map case.
    • Vars: VarsService + vars::get/vars::all SDK + App/GroupVars{Read,Write} caps + {apps,groups}/{id}/vars admin CRUD + pic vars.
    • Group secrets: owner-discriminated AAD (secret:group:{group_id}:{name}; app AAD unchanged); inherited secrets::get (resolves app-own + ancestor-group, decrypts under the resolved owner's AAD, anchored to cx.app_id); the two orthogonal gates (§5.3): runtime injection (no human-read check) vs. the group-gated value endpoint GET /groups/{id}/secrets/{name}/value (GroupSecretsRead = group_admin). GroupSecretsWrite sits at script:write scope (matches its editor role tier).
    • Effective view (masked): GET /apps/{id}/config/effective → resolved vars (with provenance) + secrets masked to {status, owner, scope} (value never returned). pic config --effective [--explain] renders it.

    Deferred (unchanged): group-owned scripts/modules (Phase 4), the manifest [vars]/[secrets] apply reconciliation (Phase 5 — Phase 3 ships CRUD API + CLI), and materialization (above).

  4. Group-inherited scripts/modules. CoW overrides; the scope-aware module/import resolver + extension points (§5.5); cache-invalidation fan-out hardening; versioning/pinning if needed.

    Status (Phase 4-lite): shipped — group-owned ENDPOINT scripts, live-resolved (no materialization). Like Phase 3's config, group scripts resolve live, not via a cache: a script is referenced by id on the runtime hot path (routes/triggers store the resolved id; the orchestrator's per-app route trie already scopes dispatch to one app and never reads script.app_id), and by name down the chain only at bind/invoke time. No materialized per-app body view, no generation counter, no fan-out invalidation — so §11.4's "cache-invalidation fan-out hardening" is moot for Phase 4-lite (revisit only if a measured hot-path cost appears).

    Shipped surface:

    • Schema: 0050_group_scripts.sql makes scripts polymorphic-owned (group_id XOR app_id, scripts_owner_exactly_one CHECK, per-owner partial-unique LOWER(name) indexes), ON DELETE RESTRICT (code is not data — a group can't be deleted out from under its scripts). Script.app_id became Option<AppId> + group_id; the cross-app isolation backstops (dispatcher, invoke, trigger-bind, orchestrator /execute/{id}) generalized from app_id == row.app_id to chain-membership, fail-closed for app-owned (zero-cost in-memory fast path; only a group candidate pays a chain query).
    • Resolver (reuses CHAIN_LEVELS_CTE): get_by_name_inherited(app_id, name) (nearest-owner wins; app's own script shadows the inherited group one — CoW) and is_invocable_by_app(script_id, app_id) (the isolation predicate lifted to the hierarchy; sibling apps' own scripts are NOT cross-invocable — only group scripts inherit).
    • Admin + CLI: GroupScripts{Read,Write} caps (viewer+/editor+ on the group, resolved via the group-ancestor walk); POST/GET /groups/{id}/scripts (endpoint-only, self-contained — modules + imports rejected); the by-id /scripts/{id} handlers are owner-polymorphic; pic scripts ls --group / deploy --group (create-or-update; --app/--group mutually exclusive).
    • invoke("name", …) resolves inherited (a script runs a shared group endpoint; it always executes under the caller's app context, never the owner's).
    • Declarative apply: a manifest route/trigger script = "name" resolves to an inherited group endpoint (resolve_inherited_targetsname_to_id), with the diff resolving the group-bound route's id → name so re-apply is idempotent.

    Phase 4b: shipped — group modules + the lexical (sealed-by-default) import resolver (§5.5). Group kind = module scripts and group-script imports are now allowed; import resolves lexically against the importing script's own defining node (the group for an inherited script — a leaf can't shadow it; verified e2e). Mechanism: owner-polymorphic ModuleScript, origin-rooted ModuleSource::resolve, ExecRequest.script_owner threaded from every dispatch + invoke() site, _source-driven lexical chaining in the resolver (cache re-keyed by ScriptId), and the single-node dangling-import plan check. Still deferred: opt-in extension points (the only remaining §5.5 piece) and invoke-by-id staying app-scoped (inheritance is by-name only); CoW is redefine-in-app + re-apply (no live auto-rebinding). Sharp edge to track: routes.script_id is ON DELETE CASCADE, so deleting a group script removes descendant apps' bound routes (within group-editor authority; the group delete itself stays RESTRICT).

  5. Project tool maps onto groups. Nested manifests, attach point, single-owner, server-computed tree plan, per-env approval gating.

    Status (Phase 5): shipped — single-repo nested tree apply, atomic. A directory tree of picloud.toml manifests (each declaring an [app] or [group] node) applies as ONE server-computed plan in ONE Postgres transaction. Per the §11.1 review, the multi-repo single-owner / attach-point / takeover layer (§7) is deferred for the solo-dev / single-repo start, as is per-env approval-policy gating ([project.environments]); env overlays (picloud.<env>.toml) already exist. Groups must pre-exist (pic groups create) — a manifest owns each node's content, not the tree shape (declarative group create/reparent is a later add).

    Shipped surface:

    • Engine: the reconcile engine generalized from app-only to an ApplyOwner { App | Group } (a group node has only scripts + vars; routes/triggers/secret-values stay app-only). The in-tx reconcile is reconcile_node_tx, shared by single-node and tree apply. apply_tree locks every node key (sorted), reconciles groups first (recording each group's name → id), then apps — so an app route/trigger can bind a group script created in the same transaction (resolved nearest-ancestor-wins across the in-tree index + pre-existing ancestors; the pool resolver can't see uncommitted rows). One commit, one post-commit route refresh.
    • Bound plan covers structure: the tree token folds every node's state_token plus every in-scope group's structure_version, so a reparent (or content edit) between plan and apply trips StateMoved — the §4.2 "content and tree-structure version" check.
    • API: POST /api/v1/admin/{groups/{id},tree}/{plan,apply}; per-node capability gating (the actor must hold the read/write caps for every node touched).
    • CLI: a [group] manifest kind; pic plan/apply on one node (app or group); pic plan/apply --dir <root> discovers + applies the whole tree, with a single <tree> bound token.

    Live- and journey-validated: a group + nested app apply atomically (app route binds the group's same-apply script); re-plan is all-noop; one invalid node aborts the whole tree (nothing written); a pic groups reparent between plan and apply is refused. The §5.1 materialized effective-view + fan-out invalidation was not built — group config/scripts resolve live (Phase 3/4), so the tree apply needs no cache-invalidation protocol.

  6. (Much later) group-level collections/topics — the v1.3 cross-app data-sharing problem, with a real shared-scope authz model. Optionally, trigger/route templates (§4.5) if cardinality demands.

    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'), 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 <root>/files/groups/<group_id>/..., a groups/ infix disjoint from the per-app files/<app_id>/ 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).

    Shared-collection triggers (, M2). A write to a shared collection now fires a trigger, resolving the "group trigger has no app to watch" gap. A group-owned trigger marked shared = true (shared BOOLEAN on triggers, 0061) watches the group's shared collection; the per-app match queries add AND t.shared = FALSE and new list_matching_shared_{kv,docs,files} select shared = TRUE triggers on the OWNING group — the shared flag is the namespace boundary between a shared collection and a same-named per-app one. ServiceEventEmitter::emit_shared fires from the group services (wired via with_events), stamping the WRITER app_id so the handler runs under the writer (the "group template runs under the firing app" model). Authored on a group [[triggers.kv|docs|files]]; validate_bundle_for rejects shared on an app owner, a non-collection kind, or an undeclared collection. Read-only shared column in pic triggers ls --group; pinned by tests/shared_triggers.rs + the shared_triggers journey.

    Shipped — D2 shared TOPICS + shared pub/sub triggers. A group declares a storeless topic shared collection (group_collections.kind widened to topic+queue, 0064); a [[triggers.pubsub]] shared = true handler watches it. 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 — a publish is a shared write), 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, and validate_bundle_for requires the topic pattern's ROOT segment (events.*events) be a declared kind='topic' collection (group-only) — the shared flag + owning-group chain walk are the isolation boundary. No message store (topics are events, not persistence). Pinned by tests/shared_topics.rs + the shared_topics journey.

    Shipped — D3 shared durable QUEUES (competing consumers). A group declares a queue shared collection; any subtree app enqueues into ONE group-keyed store (group_queue_messages, 0065) via queue::shared_collection("name").enqueue(...) (editor+, GroupQueueEnqueue). A group [[triggers.queue]] shared = true consumer materializes a consumer copy per descendant app (the M5 one-consumer-slot check is skipped for shared), and all copies claim the shared store with FOR UPDATE SKIP LOCKED — at-most-once across the subtree, horizontally scaled, each handler under its own cx.app_id. The dispatcher's queue arm routes claim/ack/nack/terminal to the group store when the consumer's source template is a shared queue (ActiveQueueConsumer.shared_group). Pinned by tests/group_queue.rs + stateful_templates.rs + the shared_queues journey. Deferred: a group dead-letter store (an exhausted shared-queue message is dropped-with-warning).

    Deferred (documented gaps): shared-topic external SSE subscription; 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)

A review after Phase 3 shipped surfaced three adjustments to the 4→6 plan; carried here as decisions to take, not yet taken:

  • Phase 4 should resolve scripts live, not materialize. §11.4 carries "cache-invalidation fan-out hardening" as a deliverable, a holdover from the materialized-view model. Phase 3 proved live resolution (recursive CTE per call) is sufficient and sidesteps the §10 invalidation SLA, and script bodies are already live-resolved per request today (§5.1). So Phase 4 should resolve group-owned scripts live too and drop the fan-out-invalidation sub-project unless a measured hot-path cost appears. This removes the scariest item from Phase 4; the real remaining cost is the module/import resolver redesign (§5.5), which is unaffected by this and stays the gating risk.
  • Consider a "Phase 5-lite" before Phase 4. The declarative project tool already applies app scripts (Phase 1) and Phase 3 shipped the group vars/secrets CRUD, so a project tool that declares group vars/secrets + app scripts needs nothing from Phase 4 (group-owned scripts). The highest-leverage day-to-day workflow (declarative config apply) can ship ahead of the hard module-resolver work. Phase 4 then adds group-owned scripts to an already-working project tool.
  • Two deferred decisions to force, not default:
    • Trigger/route templates (§4.5): bundled into Phase 6 as "much later," but the per-app binding tax bites in Phase 5 at high tenant cardinality (100 tenants × 5 triggers = 500 declarations). Decide against actual target tenant counts before defaulting it late.
    • Multi-repo ownership (§7): the single-owner / attach-point / takeover machinery is substantial and only earns its keep with a second managing repo. For a solo-dev / single-repo start, a "one repo owns the whole subtree" model covers the near term; confirm the multi-repo need exists before building the ownership layer.

12. Contracts still to draft

  • The apply bundle / plan artifact / change-report wire contract (what the CLI ships, what the server persists and returns), including the conflict and blast-radius shapes.
  • The effective-view resolver (the read primitive) — the §3 rule made executable. (Phase 3: shipped as a live recursive-CTE resolver, config_resolver/secrets_repo::resolve; the materialization + invalidation protocol of §5.1 was not built — see the Phase 3 status note in §11.3. The remaining contract work here is only for script-body inheritance, Phase 4.)
  • The full manifest schema spelling every block (scripts, routes, the 8 trigger kinds, storage config, env-scoped vars, secret-refs, domains, [project.environments] + confirm policy).