Audit #6 and #8, applied to the docs store — the same two bugs KV had, in
the same two places.
Per-app docs (#6): create/update/delete wrote the row, then emitted
best-effort. An outbox failure left a committed doc whose trigger never
fired. They now go through `atomic_write::DocsWriter`, whose Postgres impl
writes and fans out on one connection in one transaction.
Group docs (#8): the service read `count_rows`/`projected_total_bytes` on
pooled connections and wrote on another, so concurrent creators each saw the
same pre-write count and together overshot the ceiling.
`PostgresGroupDocsWriter` takes a per-group advisory lock — on its OWN
`docs`-namespaced key, so it serializes against other docs writers to that
group but not against KV writers, which draw on a separate ceiling.
The bespoke `check_total_bytes` (with its own copy of the upper-bound fast
path) is gone; group docs now shares `group_quota::check_group_write` with
group KV, so the row ceiling, the projected-bytes ceiling, and the fast path
that skips the O(n) SUM scan have exactly one implementation between them.
tests/atomic_write.rs gains the docs row-quota race (16 concurrent creators
against a ceiling of 4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit #8. `GroupKvServiceImpl` read the group's usage (`count_rows` /
`projected_total_bytes`) on one pooled connection and wrote on another. N
concurrent writers therefore each observed the same pre-write total, each
concluded it had room, and together sailed past the ceiling. The code
called this out as "best-effort ... quotas are safety rails, not exact
accounting" — but the overshoot is not small. With a ceiling of 5 and 20
concurrent writers, 19 rows land.
Route group-KV mutations through `atomic_write::GroupKvWriter`, whose
Postgres impl runs the quota reads, the row write, and the shared-trigger
fan-out on ONE connection in ONE transaction — and, crucially, takes a
per-group `pg_advisory_xact_lock` first.
The lock is the fix, not the transaction. Putting the check inside the
writing tx is necessary but NOT sufficient: under READ COMMITTED each
transaction's `COUNT(*)`/`SUM(...)` still sees a snapshot without the other
writers' uncommitted rows, so they all still pass. Serializing the
check-then-write per group is what makes a writer see its predecessor's row.
The lock key is namespaced per kind (kv/docs/…) so writes drawing on
different ceilings don't contend. A delete takes no lock — it only frees
space.
The quota POLICY (row ceiling, projected-bytes ceiling, and the
upper-bound fast path that skips the O(n) SUM scan for a group nowhere near
its cap) moves to one place, `group_quota::check_group_write`, over a small
`GroupUsage` trait. Both backends — the transactional one reading through
`&mut *tx`, and the in-memory one the unit tests use — share it, so there is
exactly one copy of the decision.
`set_if` gains a correctness nicety on the way: the row ceiling now keys on
whether the write ADDS a row, so a compare-and-swap against an absent key
with a `Some` precondition (which cannot swap, and so consumes nothing) is
no longer charged for one.
tests/atomic_write.rs pins both ceilings against 20/16 concurrent writers.
Both tests fail without the lock (19 rows stored against a ceiling of 5).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit #6. `KvServiceImpl` wrote the row, waited for it to commit, then
asked the emitter to resolve matching triggers and insert outbox rows —
a second transaction on a second connection. If that second step failed,
the row was permanently in the store with its trigger having never
fired: invisible to the caller (the write "succeeded"), unrecoverable by
any retry, and only ever logged. The code said as much:
// Audit finding (Medium): this is non-transactional with the data
// write — the row above has already committed by the time emit()
// runs, so an emit failure means triggers silently don't fire.
Introduce `atomic_write::KvWriter` — the mutating half of the service (the
write AND the fan-out it produces), so the two can share a transaction:
* `PostgresKvWriter` opens a tx, writes via `kv_repo::*_on(&mut *tx, …)`,
runs the fan-out on the SAME connection via `emit_on(&mut *tx, …)`, and
commits. An emit failure now rolls the write back and surfaces to the
script, which is the honest outcome — the caller learns the write did
not happen instead of silently getting a store that disagrees with its
triggers.
* `BestEffortKvWriter` keeps the old write-then-log-on-emit-failure
semantics for the in-memory unit tests (no Postgres).
Both sit behind one trait, so the service body has a single code path and
the choice is a constructor detail (`with_atomic_writes(pool)` in the host).
Pool-deadlock rule, documented on the module: everything inside the tx runs
on the tx's connection. A writer that held a tx and then reached for a
second pooled connection could starve — the pool is sized to the execution
concurrency cap, so N executions each wanting 2 connections deadlock. The
fan-out takes `&mut *tx` and never touches a repo.
`tests/atomic_write.rs` pins it by injecting an outbox failure (a Postgres
BEFORE-INSERT trigger scoped to the test's own app_id, so parallel tests are
unaffected): the set errors and the key is NOT in the store; likewise a
delete rolls back rather than dropping a key nothing downstream hears about.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`OutboxEventEmitter` resolved matching triggers and inserted outbox rows
through `Arc<dyn TriggerRepo>` / `Arc<dyn OutboxRepo>`, i.e. against the
pool — each query on whatever connection it happened to get. That makes a
transactional outbox impossible: the data write and the outbox rows can
never share a transaction, so a crash (or an outbox error) between them
silently loses the trigger event.
Take the emitter down to a connection instead of a pool:
* `outbox_repo::insert_on(exec, row)` and `trigger_repo::list_matching_on`
/ `list_matching_shared_on(exec, ...)` are generic over `PgExecutor`, so
the same SQL serves a pooled connection or a `&mut *tx`. The repo trait
methods delegate to them — the SQL keeps exactly one home.
* `emit_on` / `emit_shared_on` take a `&mut PgConnection` and run the whole
fan-out on it. The `ServiceEventEmitter` impl acquires one pooled
connection and calls them, so behaviour is unchanged today; a caller
holding a transaction can now pass `&mut *tx` and have the outbox rows
commit with the write.
* `OutboxEventEmitter::new` takes the `PgPool` directly (it was only ever
constructed once, in the host wiring).
Also collapses the three copy-pasted per-app match queries (kv/docs/files
differ only in the `kind` discriminator and detail table) and the three
`emit_*` bodies into one `plan()` + one match fn, so the suppression
anti-join, the chain walk, and the empty-ops-means-any-op semantic each
exist once rather than three times.
No behaviour change — pure refactor. It is the seam the transactional
write lands on next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
The v1.2 Workflows durable DAG engine gains its runtime. A dedicated
background worker (`workflow_orchestrator.rs`, mirroring `cron_scheduler`,
NOT folded into the dispatcher) advances every in-flight run step-by-step,
durably, surviving restarts.
Per tick, two phases:
A. Claim + execute — up to a small batch of `ready`, due steps are claimed
with the same `FOR UPDATE SKIP LOCKED` competing-consumer lease the queue
uses (`claim_ready_step`), one execution-gate permit per step acquired
BEFORE the claim so the shared gate bounds real parallelism. Each step
resolves its function by name in the run's app scope (never a
script-passed arg — the isolation boundary), builds an `ExecRequest`, and
runs through the injected `ExecutorClient`. Claimed steps run concurrently.
B. Advance — the outcome is written and the DAG advanced in one token-gated
transaction (`complete_step_and_advance`): pending steps whose deps are
satisfied flip to `ready`, and the run's terminal status is recomputed.
Fan-in falls out (a join waits until its last dep flips it); a stale worker
matches zero rows and writes nothing.
The graph-advance decision is a pure, DB-free function (`compute_advance`) —
promotions + terminal run status, folding `on_error` fail-vs-continue and
skipped/failed dependency satisfaction — so it is unit-tested in isolation.
Retry uses the step's own policy via `compute_backoff`; a second, slower
cadence reclaims steps leased by a crashed worker (`reclaim_stale_steps`) — the
durability safety net. Steps run with no principal (like invoke_async), and log
under the new `ExecutionSource::Workflow` so `pic logs` surfaces them.
M2 executes function steps only; `when` + input templating land in M3, nested
sub-workflows in M4. Seams present (`StepTarget`, `run_input`, `workflow_depth`).
- migration 0072: widen the `execution_logs.source` CHECK with `workflow`
- shared: `ExecutionSource::Workflow`, `StepStatus::is_terminal`
- workflow_repo: run/step state — `start_run`, `claim_ready_step`,
`complete_step_and_advance`, `reclaim_stale_steps`, `get_run`,
`list_run_steps`, `compute_advance` (+ 7 pure unit tests)
- workflow_orchestrator: the worker + config (`PICLOUD_WORKFLOW_*` knobs),
spawned in `picloud/src/lib.rs` beside the dispatcher/cron scheduler
- tests: 8 DB-gated integration tests (linear, parallel fan-out/fan-in, retry,
on_error fail/continue, double-complete idempotency, stale-lease reclaim,
end-to-end tick with a fake executor)
Verified: cargo fmt, clippy -D warnings clean, 448 manager-core lib tests,
8 workflow_orchestrator DB tests, schema snapshot reblessed, M1 workflow CLI
journeys still pass (binary boots with the orchestrator wired in).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remediate the HIGH and security-relevant findings from the 2026-07-11 audit.
H1 — the per-env approval gate is now server-authoritative. The governing
project is resolved from the target node's nearest-claimed ancestor
(`governing_env_policy`/`_tree` + `governing_project_id` +
`ProjectRepository::get_environments_by_id`), independent of the client-supplied
`[project]`. Omitting or spoofing the project block can no longer skip a gate the
owning project established; a to-create group resolves its declared parent's
chain so a fresh subtree node inherits the gate. Fails closed on any read error.
H2 — the API-key prefix slice (`&rest[..8]`) is now the boundary-safe
`rest.get(..8)`, so an attacker-supplied multibyte bearer can't panic the request
task (unauthenticated per-request DoS). Regression test added.
C1 — admin sessions gain an absolute lifetime cap (migration 0070,
`PICLOUD_SESSION_ABSOLUTE_TTL_HOURS`, default 30d): `lookup` filters it, `touch`
clamps the sliding bump to it, so a continuously-used or stolen-but-warm token
self-expires. Mirrors the data-plane app-user cap.
C2 — `Cache-Control: no-store` on the login and API-key-mint responses (the two
that return a raw credential), so a proxy/CDN/browser cache can't retain it.
B8 — file downloads are header-safe: `sanitize_stored_filename` guarantees a
valid `HeaderValue` (no panic on a control-char name) and BOTH the per-app and
group download paths now set attachment + `X-Content-Type-Options: nosniff` +
a restrictive CSP, closing a group-path stored-XSS gap.
Also folds in the server-side plan-warning plumbing (`plan_warnings`,
`PlanResult::warnings`) and the `app_only_reject` message helper that the CLI
plan-preview change builds on, plus operator security notes (reads-open shared-
topic SSE; the `--env` label is advisory, not a boundary).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shared TOPICS fanned out only to in-cluster trigger handlers; external clients
could not subscribe (per-app topics already can). Add SSE for shared topics.
- RealtimeBroadcaster gains a parallel (group_id, topic) channel map:
subscribe_group / publish_group / drop_group_topic (default no-ops so
NoopRealtimeBroadcaster + test doubles are untouched). InProcessBroadcaster
implements them with a second map; GC + channel_count span both.
- Route GET /realtime/shared/topics/{topic}: Host->app dispatch (as the per-app
route), then RealtimeAuthority::authorize_subscribe_shared resolves the OWNING
GROUP from the app's chain (kind=topic, root segment). Reads-open model — the
resolution IS the authorization, consistent with in-script shared reads; a
foreign-subtree app never resolves (404, the isolation boundary). No principal
machinery needed.
- GroupPubsubServiceImpl::with_realtime bridges a shared-topic publish to the
owning-group channel (best-effort) after the durable trigger fan-out.
- Host wires the broadcaster into the group pubsub service + the collection
resolver into the authority.
Auth-model note: chose reads-open (subtree app's Host is the grant) over
"authenticated principal + GroupKvRead" — it's both simpler and faithful to how
shared-collection reads already work. Pinned by realtime broadcaster group-map
tests, realtime_api shared-route tests (404 + stream), and
group_pubsub_service::publish_bridges_to_the_group_broadcaster. No migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An exhausted SHARED durable-queue message was `drop_exhausted()`-ed with a
warning — silent data loss. Per-app queues persist to `dead_letters` (0010);
add the symmetric group store so an exhausted shared-queue message is preserved
and operator-visible.
- Migration 0068: `group_dead_letters`, keyed by (group_id, collection),
CASCADE on the group (an app delete leaves the data — it belongs to the
group, not the consuming app).
- `GroupQueueRepo::dead_letter` (replaces `drop_exhausted`): one tx that INSERTs
the dead-letter + DELETEs the live message, filtered by claim_token so a lost
lease can't dead-letter a re-claimed message (mirrors queue_repo::dead_letter).
- Dispatcher `q_terminal` shared arm now dead-letters instead of dropping. It
returns None (not the dl id) so the per-app `fan_out_dead_letter` is SKIPPED —
firing the consuming app's per-app handlers on a shared message (competing
consumers → nondeterministic app) would be wrong. Fan-out to a *shared*
dead_letter trigger is deferred (needs a new trigger kind).
- Read side: `GroupDeadLetterRepo::list_for_group` backs a new read-only
operator endpoint GET /api/v1/admin/groups/{id}/dead-letters (GroupKvRead,
mirrors the M4 group-blobs surface). `pic dead-letters ls --group` deferred
(optional; the HTTP endpoint is the operator surface).
Pinned by group_queue::dead_letter_moves_an_exhausted_message_to_the_group_store
(store move + claim-token-mismatch guard + operator read). Schema golden
reblessed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Read side of §7 ownership, ahead of the claim logic.
- project_repo: `ProjectRepository` trait + `PostgresProjectRepository`
(list / get_by_id / get_by_slug). Writes are NOT here — a claim registers
its project via an in-tx upsert (next commit).
- group_repo: `list_with_owner()` LEFT-JOINs projects for each group's owner
slug (backs `pic groups ls`); columns qualified since id/slug/name exist on
both tables.
- ApplyService gains a `projects` handle; constructed in the picloud binary.
- tests/projects_repo: round-trip pinning list_with_owner (claimed→slug,
unclaimed→None) and that ancestors() surfaces owner_project nearest-first —
the fold input the claim path walks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First commit of §7 multi-repo ownership. A 'project' is a repo-root that
declaratively manages a slice of the shared group tree; each group node is
owned by exactly one project (single-owner-per-node). This lands the registry
+ shared types and wires the inert 0047 `owner_project` seam — no behavior
change yet (claim logic is the next commit).
- migration 0066: `projects` table (UUID pk, unique slug, name, created_by →
admin_users ON DELETE SET NULL); `groups.owner_project` gains its FK →
projects(id) ON DELETE SET NULL (un-claim, never cascade-destroy a tree) +
an index.
- shared: `ProjectId` (id_type! macro) + `Project` struct; `Group` gains
`owner_project: Option<ProjectId>`.
- group_repo: GROUP_COLS + the ancestors recursive-CTE term now carry
`owner_project`, so `ancestors()` surfaces ownership for the nearest-claimed
fold; GroupRow + From updated.
- schema snapshot re-blessed; /version schema assertion 65 → 66.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The consumption side of shared durable queues:
- Thread `shared` through BundleTrigger::Queue + QueueTriggerSpec + the
trigger identity; validate_bundle_for requires a shared queue on a group
to name a declared kind='queue' collection (a shared queue on an app is
rejected by the existing app-owner shared guard).
- materialize: a shared queue template materializes a consumer per
descendant (the M5 one-consumer-slot skip is bypassed for shared —
competing consumers are intended; each descendant gets one copy).
- dispatcher: ActiveQueueConsumer gains shared_group (from the materialized
copy's source template via LEFT JOIN); dispatch_one_queue +
handle_queue_failure route claim/ack/nack/terminal to the group store
when shared_group is Some, via q_claim/q_ack/q_nack/q_terminal helpers. A
group claim is normalized to a ClaimedMessage under the consuming app so
the handler path is unchanged; the reclaim task also drains the group
store. Exhausted shared messages are dropped (no group dead-letter store
yet — documented deferral).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The runtime for shared topics:
- pubsub_repo: fan_out_publish gains `AND t.shared = FALSE` (a shared
trigger never fires on a per-app publish); new fan_out_shared_publish
matches `shared = true` pubsub triggers on the resolved owning group,
each delivery stamped with the writer app_id (M2 model).
- GroupPubsubService trait (shared) + GroupPubsubServiceImpl: resolve the
owning group (kind='topic') from cx.app_id's chain, require editor+
(GroupPubsubPublish, fails closed for anon), size-cap, fan out.
- SDK: `pubsub::shared_topic("name")` -> GroupTopicHandle with
`.publish(subtopic, msg)` / `.publish(msg)`; wired through Services +
the picloud binary (reuses the one PubsubRepo).
- authz: Capability::GroupPubsubPublish(GroupId), editor+ / script:write.
The owning-group chain walk is the isolation boundary; a sibling-subtree
app never resolves the topic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The #[ignore]-gated version_includes_public_base_url pinned schema=42 but
migrations accreted to 63 across the v1.2 template track (…0060–0063), so
it failed under `--include-ignored`. Re-pin to current reality per the
test's own intent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
materialize::rematerialize_stateful_templates reconciles a group cron/queue
template into one app-owned copy per descendant (materialized_from = template),
via the all-apps app_chain CTE ⋈ group-owned stateful templates. A precise
create/delete diff preserves cron last_fired_at on unchanged copies; a queue
copy is skipped-with-warning when the app already fills that queue's consumer
slot (the one-consumer invariant). Called full-live at the route-rebuild
chokepoints: apply (single + tree), app create/delete (apps_api), group
reparent (groups_api) — AppsState/GroupsState gain a pool. Pinned by
tests/stateful_templates.rs (per-app copy, idempotent, new-app materializes,
reparent de-materializes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
group_blobs_api mirrors the per-app kv_api/files_api for groups: GET
/groups/{id}/kv[/{c}/{k}], /docs[/{c}/{id}], /files[/{c}/{id}] — list +
fetch/download a group's §11.6 shared KV/docs/files. Authz GroupKvRead/
GroupDocsRead/GroupFilesRead (viewer tier, reads-open trust model). Read-only
by design (writes stay script-only). The host hoists the three group repos so
the SDK services and this router share one instance.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ServiceEventEmitter gains emit_shared(cx, owning_group, event) (default no-op);
OutboxEventEmitter implements it — matches shared triggers on the owning group
and writes outbox rows stamped with the WRITER app_id (dispatch runs under the
writer). GroupKv/Docs/FilesServiceImpl gain an events field (Noop default +
with_events builder) and emit on set/create/update/delete; the host wires the
outbox emitter via with_events. Closes the "group trigger has no app to watch"
gap. Authoring + validation land in M2.4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The runtime half. Group route TEMPLATES now expand into every descendant
app's in-memory match slice, live, with no per-app materialization.
- `compile_effective_routes` consumes the `list_effective` expansion and
applies NEAREST-OWNER-WINS shadowing: for one app, identical binding
tuples (method+host+path) owned at multiple chain levels collapse to the
nearest (an app's own route shadows an ancestor-group template); a nearer
group beats a farther one. Non-identical bindings coexist and the
existing matcher precedence resolves each request. `compile_route` now
takes the effective app_id, so the matcher + dispatch are unchanged.
- `rebuild_route_table` is the single refresh chokepoint (list_effective →
compile_effective_routes → replace_all). Route CRUD, the apply reconcile,
and startup all route through it; the apply guards drop the "a group node
touches no routes" assumption (a group apply may now create templates).
- FULL-LIVE INVALIDATION: app create/delete (apps_api) and group reparent
(groups_api) rebuild the snapshot, so inherited routes take effect the
instant the tree changes — matching the live model of triggers/vars.
Best-effort, mirroring apply: a failed rebuild self-heals on the next
write or restart, never failing the committed mutation.
The isolation boundary is the chain expansion: a template lands only in the
slices of apps whose ancestor chain contains the owning group. Pinned by a
deterministic live-DB integration test (descendant inherits, sibling
subtree does not, app shadows by identical binding, non-identical coexists).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The runtime for shared group collections. A script reaches a shared store via
the explicit kv::shared("name") handle (distinct GroupKvHandle Rhai type, so
private-vs-shared scope is a deliberate choice). The service resolves the
owning group from cx.app_id's ancestor chain — the script never names a group,
and that walk is the isolation boundary (a foreign app's chain never contains
the owning group → CollectionNotShared).
- shared: GroupKvService trait + GroupKvError + NoopGroupKvService; threaded
into the Services bundle as a field defaulted to noop, opted into via a new
with_group_kv() (keeps the ~14 Services::new call sites unchanged).
- manager-core: GroupKvServiceImpl with the reads-open/writes-authed split —
reads use script_gate (anonymous public scripts skip), writes use the new
script_gate_require_principal (anonymous fails closed). Owner resolution
behind a GroupCollectionResolver trait (Postgres impl + injectable fake);
unit tests cover off-chain CollectionNotShared, anonymous-read-OK/
anonymous-write-Forbidden, authed-no-role-Forbidden.
- executor-core: GroupKvHandle + kv::shared constructor + get/set/has/delete/
list (Rhai dispatches by receiver type, reusing the block_on bridge).
- picloud: wire PostgresGroupKvRepo + resolver + GroupKvServiceImpl.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lift the Phase-4-lite restrictions now that import resolution is lexical:
- `create_group_script` (group_scripts_api): allow `kind = module` and
group scripts with `import`s; record the import edges. Module-shape and
sandbox-ceiling validation stay.
- The declarative apply path already reconciles group modules generically
(`BundleScript.kind` + `owner.script_owner()` flow through
`reconcile_node_tx`); no change needed beyond stale-comment fixes.
Adds the §5.5 dangling-import plan check (single-node plan/apply):
`check_imports_resolve` rejects an `import "<m>"` that resolves to neither a
bundle-local module nor a module reachable lexically up the node's chain —
caught at plan time instead of as a runtime 404. Roots resolution at the
node's owner (an app node reaches ancestor group modules; a group node walks
its own ancestry). ApplyService gains an injected `ModuleSource`. The tree
path opts out (a node may import a module created by another node in the same
uncommitted tx); the runtime check is the backstop there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 5 C1. Generalize the reconcile engine from app-only to an `ApplyOwner
{ App | Group }`, so a GROUP's own config + scripts can be declaratively
reconciled — the foundation for the tree-spanning project tool.
A group node is a subset of an app node (scripts + vars only; no routes /
triggers / secrets-values), so the diff, script/route/trigger/var CRUD, prune,
idempotency, and the Phase-4 inherited-target resolution are all reused
unchanged. Only the owner-varying bits switch on `ApplyOwner`:
* `load_current` — `list_for_group` + `VarOwner::Group` for a group (empty
routes/triggers/secrets); `list_for_app` for an app, exactly as before.
* script create owner (`NewScript{app_id|group_id}`), var writes (new
`set_group_var_tx`/`delete_group_var_tx` mirroring the app variants at
scope `*`), and the advisory lock (owner-tagged).
* `validate_bundle_for` rejects routes/triggers on a group bundle (422);
route-host validation, inherited-target resolution, the post-commit route
refresh, and the unreachable-endpoint warning are app-only.
`apply`/`plan` are unchanged thin wrappers over the new `apply_owner`/
`plan_owner`. New endpoints `POST /groups/{id}/{plan,apply}` gated by
`GroupScripts{Read,Write}` / `GroupVarsWrite`. `ApplyService` gains a `groups`
repo (the tree apply in C3 needs it too).
Live-validated: group plan → apply (group-owned script + var) → idempotent
re-plan noop → routes rejected 422 → DB confirms group ownership. App apply
unchanged: 391 manager-core unit tests + the apply/prune/staleness/overlay
journeys all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
ExecError::Runtime strings (filesystem paths, "blocked by SSRF policy:
link-local" cloud-metadata reconnaissance, pool-exhaustion timing,
panic fragments, and attacker-thrown strings) were returned verbatim in
the public /api/v1/execute + user-route 502 bodies. This handler only
serves anonymous data-plane callers (the authenticated script-test path
is in manager-core and keeps verbose errors), so log the full text
server-side under a correlation id and return a stable generic message
plus the id for operator grep.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The PrincipalCache (token-hash -> resolved Principal, 60s TTL) had no
eviction hook, so deactivation / password change / API-key revocation
flipped the DB rows but the cache kept serving the stale principal for
up to the TTL window. Closes the audit's PrincipalCache revocation-lag
Medium and greens authz::deactivating_user_revokes_their_api_keys.
- PrincipalCache::evict_user(user_id) + evict_token(hash).
- One shared cache Arc threaded into AuthState / AdminsState /
ApiKeysState (a per-state cache would let the middleware's own copy
keep authenticating a revoked principal).
- Evict on: deactivation, password change (both evict_user), logout
(evict_token, precise), single API-key delete (evict_user).
- H-E1 note: DB-write invalidation stays best-effort by design (a blip
must not undo the rotation); the cache evict is what makes it take
effect on the next request.
- Renamed bearer_and_cookie_produce_same_principal ->
session_token_and_api_key_produce_same_principal (cookie auth was
removed in C-1; the test never tested cookies).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Postgres-backed integration tests surfaced two assertion fixes while
verifying the audit branch:
* email_inbound::create_without_inbound_secret_is_rejected (added in the
H-B2 commit) asserted 400; TriggersApiError::Invalid maps to 422
(UNPROCESSABLE_ENTITY). Corrected.
* api::version_includes_public_base_url had two rotted assertions —
`schema` pinned at 6 (it's migrations::latest_version(), which had
climbed to 41 and is now 42 after 0042_secrets_envelope_version.sql)
and `sdk` pinned at "1.1" (the crate is at "1.10"). The test is
#[ignore]-gated so the rot went unnoticed in normal runs. Both pinned
to current reality. These were pre-existing failures, not caused by
the security changes.
Verified against a throwaway Postgres: schema_snapshot, api (53),
authz (28 of 29 — see below), dispatcher_e2e, email_inbound, invoke_e2e,
queue_e2e all green.
Known pre-existing failure (NOT fixed here, out of Tier 1+2 scope):
authz::deactivating_user_revokes_their_api_keys fails identically on the
pre-branch merge-base. It's the PrincipalCache revocation-lag Medium the
audit flagged as unfixed (resolve_principal caches by token-hash with no
eviction on deactivation; lag bounded by the 60s TTL). Deferred to a
Tier 3/4 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two gaps:
1. /api/v1/admin/apps/{id}/triggers/email accepted `inbound_secret: null`
and treated it as "this trigger is unsigned". The runtime then
skipped HMAC verification entirely. URL discovery (logs, ops
dashboards, bruteforce on the 122-bit TriggerId space) then fan-out-
amplified into the outbox for free — anonymous DoS via the
dispatcher.
2. Even signed triggers had no per-trigger rate limit on signature-
verify failures, so an attacker who knew the URL could pump unsigned
POSTs to force Argon2-equivalent work (HMAC verify + nonce-dedup +
stored-secret decrypt) at line rate.
Fix:
* triggers_api::create_email_trigger now requires a non-empty
inbound_secret. 400 on missing / empty / whitespace-only.
* email_inbound_api::receive_inbound_email returns 401 immediately when
the resolved target has no inbound_secret_encrypted column (pre-fix
rows). The previous `if let Some(ct), Some(nonce)` branch is gone.
* New `BadSignatureLimiter`: per-`(app_id, trigger_id)` sliding-window
bucket (10 fails / 60 s). On lockout the receiver returns 429 with
Retry-After instead of 401. record_failure runs on both the
"no-secret" 401 path and any verify_signature failure.
Test fallout:
* email_inbound integration tests that relied on None secret: removed
the now-impossible `unsigned_trigger_accepts_without_signature` test,
replaced with `create_without_inbound_secret_is_rejected` covering
null / "" / " "; updated `malformed_body_is_422` and
`cross_app_path_is_404` to pass + sign with a secret.
* Two new unit tests pin the limiter behavior (burst lockout + per-
trigger independence).
Audit refs: security_audit/08_dos_resource.md#h-3,
security_audit/09_external_integrations.md#f-ext-m-001.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The login handler had no admission control before
`spawn_blocking(verify_password)`. On Pi-class hardware Argon2id is ~50-
100 ms per attempt, so a few dozen concurrent anonymous POSTs to
/auth/login park every blocking worker, wedging the entire admin API
and any other path that uses `spawn_blocking`.
Adds two cheap, in-process guards modeled on EmailRateLimiter:
* `LoginRateLimiter` — two sliding-window token buckets, one per
`(remote_ip, username)` (burst 5/60s) and one per `username`
(burst 10/15min). Per-(ip, user) defeats a single attacker pounding
one account; per-user defeats credential-stuffing distributed across
many IPs. Username is case-folded so case variants share a bucket.
Maps GC lazily when they cross a soft size cap.
* Per-process `tokio::sync::Semaphore` — caps concurrent Argon2 verifies
during login. Default 2 permits (Pi-class), overridable via
`PICLOUD_LOGIN_ARGON2_PARALLELISM`. Acquired AFTER the bucket check so
attackers can't queue.
Limit check fires before the DB credentials lookup, so even an unknown
username doesn't cost a query. Denied attempts return 429 + Retry-After.
Real client IP comes from the first X-Forwarded-For entry (Caddy is the
trusted single hop); falls back to "unknown" so the per-user bucket
still gates.
4 unit tests cover bucket burst exhaustion, per-user crossing IPs, case
folding, and per-user independence.
Audit ref: security_audit/08_dos_resource.md (H-2 / H-B1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes 4 dashboard hardening findings and 5 of the Lows from the audit.
Dashboard hardening:
- Subtabs no longer re-fetch the app via api.apps.get on every page
load. users/files/dead-letters drop the fetch outright (the variable
was set but never read); queues + queues/[name] now consume the
layout's AppContext via getContext for the page title. The layout's
reloadApp() owns the historical-slug redirect — subtab-local redirect
blocks are removed so there's no race.
- The global :global(details > summary::before) chevron is now scoped
to details.chevron. The script editor's "Advanced sandbox" details
and the inbound-email-shape help-text both opt in; the script
exec-list logs no longer inherit a spurious chevron.
- deriveTab now matches the path segment by anchored ===, so a future
/apps/<slug>/queues-archived route wouldn't activate the queues tab.
Lows cherry-pick:
- ExecError gains Serialize/Deserialize derives + a snake_case tag so
RemoteExecutorClient (cluster mode v1.3+) can round-trip the variant.
- triggers_api rejects queue triggers whose visibility_timeout_secs is
below the dispatcher's per-message executor budget; with no minimum
the reclaim task races the handler and the queue silently
double-delivers. Existing test using 5s updated to 30s.
- New migration 0040: execution_logs.script_id cascade switched from
ON DELETE CASCADE to ON DELETE SET NULL so deleting a script no
longer wipes the forensic history that motivated the delete.
- New migration 0041: dead_letters composite index on
(app_id, created_at DESC) so the "list all" dashboard view stops
falling back to seqscan + sort when unresolved=false.
- Schema snapshot re-blessed.
Deferred to v1.2: the ExecRequest principal serde(skip) marker
(documented in-place; the cluster-mode PR will introduce the wire-safe
snapshot at that point) and the `pic --help` mention of
`picloud admin reset-password` (one-line follow-up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the queue DL fan-out gap (audit Medium) and surfaces the
KV/docs/files non-transactional emit gap to operators.
- handle_queue_failure previously called queue.dead_letter to write the
DL row but never invoked fan_out_dead_letter. The comment claimed
"the outbox arm fires registered dead_letter handlers off the new row"
— but queue DL rows are written via a separate path that bypasses the
outbox entirely, so handlers filtered on source="queue" sat idle
forever. Refactor fan_out_dead_letter to take a DeadLetterFanOutCtx
struct so both the outbox arm and the queue arm can call it; the
queue arm constructs a TriggerEvent::Queue from the claimed message
and passes it through.
- New integration test queue_dead_letter_fans_out_to_dead_letter_handler
registers a dead_letter trigger filtered on "queue", forces queue
exhaustion, asserts the handler fires with the correctly-shaped event.
- KV/docs/files services committed the data write then ran events.emit
as a separate operation, logging-and-swallowing on failure. Bump the
six call sites from tracing::warn to tracing::error with an
event_emit_failure=true marker so operators can grep them. The full
single-tx repo refactor (extending ServiceEventEmitter with
emit_in_tx + tx-aware *_repo methods) is documented in kv_service.rs
as a v1.2 follow-up — it's a meaningful redesign that deserves its
own pass (pubsub_service::fan_out_publish is the reference shape).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes 4 High-severity audit findings:
- describe_event broken for HTTP-async payloads: was reading source/op
JSON fields that HttpDispatchPayload doesn't carry, producing empty
source on DL rows. from_wire("") then fell back to Kv on replay, the
dispatcher tried to resolve a non-existent trigger, and replay no-op'd
silently. Now branches on OutboxSourceKind: HTTP rows emit
"<method> <path>" + source="http"; Invoke rows emit "invoke_async" +
source="invoke"; TriggerEvent payloads keep top-level field extraction.
- HTTP outbound Authorization leaked across cross-origin redirects.
Manual redirect loop (Policy::none) reused header_map on every hop
and only scrubbed Content-Type for POST->GET. Now compares
url::Url::origin() across hops and strips Authorization,
Proxy-Authorization, Cookie when origin changes — matching reqwest's
default policy.
- F-S-010 inbound email HMAC had no replay protection. Signature was
computed over body only with no timestamp or nonce, so captured POSTs
were replayable indefinitely. Adds X-Picloud-Timestamp header bound
into the HMAC input (signed string is ts || "." || body), 300s
tolerance window, and a process-local LRU keyed on
(ts, sha256(body)) with 600s TTL to catch within-window replays.
Breaking change: webhook senders must include the timestamp header.
- expected_schema.txt re-blessed for migrations 0036-0039 (the previous
snapshot stopped at 0035 so CI would have gone red on first run).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cargo fmt regroups; three clippy fixes:
- F-S-006: hoist MAX_API_KEY_CANDIDATES to module scope to satisfy
clippy::items_after_statements.
- F-S-009: use map_or instead of map().unwrap_or() per
clippy::map_unwrap_or.
- F-P-012: replace `|c| c.encode()` closure with the method itself
per clippy::redundant_closure_for_method_calls.
No behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
invoke_service::resolve and enqueue_async performed no authz check —
no AppInvoke capability existed. Same-app isolation was preserved
(cross-app guards work), but within one app an anonymous public-HTTP
script could trigger any other script (e.g. an admin-only worker that
hits secrets/files/external HTTP). Worse: invoke_async runs the
callee with principal: None, so the callee could hold capabilities
the original public caller shouldn't.
- Add Capability::AppInvoke(AppId). app_id() / scope_for_capability
(script:write) / role_satisfies (editor+) are all updated.
- InvokeServiceImpl gains an optional `authz: Option<Arc<dyn AuthzRepo>>`
+ a `with_authz` builder. When set, resolve() runs script_gate on
AppInvoke before doing the cross-app id check.
- picloud/src/lib.rs wires it: `InvokeServiceImpl::new(...).with_authz(...)`.
- Anonymous callers (cx.principal == None) continue to skip the check
via script_gate, preserving the public-HTTP convention.
Existing 5 invoke_service unit tests still pass (the tests use the
authz-less constructor, so the gate is a no-op there).
AUDIT.md anchor: F-S-012.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A 70-line newtype lived in picloud/src/lib.rs wrapping
Arc<PostgresScriptRepository> and re-implementing every ScriptRepository
method as `self.0.method(...).await`. Hand-delegation invited silent
skew when the trait gained a method, and forced 8 call sites to know
about the wrapper.
Add a blanket
impl<T: ScriptRepository + ?Sized> ScriptRepository for Arc<T>
in manager-core::repo. The implementations forward via (**self).method(...)
so any owner of an Arc<dyn ScriptRepository> (or Arc<ConcreteImpl>) can
be passed where the trait is expected.
Migrate the 7 picloud-binary call sites:
Arc::new(PostgresScriptRepoHandle(script_repo.clone())) → script_repo.clone()
Delete the newtype + its hand-delegated impl. Leaves a 6-line comment
documenting why the boilerplate is gone.
AUDIT.md anchor: F-Q-011.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
attach_principal_if_present runs on every request that carries a
Bearer header, including data-plane paths that may not even need
authz. Each call paid three DB round-trips on the session path
(lookup + admin_users.get + touch) plus an Argon2 verify per
prefix-colliding candidate on the API-key path. A hot user with
multiple keys serialized every request behind N×Argon2.
Add a process-shared PrincipalCache keyed on hash_token(bearer) →
(Instant, Principal), TTL 60s. resolve_principal checks the cache
first; on miss falls through to the verify_api_key / verify_session
path and writes back on success.
- Lazy GC: when the cache exceeds 1024 entries, sweep expired before
inserting (kept simple — production sees few unique hot tokens).
- Cache wired through AuthState; constructed once in picloud/src/lib.rs.
- Skip-when-route-doesn't-need-auth scaffolding is deferred to a
follow-up — it requires touching the router shape, which is more
invasive than the 60s cache alone warrants.
AUDIT.md anchor: F-P-009 (cache; skip-path deferred).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
init_db hard-coded max_connections(10). The execution gate
(ExecutionGate, PICLOUD_MAX_CONCURRENT_EXECUTIONS default 32) lets 32
script executions run concurrently, each doing multiple sequential
DB calls — plus the dispatcher tick every 100ms, the cron tick, three
GC sweeps, and the auth middleware running per request all draw from
the same pool. With max=10 vs gate=32, pool starvation surfaces as
acquire_timeout errors and tail-latency spikes under load.
- DEFAULT_DB_MAX_CONNECTIONS = 32 (matches gate default).
- Env knob: PICLOUD_DB_MAX_CONNECTIONS overrides; invalid/zero → default.
- Documented in CLAUDE.md runtime config table.
AUDIT.md anchor: F-P-003.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files (per-file cap), secrets (64 KB default), and email (25 MB default)
already enforce limits; kv::set, docs::create/update, pubsub::publish_durable
and queue::enqueue accepted any JSON value straight to a JSONB column with
no size validation. An anonymous public-HTTP script could fill disk via
queue::enqueue or amplify a single publish into N outbox rows × payload bytes.
Adds four new error variants:
- KvError::ValueTooLarge { limit, actual }
- DocsError::ValueTooLarge { limit, actual }
- PubsubError::MessageTooLarge { limit, actual }
- QueueError::PayloadTooLarge { limit, actual }
Each stateful service grows a `max_value_bytes` field with:
- Conservative 256 KB default (DEFAULT_KV_MAX_VALUE_BYTES etc.).
- New `with_max_*` constructor preserving the old `new()` signature.
- Env-knob reader (`*_max_*_from_env()`) — mirrors SecretsConfig::from_env.
Validation runs at the entry point BEFORE authz so an anonymous DoS doesn't
pay a membership lookup per attempt.
Wired via env knobs:
- PICLOUD_KV_MAX_VALUE_BYTES
- PICLOUD_DOCS_MAX_VALUE_BYTES
- PICLOUD_PUBSUB_MAX_MESSAGE_BYTES
- PICLOUD_QUEUE_MAX_PAYLOAD_BYTES
Documented in CLAUDE.md runtime config table.
New unit test in queue_service verifying the cap fires before authz.
AUDIT.md anchor: F-S-001. Depends on F-Q-004 (Backend variant).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>