Commit Graph

44 Commits

Author SHA1 Message Date
MechaCat02
2fc9476f9e feat(interceptors): §9.4 service interceptors — thin KV allow/deny slice
Smallest honest vertical slice of §9.4: a `[[interceptors]]` block (app OR
group) binds a script to run BEFORE `kv::set`/`delete`; it reads the operation
context (`ctx.request.body`: service, action, collection, key, value, caller
ids) and returns `#{ allowed, reason }` — `allowed == false` denies the op (the
write never runs, the caller gets a runtime error).

Reuses two existing mechanisms rather than inventing new ones:
- Registration mirrors extension points (§5.5): a marker table
  `0073_interceptors.sql` (owner-polymorphic app_id/group_id XOR, keyed
  (service, op) → script), `interceptor_repo` (insert/delete/list + the
  nearest-owner-wins `resolve_before` chain walk), reconciled through the
  declarative apply exactly like `vars` (create/update/delete, prunable).
- Execution reuses the `invoke()` re-entry path: the new `InterceptorService`
  (shared trait + Postgres-backed impl) only RESOLVES the script name (keeping
  executor-core Postgres-free); the executor's `sdk::interceptor::run_before`
  resolves that name and runs it via `run_resolved_blocking` (extracted from
  `invoke_blocking` — shared depth bound + AST cache). An un-hooked write pays
  one indexed `Ok(None)` resolve; no interceptor ⇒ zero overhead.

Nearest-owner-wins so an app overrides a group's interceptor, and a group
interceptor is inherited by every descendant app — the chain walk is the
isolation boundary (a sibling subtree never matches). `validate_bundle_for`
restricts the MVP to `service = "kv"`, `op ∈ {set, delete}`, one marker per
(service, op).

Deferred (documented in §9.4): the `data` transform return, services other
than kv, `after_*` hooks, chaining + circular-dependency guard, the timeout
policy, and a `pic interceptors ls` read surface (needs a server route).

Pinned by `tests/interceptors.rs` (deny blocks the write; allow passes;
group→app inheritance), schema snapshot re-blessed. 154/154 journeys pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:44:50 +02:00
MechaCat02
3da70a66c7 feat(cli): add pic docs/dead-letters ls group read mirrors
Two read-only operator surfaces existed server-side but the CLI was app-only:

