docs(design): record Phase 3 shipped + the live-resolution decision

Phase 3 (group-inherited config) is implemented, so fold the outcome back
into the design doc (per its own "before it drifts" note) — and flag the
one deliberate divergence from the plan.

§5.1 / §11.3 / §12 prescribed a *materialized* per-app effective view with
a generation counter and fan-out invalidation. We shipped **live,
resolve-on-read inheritance** instead: one recursive CTE per
`vars::get`/`secrets::get`/`config/effective`, no cache. The org tree is
small and there was no pre-existing config cache to fight, so this is
cheap and sidesteps §10's unsolved invalidation SLA.

Records, mirroring the Phase 1/2 status blocks:
* a "Status (Phase 3):  shipped" block in §11.3 with the full surface
  (0048/0049 schema incl. `apps.environment`, the §3 resolver, vars SDK +
  CRUD, group-secret AAD + inherited read, the two secret gates, masked
  `config/effective`, `--explain`) and the live-vs-materialized rationale;
* §5.1 runtime-model bullet annotated as the deferred (Phase-4 script
  inheritance) design, not Phase-3 runtime;
* §10 "invalidation SLA" and "inherited-membership revocation lag" marked
  moot/resolved — no cache means immediate propagation;
* §12 resolver contract + the header framing updated to live resolution.

Doc-only; no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-24 22:53:58 +02:00
parent 79f8c9d420
commit ca360d84cb

View File

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