The apply_ownership journey gains an attach-ceiling case: a group node below
the attach point applies; the attach point itself and a sibling subtree are
both refused (422, message names the attach point). Design doc §7 + CLAUDE.md
record M2 shipped and re-point 'Next' at M3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
End-to-end coverage of the M1 ownership claim through the real CLI + server:
claim-on-first-apply + owner column, idempotent re-apply by the owner, a
second project refused (409, naming the owner), --takeover by an admin,
app-inheritance from the nearest claimed ancestor (owning project ok; foreign
or absent project refused), and the capability gate — a member with only an
editor GROUP role can reconcile but is refused --takeover (needs group-admin),
with ownership unchanged after the failed takeover.
- tests/apply_ownership.rs (registered in cli.rs); a grant_group_membership
helper in tests/common/member.rs (group-level, mirroring the app one).
- design doc §7 gains an M1-shipped status note; CLAUDE.md records §7 M1 and
re-points 'Next' at M2 (attach ceiling) + M3 (blast-radius / pic projects ls).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CLI journey (shared_queues): authoring + `pic triggers ls --group` shared
column + the two validation rejections (undeclared queue collection; shared
on an app), mirroring the shared_topics/shared_triggers norm; the live
competing-consumer + dispatcher behaviour is pinned by the deterministic
manager-core tests. Docs: CLAUDE.md + design doc §11.6 record D3 as shipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deterministic manager-core test (tests/shared_topics.rs) proves the
namespace boundary both ways: a shared publish fans out only to the
group's shared pubsub trigger (not a per-app or non-shared group
template), and a per-app publish never hits the shared trigger. CLI
journey (shared_topics) covers authoring + `pic triggers ls --group`
shared column + the two validation rejections (undeclared topic; shared
on an app), mirroring the shared_triggers norm. Docs: CLAUDE.md + design
doc §11.6 record D1 + D2 as shipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the design doc §4.5 and CLAUDE.md current-focus: email joins
cron+queue as a materialized stateful template via the shared-group-secret
model; drop the email deferral and the stale "email rejected on a group"
note; retarget "Next" to multi-node / multi-repo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 0063: partial unique index on (app_id, materialized_from) + ON CONFLICT DO
NOTHING in the reconciler, so a materialized copy is idempotent under
concurrent reconciles (two parallel app-creates no longer duplicate a copy).
- stateful_templates journey: a group cron template + a descendant app → a
materialized cron copy in `pic triggers ls --app`; re-apply is a NoOp.
- docs (§4.5 + CLAUDE.md): cron+queue materialization implemented; group EMAIL
templates deferred (per-descendant inbound-secret reseal needs the master key
threaded through the hooks + a secret-ref schema) and cleanly rejected at
apply for now, alongside a `materialized` ls column.
Completes the v1.2 near-term batch (M1-M5, cron+queue); group email is the one
scoped follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- `shared` column in `pic triggers ls --group` (TriggerTemplateInfo + DTO +
report + renderer).
- manager-core/tests/shared_triggers.rs: a shared write matches the group's
shared trigger and NOT a same-named per-app trigger; a per-app write matches
the app trigger and NOT the shared one (the `shared` flag is the boundary).
- shared_triggers journey: declarative authoring applies, ls --group shows
shared, an undeclared-collection shared trigger + an app shared trigger are
rejected.
- docs: §11.6 + CLAUDE.md move shared-collection triggers from Deferred to
implemented.
Completes M2. (Pre-existing async-dispatcher timing flakes pass in isolation.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- dangling_suppress_warnings takes an ApplyOwner and walks the group's own
chain (GROUP_CHAIN_LEVELS_CTE) for a group node; runs for both owners.
- GET /groups/{id}/suppressions + `pic suppress ls --group` (client + cmd +
main.rs; --app/--group mutually exclusive).
- suppress journey: a child group declines a parent template for its subtree;
suppress ls --group shows the marker. Docs §4.5 + CLAUDE.md move group-level
suppression from Deferred to implemented.
Completes M1. (An unrelated pre-existing email_inbound dispatch-timing flake
passes in isolation.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- tests/sealed.rs: a group declares a sealed [[routes]] template; a descendant
that suppresses it still serves the path, apply warns "sealed — no effect",
routes ls --group shows sealed=true, and sealing an [app] route is rejected.
- docs: §4.5 trust-model callout + CLAUDE.md move `sealed` from Deferred to
implemented — group templates are advisory-by-default *unless* sealed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two review findings.
1. Per-app opt-out changes the group-template guarantee from "runs on every
descendant" to "runs unless the descendant declines" — a footgun for
compliance hooks (an audit/security trigger a tenant can silently opt out
of). There is no non-suppressible flag. Document this in design §4.5 +
CLAUDE.md, and record the deferred mitigation: a `sealed`/`mandatory`
group-template marker the trigger anti-join + route filter skip (cheap —
both filters are centralized).
2. The dangling-suppress warning path had no coverage. The suppress journey
now applies a `[suppress] triggers=["does-not-exist"]`, asserts the apply
still succeeds and the report warns "no effect".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Journey tests/suppress.rs: a group declares a `[[routes]]` template; a
descendant app applies `[suppress] routes=["/ghello"]` → stops serving it
(via `routes match`), a sibling still serves it, `pic suppress ls --app`
shows it, re-apply is a NoOp; pruning the block re-inherits. The
trigger-side filter + isolation are pinned at the repo layer by
template_suppression.rs.
- Docs: design §4.5 (per-app opt-out closing the deferred gap for both
templates — coarse-by-reference, inheritance-only, the two consumption
points; group-level suppress still deferred), CLAUDE.md current-focus.
Full journey suite 121/121; workspace tests 34 suites/0 failures.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two review findings.
1. After R3 every production rebuild path uses `compile_effective_routes`;
`compile_routes` (the old app-only compile) was called only by its own
two unit tests, and its "runs in build_app" doc was stale — so the
lenient-skip + disabled-drop regression guards no longer covered the
live path. Remove `compile_routes` + its re-export, fold the lenient-skip
rationale onto `compile_one`, and port both guards to
`compile_effective_routes` (app-only rows are just depth-0 effective
rows). Add a third guard pinning nearest-owner shadowing at the pure-
compile layer. Fix the stale `compile_routes` mention in apply_service.
2. Document the disabled + inherited semantic: the `enabled` filter runs
before the shadow check, so a disabled own-route does not claim its
binding and an enabled ancestor template at the same path falls through
("disabled = absent", §4.3). Recorded on `compile_effective_routes` and
in design §4.5 — the corollary (can't 404 an inherited route by
disabling a same-path own route) points at the deferred per-app opt-out.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Visibility + end-to-end coverage + docs for group route templates.
- ApplyService::route_report(Group) → GET /api/v1/admin/groups/{id}/routes
(viewer-tier GroupScriptsRead), surfacing a group's own route templates
(method, host, path, handler script, dispatch, enabled).
- CLI: `pic routes ls --group <g>` (the script-id positional now conflicts
with --group), client `group_routes_list` + RouteTemplateDto.
- Journey `group_routes.rs`: a group declares a `[[routes]]` template
binding a group handler; a DESCENDANT app serves it (via `routes match`
against the live RouteTable), a SIBLING-subtree app does not, `routes ls
--group` shows it, re-apply is a NoOp. Shadowing + isolation are
additionally pinned at the repo layer by group_route_templates.rs.
- Update the manifest unit test: a [group] now accepts route templates and
rejects only the stateful trigger kinds.
- Docs: design §4.5 (the live route-template model + full-live
invalidation + nearest-wins shadowing + deferrals), CLAUDE.md focus.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- apply_service: trigger_report(group) → TriggerTemplateInfo
(kind/target/script/enabled); resolve_inherited_targets_for(Group) now
surfaces the group's OWN endpoint scripts so a template's handler
validates (fixes "binds to unknown script" when the handler is a
pre-existing group script, not declared in the same manifest).
- apply_api: GET /groups/{id}/triggers (GroupScriptsRead).
- CLI: `pic triggers ls --group <g>` (--app/--group mutually exclusive)
+ the client method + DTO.
- tests/group_trigger_templates.rs (manager-core, live DB): the chain
union matches a descendant app's kv insert against the group template
and NOT a sibling subtree — the isolation boundary, deterministic.
- tests/group_triggers.rs (journey): apply a kv template, ls --group
shows it, re-apply NoOp, cron-on-group rejected.
- docs: design §4.5 (live-event-kinds decision + deferrals), CLAUDE.md.
Full journey suite 119/119; workspace tests 0 failures; clippy -D clean;
schema unchanged (blessed in T1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- collections.rs: shared_files_collection_is_read_write_across_the_subtree
— a group declares kv+docs+files in one string-or-table manifest; authed
app A creates a blob via files::shared_collection("assets").create(...),
app B lists + gets the SAME bytes back across the subtree, a
sibling-subtree app gets CollectionNotShared, collections ls shows all
three kinds. Live-verified the bytes land under
files/groups/<group_id>/assets/<id[0:2]>/<id>.
- .gitignore: ignore /crates/picloud-cli/data (the CLI journey harness
spawns the server from that dir; the files journey is the first CLI test
to write blobs).
- docs: design §11.6 (KV+DOCS+FILES shipped; topics/queue still deferred),
sdk-shape (files::shared_collection), CLAUDE.md current-focus.
Full journey suite 117/117; schema blessed (55 migrations); workspace
clippy -D clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
End-to-end docs journey: a group declares a kv AND a docs collection via the
string-or-table manifest; an authenticated app A docs::shared_collection(
"articles").create(#{...}), app B finds it back across the subtree (exercising
the shared find DSL); a sibling-subtree app gets CollectionNotShared;
collections ls shows both kinds; re-apply NoOp. Full journey suite 116/116.
Docs: groups-and-project-tool §11.6 (KV+docs slices shipped; files/topics/queue
deferred), sdk-shape.md (docs::shared_collection + string-or-table), CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finish the §11.6 KV slice: declarative authoring, read-only inspection, the
runtime journey, and docs — plus a forced SDK-name fix.
- SDK name: `shared` is a Rhai RESERVED keyword, so `kv::shared(...)` is a parse
error. Renamed the handle constructor to `kv::shared_collection(...)`
(keeps the user's "shared" intent; unambiguous). Updated everywhere.
- CLI manifest: `collections = [...]` on `[group]` only (ManifestApp has no such
field + deny_unknown_fields → an app manifest carrying it is a hard error);
Manifest::collections() accessor; build_bundle emits it.
- plan/apply: a `collection` row group in `pic plan`; created/deleted counts in
the apply summary; collections threaded through PlanDto/NodePlanDto/
ApplyReportDto.
- read-only `pic collections ls --group` → GET /admin/groups/{id}/collections
(viewer+), backed by ApplyService::collection_report + CollectionInfo.
- journey: a group declares `catalog`; app A writes, app B (same subtree) reads
it back; a sibling-subtree app gets CollectionNotShared; `collections ls` +
re-apply NoOp. Full suite 114/114.
- docs: groups-and-project-tool §11.6 (KV-only MVP shipped + deferrals),
sdk-shape.md (the shared-collection handle + trust model), CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Move the manifest `extension_points` from a top-level key to an `[app]`/
`[group]` node key (`ManifestApp`/`ManifestGroup` + a `Manifest::extension_points()`
accessor). A bare top-level array placed after the node header was silently
re-nested by TOML into that table and dropped; as a node key it parses
unambiguously wherever it sits. build_bundle/pull/init updated.
- Journeys (live server + Postgres): a group default body resolves for an
inheriting app; the app provides its own module and OVERRIDES the EP (the
inverse of the Phase 4b sealed import); `extension-points ls` shows the
provider; a body-less EP with no provider fails `pic plan`.
- Re-bless the schema snapshot (new `extension_points` table).
- Docs: §5.5 marked complete (extension points ✅) + CLAUDE.md current-focus.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two journeys against a live server + Postgres:
- `group_module_is_lexically_sealed_under_inheritance`: a group module +
a group endpoint that imports it, inherited by an app — proves the §5.5
trust boundary (a leaf's same-named module does NOT shadow the inherited
endpoint's import) and app-origin CoW (an app endpoint's import resolves
the app's module).
- `dangling_import_is_rejected_by_plan`: a manifest script importing a
non-existent module is a `pic plan` error.
Docs: mark §5.5 residual resolved + §11 Phase 4b ✅ in the design doc;
update CLAUDE.md current-focus (extension points are the remaining §5.5
piece).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 5 C5. Three `pic ... --dir` tree journeys:
* a group + a nested app apply atomically as one tree (the app's route binds
the group's same-apply `shared` script); the group node's script is
created; re-plan is all-noop (idempotent),
* one invalid node aborts the whole tree — the valid group node's script is
NOT created (true all-or-nothing),
* a `pic groups reparent` between `pic plan --dir` and `pic apply --dir` trips
the bound-plan (StateMoved) check via the tree's structure version.
Docs: `groups-and-project-tool.md` §11.5 status (✅ shipped — single-repo
nested tree apply; multi-repo single-owner + per-env approval gating deferred
per §11.1) + CLAUDE.md current-focus.
Gates: fmt, clippy -D warnings, cargo test --workspace, check-versioning (50
migrations — Phase 5 added none), schema snapshot unchanged, full journey suite
108/108. (Pre-existing flake `queue_e2e::queue_receive_acks_on_success` — a
racy immediate `count==0` after the async ack; flaky at the pre-Phase-5
baseline too, unrelated to this work.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mark §11.4 ✅ shipped with the live-resolution decision (no body
materialization / invalidation fan-out, mirroring Phase 3's config), the
shipped surface, and the deliberate Phase-4b deferrals (group modules + lexical
import resolver, invoke-by-id, live auto-rebinding). Note the sharp edge:
routes.script_id ON DELETE CASCADE means deleting a group script drops
descendant routes. Update the §5.1 materialization box and CLAUDE.md
(current-focus + the data-model invariant now covers group-owned scripts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The groups/project-tool work had no home in the blueprint's version
scheme, two "Phase N" numbering schemes overlapped, and CLAUDE.md's
"current focus" was stale (claimed v1.1.0 SDK foundation; we're at v1.1.9
with the SDK line complete). Reconcile all three:
* blueprint §12 Phase 5 (v1.2, already titled "Advanced Workflows &
Hierarchies") — split into a Hierarchies track (the groups + project
tool initiative, with §11 Phases 1–3 marked shipped) and a Workflows
track, with sequencing flagged as an open call. This gives the work an
explicit version home: the Hierarchies half of v1.2.
* design doc header — Status Draft → Active, "scheduled as the Hierarchies
track of v1.2", and an explicit warning that §11's local Phase 1–6
numbering is distinct from the blueprint product-phase numbering.
* design doc §11.1 — a post-Phase-3 re-sequencing review: resolve Phase 4
scripts LIVE (drop the fan-out-invalidation sub-project, per the Phase 3
result); consider a "Phase 5-lite" (declarative apply of vars/secrets +
app scripts) before the hard group-scripts work; and force the two
deferred decisions (trigger/route templates vs tenant cardinality;
multi-repo ownership vs a single-repo start).
* CLAUDE.md — refresh "current focus" to v1.2 Hierarchies, and correct the
now-broken "every v1.1+ table is app_id NOT NULL" invariant: the new
group-inheritable config tables (vars, secrets) use a polymorphic owner
(group_id XOR app_id), not app_id NOT NULL.
Doc-only; no code change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Update §4.3: the fire-time `enabled` re-check is shipped and test-backed on
BOTH async arms (the unified outbox `!resolved.active` gate and the queue
arm's fresh-script nack), with the specific regression tests cited, plus the
same-app backstop now present on every async build path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fold the implementation outcome back into the project-tool spec (§11) as
the design asks. Records what landed (init/pull/config/plan/apply/prune,
env overlays, enabled lifecycle, trigger name+backfill, content-fingerprint
bound-plan, apply lock) and the deliberate deferrals: tree-structure
version → groups; vars/--explain → Phase 3; trigger upsert-by-name + the
dashboard enabled toggle → tracked follow-ups beyond the §11 bullets.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The architecture spec for the full vision — a declarative project tool
(picloud.toml + pull/plan/apply/prune) and a server-side nested-groups
hierarchy with live-resolve inheritance. Reviewed across several passes
for consistency, gaps, and contradictions; resolutions are folded in
beside the topics they settle.
This commit lands the spec only. The first slice of implementation (the
single-app reconcile foundation) follows; groups, env-scoping, and the
`enabled` toggle are later milestones called out in the doc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>