- `pic dead-letters ls --group <slug>` — mirrors the app listing onto a group's
  shared-queue dead-letters (§11.6 D3, `GET /groups/{id}/dead-letters`), via the
  existing `require_one_owner`/`OwnerRef` dispatch. List-only (show/replay/
  resolve stay app-only, matching the server's operator surface); a group DL row
  shows its `collection` and carries no trigger, so it renders its own columns.
- `pic docs ls/get --group <slug>` — a new `Docs` command (`cmds/docs.rs`)
  wrapping `GET /groups/{id}/docs[/{collection}/{id}]`, mirroring `pic kv`.
  Group-only: per-app docs have no admin read route yet (unlike kv/files), so
  there is no `--app` variant — noted for a later pass.

Read-only by design (writes go through the SDK). New client methods +
`GroupDocsListDto`/`GroupDeadLetterDto`. Pinned by a new
`operator_reads_shared_docs_and_group_dead_letters_via_cli` journey
(152/152 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:00:37 +02:00
MechaCat02
eaf5ace30f fix(workflows): close review gaps — pull round-trip, dup-names, reclaim budget
Adversarial review of the v1.2 Workflows track surfaced two HIGH footguns and
several correctness/hardening gaps. Fixes:

HIGH
- pull round-trip: `pic pull` dropped `[[workflows]]`, so a later
  `apply --prune` silently deleted them. The list endpoint now returns the full
  `definition`; pull rebuilds the manifest block (inverse of workflow_to_wire).
- case-colliding names: two workflows differing only by case collided in the
  reconcile diff (keyed by lower(name)), silently dropping one. Rejected up
  front in validate_bundle_for.

MEDIUM
- reclaim retry budget: a crashed attempt (no outcome) consumed the retry
  budget. reclaim_stale_steps now decrements `attempt` (floored) and clears
  `next_attempt_at`, so a crash no longer counts as a failed try.
- on_error/backoff: typed the manifest fields against the shared enums so a
  bad value fails at TOML parse with a clear message, not an opaque 500.
- dedupe depends_on: a repeated dependency inflated the Kahn in-degree past the
  single decrement, reporting a spurious cycle for a valid DAG. Count distinct.
- canceled child: a canceled sub-workflow resolved the parent step with a
  message naming the cause instead of a generic "failed"; documented that a
  run-level cancel op is not yet supported.

LOW
- list_run_steps is now app-scoped at the query (JOIN workflow_runs) rather
  than relying on caller pre-verification.
- partial failure is surfaced: a run that succeeds with on_error=continue
  failures records them in the run's `error` field.
- admin-started runs log the root_execution_id against the principal.
- documented the deliberate when(missing→false) vs template(missing→fail)
  asymmetry; corrected the claim's atomicity comment.

Tests: unit (dedupe-deps), DB-gated reclaim-budget assertion, and two new CLI
journeys (duplicate-name rejection, pull→plan clean round-trip). fmt + clippy
-D warnings clean; 450 lib + 14 orchestrator DB + 5 workflow journeys pass;
schema snapshot unchanged (no migration).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 18:56:44 +02:00
MechaCat02
5a630e1d9f feat(workflows): M5 — SDK workflow::start + admin run API + pic CLI
The user-facing surface for the durable DAG engine. A workflow run can now be
started three ways, all resolving the workflow by name in the caller's app
(`cx.app_id` / the resolved app — never a passed-in arg, the isolation
boundary), seeding a run the orchestrator advances.

SDK seam (shared):
- `WorkflowService` trait + `NoopWorkflowService` + `WorkflowError` in
  `shared::workflow` (mirrors `InvokeService`); added to `Services` as a
  noop-defaulted `with_workflow` builder (all `Services::new` call sites
  unchanged).
- `WorkflowServiceImpl` (manager-core): resolves + seeds a run; authenticated
  callers gated on `AppInvoke` (anonymous skips, script-as-gate), the same gate
  `invoke()` uses.
- `executor-core::sdk::workflow` — the Rhai bridge `workflow::start(name, input)`
  / `workflow::start(name)`, registered in `register_all`. Returns the run id.

Admin API (manager-core `workflows_api`, mounted in the binary):
- `GET  /apps/{app}/workflows`              — definitions (AppRead)
- `POST /apps/{app}/workflows/{name}/runs`  — start a run (AppInvoke)
- `GET  /apps/{app}/workflows/{name}/runs`  — run history (AppRead)
- `GET  /apps/{app}/workflow-runs/{run_id}` — one run + its steps (AppRead)
  `app_id` scopes every query; a foreign run 404s.

CLI (`pic workflows`): `ls` · `run <name> [--input JSON]` · `runs <name>` ·
`run-status <run_id>` — all `--app`-scoped; run definitions stay declarative
(`[[workflows]]` → `pic apply`).

Also: `list_runs_for_workflow` repo reader.

Tests: `workflow_service_start_seeds_a_run` (DB-gated — the SDK/API-shared
entry) + `workflow_run_executes_end_to_end` CLI journey (apply → `pic workflows
run` → poll `run-status` → succeeded, through the real binary + orchestrator).
fmt + clippy -D warnings clean, 449 manager-core lib tests, 14 orchestrator DB
tests, 18 executor-core lib tests, 3 workflow CLI journeys green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 17:48:38 +02:00
MechaCat02
44f992cbb0 feat(workflows): M1 — schema + definition validation + declarative reconcile
First milestone of the v1.2 Workflows track (blueprint §9.1/§9.2): the durable
DAG engine's foundation — the definition model, its validation, and the
declarative `apply` reconcile path. No execution yet (the orchestrator is M2).

- Migration 0071_workflows: `workflows` (owner-polymorphic like scripts, app-
  owned in M1), `workflow_runs`, and `workflow_run_steps` (the per-step
  competing-consumer lease table the M2 orchestrator will claim). Schema golden
  reblessed.
- `shared::workflow`: the `WorkflowDefinition` / `WorkflowStepDef` DTOs (shared
  by the CLI, apply, and the future orchestrator) + run/step status enums.
- Pure, DB-free `workflow_template` (input `{{ input.x }}` / `{{ steps.a.output.y }}`
  resolution, type-preserving) and `workflow_expr` (a small safe JSON-predicate
  evaluator for `when` — keeps manager-core free of a scripting engine).
- `workflow_repo`: read trait + reconcile tx free-fns (insert/update/delete).
- apply_service: `BundleWorkflow` + `Plan.workflows` + `CurrentState.workflows`
  + `ApplyReport.workflows_*`; server-side `validate_workflow_definition`
  (unique/acyclic steps via topological sort, deps exist, function XOR workflow,
  `when`/template parse) rejecting on a group node; `diff_workflows` by
  lower(name) (Update on a definition change) + in-tx reconcile.
- CLI: `[[workflows]]` + `[[workflows.steps]]` manifest structs → wire bundle;
  plan/apply render + report counts.
- Tests: 16 manager-core lib tests (template, expr, definition validation) +
  the `workflows` CLI journey (apply → NoOp → prune; cyclic DAG rejected).

Deferred to later milestones: the orchestrator worker (M2), conditional/template
runtime wiring (M3), nested sub-workflows (M4), the SDK/API/CLI run surface
(M5), the dashboard (M6). The `group_id` column + run/step tables ship now so
those slot in without a migration churn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:46:32 +02:00
MechaCat02
a3c4267f6c feat(cli): plan warnings, apply confirm + blast-radius gate, files --group, unified owner-XOR
Remediate the CLI/DX findings from the 2026-07-11 audit.

B4 — `pic plan` now previews the apply-time desired-state warnings (disabled
binding, unreachable endpoint, dangling `[suppress]`), so plan and apply agree
for CI review. Wire fields are `#[serde(default)]` (older-server tolerant).

B5 — `pic apply` no longer mutates on a first run (no recorded plan) without a
preview + confirm, and a large blast radius now triggers an extra confirmation.
Both gates check `is_terminal()` before any read — a non-TTY never hangs on
stdin and fails closed without `--yes`; `--yes`/`--force` bypass headlessly.
`--force` help now notes it also skips these prompts.

C3 — `pic files ls`/`get` gain `--group`, mirroring `pic kv --group`, so the
shipped group shared-files admin surface is reachable from the CLI.

C4 — a shared `require_one_owner(app, group)` helper (cmds/mod.rs) gives one
canonical message for the `--app`/`--group` XOR, wired into every such site
(kv, files, vars, secrets, suppress, extension-points, triggers ls, scripts
deploy). routes ls (positional script_id) and scripts ls (lists all) keep their
own shapes deliberately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:47:59 +02:00
MechaCat02
4b4b3148b7 feat(apply): server-side enforcement of per-env approval gating (§3 M3 / §4.2)
The per-env approval gate (`[project.environments]`, `confirm = true`) was
client-side only — the policy was `#[serde(skip_serializing)]`, so a patched or
older CLI applying to a confirm-required env bypassed it entirely. This makes
the gate server-enforced, admin-gated, and audited (the deferred §4.2 piece).

- Wire: `ProjectDecl` gains `environments` (the CLI stops skip_serializing it and
  sends the selected `env` + the `--approve` set on the apply request). The
  server RE-DERIVES the gate from the request (`env_gate_check`): a
  confirm-required env not in `approved_envs` → 409 `ApprovalRequired`.
- Admin gate: an approved override additionally requires `AppAdmin`/`GroupAdmin`
  on EVERY declared node — a step up from the editor write caps an ordinary
  apply needs (reusing the admin caps per §4.2) — and emits a `tracing::info!`
  audit line. Enforced on all three handlers (single app, single group, tree);
  the check runs before any tx, so a denial (403) precedes any mutation.
- Token: the env policy folds into the TREE bound-plan token (a policy edit
  between plan and apply trips StateMoved). The single-node token is left
  unchanged (a bare live-state hash), so plan/apply still match without threading
  a project into it.
- CLI: single-node `apply`/`plan` now resolve the GOVERNING project (own block
  else nearest ancestor's) so a leaf apply carries the root's policy; `plan`
  surfaces `approvals_required`. Client-side `require_env_approval` fast-fail
  kept as UX.

Adapts the earlier feat/ownership-and-approval M5 (654e387) to main's DTOs; main
addresses tree group nodes by slug only, so the admin loop mirrors authz_tree
(app: resolve_app_id fail-closed; group: get_by_slug, skip to-create).

Threat-model note (honest scope, in the doc + code comments): this closes the
patched/older-CLI bypass and admin-gates + audits the override, but is NOT a
hermetic boundary against a hand-crafted request that OMITS the policy — the
policy is applier-supplied, not persisted server state. Full closure (persist a
`project_environments` source of truth + a server-determined env) is deferred.

Review (adversarial pass): fixed a MEDIUM (single-node `plan` didn't resolve the
governing project, so a leaf plan under-reported the gate `apply` enforces) and a
LOW (duplicate clippy attr); corrected overstated "can't bypass" wording.

Tests: ProjectDecl gate unit test; env_approval journeys
`server_enforces_the_gate_against_a_non_stock_client` (raw request → 409 without
approval, admin-approved → 200) and `approving_a_gated_apply_requires_admin`
(editor + --approve → 403). 417 lib tests + 33 CLI journeys + workspace clippy
-D warnings + fmt clean. No migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:01:04 +02:00
MechaCat02
cb3d458cfd feat(apply): §6 M2 — structural-divergence detection + declarative reparent
With the declared parent on the wire (M1), `apply_tree` now compares each
EXISTING group node's server parent to the manifest's (directory-nesting)
parent and resolves a divergence per a request `StructureMode` (default
`Refuse` — a pre-M2 CLI never reshapes the tree):

- `Refuse` → `ApplyError::StructuralDivergence` (422), naming the flags.
- `ForceLocal` → reparent to the declared parent IN the apply tx via the
  extracted `group_repo::reparent_group_tx` (cycle guard + structure_version
  bump), §5.6-gated (`GroupAdmin` on node + source + destination) and
  attach-ceilinged.
- `AdoptServer` → keep the server shape, reconcile content in place.

M1's `create_missing_groups_tx` generalized to `reconcile_group_structure_tx`
(create + reparent share the coarse-lock / attach-ceiling / RBAC path; both are
recorded `structurally_changed` so the pool-based attach re-check skips their
uncommitted rows). `pic plan` previews the drift (`divergence_preview` → a
`structure` row naming server + manifest parents). CLI:
`--force-local-structure` / `--adopt-server-structure` (mutually exclusive) →
the `structure_mode` wire field; the 422 surfaces verbatim. A concurrent
`pic groups reparent` between plan and apply still trips `StateMoved` (the tree
token already folds structure_version).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:23:10 +02:00
MechaCat02
ffa8b02506 fix(apply): restore bare-bundle wire compat on the plan endpoints
§7 M3 wrapped the three plan request bodies in a `{bundle, project}` struct,
silently breaking version-skew compatibility: a pre-§7 `pic plan` POSTs a BARE
bundle at the JSON top level and now fails deserialization (422 "missing field
bundle"), even though the sibling apply request deliberately kept its added
fields additive (`#[serde(default)]`) so an older CLI still works.

`#[serde(flatten)]` the bundle in `PlanRequest`/`TreePlanRequest` so the bundle
fields sit at the top level again: a bare bundle deserializes (`project`
defaults to `None`) and a newer client sends the same fields plus a top-level
`project` key. The CLI now builds that shape via a `plan_body` helper (bundle
object + optional `project`), which an older server also accepts (Bundle has no
`deny_unknown_fields`, so the extra key is ignored) — compatible in both skew
directions. Apply is left unwrapped (it was already `{bundle, ...}` pre-§7).

Pinned by `plan_request_accepts_a_bare_bundle` / `_a_flattened_project` /
`tree_plan_request_accepts_a_bare_bundle` unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 07:37:35 +02:00
MechaCat02
f673922d89 feat(cli): pic projects ls + §7 M3/track-complete docs
§7 M3 (part 3) — the ownership track becomes inspectable, completing M1–M3.

- server: GET /api/v1/admin/projects (projects_api) lists every project + its
  owned-group count (projects repo list_with_counts, one GROUP BY); behind the
  /admin middleware, like the groups list.
- CLI: pic projects ls (cmds/projects.rs + client projects_list); the
  apply_ownership plan-preview journey now also asserts projects ls (plat owns
  exactly 1 group; teamb listed).
- docs: design §7 + CLAUDE.md record M3 shipped and the whole §7 multi-repo
  ownership track (M1 claim · M2 attach ceiling · M3 preview + projects ls)
  COMPLETE; deferred = structural-divergence + declarative tree shape + per-env
  approval gating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:26:10 +02:00
MechaCat02
30fe160f16 feat(apply): cross-repo blast-radius in the plan preview
§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>
2026-07-06 21:17:15 +02:00
MechaCat02
b4eb226d20 feat(apply): project on the plan wire + ownership preview annotation
§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>
2026-07-06 21:09:55 +02:00
MechaCat02
b33c87e5c4 feat(cli): [project] manifest block, --takeover, groups ls owner column
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>
2026-07-06 20:46:09 +02:00
MechaCat02
137103a429 feat(triggers): show a materialized column in pic triggers ls --app (D1)
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>
2026-07-02 21:30:32 +02:00
MechaCat02
24d834a102 feat(group-blobs): pic kv ls/get --group + journey + docs (M4.3+M4.4)
- `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>
2026-07-01 21:26:12 +02:00
MechaCat02
9a4b83dfcf feat(shared-triggers): visibility + tests + docs (M2.5)
- `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>
2026-07-01 21:05:30 +02:00
MechaCat02
16267cec06 feat(suppress): group-suppress warnings + ls + journey + docs (M1.5)
- 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>
2026-07-01 20:16:22 +02:00
MechaCat02
fa0702870a feat(sealed): sealed visibility + ineffective-suppress warning (§11 tail M4)
- `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>
2026-07-01 19:38:18 +02:00
MechaCat02
9601046e29 feat(suppress): pic suppress ls --app + dangling-suppress warning (§11 tail S4)
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>
2026-07-01 08:02:04 +02:00
MechaCat02
32cb6c1f1f feat(suppress): author + persist per-app template suppressions (§11 tail S2)
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>
2026-07-01 07:32:26 +02:00
MechaCat02
a977ff6a32 feat(cli): pic routes ls --group + group-route journey + docs (§11 tail R5)
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>
2026-06-30 22:03:59 +02:00
MechaCat02
c492a08775 feat(cli): pic triggers ls --group + group-template journeys + docs (§11 tail T4)
- 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>
2026-06-30 21:02:03 +02:00
MechaCat02
5efb068b9f feat(cli): kind-aware shared-collection reconcile + string-or-table manifest (§11.6 docs C4)
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>
2026-06-30 19:04:43 +02:00
MechaCat02
0973344515 feat(cli): collections manifest + ls + journeys; rename to kv::shared_collection (§11.6 C5)
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>
2026-06-29 22:26:43 +02:00
MechaCat02
82b4579317 feat(modules): no-provider plan check + read-only extension-points ls (§5.5 C4)
- 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>
2026-06-29 20:31:14 +02:00
MechaCat02
e62e073970 feat(apply): declarative extension_points reconcile (§5.5 C3)
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>
2026-06-29 20:22:05 +02:00
MechaCat02
1662d7806a feat(cli): nested-manifest discovery + pic plan/apply --dir tree mode (Phase 5 C4)
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>
2026-06-25 22:25:29 +02:00
MechaCat02
da03fda1d6 feat(cli): [group] manifest + pic plan/apply for a group node (Phase 5 C2)
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>
2026-06-25 21:57:58 +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
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
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
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
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
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
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
3b650a2b14 feat: declarative project-tool foundation (pull/plan/apply/prune)
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>
2026-06-20 21:52:21 +02:00
MechaCat02
51f14fa2b1 feat: E2E #2 (Stash) gap remediation + S6 hardening
Some checks failed
CI / Rust — fmt, clippy, test (push) Failing after 6m19s
CI / Dashboard — check (push) Successful in 9m48s
Closes the gaps and the one security finding from the second end-to-end
CLI test (E2E_STASH_REPORT.md), plus the H1 boot-regression found while
re-reviewing those fixes.

Security
- S6: reserved-path validation (`check_reserved`) now case-folds before
  comparing, so `/API/v2/x`, `/HEALTHZ`, `/Admin/x` are rejected like
  their lowercase forms. Request-time matching stays case-sensitive.
- S10: "public route != public data" callout in sdk-shape.md (script_gate
  skips authz when the principal is anonymous).

Observability / features
- G1: trigger executions now write `execution_logs`. Migration 0043 adds
  a `source` column (CHECK mirrors ExecutionSource/OutboxSourceKind,
  DEFAULT 'http' backfills history); a shared `build_execution_log` helper
  in executor-core; dispatcher logging for outbox triggers + queue
  consumers (skips sync-HTTP rows the orchestrator already logs). `pic
  logs` gains a source column + `--source` filter.
- G5: dev-only in-memory email capture under PICLOUD_DEV_MODE with no SMTP
  (email::send succeeds locally), readable at GET /api/v1/admin/dev/emails
  (Owner/Admin only; route mounted only in capture mode).
- G6: generalized the Rhai in-place-mutation footgun note (trim/replace/
  make_upper/make_lower/crop/truncate/pad return ()).
- G2/G3/G4 (CLI): `pic members`, `pic files`, `pic queues`, read-only
  `pic kv` (+ new kv_api.rs); `pic deploy --timeout/--memory/--kind/
  --sandbox`; first-class `pic triggers create-{docs,files,pubsub,queue,
  email}` wrappers. All new client path segments percent-encoded via seg().

H1 regression fix (found in re-review)
- The S6 change also runs in `compile_routes`, which compiles every stored
  route at boot and on each route CRUD. A single stored route the new
  validation rejects (creatable while the S6 gap existed) made the whole
  compile Err and aborted startup. `compile_routes` is now lenient: it
  skips an un-compilable row with a warning instead of bricking boot
  (route creation still validates separately). Migration 0044 sweeps
  pre-existing reserved-path routes on upgrade (WHERE mirrors
  check_reserved exactly). Added regression tests for both.

Verified: cargo fmt, clippy --all-targets --all-features -D warnings, the
schema_snapshot test, and the new S6/lenient-compile unit tests all pass;
boot-resilience and G1/G5 confirmed live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:01:04 +02:00
MechaCat02
a91b134285 fix(review): harden the E2E-gap fixes after security/regression review
Addresses findings from an independent review of the gap-closing commits:

- R1 (regression): add #[serde(default)] to HttpDispatchPayload.method so
  async-HTTP outbox rows enqueued before the field existed still decode
  after upgrade instead of dead-lettering on "missing field `method`".
  Adds a regression test for the missing-key path.
- method case: uppercase ctx.request.method at the orchestrator boundary
  so it honors its documented "uppercased" contract for extension verbs.
  Route matching is already case-insensitive, so matching is unaffected.
- CLI hardening: percent-encode free-form path segments (app slug, topic
  name, ids, secret name) in the reqwest client so a value containing
  `/ ? #` can't break out of its URL segment. Adds a `seg()` helper +
  unit test and applies it uniformly across all path-interpolating
  methods (new and pre-existing).
- S1 (docs): correct the users::email_available enumeration framing — it
  adds no new vector vs. create's uniqueness error, but is cheaper and
  unthrottled; there is no built-in throttle/CAPTCHA primitive, so the
  honest mitigation is a kv-based counter. Updated SDK doc-comments,
  trait docs, and stdlib-reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 09:59:24 +02:00
MechaCat02
04a24ea0b7 feat(cli): close E2E To-Do CLI gaps (B1, B2, F2, F1, F3)
A CLI-only walkthrough (E2E_TODO_REPORT.md) found two control-plane
operations with no `pic` command — every CLI-created app 404'd until it
claimed a domain, and external SSE feeds needed a raw topic-registration
call — plus several friction points. All admin APIs already existed; this
adds thin wrappers mirroring `routes`/`triggers`:

- B1: `pic apps domains {ls,add,rm}` over apps/{id}/domains.
- B2: `pic topics {ls,create,update,rm}` over apps/{id}/topics, and a
  triggers help note distinguishing a pubsub trigger from topic
  registration.
- F2: `pic users {ls,show,reset-password,revoke-sessions}` over the app
  end-user admin surface (read + the two admin actions; create/invitations
  deferred).
- F1: `apps create` and `deploy` now honor `--output json`, emitting the
  created object so scripts can capture the id.
- F3: non-interactive login via `--username` + `--password-stdin` (inline
  passwords still rejected, mirroring the `--token` rule).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:43:03 +02:00
MechaCat02
b0f7b72dd6 fix(review): close 5 follow-up gaps from Stage 6 audit re-review
- F-T-003 actually closed: clients/typescript/src/subscribe.ts now
  bounds the 401-refresh loop at 3 consecutive refusals, surfaces an
  onError describing the loop, and resets on any successful stream
  open. New test covers the cap. The Stage 2 dashboard fix only
  addressed adminRequest in dashboard/src/lib/api.ts; the originally-
  cited TS client file was untouched.

- users/invitations subtab dropped the redundant api.apps.get fetch
  the other 5 subtabs already shed in Stage 6.

- Queue visibility-timeout validator pulled out as
  validate_queue_visibility_timeout, with two thresholds: hard-reject
  below MIN_QUEUE_VISIBILITY_TIMEOUT_SECS (30s — catches typos), warn-
  log between MIN and SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS (300s) so
  operators see when their visibility is below the dispatcher's
  per-message executor budget. Stage 6 only had the hard floor; the
  reviewer caught that a 60s handler still races a 30s visibility
  even after the floor. Four new unit tests cover none/above-safe/
  between/below-min.

- pic dead-letters count subcommand: cheap headless probe for
  unresolved DL totals, parallels the dashboard's badge query.

- pic dead-letters replay now has a happy-path integration test
  (replay_against_real_dl_row_succeeds): inserts a synthetic DL row
  directly via the rust-postgres sync driver (added as dev-dep),
  drives `pic dead-letters replay`, asserts the row is resolved with
  reason=replayed and count drops back to 0. Plus a count smoke test.

All 75 CLI integration tests + 16 TS client tests + 4 new
visibility-timeout unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 17:20:34 +02:00
MechaCat02
24490d5ddb feat(cli): add pic triggers + dead-letters + secrets subcommands
Closes the audit's High-severity CLI coverage gap, raising the count
from ~25 to ~40+ subcommands and bringing the integration test count
from 63 to 73.

- pic triggers {ls, rm, create-kv, create-cron, create-dead-letter,
  create-from-json}: three per-kind wrappers cover the most common
  trigger shapes; the generic create-from-json is the escape hatch
  for docs/files/pubsub/email/queue and any future advanced retry
  knobs — body JSON inline, via @<file>, or "-" for stdin.

- pic dead-letters {ls, show, replay, resolve}: full operator
  workflow for the dispatcher's dead_letters rows, including the
  --unresolved filter and the per-row replay + manual-resolve actions.

- pic secrets {ls, set, rm}: list names + updated_at, set values
  via stdin (the only secure channel — inline values would leak
  into shell history), and delete by name. --json on set treats
  stdin as raw JSON for non-string values.

Ten new integration tests follow the established #[ignore] pattern,
gated on DATABASE_URL. All 73 ignored tests pass against the local
dev stack.

The `pic admin reset-password` server-binary subcommand exists on
the picloud binary side already; the audit's "surface it in pic
--help" note is a one-line addition deferred to Stage 6 with the
other small UX touches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 21:41:57 +02:00
MechaCat02
59645e8159 feat(cli): add pic routes + pic admins subcommands
Closes the audit's Critical "operator can't deploy a serverless endpoint
end-to-end from the CLI" finding. Two new subcommand families bring the
CLI coverage from 14 commands to ~25:

- pic routes {ls, create, rm, check, match}: full route CRUD plus the
  dry-run conflict checker and the URL matcher already exposed by the
  dashboard. The create form accepts --path-kind, --host-kind, and
  --dispatch (sync|async) so async routes can finally be created
  headlessly. The ls output adds a dispatch column.

- pic admins {ls, create, show, set, rm}: per-instance admin user
  management. Create reads passwords from stdin via --password - so
  shell history never sees the cleartext. Set is a JSON-Merge-Patch
  shape that lets operators deactivate accounts or rotate roles
  without touching the dashboard.

Six new ignored integration tests follow the established #[ignore]
pattern (DATABASE_URL gates them). They cover the happy-path round
trip, the async-dispatch persistence path, the password-required
error path, and the capability gate (a Member sees HTTP 403). All
pass against the local dev stack with PICLOUD_DEV_MODE=true +
PICLOUD_DEV_INSECURE_KEY=i-understand-this-is-insecure.

The `pic members` and `pic domains` subcommands the audit mentioned
are deferred — the apps_api shape may shift in v1.2 with per-app
roles and rebuilding the CLI surface twice would be churn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 21:35:09 +02:00
MechaCat02
f147665157 feat(cli): real auth, delete commands, api-keys, JSON output, env override
Address the review findings on the CLI surface:

* `pic login` now prompts for username + password and POSTs to
  `/api/v1/admin/auth/login`. `--token` (and `PICLOUD_TOKEN`) still
  works for paste-a-bearer flows (CI, long-lived API keys). Falls
  back to a plain stdin read when no controlling tty is attached.
* `pic logout` revokes the session server-side and deletes the local
  credentials file. Idempotent.
* `PICLOUD_URL` / `PICLOUD_TOKEN` now override the on-disk credentials
  file for every command via `config::resolve`, not just for
  `pic login`. Matches gcloud/aws/kubectl semantics.
* New commands: `pic apps delete [--force]`, `pic apps show`,
  `pic scripts delete`, `pic api-keys mint|ls|rm`, plus top-level
  `pic invoke` / `pic deploy` shortcuts.
* `pic scripts ls` (no `--app`) now issues a single
  `GET /admin/scripts` + one `apps_list` in parallel and joins
  client-side, instead of walking N+1 per-app calls that aborted on
  the first 404 — the bug the test suite was retrying around.
* Global `--output tsv|json` flag wired through every list/show and
  through `whoami` / `logs`. TSV stays pipe-friendly; JSON is a real
  array of objects (or a flat object for single-row views).
* `whoami` and `logs` now emit labeled output instead of headerless
  tab lines, consistent with the existing `apps ls` / `scripts ls`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:33:44 +02:00
MechaCat02
7b50047730 feat(cli): add pic command-line client (login, apps, scripts, logs)
Adds a new workspace crate `picloud-cli` shipping a `pic` binary that
drives the edit-deploy-invoke-tail-logs loop against PiCloud's admin
and execute HTTP surface. Eight subcommands cover the minimum a
developer needs to never open the dashboard:

  pic login                    (paste URL + bearer token, validates via /auth/me)
  pic whoami                   (re-validates and prints principal)
  pic apps ls | create
  pic scripts ls | deploy | invoke
  pic logs <id>

Credentials persist as TOML under the platform config dir (resolved
via `directories`); on POSIX the file is forced to mode 0600.
PICLOUD_URL + PICLOUD_TOKEN env vars short-circuit interactive prompts
for CI and integration tests.

The CLI redeclares minimal request/response structs in `client.rs`
rather than depending on `manager-core` — keeps the blast radius
contained without touching the existing crate boundaries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:53:49 +02:00