Findings from a 3-lens end-to-end review (correctness, security, tests).
Security review came back clean (cross-repo authz sound, isolation intact,
M5 gate sound modulo the documented manifest-trust boundary). No behavior
changes here — only accuracy + coverage:
- apply_service Phase B2: correct the descendant-expansion comment. The chain
is COMMITTED ancestry (ancestors() reads the pool), so a reparent in the same
apply takes effect next apply — same "one more apply" shape the in-tree path
and group-create reparenting already have. Authz stays sound: the gate and the
expansion consume the SAME chain (checked == written).
- doc §4.5: document the two known "one more apply / no data risk" limitations
— reparent-in-same-apply template resolution, and the `{env}` source split
(declared apps use --env; cross-repo descendants use apps.environment).
- approval journey: give the app real (`[vars]`) content so the editor member's
AppVarsWrite is actually exercised — the admin-gate test now proves approval
is ABOVE editor-write, not just "any non-admin is refused". (Vars cascade with
the app; scripts are ON DELETE RESTRICT and would break AppGuard teardown.)
- templates journey: assert descendant expansions are idempotent (stable row
ids on re-apply — no churn), matching the in-tree guarantee.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route/trigger templates declared at a group now fan out to EVERY descendant
app in the DB subtree, not just the app nodes present in the current
`pic apply --dir`. A template change at group N reaches subtree(N) across
repos, and a removed/disabled template reaps its expansions on those
descendants too.
- group_repo: recursive `descendant_app_ids` (+ `_tx`) — inverse of the
ancestor chain CTE, app_id-ordered, depth-bounded.
- apply_service: Phase B2 expands templates into descendants of every in-tree
group not already handled in Phase B. All descendant locks are taken up
front in the single sorted batch (their union is invariant under reparenting
in-tree groups), so there is no out-of-order mid-tx locking / deadlock.
Per-recipient write caps (AppWriteRoute / AppManageTriggers /
AppSecretsRead-for-email) are required AUTHORITATIVELY IN-TX against the
post-reparent, already-locked app — checked set == written set, no TOCTOU.
expand_*_templates_tx are now self-contained (load hand-declared identities
from the tx), so collisions are caught on descendants too. Blast radius now
counts the true DB subtree. Descendant expansion errors are tagged with the
app slug (e.g. an {env} template on a NULL-environment descendant names it).
- apply_api: the pre-tx descendant authz pass is removed in favour of the
in-tx gate (sound via the hierarchy-aware effective_app_role).
- templates journey: out-of-tree descendant at depth 1 AND 2 receives the
expansion and is reaped on template removal.
- doc §4.5: strike the in-apply-only scope limitation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route/trigger template `{var:NAME}` placeholders now resolve against vars
applied in the SAME `pic apply` transaction, not committed-only state — so a
var and a template referencing it converge in one apply instead of two.
- config_resolver: add tx-scoped `fetch_var_candidates_tx`, sharing the SQL
tail (`VAR_CANDIDATES_TAIL`) with the pool variant so they can't drift.
- apply_service: `expand_route_templates_tx`/`expand_trigger_templates_tx`
read vars via the tx (vars are reconciled in Phase A / the app's own
reconcile, both before Phase B expansion).
- templates journey: `route_template_var_resolves_in_same_apply` — would fail
against the old committed-only read.
- doc §4.5: strike the `{var:NAME}` committed-only limitation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes §4.5 templates (M4a shipped routes). A group declares a trigger once;
the tree apply fans it out into one concrete per-app_id trigger on each
descendant app, reusing the M4a expansion engine over the trigger insert path.
- Migration 0054: `trigger_templates` table (group-owned; the whole
BundleTrigger wire object stored as `spec` JSONB, placeholders unresolved) +
`triggers.from_template` provenance column + index.
- `template_repo.rs`: trigger-template CRUD + chain-load.
- Manifest: `[group]` `[[trigger_templates]]` (flat: name, kind, script, + kind
params); build_bundle emits them; rejected on an app node.
- Apply: group nodes reconcile trigger-template rows; tree Phase B expands each
chain trigger template into a concrete trigger per descendant app —
placeholders resolved in every spec string leaf, then the typed trigger is
rebuilt and inserted through the shared `insert_bundle_trigger_tx` (email
secrets resolved + re-sealed per recipient app). Idempotent via from_template
(one trigger per template per app; semantic-identity compare → no-op /
delete+recreate / reap). Collision with a hand-declared trigger or between
templates is a hard error. Each resolved trigger is re-validated with the
per-kind shape check (cron/queue/etc.) so a {var:}-injected bad
schedule/timeout can't slip in.
- Authz: declaring → GroupScriptsWrite; receiving → AppManageTriggers, plus
AppSecretsRead for email-trigger recipients (parity with hand-declared email).
- Plan/token/report extended for trigger templates; blast radius covers both.
- Tests: tests/templates.rs trigger fan-out (kv + {app_slug}, stable-ids,
prune-reap); resolve_placeholders_in_json unit test; schema golden reblessed.
Reviewed (no CRITICAL; isolation + per-app email-secret sealing verified
sound). Closed a HIGH validation-parity gap (re-validate resolved triggers) and
a MEDIUM authz gap (AppSecretsRead for email expansions). v1.2 Hierarchies
template work (M4) complete.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
§4.5 of the groups/project-tool design. A group declares a route ONCE; the
tree apply fans it out into one concrete per-app_id route on every descendant
app — instantiation, not inheritance (the cross-app isolation boundary is
unchanged; expanded rows stay app-owned).
- Migration 0053: `route_templates` table (group-owned) + `routes.from_template`
provenance column + index.
- `template_repo.rs`: tx-aware CRUD + chain-load (mirrors group_repo/route_repo).
- Manifest: `[group]` `[[route_templates]]`; build_bundle emits them; rejected
on an app node (422).
- Apply: group nodes reconcile template rows (create/update/delete via the
plan); tree Phase B expands each chain template into a concrete route per
descendant app node — placeholders {app_slug}/{env}/{var:NAME} resolved per
app (unknown var/placeholder = hard error), script bound nearest-owner-wins.
Idempotent via from_template (diffed create/update-as-replace/delete; re-apply
is no-churn, --prune reaps expansions). Collision with a hand-declared route,
or between two templates, is a hard error. Each RESOLVED expansion is
validated like a hand-declared route (reserved-path, path/host parse,
host-claim) so a {var:}-injected path or unclaimed strict host can't slip in.
- Plan: route-template diff at the group node + blast-radius (descendant app
count in this apply); state_token folds each template (edit trips StateMoved).
- Authz: declaring → GroupScriptsWrite; an app receiving expansions →
AppWriteRoute (gated even with no hand-declared routes).
- Tests: tests/templates.rs (fan-out + per-slug + idempotent-stable-ids +
prune-reap + collision-rejected); placeholder/validation unit tests; schema
golden reblessed.
Reviewed (no CRITICAL/HIGH; isolation core verified sound). Closed the two
validation-parity MEDIUMs (host-claim + reserved-path/pattern on expansions)
and added an out-of-apply-descendant prune warning. Scope: expansion targets
app nodes in the tree apply (not yet cross-repo descendants); {var:NAME} uses
committed vars. M4b (trigger templates) reuses this engine next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>