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>
Data layer for extending shared group collections from KV to docs. KV behavior
unchanged (callers pass kind="kv").
- 0054_group_docs.sql: widen the group_collections kind CHECK to ('kv','docs')
and add the group-keyed group_docs store (clone of docs/0013, keyed by
group_id, CASCADE on group delete) + its (group_id,collection) and data GIN
indexes.
- group_collection_repo: thread a `kind` parameter through list_for_owner,
resolve_owning_group, insert/delete_collection_tx; add list_all_for_owner ->
(name,kind) for the kind-aware diff (D4). Relocate the injectable
GroupCollectionResolver trait + Postgres impl here (now shared by kv+docs;
resolve takes kind) — was in group_kv_service.
- group_docs_repo: a near-clone of docs_repo keyed by group_id; find reuses the
generalized build_find_query.
- docs_repo: generalize build_find_query(table, owner_col, owner_id, …) — both
are compile-time literals (no injection); app docs passes ("docs","app_id"),
group docs ("group_docs","group_id"). row_to_doc/build_find_query are now
pub(crate) for reuse.
Schema snapshot re-blessed (55 migrations).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two review findings:
- F2 (defense-in-depth): validate_bundle_for now rejects `collections` on an
app node — the inverse of the existing group route/trigger guard. The CLI
already prevents it (ManifestApp has no field + deny_unknown_fields), but a
hand-rolled wire Bundle POSTed to /apps/{id}/apply would otherwise insert an
inert app_id-owned group_collections row that no resolver reads.
- F1 (coverage): a journey for nearest-ancestor-group-wins, the CoW-shadowing
guarantee the resolver's `ORDER BY depth LIMIT 1` provides. A parent and a
child group both declare `catalog`; an app under the child writes, and a
second app directly under the parent reads its (separate, empty) store —
proving the deep write stayed in the nearest (child) store. Previously only
the FakeResolver and flat sibling groups were exercised, so the ordering was
untested.
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>
Thread group-collection markers through the apply engine — a name-only
resource modeled field-for-field on extension_points (§5.5): Bundle.collections,
CurrentState.collection_names, Plan.collections (+ is_noop), diff_collections
(declared→Create / live-undeclared→Delete / else NoOp), load_current loads the
names, reconcile_node_tx inserts on Create + deletes on prune via
group_collection_repo, state_token folds in coll|<name>, validate_bundle
rejects empty/duplicate names (no reserved-name guard — a collection is a data
namespace, not an importable module), ApplyReport gains collections_created/
deleted. Pruning a marker removes only the declaration; the group_kv_entries
data survives until the owning group is deleted.
The apply engine stays owner-generic; restricting declarations to [group] nodes
is enforced at the CLI manifest layer (next commit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The single-node path runs `check_imports_resolve` +
`check_extension_points_provided`; the tree path can't (an in-tree,
uncommitted node may legitimately provide a module, so a pool-based check
would false-positive) and leans on the runtime backstop. Emit a debug log
when a tree apply contains app nodes so the deferral isn't a silent gap.
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>
Lift the Phase-4-lite restrictions now that import resolution is lexical:
- `create_group_script` (group_scripts_api): allow `kind = module` and
group scripts with `import`s; record the import edges. Module-shape and
sandbox-ceiling validation stay.
- The declarative apply path already reconciles group modules generically
(`BundleScript.kind` + `owner.script_owner()` flow through
`reconcile_node_tx`); no change needed beyond stale-comment fixes.
Adds the §5.5 dangling-import plan check (single-node plan/apply):
`check_imports_resolve` rejects an `import "<m>"` that resolves to neither a
bundle-local module nor a module reachable lexically up the node's chain —
caught at plan time instead of as a runtime 404. Roots resolution at the
node's owner (an app node reaches ancestor group modules; a group node walks
its own ancestry). ApplyService gains an injected `ModuleSource`. The tree
path opts out (a node may import a module created by another node in the same
uncommitted tx); the runtime check is the backstop there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 5 C3 — the headline. A whole project subtree (group + app nodes) is
reconciled in ONE Postgres transaction, all-or-nothing.
`POST /api/v1/admin/tree/{plan,apply}` take a `TreeBundle { nodes: [{kind,
slug, bundle}] }`. The actor must hold the relevant read/write caps for EVERY
node (per-kind, widened on prune) — `authz_tree` + reusable
`require_{app,group}_node_writes` (now shared with the single-node handlers).
Engine:
* The in-tx reconcile body of `apply_owner` is extracted to `reconcile_node_tx`
(returns the node's post-create `name → script_id`), so single-node and
tree apply share one implementation — behaviour-preserving (391 unit tests
+ app journeys green).
* `prepare_tree` resolves each slug→owner, validates (an app's route/trigger
target may bind to an in-tree ancestor group's script), computes each
node's plan, and folds a combined bound-plan token = every node's
`state_token` + every in-scope group's `structure_version` (so a reparent
or content edit between plan and apply trips StateMoved).
* `apply_tree`: lock every node key (sorted, deadlock-free); reconcile GROUPS
first, recording each group's `name → id` in an in-memory index; then APPS,
resolving inherited route/trigger targets nearest-ancestor-wins across the
in-tree index (incl. scripts created earlier in THIS tx, invisible to the
pool resolver) and pre-existing out-of-tree ancestors. One commit, one
post-commit route refresh.
Live-validated against the dev DB:
* Headline: a group node creates `shared`; an app node in the SAME apply
binds `GET /greet` to it — the route ends up bound to the group-owned
script, and re-plan is all-noop (idempotent).
* Atomicity: one invalid node → 422 and ZERO rows written (the valid group
node's script is not created).
CLI tree discovery + `pic plan/apply` tree mode is C4; journeys + docs are C5.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 5 C1. Generalize the reconcile engine from app-only to an `ApplyOwner
{ App | Group }`, so a GROUP's own config + scripts can be declaratively
reconciled — the foundation for the tree-spanning project tool.
A group node is a subset of an app node (scripts + vars only; no routes /
triggers / secrets-values), so the diff, script/route/trigger/var CRUD, prune,
idempotency, and the Phase-4 inherited-target resolution are all reused
unchanged. Only the owner-varying bits switch on `ApplyOwner`:
* `load_current` — `list_for_group` + `VarOwner::Group` for a group (empty
routes/triggers/secrets); `list_for_app` for an app, exactly as before.
* script create owner (`NewScript{app_id|group_id}`), var writes (new
`set_group_var_tx`/`delete_group_var_tx` mirroring the app variants at
scope `*`), and the advisory lock (owner-tagged).
* `validate_bundle_for` rejects routes/triggers on a group bundle (422);
route-host validation, inherited-target resolution, the post-commit route
refresh, and the unreachable-endpoint warning are app-only.
`apply`/`plan` are unchanged thin wrappers over the new `apply_owner`/
`plan_owner`. New endpoints `POST /groups/{id}/{plan,apply}` gated by
`GroupScripts{Read,Write}` / `GroupVarsWrite`. `ApplyService` gains a `groups`
repo (the tree apply in C3 needs it too).
Live-validated: group plan → apply (group-owned script + var) → idempotent
re-plan noop → routes rejected 422 → DB confirms group ownership. App apply
unchanged: 391 manager-core unit tests + the apply/prune/staleness/overlay
journeys all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review of Phase 4-lite found two real gaps, both rooted in the
apply engine resolving script `id → name` from `current.scripts` (app-own)
only — so a route/trigger bound to an inherited GROUP script (whose id is not
app-own) couldn't be named:
* **Prune gap (reachable via pure declarative apply):** a trigger bound to an
inherited group script, once dropped from the manifest, never diffed to a
Delete (the diff couldn't resolve its identity once the bundle stopped
referencing the name), so `apply --prune` silently orphaned it.
* **Bound-plan false-negative:** `state_token` excluded such a trigger from
its fingerprint, so the StateMoved check missed a concurrent edit to that
binding class. (Routes were already safe — they hash the raw `script_id`.)
Fix: `resolve_current_target_names` resolves the names of group scripts the
app's CURRENT routes/triggers are bound to (by id, independent of the bundle),
and that map now feeds `compute_diff_with_names`, `state_token_with_names`, and
the prune trigger-identity map. App-owned bindings are unaffected (their names
are already in `current.scripts`). Also fixes a stale "in this app" conflict
message for group-owned scripts.
New journey `inherited_bound_trigger_is_pruned_when_dropped` proves prune now
removes an inherited-bound cron trigger. Full journey suite 105/105.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4-lite C4 — the headline declarative path. An app's manifest can now bind
a `[[routes]]` / `[[triggers]]` to a script it does NOT declare, resolving the
name to an inherited group-owned endpoint on the app's chain (nearest-owner
wins; an app's own script of the same name still shadows it — CoW).
* `resolve_inherited_targets(app_id, bundle)` resolves every route/trigger
target name that isn't an app-own manifest script to a group endpoint via
`get_by_name_inherited`, returning `name → script_id`. Run once before the
apply transaction (group scripts pre-exist, committed).
* `validate_bundle` takes the inherited endpoint names so "binds to unknown
script" / "is a module" checks pass for inherited targets.
* The apply transaction merges the inherited targets into `name_to_id`
(app-own keeps precedence), so route/trigger inserts bind to the group
script's id.
* `compute_diff_with_inherited` adds the inherited ids → names to the diff's
name map, so a route bound to a group script resolves its name and
re-apply is idempotent (without this it read as a perpetual rebind).
Live-validated: an app under group G with a manifest binding `GET /greet` to
the group's inherited `greet` plans as `route create → greet` (no script
create), applies (+1 route, +0 scripts), and re-plans as `noop` (idempotent).
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>
Extends the Phase-1 apply engine to manage app `vars` declaratively,
alongside scripts/routes/triggers/secrets. The `Bundle` gains a `vars`
map (key → JSON value; values are non-secret, so they live inline, unlike
secret names). `diff_vars` is value-sensitive: a changed value is an
Update, a live var the manifest dropped is a Delete (applied only under
`--prune` — vars are prunable config, unlike never-pruned secrets).
Writes go through `set_app_var_tx`/`delete_app_var_tx` inside the apply
transaction (mirroring `PostgresVarsRepo::set` for `VarOwner::App`, scope
`*`), so they commit atomically with the rest of the apply and roll back
together on failure. `CurrentState` loads the app's OWN scope-`*`,
non-tombstone vars (inherited group vars and tombstones are out of scope
for the manifest); `state_token` folds them in so the bound-plan check
catches an out-of-band `pic vars set` between plan and apply. Keys are
validated against the same kebab rule as the vars admin API, and the
apply handler now requires `AppVarsWrite` when vars are touched or
`--prune` is set.
Scope: app-owned vars at scope `*` (an app *is* an environment). Group
vars via manifest and tombstone-via-manifest wait for the nested-manifest
model. Unit tests cover the create/update/noop/delete diff and the
state-token value sensitivity.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migration 0049 reshapes `secrets` to the same polymorphic-owner contract
as `vars` (0048): a secret is owned by an app XOR an ancestor group,
carries an `environment_scope`, and the old PK `(app_id, name)` becomes
two partial-unique indexes `(owner, environment_scope, name)`. Existing
rows backfill to `app_id` + scope `'*'`; the v1 AAD does NOT include the
scope, so every current ciphertext keeps decrypting byte-for-byte.
`SecretsRepo` is generalised to be owner+scope keyed (`SecretOwner` moves
down from `secrets_service` and is re-exported for path stability). The
SDK read path now goes through `SecretsRepo::resolve`, which reuses the
shared `CHAIN_LEVELS_CTE` to walk app→ancestor-group→root, env-filters,
and takes the nearest level (`@E` beating `*` within a level) — returning
the winning owner so the value is decrypted under the AAD it was sealed
with. Runtime injection stays anchored to `cx.app_id`: an app only ever
resolves its own and its ancestors' secrets.
All callers updated to owner+scope (`SecretOwner::App(_)`, scope `'*'`):
the app-secrets admin API, the SDK service, and the apply email-secret
path. The 21 secrets unit tests (incl. the app/group AAD-disjointness and
cross-row swap proofs) stay green; the chain-walk was live-verified
against Postgres (nearest-wins + env-filter). Admin API for group secrets
and the masked-read endpoint land next (Step E).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The crypto foundation for group-owned secrets (the §5.3 'single hardest
correctness detail'), done first and proven before the storage/resolution
layer.
- SecretOwner{App(AppId)|Group(GroupId)}; secret_aad/seal/open take the
owner. The app AAD is byte-identical to the pre-Phase-3
'secret:{app_id}:{name}', so every existing v1 row decrypts unchanged;
group secrets use a disjoint 'secret:group:{group_id}:{name}' namespace
(the 'group:' infix separates app/group AAD even for equal UUIDs).
- All callers (SDK get/set, secrets_api, apply email-secret read) wrapped
in SecretOwner::App — behavior identical for app secrets.
- New audit test aad_distinguishes_app_and_group_owner proves the two
namespaces don't collide (both directions, even reusing the UUID); the
existing cross-app/cross-name swap audit tests stay green.
16 secrets_service tests pass; clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The interactive create path rejects a module whose name shadows a built-in
SDK namespace (`kv`, `secrets`, …) so `import "kv"` can't resolve to a user
module, but `pic apply`'s `validate_bundle` skipped the check — a parity gap
that let a manifest create what the dashboard forbids. Gate it on
`ScriptKind::Module` and share the same `RESERVED_MODULE_NAMES` const (now
`pub(crate)`); endpoints remain unrestricted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Holistic Phase-1 review found the "a disabled script can't execute via
any path" guarantee (§4.3) held only for the sync user-route, the
execute-by-id bypass, and the trigger outbox arm — three paths still ran
disabled scripts:
- Queue arm: `list_active_queue_consumers` filtered the trigger's
`enabled` but not the bound script's. Add `JOIN scripts … AND
s.enabled = TRUE` so a disabled script's queue trigger stops consuming.
- Async-HTTP (202) + queued invoke() arms: `build_http_request` /
`build_invoke_request` hardcoded `active: true`. Set it to
`script.enabled`, and MOVE the dispatcher's fire-time `active` drop to
after the source-kind match so it covers all three arms uniformly
(previously trigger-only).
- `invoke()` (script-to-script): `resolve_id`/`resolve_name` checked
cross-app isolation but not `enabled`. A disabled target now resolves
to NotFound (indistinguishable from absent), matching the data plane.
Also (review LOW/§4.7):
- The route-on/script-off 404 returned `NotFound(script_id)`, leaking the
internal id and distinguishable from absent. Return the same flat "no
route matches" 404 as the unmatched case.
- Add the §4.7 "enabled endpoint with no route and no trigger" reachability
warning (was unimplemented; only disabled-target shipped).
Tested: manager-core lib 368 + orchestrator 75 + 22 project-tool journeys
(incl. a new enabled-route→disabled-script flat-404 e2e) green; clippy
-D warnings clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Read the new `name` column through the data model: `Trigger`/`TriggerRow`
carry it, every trigger SELECT/RETURNING includes it, and the row→Trigger
assembly + the interactive create paths populate it (the create INSERTs
still omit it, so they take the migration's default). Behavior unchanged
— the apply diff still keys on the semantic tuple; B-2 switches it to
name-keying (enabling Update) and wires the manifest.
Tested: manager-core lib 367 + trigger journeys green; clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- §4.7 warning: `apply` now reports when an enabled route or trigger is
bound to a disabled script — deployed but unreachable (route 404s,
trigger won't fire). A warning, not an error: it's valid desired state,
the operator just shouldn't be surprised by the silent 404.
- E2E journey (`tests/enabled.rs`): apply an active script and confirm
`/api/v1/execute/{id}` 200s; flip `enabled = false` in the manifest and
re-apply → 404; re-enable → 200. Exercises the whole path: manifest →
diff → apply → runtime honoring.
Tested: manager-core lib 367 + cli bins 31 + 15 project-tool journeys
(incl. the new enabled e2e) green; clippy -D warnings clean; versioning
gate passes (migration 0045).
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>
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>