diff --git a/docs/design/groups-and-project-tool.md b/docs/design/groups-and-project-tool.md index 0f3ca47..3703a42 100644 --- a/docs/design/groups-and-project-tool.md +++ b/docs/design/groups-and-project-tool.md @@ -10,8 +10,10 @@ > 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, implemented as a *materialized, auto- -> invalidated* view (§5.1). Fold the outcome back into the blueprint before it drifts. +> 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. --- @@ -425,6 +427,15 @@ Two distinct constraints: - **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. - 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 @@ -800,7 +811,10 @@ 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. Highest-risk open item. + 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.) @@ -810,8 +824,11 @@ Resolved items now live inline next to their topic. What genuinely remains: 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. + **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 @@ -879,6 +896,46 @@ Resolved items now live inline next to their topic. What genuinely remains: 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. 5. **Project tool maps onto groups.** Nested manifests, attach point, single-owner, server-computed @@ -893,7 +950,9 @@ Resolved items now live inline next to their topic. What genuinely remains: - 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, plus the - materialization + invalidation protocol (§5.1). +- 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).