Commit Graph

451 Commits

Author SHA1 Message Date
MechaCat02
9598db149b docs: record Phase 4-lite (group-owned scripts) as shipped, live-resolved
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>
2026-06-25 20:44:51 +02:00
MechaCat02
c29e4b9c53 test(cli): journey for group-script inheritance, CoW, isolation + apply binding
Phase 4-lite C5. Two `pic` journeys:
  * `group_script_is_inherited_with_cow_and_isolation` — a group endpoint
    `shared` is invoked by name from an app under the group; the app's own
    `shared` then shadows it (CoW); an app outside the group cannot reach it.
  * `manifest_binds_route_to_inherited_group_script_idempotently` — a manifest
    that declares no `greet` script binds a route to the inherited group
    `greet`; plan shows a route create (no script create), apply succeeds, and
    re-plan is a clean no-op.

Adds a `ScriptGuard` (deletes a script by id on drop) since a group-owned
script blocks its group's deletion (ON DELETE RESTRICT) — it must drop before
the GroupGuard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:42:35 +02:00
MechaCat02
701ec90b56 feat(apply): declaratively bind routes/triggers to inherited group scripts
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>
2026-06-25 20:32:24 +02:00
MechaCat02
719a17432f feat(scripts): inherited resolution for invoke() + chain-aware dispatch guards
Phase 4-lite C3. A descendant app can now resolve and run a group-owned script
inherited down its ownership chain — the core of group-script inheritance.

