§7 M3 (part 2): planning a change to a GROUP node now reports the descendant
apps owned by OTHER projects that the change fans out to — the cross-repo
signal an operator needs before touching shared config.
- OwnershipPreview gains `blast_radius: Vec<ProjectImpact>`; `group_blast_radius`
walks the group's subtree (recursive CTE) and groups descendant apps by their
nearest-claimed-ancestor project, excluding the planning project. Ancestor
resolution is memoized per group (one walk per distinct descendant group, not
per app).
- `pic plan` renders `blast_radius` rows (mode-agnostic).
- the apply_ownership journey gains a plan-preview case pinning both the
ownership annotation (claim / conflict) and the blast radius (two other
projects' apps surfaced) end to end.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
§7 M3 (part 1): `pic plan` now surfaces the ownership outcome BEFORE apply.
- plan / plan_owner / plan_tree take the declaring `[project]`; each result
carries an `ownership` preview (pure `preview_ownership`, unit-tested):
claim (unclaimed + project), owned (already this project), conflict (owned by
X — named), or unclaimed. A group node previews its OWN owner; an app node its
nearest claimed ancestor (read-only, on the pool).
- the attach-point ceiling (M2) is now previewed at plan too, so `pic plan`
outside the subtree fails early — consistent with apply.
- wire: PlanRequest / TreePlanRequest wrap { bundle, project } (project
`#[serde(default)]` → a pre-M3 CLI still plans); the plan DTOs + `pic plan`
gain an `ownership` row (mode-agnostic: shows in tsv + json).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
§6/§7 M2: a project's attach point is its ceiling — applies are refused for
any node not strictly within the declared subtree, so a repo can't reach (or
claim) above its local root even with the RBAC to do so.
- `[project] parent_group = "<slug>"` (ManifestProject + ProjectDecl); absent
= instance root = no ceiling (the default, backward-compatible).
- `check_within_attach`: the attach group must be a PROPER ancestor of a group
node (you can't apply the attach point itself) or an ancestor (inclusive) of
an app node's group; resolved via `groups.ancestors`. Enforced read-only in
apply_owner (prologue) + apply_tree (per node), before the claim.
- `ApplyError::OutsideAttachPoint` → 422 (a scope error, like Invalid).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Makes §7 ownership usable end to end from the CLI.
- manifest: a `[project]` block (slug + optional name), independent of the
[app]/[group] XOR; threaded onto every apply from the repo. --dir reads it
from the tree ROOT's picloud.toml (a [project] elsewhere is ignored with a
note).
- client: apply_node/apply_tree carry project + takeover; the 409 branch now
surfaces the server message verbatim (covers StateMoved AND OwnershipConflict,
both self-contained). New groups_list_with_owner captures the owner slug.
- pic apply --takeover flag; pic groups ls gains an `owner` column (the owning
project's slug, or — when unclaimed).
- server: /api/v1/admin/groups now returns each group flattened with its owner
slug (via list_with_owner) — the shared Group deserialize ignores the extra
field, so pic groups tree / the dashboard are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The M5.5 shared-group-secret work made a [group] [[triggers.email]] template
parse successfully (it materializes per descendant app; the inbound secret is
resolved against the group's own store server-side at apply). The unit test
still asserted the old parse-time rejection and had been failing on main under
`cargo test --workspace`. Update it to assert the current behavior: parse
accepts a group email template, like the group cron template above it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The consumption side of shared durable queues:
- Thread `shared` through BundleTrigger::Queue + QueueTriggerSpec + the
trigger identity; validate_bundle_for requires a shared queue on a group
to name a declared kind='queue' collection (a shared queue on an app is
rejected by the existing app-owner shared guard).
- materialize: a shared queue template materializes a consumer per
descendant (the M5 one-consumer-slot skip is bypassed for shared —
competing consumers are intended; each descendant gets one copy).
- dispatcher: ActiveQueueConsumer gains shared_group (from the materialized
copy's source template via LEFT JOIN); dispatch_one_queue +
handle_queue_failure route claim/ack/nack/terminal to the group store
when shared_group is Some, via q_claim/q_ack/q_nack/q_terminal helpers. A
group claim is normalized to a ClaimedMessage under the consuming app so
the handler path is unchanged; the reclaim task also drains the group
store. Exhausted shared messages are dropped (no group dead-letter store
yet — documented deferral).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `shared` to BundleTrigger::Pubsub + PubsubTriggerSpec + the trigger
identity (so toggling re-diffs) + current_trigger_identity. validate_bundle_for
now allows a `shared` pubsub trigger on a group and requires the topic
pattern's ROOT segment (events.* -> events) be a declared kind='topic'
collection; a wildcard root is a runtime match. Apply-side plumbing only —
the shared publish path + dispatch boundary land next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Widen the group_collections kind allow-list (migration 0064) + the
manifest CollectionKind enum + apply_service COLLECTION_KINDS to include
'topic' (D2, storeless publish namespace) and 'queue' (D3, group-keyed
durable queue — store lands in 0065). Foundation shared by both
milestones; routes through the existing owner-generic reconcile +
resolve_owning_group path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A materialized copy of an M5 group stateful template (cron/queue/email) now
reads as `materialized = true` — inherited, read-only — distinct from a
hand-authored trigger. Threads a derived `materialized` bool (=
`materialized_from IS NOT NULL`) row → domain → API → CLI, mirroring the
`sealed`/`shared` columns; `list_for_app` SELECTs `materialized_from`. No
migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the last M5 gap: a [group] may now declare an email trigger
template, materialized into a per-descendant-app row like cron/queue.
Uses the shared-group-secret model: the template resolves its
inbound_secret_ref against the GROUP's own secret store once at apply and
seals it (resolve_and_seal + insert_email_trigger_tx generalized to a
SecretOwner / ScriptOwner). Email secrets are v0/no-AAD, so the sealed
blob is portable across rows — materialize copies the bytes verbatim into
each descendant (no master key, no reseal, no new schema column, no
threading into the CRUD hooks). Each copy has its own trigger_id / inbound
webhook URL.
- apply_service/manifest: drop the two group-email rejections; a group
email template with an unset group secret still fails apply hard.
- materialize: add "email" to MATERIALIZED_KINDS + a byte-copy detail arm.
- trigger_repo: email_inbound_target gains AND t.app_id IS NOT NULL so a
group TEMPLATE row is never directly invocable via its own webhook URL
(mirrors the cron/queue app_id guards).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0062 adds `materialized_from` on triggers (a managed app-owned copy links back
to its group template; CASCADE). The scheduler + queue-consumer queries gain
`AND t.app_id IS NOT NULL` so a group-owned stateful TEMPLATE is never
dispatched directly — only the per-descendant materialized app rows are.
validate_bundle_for + manifest parse now allow cron/queue on a group (email
stays rejected pending its per-app inbound-secret handling in M5.5). The
materialization reconcile that expands templates into app rows lands in M5.2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- `pic kv ls --group` / `pic kv get --group` (mutually exclusive with --app)
hit the M4 admin API; client group_kv_list/group_kv_get.
- collections journey: a script writes a shared KV value, the operator lists +
fetches it via the admin API with no script.
- docs: §11.6 + CLAUDE.md record M3 quotas + M4 read-only admin API.
Completes M4.
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>
`shared = true` on a group [[triggers.kv|docs|files]] flows manifest → Bundle
→ insert_trigger_tx (new shared column), mirroring sealed: shared lives on the
Trigger DTO + is part of the apply diff identity (bundle + current sides) so a
toggle re-applies. validate_bundle_for rejects shared on an app owner, on a
non-collection kind (pubsub/cron/etc.), and on a concrete collection the group
does not declare as a shared collection of that kind. Emission (M2.3) now has
authored triggers to match.
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>
suppression_repo takes a ScriptOwner (app or group), binding the matching
owner column. apply_service load_current / create / prune / suppression_report
all thread owner.as_script_owner(); the validate_bundle_for group-suppress
rejection and the manifest parse guard are dropped — a [group] may now declare
[suppress] to decline a template it inherits from a higher ancestor. No
runtime consumption effect yet (the chain filters land in M1.3/M1.4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- `pic triggers ls --group` / `routes ls --group` gain a `sealed` column
(threaded through TriggerTemplateInfo/RouteTemplateInfo + the two DTOs).
- dangling_suppress_warnings now also warns when a suppress reference matches
only SEALED inherited templates ("... is sealed — the suppression has no
effect") via `bool_or(NOT sealed)` per handler/path — making the mandatory
guarantee observable at plan/apply time, alongside the existing typo guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Thread `sealed` from the manifest through to the DB column:
- manifest: `sealed` on ManifestRoute + the four event-kind trigger specs
(Kv/Docs/Files/Pubsub), flowing to Bundle via serde.
- persist: NewRoute.sealed + insert_route_tx; insert_trigger_tx `sealed`
param; the reconcile passes br.sealed / bt.sealed().
- validate_bundle_for rejects `sealed` on an app owner (meaningless — an
app route/trigger is never inherited).
- diff: `sealed` joins the route Update comparison and the trigger identity
(both bundle + current sides), so toggling it re-applies rather than NoOp.
`sealed` lives on shared Route + manager-core Trigger (both pure DTOs) so
the diff can see the current value; RouteRow/TriggerRow default it so
RETURNING clauses that omit it still hydrate. No runtime read effect yet —
the two suppression gates land in M3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Visibility + typo guard.
- `pic suppress ls --app <a>` — read-only (kind, reference):
ApplyService::suppression_report(App) → GET /apps/{id}/suppressions
(viewer-tier AppRead), client + cmd + main.rs wiring.
- Dangling-suppress warning: at apply, a suppress reference that matches no
inherited template on the app's chain (no ancestor-group trigger bound to
that script name / no ancestor-group route at that path) emits an
ApplyReport warning — the suppression silently does nothing otherwise.
Soft (never fails the apply), reusing the existing warnings channel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The declarative half — a `[suppress]` block persists as markers; no runtime
effect yet (S3 consumes them).
- manifest: `[suppress]` table on an app with `triggers = [...]` (handler
script names) + `routes = [...]` (paths), `deny_unknown_fields`; rejected
on a `[group]` (a group just wouldn't declare the template).
- suppression_repo.rs: app-keyed `list_for_app` / `insert` / `delete_tx`
over `(app_id, target_kind, reference)`, mirroring extension_point_repo
minus the owner polymorphism.
- apply_service: the extension-point marker-reconcile pattern —
`Bundle.suppress_triggers/_routes`, `Plan.suppressions`,
`CurrentState.suppressions`, `load_current(App)` load, `diff_suppressions`
(key `"{kind}:{reference}"`, split on the first `:` so a route param path
survives), create + prune reconcile blocks, `validate_bundle_for` group
reject, `ApplyReport` counters.
- CLI: `build_bundle` carries the two vecs; `pic plan` + apply report gain a
suppressions row (DTOs + display).
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>
Open the declarative path so a [group] can declare route TEMPLATES.
- manifest.rs: drop the blanket "[group] cannot declare [[routes]]"
rejection (routes join the event trigger templates a group may own).
- apply_service:
- validate_bundle_for: remove the group routes rejection. A group
route's `script` is validated as binding a group-owned ENDPOINT via
`resolve_inherited_targets_for(Group)`, now extended to scan
bundle.routes as well as bundle.triggers — so a template binding the
group's own (declared or pre-existing) endpoint resolves, and one
pointing at a module or a missing name is a 422.
- insert_bundle_route takes a ScriptOwner (was app-only): a group node
writes a group_id route TEMPLATE, an app node an app-owned route.
- load_current(Group) loads the group's own routes via list_for_group
so re-apply is a NoOp and the template is prune-able.
Host-claim validation stays App-only (already guarded) — a template has
no single app; a descendant serves it on whatever host it claims (so
templates use host_kind = any in practice).
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>
A [group] node may now declare EVENT trigger templates (kv/docs/files/
pubsub) that bind a group-owned handler. cron/queue/email stay app-only
(rejected on a group at both the manifest and the reconcile layer).
- Trigger is now owner-polymorphic (app_id: Option, group_id: Option),
mirroring Script's Phase-4 reshape; TriggerRow carries group_id
(#[sqlx(default)] so interactive RETURNING clauses still hydrate).
- insert_trigger_tx takes a ScriptOwner (App XOR Group) and writes the
group_id column; the queue advisory-lock branch is app-only.
- TriggerRepo::list_for_group loads a group's templates; load_current's
Group arm uses it so the diff is NoOp on re-apply and prune-able.
- apply_service reconcile drops the "triggers are app-only" assumption;
validate_bundle_for rejects non-event kinds on a group. The semantic-
identity diff is already per-owner.
- CLI manifest allows event-kind [[triggers]] on [group], rejects
cron/queue/email there.
Templates don't fire yet — the live dispatch union lands in T3. All
trigger/apply/manifest unit tests green; clippy -D clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- manifest: add CollectionKind::Files (+ "files" in as_str()); the
string-or-table `collections` form now accepts { name, kind = "files" }.
- apply_service: add "files" to COLLECTION_KINDS. The rest of the
reconcile (CollectionSpec, diff_collections, validate_bundle,
state_token, the app-owner rejection) is already kind-generic.
- `pic collections ls` shows the files kind with no code change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the declarative collection surface carry a `kind` so docs (and future
kinds) are declarable. Back-compatible: the shipped `collections = ["catalog"]`
form still means kv.
- manifest: `collections` entries are now `CollectionDecl` — an untagged enum of
a bare string (kv shorthand) or a `{ name, kind }` table (kind ∈ kv/docs).
`Manifest::collections()` normalizes to `(name, kind)` pairs; build_bundle
emits `[{name, kind}]`. Still `[group]`-only. Unit test covers the
string-or-table mix + app rejection.
- apply: `Bundle.collections: Vec<CollectionSpec>` ({name, kind=default "kv"});
`CurrentState.collections: Vec<(name,kind)>` via list_all_for_owner;
`diff_collections` keys each change by name with `detail = kind` and identity
`(LOWER(name), kind)` (so a kv and a docs collection of the same name are
distinct); reconcile reads the kind from `detail`; state_token folds
`coll|{kind}|{name}`; validate_bundle rejects unknown kinds + dups by
(name,kind); `collection_report`/CollectionInfo + `pic collections ls` gain a
kind column.
Existing kv journeys + nearest-wins still green (the bare-string form normalizes
to kind=kv end to end).
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>
`extension_points` is an `[app]`/`[group]` node key. Placed at top level
(before any table header) TOML reads it as a root key — and `Manifest`
silently dropped unknown root keys, the same silent-drop class that bit in
C5 (a key absorbed under `[group]` and discarded). Add
`deny_unknown_fields` to `Manifest`, `ManifestApp`, and `ManifestGroup` so a
misplaced or typo'd key is a hard parse error instead of a no-op apply.
Regression test covers top-level placement, an in-`[app]` typo, and the
correct node-key form.
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>
- apply: `check_extension_points_provided` (app nodes) — every EP visible on
the app's chain (its own + inherited) must have a provider: a same-apply
module, or one resolvable up-chain (override or default body). Else a clean
`pic plan` error instead of a runtime failure. Single-node only (the tree
path leans on the runtime backstop, like the dangling-import check).
- read-only surface: `extension_point_report` + `ExtensionPointInfo`;
`GET /{apps|groups}/{id}/extension-points` (AppRead / GroupScriptsRead);
`pic extension-points ls --app|--group` showing declared-vs-inherited + the
resolved provider (`app override` / `inherited default` / `unset`).
- `pic pull` round-trips an app's OWN declared EPs (filtered `declared_here`),
so pull→plan stays idempotent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Thread extension-point markers through the manifest and the apply engine —
a name-only resource modeled on `secrets` (shape) with `vars`-style
create/prune write behavior.
- manifest: top-level `extension_points = ["theme", …]` (allowed on app and
group; overlay stays base-only via deny_unknown_fields); `build_bundle`
emits the names.
- apply_service: `Bundle.extension_points`, `CurrentState.extension_point_names`,
`Plan.extension_points` (+ is_noop), `diff_extension_points`
(declared→Create / live-undeclared→Delete / else NoOp), `load_current`
loads them, `reconcile_node_tx` inserts on Create + deletes on prune,
`state_token_with_names` folds in `ep|<name>`, `validate_bundle` rejects
duplicates + reserved names, `ApplyReport` gains created/deleted counts.
- CLI client + plan/apply rendering: `extension_points` in PlanDto/NodePlanDto/
ApplyReportDto, an `extension_point` row group in `pic plan`, a count in the
apply summary.
pull sets it empty for now (wired to the read endpoint in C4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 5 C4. `pic plan --dir <root>` / `pic apply --dir <root>` discover every
`picloud.toml` under a directory (groups + apps), assemble the wire tree
bundle, and drive the C3 `/tree/{plan,apply}` endpoints — a whole project
subtree reconciled and reviewed as one unit.
* New `discover` module: recursive walk (skips `.picloud`/`.git`/`scripts`/
`target`), one node per manifest, app/group inferred from the manifest;
rejects a duplicate (kind, slug). On-disk nesting is organizational — the
server resolves ancestry from its own group tree.
* `client`: `plan_tree`/`apply_tree` + `TreePlanDto`/`NodePlanDto`. `pic plan
--dir` renders a per-node diff table and records ONE tree bound-token under
a `<tree>` linkstate marker; `pic apply --dir` replays it (force/`--yes`/
prune apply tree-wide). `--dir` conflicts with `--file`.
Live-validated: a nested tree (root group dir + nested app dir, app route bound
to the group's inherited script) → `pic plan --dir .` shows both nodes' changes
→ `pic apply --dir .` applies all in one tx → re-plan all-noop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 5 C2. A `picloud.toml` can now declare a `[group]` node (instead of
`[app]`): the group's own scripts + `[vars]` (+ secret names). `pic plan` /
`pic apply` reconcile it via the C1 group endpoints — the same plan/apply/
bound-token flow as an app, routed by node kind.
* `Manifest` is now app-XOR-group: `app`/`group` are both optional with an
exactly-one check in `parse`, plus a group-node guard that rejects
`[[routes]]`/`[[triggers]]` (those bind to an app). Accessors `slug()` /
`is_group()` replace the hardcoded `manifest.app.slug`.
* `client`: `plan_node`/`apply_node` take a `NodeKind { App | Group }` that
selects the `apps` vs `groups` API path; the old `plan`/`apply` wrappers
are gone (callers pass the kind).
* `pic plan`/`apply` detect the node kind from the manifest and label output
`group`/`app`; `pic config --effective` cleanly rejects a group manifest
(effective config is an app's inherited view).
Live-validated: a `[group]` manifest with a script + var → `pic plan` (script
+ var creates) → `pic apply` (group-owned) → idempotent re-plan noop →
`pic scripts ls --group` shows it. Manifest unit test for group parse +
app-only-block rejection; init/pull/plan/apply/overlay journeys all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4-lite C2. Lets a group own scripts (templates inherited by descendant
apps), with the create/list/manage surface — but not yet the inherited
resolution (binding + runtime), which is C3.
Capabilities: add `GroupScriptsRead` (viewer+ on the group → script:read) and
`GroupScriptsWrite` (editor+ → script:write), mirroring the group-vars tier and
resolved through the same group-ancestor walk.
API:
* New `group_scripts_api`: `POST/GET /groups/{id}/scripts` — create a
group-owned endpoint script and list a group's own (non-inherited) rows.
Phase 4-lite is endpoint-only and self-contained: `kind=module` and any
`import` are rejected (group modules + the lexical resolver are Phase 4b).
Owner resolved first (slug-or-uuid); capability bound to the resolved id.
* The by-id `/scripts/{id}` get/update/delete/logs handlers are now
owner-polymorphic via a `script_cap` helper: app-owned scripts gate on
`App*` exactly as before; group-owned on `GroupScripts*`. This is what
makes `deploy --group` idempotent (update reuses the by-id PUT) and lets a
group script be deleted by id. (C1 had these fail closed for groups.)
Repo: `list_for_group(group_id)` (the group's own rows).
CLI: `pic scripts ls --group <g>`, `pic scripts deploy --group <g>` (create or
update by name; `--app`/`--group` are mutually exclusive, exactly one
required). `pic scripts delete <id>` already works for group scripts via the
owner-polymorphic by-id route.
Live-validated against the dev DB: create → update (v2 via the by-id PUT) →
list → delete, plus module rejection and the polymorphic row shape
(`app_id NULL`, `group_id` set). Group scripts still can't be routed/triggered
or invoked — that lands in C3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4-lite C1. Make script ownership polymorphic so a script can be owned
by a GROUP (a template inherited by descendant apps) instead of an app —
mirroring vars/secrets (0048/0049), but ON DELETE RESTRICT (code is not data).
Schema (0050): `scripts.group_id` (nullable FK→groups RESTRICT), `app_id` made
nullable, `scripts_owner_exactly_one` CHECK, and the per-app name index split
into two per-owner partial-unique indexes. Existing app-owned rows keep their
exact `(app_id, lower(name))` uniqueness.
Type: `Script.app_id` becomes `Option<AppId>`; add `Script.group_id` +
`ScriptOwner` / `is_owned_by_app()`. `NewScript` gains the same polymorphic
owner. The execution-context app (what a script runs *under*) is supplied by
the invoking route/trigger/caller, never read off the script — group scripts
have no single app.
Behavior is fully preserved for app-owned scripts (the only kind creatable
today): every isolation backstop and authz site now uses `is_owned_by_app`,
which is byte-identical for app owners and **fails closed** for group owners.
A group script therefore can't yet be run, route/trigger-bound, invoked, or
managed via the app-script API — those land in C2 (group-script creation) and
C3 (chain-membership resolution + binding). The `/execute/{id}` bypass 404s a
group script (no app context to run under).
Re-blesses expected_schema.txt; note the golden was last blessed at migration
0044, so this also captures the already-committed 0045–0049 schema.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the vars round-trip: pull now fetches the app's OWN env-agnostic
vars (`vars_list` filtered to scope `*`, non-tombstone) and writes them
into the manifest `[vars]` block, alongside scripts/routes/secrets.
Inherited group vars stay out (pull exports own rows only, §4.6); a value
TOML can't represent (e.g. JSON null) is skipped with a warning. So
`pic pull` → edit → `pic apply` is now lossless for app config vars.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a `[vars]` block to `picloud.toml` (key → TOML value), reconciled by
`pic plan`/`pic apply` alongside scripts/routes/triggers/secrets. Unlike
`[secrets]` (name-only), var values live inline since they aren't secret.
The overlay (`picloud.<env>.toml`) may carry `[vars]`; overlay keys win
per key (the env-specific value of an env-agnostic default).
`build_bundle` serializes the vars to JSON for the wire (TOML round-trips
via serde); `pic plan` shows a `var` row group and `pic apply` reports
`vars +c ~u -d`. The `PlanDto`/`ApplyReportDto` gain the matching fields.
`pic pull` carries an empty `[vars]` for now (export lands next). Adds a
round-trip + overlay-precedence unit test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors `pic vars` on the secrets surface: `pic secrets ls/set/rm` take an
`--app`/`--group` owner selector (exactly-one) with `--env` for group
secrets. Adds `pic secrets read --group <g> <name> [--env]` — the only
command that reveals a secret value, hitting the group-gated value
endpoint. `pic config --effective` now folds in the resolved vars section
(value + owner + scope) and an `--explain` provenance view, alongside the
existing masked-secrets cross-reference, via `GET /config/effective`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the vars half of Phase 3 end-to-end:
- vars_repo: VarOwner{Group|App} upsert/delete/list (owner-kind-specific
SQL; ON CONFLICT restates the partial-index predicate).
- vars_api: GET/PUT/DELETE under /apps/{id}/vars and /groups/{id}/vars,
resolve-then-require gated on App/GroupVars{Read,Write}, secrets-style
error mapping + key/env-scope validation.
- pic vars ls/set/rm (--group|--app, --env, --json, --tombstone).
- journey test: a group var is inherited by a descendant app's
vars::get(), and an app-level value overrides it (proximity) — green.
386 manager-core lib tests + the vars journey pass; clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the `pic groups` command family over /api/v1/admin/groups:
ls/tree, create (--parent), show (path + subgroups + apps), rename
(name/description; slug frozen), reparent (--to), rm (--recursive,
leaf-first; never auto-deletes apps), and members add/set/rm/ls. Also
`pic apps create --group <slug>` to place an app under a group.
End-to-end journey tests (tests/groups.rs, DB-gated) cover tree CRUD,
delete=RESTRICT (409 on a non-empty group), reparent cycle rejection, and
the headline invariant: a group_admin on an ancestor group can deploy to
an app it is NOT a direct member of (inherited membership), and loses that
access immediately on revoke — all green against a live server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four review fixes on the `pic` surface:
- `config --effective` gains `--env`: it now resolves through the same
overlay as `plan`/`apply`, so the masked report targets the app those
commands act on instead of silently reading the base slug's secrets.
- `pull` gains `--force` and refuses to overwrite an existing
`picloud.toml` (and colliding `scripts/*.rhai`) without it — fail-fast
before any network call or write, mirroring `init`.
- Overlays (`ManifestOverlay`/`OverlayApp`) get `deny_unknown_fields`: a
`[[scripts]]`/`[[routes]]`/`[[triggers]]` table or a typo'd key in an
overlay now errors loudly instead of being silently dropped. +unit test.
- `apply` 409 (StateMoved — the only 409 the endpoint emits) surfaces an
actionable hint (re-run `pic plan`, or `--force`) instead of a bare
`HTTP 409`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The §4.1 base+overlay model for the single-app world: a base `picloud.toml`
holds the shared scripts/routes/triggers; a sparse `picloud.<env>.toml`
carries the per-environment slug (+ extra secret names). `pic plan/apply
--env <E>` merges the overlay onto the base before diffing/applying, so an
environment deploys to its own app ("an environment is an app", §2)
without duplicating the shared definitions.
Merge is sparse: overlay `app.slug`/`name` replace the base's; secret
names union. Scripts/routes/triggers always come from the base. Rich
per-key `vars` resolution stays Phase 3.
Tested: unit tests for the merge + overlay-path derivation; a journey
proving `apply --env staging` deploys to the staging app and leaves the
base app untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The §11 Phase-1 `config --effective` surface. Single-app today, "config"
= the app's secrets, so it cross-references the manifest's declared
secret names against what's set on the server and renders each masked:
`<set>` (managed / on-server-not-in-manifest) or `<unset>` (declared but
not pushed). Values are never fetched or shown (§4.6). Shaped to grow an
`--explain` mode and inherited `vars` when groups/vars arrive (Phase 3).
Tested: a journey verifying declared-but-unset → `<unset>`, post-`secret
set` → `<set>`/managed, and the value never appears in output.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase-1 `enabled` three-state lifecycle (§4.3), data-model half. Triggers
already carried `enabled`; this adds it to scripts and routes and threads
it through the declarative project tool. Runtime honoring (disabled route
404 / script non-invocable / dispatcher fire-time re-check) is the next
commit — this change only stores and reconciles the flag.
- Migration 0045: `enabled BOOLEAN NOT NULL DEFAULT TRUE` on scripts +
routes. Default true ⇒ no behavior change on migrate.
- `Script`/`Route` (shared) gain `enabled` (serde default true via the new
`picloud_shared::default_true`); repos' SELECT/INSERT/UPDATE SQL, row
structs, `NewScript`/`NewRoute`/`ScriptPatch` all carry it.
- Apply diff treats `enabled` as a declarative field (omitted ⇒ active):
`script_update_reason` + `diff_routes` detect a toggle as an Update, and
the create/update/insert paths persist it. The bound-plan `state_token`
re-includes script/route `enabled` (removed in the earlier review fix
precisely because the diff didn't key on it yet — now it does).
- CLI manifest model + `build_bundle` + `pull` round-trip `enabled`
(serialized only when false; omitted ⇒ active). `pic init` scaffold and
the interactive script/route create paths default active.
Tested: manager-core lib 365 (incl. enabled diff + token sensitivity) +
cli bins 31 (incl. manifest skip-serialize) green; clippy -D warnings
clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remediation after an independent review of the two feature commits.
pic init:
- Fix the headline `pic init --dir <new-dir>` flow: slug derivation used
`canonicalize()`, which fails on a not-yet-created directory → empty
slug → bail. Derive from the path's own final component first, falling
back to canonicalize only for `.`. Add a test that exercised the gap
(the existing tests all passed an explicit slug).
- `ensure_gitignored` no longer overwrites an existing-but-unreadable
`.gitignore` (only a genuinely-absent file is treated as empty).
Bound-plan staleness:
- Token trigger coverage now mirrors the diff exactly via
`current_trigger_identity`: dead-letter triggers (which the diff
ignores and the manifest can't represent) and the currently-inert
`enabled` flag are no longer hashed, so an out-of-band dead-letter
change can't force a spurious `--force`. Add a test.
- Correct two overclaiming comments: the check shares the diff's
pool-read apply-vs-interactive-write window (it is not tx-scoped), and
the token deliberately mirrors the diff's inputs.
- `.picloud/` self-ignores via a `*` `.gitignore` written alongside the
plan token, so projects created with `pic pull`/`plan` (not just
`init`) keep it out of git.
- `clear_plan` is now app-scoped: it won't discard a token recorded for a
different app sharing the directory.
Tested: manager-core lib 364 + cli bins 31 + 14 project-tool journeys
green; clippy -D warnings clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`pic plan` now records a fingerprint of the live state it diffed against;
`pic apply` replays it and the server refuses (HTTP 409) if the app
changed underneath the reviewed plan — the §4.2 "apply exactly what you
reviewed" guarantee, in its content-addressed form (no migration, no
changes to interactive write paths).
Server (manager-core):
- `state_token(CurrentState)`: deterministic FNV-1a fingerprint over what
the diff keys on — script name+version (version bumps on any edit),
route identity+binding/attrs, trigger membership+enabled, secret names.
Order-independent; a collision can only yield a false "unchanged", never
a false refusal.
- `plan` returns it (flattened onto the plan JSON, so the wire stays
additive); `apply` takes an optional `expected_token` and, under the
apply lock before any write, returns `StateMoved` (409) on mismatch.
CLI:
- `.picloud/` link state (`linkstate`): `pic plan` writes the token scoped
to the app slug; `pic apply` replays it, then clears it on success (the
token is single-use — the next apply re-plans). `--force` skips the
check; apply with no recorded plan still works standalone (today's
behavior). `.picloud/` is already gitignored by `pic init`.
The tree-structure version (the other half of §4.2's counter) stays a
deliberate no-op until groups exist — it only guards reparent/structural
moves, which don't exist single-app.
Tested: state_token unit test (stable/order-independent/sensitive) + a
staleness journey (plan → out-of-band deploy → apply refuses → --force
applies); manager-core lib 363 + cli bins 31 + 13 journeys green; clippy
-D warnings clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Offline project scaffolding — the on-ramp to pull/plan/apply. Writes a
`picloud.toml` describing a minimal *working* app (a `hello` endpoint
bound to GET /hello) plus commented examples for the other blocks,
`scripts/hello.rhai`, and a `.gitignore` that pre-ignores the project
tool's `.picloud/` link-state directory (the home for the bound-plan
state token, landing next). Refuses to overwrite an existing manifest
without `--force`; derives the app slug from the directory name when not
given (validated against the canonical slug rule).
The active manifest is rendered through the same `to_toml` model the rest
of the tool uses, so a freshly-init'd project is guaranteed valid and
immediately `pic plan`-able. Offline — no server contact, so the journey
tests need no DATABASE_URL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remediation of the single-app reconcile foundation after two independent
review passes. No new feature surface — closes correctness, parity, and
safety gaps in pull/plan/apply/prune.
apply engine (manager-core):
- validate_bundle reached parity with the interactive trigger API: reject
an empty kv/docs/files collection_glob and a malformed pubsub
topic_pattern (previously written and silently never matched), and lift
the queue visibility floor to MIN_QUEUE_VISIBILITY_TIMEOUT_SECS (30) —
apply had accepted a [5,29] value the dashboard refuses. Per-kind checks
extracted into a pure, unit-tested validate_trigger_shape.
- An omitted script `description` now means "leave as-is" (matching the
other optional fields and the function's own documented contract)
instead of clearing the stored value.
- Doc fixes: drop stale "next milestone" notes; record the one deliberate
plan/apply divergence (set-but-empty secret); document delete_route_tx
as intentionally idempotent for reconcile.
CLI:
- Gate destructive `apply --prune` behind confirmation: interactive y/N,
or `--yes` for CI; a non-interactive prune without `--yes` refuses
rather than silently deleting. Journey tests pass `--yes`; a new test
proves the gate refuses and deletes nothing.
- Harden pull's filename safety: reject all control characters and the
Unicode bidi-override / zero-width chars used for terminal/filename
spoofing, and cap length at 200 bytes (NAME_MAX headroom).
Tested: manager-core lib (incl. queue-floor + sparse-description parity)
+ CLI bins + 9 project-tool journeys green; clippy -D warnings clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a server-side, atomic, declarative reconcile loop for a single app —
the foundation of the project-tool design. Developers describe an app's
scripts, routes, triggers, and secret-names in `picloud.toml`, then
`pic pull / plan / apply [--prune]` to converge live state to the manifest.
Server (manager-core):
- apply_service: a pure diff engine (compute_diff) shared by plan and
apply, plus an ApplyService that composes the existing per-repo writes
into ONE Postgres transaction. Identity keys mirror the DB UNIQUE
constraints (script=lower(name); route=(method,host_kind,host,
path_kind,path); trigger=per-kind semantic tuple; secret=name).
Apply takes a per-app advisory lock, recomputes the diff in-tx, applies
scripts -> routes -> triggers, prunes dependents-first, commits, then
refreshes the route table once post-commit.
- apply_api: POST /apps/{id}/plan (AppRead) and /apps/{id}/apply.
Apply requires the per-kind write caps the bundle exercises (all three
when --prune), plus AppSecretsRead when it binds an email trigger.
- tx-accepting repo siblings (insert/update/delete *_tx) so the existing
create/update/delete delegate to one SQL definition each.
- email triggers reference an inbound secret by NAME; the value is
resolved, decrypted (AAD-bound), and re-sealed server-side at apply —
it never travels in the manifest.
CLI (picloud-cli):
- manifest.rs (picloud.toml model), client plan/apply, and the pull/plan/
apply commands. pull rejects filesystem-unsafe script names up front.
Safety properties enforced and tested:
- idempotent: a freshly-pulled manifest re-applies as all-NoOp.
- atomic: a mid-bundle failure rolls back with nothing written.
- routes delete-before-insert so a freed binding is reusable in one apply.
- queue one-consumer invariant held inside the shared tx.
- email triggers are never pruned, and a script that still owns an
email/dead-letter trigger can't be pruned (the FK cascade would destroy
the sealed secret) — refused with a pointer to `pic triggers rm`.
- plan and apply agree on unset email-secret references.
No migration: the existing schema's UNIQUE constraints serve as identity
keys. Groups, env-scoping, and the `enabled` toggle are later milestones.
Tested: manager-core lib (360) + CLI bins (27) + 8 project-tool journeys
(pull/plan/apply/prune/email+queue), all green; clippy -D warnings clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>