# 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
> 1–6 have all shipped** — project tool, groups + hierarchy RBAC, group-inherited config, group
> scripts/modules + extension points, nested tree apply, §4.5 trigger/route templates, §11.6 shared
> collections, §7 multi-repo ownership, §3 M3 (now server-authoritative) approval gate, §6 group
> create/reparent — plus the Track A closeout and the 2026-07-11 audit remediation. **v1.2 Hierarchies
> is complete;** the separate Workflows track has since **shipped** (M1–M6), leaving only multi-node
> cluster propagation and §9.4 service interceptors. NB: §11's
> own Phase 1–6 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](https://docs.gitlab.com/ci/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](https://kubectl.docs.kubernetes.io/references/kustomize/kustomization/patches/));
   Helm deep-merges nested maps and uses `null` to delete a defaulted key
   ([Helm values](https://helm.sh/docs/chart_template_guide/values_files/)).

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

```toml
# 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 plan* — **detecting 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](https://developer.hashicorp.com/terraform/cloud-docs/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](https://kubernetes.io/docs/reference/using-api/server-side-apply/)). 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 only** — `enabled` 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](https://www.pulumi.com/docs/iac/operations/stack-management/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](https://developer.hashicorp.com/terraform/cloud-docs/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/subsumption** — `ops = []` 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. (The per-app `materialized` column in `pic triggers ls --app` shipped as
> D1.) **Deferred:** 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-only** —
> `validate_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 M1–M3) is complete.** The items once deferred here have all **since shipped**: §6
structural-divergence detection + declarative group create/reparent (see the §6 M1/M2 blocks below) and
per-env approval gating (`[project.environments]`, now server-authoritative — see §3 M3). Only multi-node
cluster propagation remains deferred.

**§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_claim` → `Claim`). `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): `Refuse` → `ApplyError::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_envs` → **409 `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}`.

**HERMETIC (shipped — Track A M1, 2026-07-11):** the policy is now PERSISTED server-side — `project_environments`
(migration `0067`, `(project_id, env_name, confirm)`, CASCADE) is written declaratively inside every apply's
`upsert_project_tx`, and the gate evaluates against the **effective policy = persisted ∪ declared**
(`ApplyService::effective_env_policy` unions `ProjectRepository::get_environments_by_slug` with the request's
`[project.environments]`). An env is confirm-required if EITHER the stored row OR the request marks it so, which
closes both remaining bypasses: a hand-crafted request that OMITS the policy still trips a persisted gate, and
flipping a gate to `confirm = false` in the same request does NOT un-gate it (the ungating apply must itself pass
the gate; the declarative replace then takes effect for next time — a monotonic, TOCTOU-safe rule). `plan`'s
`approvals_required` is likewise the effective set, so CI sees a persisted gate even when the manifest omits it.
Pinned by `tests/env_approval.rs::{persisted_policy_gates_a_request_that_omits_it,
flipping_a_gate_to_false_in_the_same_request_still_gates}` + `tests/projects_repo.rs::get_environments_by_slug_returns_persisted_policy`.
**Still deferred (separate, not a gate hole):** 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`.

```mermaid
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

```mermaid
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.

```mermaid
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

```mermaid
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

```mermaid
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)

```mermaid
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

```mermaid
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

```mermaid
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_targets` → `name_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. The **multi-repo single-owner / attach-point /
   > takeover** layer (§7), **per-env approval-policy gating** (`[project.environments]`), and declarative
   > **group create/reparent** were all deferred at Phase 5 and have **since shipped** (§7 M1–M3, §3 M3
   > server-authoritative, §6 M1/M2). Groups no longer need to pre-exist — a manifest can own the tree
   > *shape* as well as each node's *content*. Env overlays (`picloud.<env>.toml`) already existed at
   > Phase 5.
   >
   > 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.
   > **External SSE subscription shipped (Track A M6):** `GET /realtime/shared/topics/{topic}` streams a
   > shared topic to external clients. Host→app dispatch (as the per-app SSE route) identifies the subscriber
   > app; `RealtimeAuthority::authorize_subscribe_shared` resolves the OWNING GROUP from that app's chain
   > (kind=`topic`, the topic's root segment) — the reads-open model: the resolution IS the authorization (a
   > foreign-subtree app never resolves → 404, the isolation boundary), consistent with in-script shared-read
   > semantics. The `RealtimeBroadcaster` gained a parallel `(group_id, topic)` channel map
   > (`subscribe_group`/`publish_group`/`drop_group_topic`, default no-ops so non-realtime bundles are
   > unchanged); `GroupPubsubServiceImpl::with_realtime` fans a publish to the owning-group channel (best-
   > effort) after the durable trigger fan-out. Pinned by `realtime` broadcaster + `realtime_api` SSE tests +
   > `group_pubsub_service::publish_bridges_to_the_group_broadcaster`. Deferred: multi-node propagation
   > (cluster mode swaps the in-process broadcaster for LISTEN/NOTIFY behind the same trait).
   >
   > **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. **Group dead-letter
   > store shipped** (Track A M2, `group_dead_letters` `0068`): an exhausted shared-queue message is
   > preserved (atomic `GroupQueueRepo::dead_letter`) and operator-visible at read-only
   > `GET /api/v1/admin/groups/{id}/dead-letters`. Deferred: fan-out to a *shared* dead-letter trigger.
   >
   > **Deferred (documented gaps):** per-group write-rate limits; app-declared (as opposed to group-owned)
   > collections. The items once listed here — shared-topic external SSE subscription, per-group total-size
   > byte quotas, CAS/`set_if`, and the read-only operator admin API for shared blobs — have all **shipped**
   > (Track A M4–M6 and §11.6 M4). 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).

---

## 13. Operator security notes (audit 2026-07-11)

Two behaviors that are correct-by-design but easy to misread — call them out for operators:

- **Shared-topic SSE is world-readable by anyone who can name a subtree domain.** A group
  `kind='topic'` shared collection is *reads-open by design* (the declaration is the grant, matching
  shared KV/docs/files reads). An anonymous request to any descendant app's host can subscribe to the
  group's shared topic. This is intentional — but if a topic carries sensitive data, do NOT rely on
  obscurity: gate it at the app (an authed route that proxies the stream) or don't share it. Per-topic
  opt-in read gating is a possible future addition.
- **The `--env` label is a client assertion, not an identity.** The env-approval gate governs *whether
  a confirm-required environment needs `--approve`*, but the label an apply carries (`--env production`)
  is chosen by the client. It does not by itself grant or restrict reach — the governing project's
  persisted policy (resolved server-side from the node's nearest-claimed ancestor, audit 2026-07-11 H1)
  is what's authoritative. Mislabeling `production` content as `staging` cannot dodge a gate the owning
  project set on the *node*, but operators should treat the label as advisory metadata, not a boundary.