Resolver (repo, reusing config_resolver's CHAIN_LEVELS_CTE):
  * `get_by_name_inherited(app_id, name)` — walk app → ancestor groups
    nearest-first, return the nearest script of that name (an app's own script
    shadows an inherited group one: CoW).
  * `is_invocable_by_app(script_id, app_id)` — EXISTS over the same chain; the
    cross-app isolation predicate generalized to the hierarchy.

invoke(): `invoke("name", …)` resolves via `get_by_name_inherited`, so a script
can call a shared group endpoint by name. A group script always runs under the
*caller's* app context, never its owner.

Dispatch guards (the isolation backstops touched in C1) are now chain-aware via
a `Dispatcher::script_invocable` helper and the chain-aware
`validate_trigger_target`: app-owned scripts keep the in-memory fast path (no
query, behavior byte-identical to the pre-Phase-4 same-app guard); only a
group-owned candidate pays one chain query, and a non-member / query error
fails closed (dead-letter or reject — never run under another app's SdkCallCx).

The orchestrator HTTP route path needs no change: its per-app route trie
already scopes routes to one app, and it dispatches under the route's app
without reading script.app_id — a route bound to a group script just works.

Live-validated against the dev DB: an app under group G invoking the group's
`shared` returns its output; an app NOT under G cannot resolve it (isolation);
and after the app deploys its own `shared`, the same call returns the app's
version (CoW shadowing). Route/trigger binding of inherited scripts via the
declarative engine is C4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:23:07 +02:00
MechaCat02
43ed4e8e7f feat(scripts): group-owned script admin API + pic scripts … --group
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>
2026-06-25 20:10:20 +02:00
MechaCat02
48178c5f60 feat(scripts): polymorphic script owner (app XOR group) — Phase 4 foundation
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>
2026-06-25 19:53:05 +02:00
MechaCat02
27dc04819f test(cli): journey for declarative app-var reconciliation
`apply_reconciles_app_vars` drives the full cycle through `pic` against a
real server: a manifest `[vars] region = "eu"` is applied (var created +
injected — a script `vars::get("region")` returns "eu"), the value is
changed and re-applied (Update, returns "us"), then the var is dropped and
`apply --prune` removes it (`vars::get` resolves to `()` / JSON null).
Exercises the Bundle.vars wire, diff_vars create/update/delete, the in-tx
writes, and the runtime resolver end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:06:44 +02:00
MechaCat02
181622a84a feat(cli): pic pull exports an app's own vars into [vars]
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>
2026-06-25 08:03:26 +02:00
MechaCat02
34dcae98a2 feat(cli): declare app vars in the manifest + plan/apply them
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>
2026-06-25 07:40:36 +02:00
MechaCat02
e517f6dc8c feat(apply): reconcile app-owned vars in the declarative engine
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>
2026-06-25 07:35:12 +02:00
MechaCat02
b2822c8435 Merge: groups Phase 3 — group-inherited config (vars + group secrets)
Adds the net-new env-scoped `vars` table + the §3 resolution engine
(env pre-filter, proximity-first, per-key map merge, explicit tombstones),
group-owned environment-scoped secrets with owner-discriminated AAD
(secret:group:{group_id}:{name}, disjoint from the unchanged app AAD so
existing ciphertexts still decrypt), masked group-secret reads (app devs
see exists, never plaintext; only owning-group principals read values;
runtime injection bypasses the human gate), the vars:: SDK, and
pic vars / pic secrets --group / config --effective. Migrations 0048
(vars) and 0049 (group secrets reshape).

Verified before merge: fmt/clippy(-D warnings) clean; manager-core +
executor-core 523 unit tests pass (incl. AAD cross-owner-swap rejection
and the pure §3 resolver semantics); check-versioning OK (49 migrations);
all 101 CLI journeys pass against a live DB, including masked-read
(group_secret_value_is_masked_from_app_devs), runtime injection +
override, and var inheritance. Commit 79f8c9d also fixes the 6
long-standing stale logs/scripts/roles journey assertions, so the suite
is now fully green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:19:08 +02:00
MechaCat02
b46667c309 docs: reconcile the groups/project-tool initiative into the roadmap
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>
2026-06-25 07:12:25 +02:00
MechaCat02
ca360d84cb docs(design): record Phase 3 shipped + the live-resolution decision
Phase 3 (group-inherited config) is implemented, so fold the outcome back
into the design doc (per its own "before it drifts" note) — and flag the
one deliberate divergence from the plan.

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

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

Doc-only; no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:53:58 +02:00
MechaCat02
79f8c9d420 test(cli): refresh stale deploy + logs journey assertions
Six journeys asserted against output formats that changed in earlier
commits and had been failing regardless of branch:

* `pic scripts deploy` prints a `name`/`version`/`action` KvBlock, not a
  prose "Created X vN" line. The name-override, version-bump, and the
  editor-can-deploy role check now parse the KvBlock fields (asserting the
  exact version + created/updated action) via a small `kv_field` helper.
* `pic logs` gained a `source` column (now `created_at, source, status,
  summary`); the success/error-status and summary-truncation checks now
  index the status at col 2 and the summary at col 3.

No production code touched — the deploys/logs always succeeded; only the
assertions were stale. Full `--test cli --include-ignored` suite is now
green (101 passed, 0 failed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:37:36 +02:00
MechaCat02
bb68e5e50a fix(secrets): composite keyset for group-secret admin list pagination
`list_meta` orders by `(name, environment_scope)` — a group secret name
can carry several env-scoped rows — but the cursor encoded only `name`
and paginated with `name > $cursor`. When a name's scopes straddled a
page boundary, the remaining scope rows were silently skipped from the
admin listing. Switch to a composite `(name, environment_scope)` keyset
(base64url of `name \x1f scope`) and a Postgres row-comparison predicate.
App secrets (single `*` scope per name) were unaffected; group secrets
with multi-scope names now page completely. Found in final branch review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:13:00 +02:00
MechaCat02
11ac168839 test(secrets): group-secret inheritance + masked-read journeys
Two end-to-end journeys via `pic` against a real server:

* `group_secret_is_injected_then_app_value_overrides` — a group-owned
  secret is injected into a descendant app's script through `secrets::get`
  (decrypted under the group AAD), then an app-owned secret of the same
  name shadows it (decrypted under the app AAD). Drives the resolver + both
  owner-AAD open paths against Postgres.
* `group_secret_value_is_masked_from_app_devs` — the §5.3 boundary: an app
  dev (editor on the app, no group role) is denied the secret VALUE (403)
  yet still sees it EXISTS, masked, in `config/effective` (status set,
  owner group, no value); a group_admin reads the value. Proves an app
  runs with config its own devs cannot read.

Also updates the existing app-secrets `ls` journey for the new `env`
column (group secrets are env-scoped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:09:14 +02:00
MechaCat02
30441549d5 feat(cli): pic secrets --group, value read, and effective vars/config
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>
2026-06-24 22:00:32 +02:00
MechaCat02
c914758c09 feat(secrets): group-secrets admin API, masked read, config/effective
Adds the Phase-3 admin surface on top of the group-secrets storage:

* `secrets_api` gains group routes under `/groups/{id}/secrets`
  (set/list/delete, env-scoped) gated `GroupSecretsWrite` (editor+), plus
  the ONE plaintext endpoint `GET /groups/{id}/secrets/{name}/value` gated
  `GroupSecretsRead` (group_admin only). That is the masked-secret
  boundary: a descendant app's dev sees a group secret EXISTS and consumes
  it at runtime via `secrets::get`, but only a reader at the OWNING group
  gets the value. App secrets stay env-agnostic (a stray `env` is rejected).
  The owner is resolved first, then the capability binds to the resolved
  id — never a path param.

* `config_api`: `GET /apps/{id}/config/effective` (gated `AppVarsRead`)
  returns the resolved view a dev would get — every inherited var with its
  value + provenance, and every inherited secret MASKED (name/owner/scope,
  never the value). Backed by a new `fetch_effective_secret_meta`
  (DISTINCT-ON nearest-wins, same ordering as the per-name resolver).

* authz: `GroupSecretsWrite` moves from `app:admin` to `script:write`
  scope so its API-key scope matches its editor role tier (closing the
  latent scope/role mismatch the checkpoint review flagged); the value
  read `GroupSecretsRead` stays at `app:admin`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:00:32 +02:00
MechaCat02
e6b4792389 feat(secrets): group-owned, env-scoped secrets + inherited resolution
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>
2026-06-24 21:48:33 +02:00
MechaCat02
b588fc9d35 fix(config): same-level @E config suppresses * instead of merging it
§3 step 1 treats an environment scope as *eligibility*, not a merge tier:
within a single level a value scoped `@E` shadows the same level's `*`
fallback — it never layers on top of it. The resolver's map-run loop
collected every leading object row regardless of (depth, scope), so a
level holding both an `@E` map and a `*` map would deep-merge the two
(and report a bogus `*` layer in `merged_from`) rather than letting the
`@E` map win alone.

Dedup the sorted candidates by depth before the map run: each level is a
single owner (single-parent tree) with at most one `@E` and one `*` row
per key after env-filtering, and `@E` sorts first, so keeping the first
row per level yields the level's winner. Scalar/tombstone cases already
went through the boundary path and were unaffected; only same-level
map-vs-map mis-merged.

Adds two regression tests (same-level suppression; suppressed `*` stays
invisible to cross-level merge). Found in checkpoint review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:32:15 +02:00
MechaCat02
6bd0f4699d feat(secrets): owner-discriminated AAD (SecretOwner) for group secrets
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>
2026-06-24 21:17:18 +02:00
MechaCat02
9ee85993d8 feat(vars): admin CRUD API + pic vars CLI
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>
2026-06-24 21:11:46 +02:00
MechaCat02
343f6d3b4d feat(vars): VarsService + vars:: SDK + config capabilities
Scripts can now read group-inherited config via vars::get(key) / vars::all().

- shared: VarsService trait (read-only get/all) + NoopVarsService; added as
  the 14th field on the Services bundle.
- manager-core: VarsServiceImpl resolves the calling app's config via
  config_resolver (derives app_id from cx.app_id; AppVarsRead-gated for
  authed principals, script-as-gate for anon).
- executor-core: vars:: Rhai bridge (get/all), registered in the SDK.
- authz: AppVarsRead/Write, GroupVarsRead/Write, GroupSecretsRead/Write
  capabilities (group caps resolve via the Phase-2 ancestor walk; the
  GroupSecretsRead human-read gate is group_admin-only, distinct from an
  app's runtime injection).
- wired VarsServiceImpl into the picloud binary; updated the executor-core
  test Services::new call sites.

386 manager-core lib tests green; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:59:23 +02:00
MechaCat02
35dbd9f368 feat(config): vars schema + the §3 resolution engine
Phase 3 foundation (docs §3): env-filtered, proximity-first config
inheritance down the group tree.

- Migration 0048: `vars` (polymorphic group|app owner via two nullable FKs
  + CHECK exactly-one, env scope, JSONB value, explicit tombstone) +
  `apps.environment` (the env marker the resolver filters on — 'an
  environment is an app').
- config_resolver: a shared chain-walk CTE (mirrors effective_app_role) that
  walks app → ancestor groups, env-filters in SQL, plus a pure Rust
  resolution pass implementing the §3 semantics SQL can't — per-key
  proximity-first, @E-over-* within a level, map deep-merge, tombstone
  suppression, and provenance for --explain.

Verified: 8 unit tests (incl. the §3.2 proximity-beats-env-specificity call,
deep-merge, tombstone, boundary) + the candidate-fetch CTE against live
Postgres (env-filter drops a non-matching @production value; nearer level
shadows farther).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:43:16 +02:00
MechaCat02
6db057fb08 Merge: groups Phase 2 — org/RBAC/UI container (migration 0047)
Adds the GitLab-style single-parent group tree above apps: migration
0047 (groups + group_members + apps.group_id NOT NULL/RESTRICT with
single-root backfill), tree repos with an ancestor-walk cycle guard
under a coarse structural advisory lock, slug-freeze and structure_version,
hierarchy-aware RBAC (effective role = MAX across app membership + every
ancestor group, resolved live so revocation is immediate), new
Group{Read,Write,Admin}/InstanceCreateGroup caps that bound API keys
cannot obtain, plus `pic groups` and a dashboard group tree/detail/members.

Verified before merge: fmt/clippy(-D warnings)/check clean; manager-core
378 unit tests pass (incl. hierarchy authz + group repos); dashboard
npm run check 0 errors; check-versioning OK (47 migrations sequential).
New CLI groups journeys pass against a live DB — cycle guard
(reparent-into-descendant rejected), delete=RESTRICT, and
inherited-group-admin deploy + live revoke. The 6 failing logs/scripts/roles
journeys are the same pre-existing format drift on main, not regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:26:21 +02:00
MechaCat02
49c4fb41ce docs(design): mark Phase 2 (groups) shipped + record decisions/deferrals
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:16:29 +02:00
MechaCat02
8725939172 feat(dashboard): group tree, detail page, and members tab
SvelteKit UI for Phase-2 groups, mirroring the apps/users patterns:
- api.ts: Group/GroupDetail/GroupMember types + an api.groups client
  (list/get/create/update/reparent/delete + nested members CRUD); optional
  group selector wired into app create.
- routes/groups: a collapsible tree overview (assembled from parent_id)
  with a New-group form, and a detail page with breadcrumb, subgroups/apps
  lists, a rename form (no slug field — slug is frozen), a reparent
  control, a delete button that surfaces the 409 RESTRICT message, and a
  Members tab reusing RoleChip/ConfirmModal/ActionMenu.
- +layout.svelte: a Groups nav entry beside Apps.

npm run check: 0 errors; npm run build: success.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:15:44 +02:00
MechaCat02
eea1d8984e feat(cli): pic groups (tree/create/rename/reparent/rm/members)
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>
2026-06-24 20:15:33 +02:00
MechaCat02
a432091191 feat(groups): tree repos, hierarchy-aware RBAC, admin API
Server-side foundation for Phase-2 groups (no group-owned resources yet):

Shared types:
- GroupId, Group; App gains group_id; AppRole::{precedence,max} for
  folding the highest effective role across the membership chain.

Repos:
- group_repo: tree CRUD with reparent (ancestor-walk cycle guard under a
  coarse instance-wide structural advisory lock; slug frozen; bumps
  structure_version) and delete=RESTRICT (refuses non-empty groups).
- group_members_repo: per-(user, group) role grants, mirroring app_members.

Hierarchy-aware authz (§5.3):
- AuthzRepo gains effective_app_role / effective_group_role (default to
  direct membership / none, so the ~18 existing test stubs are untouched);
  the Postgres impl resolves each via one depth-bounded recursive CTE that
  MAXes the app's own row with every ancestor group_members row.
- can(): the Member path now folds inherited group roles, so a group_admin
  on any ancestor is implicitly app_admin beneath it. New Capability
  variants InstanceCreateGroup / Group{Read,Write,Admin}; group caps carry
  no app_id (bound API keys can't manage groups). 8 new unit tests.

Admin API:
- groups_api: group CRUD + reparent (admin at both source and destination
  parent, §5.6) + per-group members, all capability-gated.
- apps: POST /apps takes an optional parent group (default root); app
  responses carry group_id; my_role now reflects the effective role.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:59:00 +02:00
MechaCat02
2b27012f56 feat(groups): schema + root-group backfill (migration 0047)
Phase 2 (blueprint §5): groups form a single-parent org tree ABOVE apps.
This migration adds the tree + membership tables and gives every app a
parent (§9 adoption):

- groups: id, parent_id (self-FK ON DELETE RESTRICT), slug (instance-
  global UNIQUE, frozen at creation), name, description, structure_version
  (bumped on structural mutation), owner_project (inert §7 seam).
- group_members: (group_id, user_id, role) with the SAME three role
  literals as app_members so AppRole round-trips and the authz rank table
  covers both.
- apps.group_id: nullable add → backfill every app under a seeded 'root'
  group → promote to NOT NULL + FK RESTRICT.

No group-owned resources yet (scripts/secrets stay app-owned — Phase 3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:58:48 +02:00
MechaCat02
c900ca5bbf Merge: declarative project-tool foundation (Phase 1) + enabled lifecycle
Adds the declarative `pic` project tool (init/pull/plan/apply/prune,
env-scoped overlays, config --effective), the three-state `enabled`
lifecycle on scripts/routes with dispatcher fire-time re-checks, the
trigger `name` column + backfill, and cross-app/disabled-execution
backstops on every async dispatch path. Design captured in
docs/design/groups-and-project-tool.md.

Verified before merge: cargo fmt/clippy(-D warnings)/check clean;
manager-core 370+ unit tests pass (incl. dispatcher enabled-gates and
cross-app isolation); all new CLI journey tests pass against a live DB.
The 6 failing logs/scripts/roles journey tests are pre-existing format
drift on main, not regressions from this branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:17:36 +02:00
MechaCat02
695987d6b7 docs(design): record async disabled-execution + cross-app backstops
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>
2026-06-24 19:06:52 +02:00
MechaCat02
d4b5632db1 style(email): wrap over-width lines (cargo fmt)
Incidental `cargo fmt --all` normalization of two pre-existing over-width
`.lock()` chains in DevEmailSink; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:06:52 +02:00
MechaCat02
345f265062 fix(cli): env-aware config, pull clobber guard, strict overlays, 409 hint
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>
2026-06-24 19:06:45 +02:00
MechaCat02
aa3995ae05 fix(apply): reject reserved module names in validate_bundle (parity)
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>
2026-06-24 19:06:29 +02:00
MechaCat02
a27863d4a6 fix(dispatcher): close disabled-execution + cross-app gaps on the async paths
The `enabled` lifecycle's "a disabled script is not executable via ANY
path" guarantee leaked on the queue arm, and the outbox trigger arm lacked
the cross-app backstop its siblings carry.

- Queue arm: `dispatch_one_queue` bypasses the unified `dispatch_one`
  fire-time gate, so its only `enabled` guard was the per-tick
  `list_active_queue_consumers` snapshot — a TOCTOU window for a message
  claimed in a tick where the script was still enabled. Re-check the
  freshly-read `script.enabled` before executing and release the claim
  (`nack`) when disabled; the message stays queued and resumes on
  re-enable. The nack is load-bearing: `reclaim_visibility_timeouts` only
  reclaims claims for enabled triggers, so without it the message would
  sit claimed indefinitely.
- Trigger arm: `resolve_trigger` built the ExecRequest with
  `app_id = row.app_id` while sourcing the body from `trigger.script_id`
  with no same-app check — the guard the queue/http/invoke arms already
  have. Add it so a hand-edited/restored row can't run one app's script
  under another's SdkCallCx.

Both regression-locked by new unit tests (full mock harness, verified to
fail if the gate is removed):
- queue_enabled_gate::disabled_queue_consumer_releases_claim_without_executing
- outbox_enabled_gate::disabled_http_outbox_row_is_dropped_without_executing

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:06:21 +02:00
MechaCat02
b3f05dfe2a fix(enabled): close disabled-script execution on the async paths (review)
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>
2026-06-23 21:59:50 +02:00
MechaCat02
5e62f4acfe docs(design): mark Phase 1 shipped + record decisions/deferrals
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>
2026-06-23 21:20:32 +02:00
MechaCat02
73c8c289c1 feat(triggers): surface name on the Trigger model (§4.5, step B-1)
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>
2026-06-23 21:17:52 +02:00
MechaCat02
816f143ffd feat(triggers): add name column + backfill (§4.5, schema step)
Schema groundwork for the trigger `name` identity (the manifest merge key
that will let `apply` Update a trigger in place rather than only
Create/Delete). A `gen_random_uuid()` default keeps the existing write
paths valid and unique without code changes; existing rows are backfilled
to the readable `{kind}-{n}` form; `UNIQUE(app_id, name)` is enforced.

No behavior change yet — the manager-core write paths and the apply diff
start using the name in the follow-up. Verified the migration applies and
the trigger journeys (which create triggers via the unnamed path → default
name) stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:09:22 +02:00
MechaCat02
2ba476aac8 feat(cli): env-scoped overlays — --env merges picloud.<env>.toml
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>
2026-06-23 20:58:04 +02:00
MechaCat02
79153b2063 feat(cli): pic config --effective (masked secret resolution)
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>
2026-06-23 20:54:46 +02:00
MechaCat02
9e1c24f729 test(enabled): end-to-end route-disable journey
Close the coverage asymmetry flagged in review: script-disable had a full
e2e journey but route-disable was only unit-tested (compile_routes) plus
the apply-refresh path. Add an HTTP-level journey — claim a Host for the
app (two-phase dispatch needs it), then serve `/hello` (200), flip the
route's `enabled = false` and re-apply (404, indistinguishable from
absent), and re-enable (200).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:48:04 +02:00
MechaCat02
8c805a07d0 feat(enabled): apply-time reachability warning + end-to-end journey
- §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>
2026-06-23 20:10:13 +02:00
MechaCat02
bfab7c781d feat(enabled): dispatcher fire-time re-check for disabled triggers/scripts
Close the §4.3 outbox gap: the match-time `enabled` check can't see a
trigger (or its target script) disabled *after* an outbox row was
enqueued, so a pending row would still fire. `resolve_trigger` now folds
`trigger.enabled && script.enabled` into the resolved row, and
`dispatch_one` drops the row at fire time when inactive instead of firing
a stale event.

The queue arm needs no change here: `list_active_queue_consumers` already
re-lists only enabled queue triggers each tick, so a disable is honored
promptly without a stale-row window.

Tested: manager-core lib 366 green; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:48:56 +02:00
MechaCat02
4223d3c320 feat(enabled): runtime honoring for disabled scripts and routes
Make the `enabled` flag bite at the data plane (§4.3).

- Routes: `compile_routes` drops disabled rows from the match table, so a
  request to a disabled route 404s indistinguishably from an absent one
  (no info leak). Rebuilt on every route write + at startup, as today.
- Scripts: the orchestrator's two execute paths — `/execute/{id}` bypass
  and the user-route handler — 404 when the resolved script is disabled.
  The route-handler check also covers an enabled-route-bound-to-disabled-
  script (§4.7): the binding is unreachable rather than 500/executing.

Still pending: the dispatcher fire-time re-check for already-enqueued
outbox rows (next commit).

Tested: route_admin disabled-route-dropped unit test + orchestrator suite
(75) green; clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:42:45 +02:00
MechaCat02
55cf995eda feat(enabled): scripts/routes enabled column + declarative data path
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>
2026-06-23 19:36:40 +02:00
MechaCat02
627996cde7 fix(project-tool): address independent review of init + staleness
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>
2026-06-22 22:07:31 +02:00
MechaCat02
be5df06a48 feat(project-tool): bound-plan staleness check (content fingerprint)
`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>
2026-06-22 21:48:27 +02:00
MechaCat02
b8a4f30219 feat(cli): pic init scaffolds a new declarative project
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>
2026-06-22 21:32:07 +02:00