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>
The per-env approval policy was applier-supplied — a hand-crafted request
that omitted `project.environments` was ungated, and flipping a gate to
`confirm = false` in the same request un-gated it. Persist the policy
server-side and enforce against `persisted ∪ declared`.
- Migration 0067: `project_environments (project_id, env_name, confirm)`,
CASCADE on the project. Written declaratively (delete-then-insert) inside
`upsert_project_tx`, same tx as the project row + node claim.
- `ProjectRepository::get_environments_by_slug` (read side) +
`ApplyService::effective_env_policy` union the persisted policy with the
request's declared one (confirm if EITHER says so — monotonic).
- `env_gate_check` now evaluates the effective policy; the three handlers load
it before the gate. `plan.approvals_required` is the effective gated set, so
CI sees a persisted gate even when the manifest omits it.
- The union rule closes both bypasses: an omitted policy still trips a
persisted gate, and an ungating apply must itself pass the gate (the
declarative replace then takes effect next time) — TOCTOU-safe.
Pinned by env_approval::{persisted_policy_gates_a_request_that_omits_it,
flipping_a_gate_to_false_in_the_same_request_still_gates} +
projects_repo::get_environments_by_slug_returns_persisted_policy. Schema golden
reblessed.
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>
Widen the group_collections kind allow-list (migration 0064) + the
manifest CollectionKind enum + apply_service COLLECTION_KINDS to include
'topic' (D2, storeless publish namespace) and 'queue' (D3, group-keyed
durable queue — store lands in 0065). Foundation shared by both
milestones; routes through the existing owner-generic reconcile +
resolve_owning_group path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 0063: partial unique index on (app_id, materialized_from) + ON CONFLICT DO
NOTHING in the reconciler, so a materialized copy is idempotent under
concurrent reconciles (two parallel app-creates no longer duplicate a copy).
- stateful_templates journey: a group cron template + a descendant app → a
materialized cron copy in `pic triggers ls --app`; re-apply is a NoOp.
- docs (§4.5 + CLAUDE.md): cron+queue materialization implemented; group EMAIL
templates deferred (per-descendant inbound-secret reseal needs the master key
threaded through the hooks + a secret-ref schema) and cleanly rejected at
apply for now, alongside a `materialized` ls column.
Completes the v1.2 near-term batch (M1-M5, cron+queue); group email is the one
scoped follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0062 adds `materialized_from` on triggers (a managed app-owned copy links back
to its group template; CASCADE). The scheduler + queue-consumer queries gain
`AND t.app_id IS NOT NULL` so a group-owned stateful TEMPLATE is never
dispatched directly — only the per-descendant materialized app rows are.
validate_bundle_for + manifest parse now allow cron/queue on a group (email
stays rejected pending its per-app inbound-secret handling in M5.5). The
materialization reconcile that expands templates into app rows lands in M5.2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A group-owned trigger marked `shared = true` watches a §11.6 shared
collection instead of per-app collections — the namespace boundary that lets
a shared-collection write fire a trigger. Mirrors the sealed column; existing
rows default false (per-app, unchanged). Match-query split + emission land in
M2.2/M2.3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reshape the app-only suppression marker to a group/app polymorphic owner
(mirrors 0056/0057 + the 0051/0052 config markers): nullable group_id
(CASCADE), nullable app_id, exactly-one CHECK, per-owner partial unique
indexes. Lets a [group] decline a template it inherits from a higher
ancestor for its whole subtree; consumption filters land in M1.3/M1.4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A group template can be marked `sealed = true` so the per-app suppression
filters skip it — closing the advisory-by-default compliance footgun the
suppression review flagged. Only meaningful on a group-owned template;
existing rows default unsealed. Consumption gates land in M3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A group TRIGGER/ROUTE template inherits to every descendant app; until now
a descendant could shadow one but not decline it. This marker records that
an app opts OUT of a specific inherited template — coarse by REFERENCE (a
handler script name for triggers, a path for routes), not row id (template
ids churn on re-apply, references are stable so re-apply is a NoOp).
App-only (a group would just not declare the template) → app_id NOT NULL,
no polymorphic owner; pure app config → ON DELETE CASCADE. A target_kind
discriminator ('trigger'|'route') keeps it one table, one reconcile loop.
Schema blessed at 58 migrations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A group-owned route is a TEMPLATE, expanded into every descendant app's
in-memory RouteTable slice at rebuild (live, no materialization). The
schema reshape mirrors 0056_group_triggers exactly: a nullable group_id
FK→groups ON DELETE RESTRICT (a route template is a binding, not data),
app_id made nullable, an exactly-one owner CHECK, and the per-app unique
binding index split into per-owner partials. A group can't declare two
identical templates; an app declaring an identical binding is a
deliberate shadow resolved at rebuild (nearest-owner-wins), not a DB
conflict. Adds routes_group_id_idx for the expansion join.
Schema blessed at 57 migrations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reshape the triggers table to allow a GROUP owner, mirroring
0050_group_scripts exactly. A group-owned trigger is a TEMPLATE: never
dispatched directly, but unioned into every descendant app's match
queries via the ancestor-chain CTE (live, no per-app materialization).
- 0056_group_triggers.sql: add nullable group_id (FK→groups ON DELETE
RESTRICT), make app_id nullable, add the exactly-one owner CHECK, and
split the name-unique + dispatch-hot indexes into per-owner partials
(idx_triggers_group_kind_enabled serves the chain-union lookups).
- Detail tables unchanged (they hang off trigger_id).
Schema snapshot blessed (56 migrations); existing trigger tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the §11.6 shared-collection machinery to the filesystem-backed
`files` blob store (after KV/0053 and docs/0054).
- 0055_group_files.sql: widen the group_collections kind CHECK to admit
'files'; add a group-keyed `group_files` metadata table mirroring
`files` (0018), CASCADE on group delete.
- files_repo: generalize the four path/IO free functions
(shard_dir_at / final_path_at / write_atomic_at / read_verify_at) to
take an owner-relative dir instead of `app_id`, and make them (plus the
cursor helpers) pub(crate). The security-sensitive atomic-write +
checksum-on-read mechanics now have a single source shared by the app
and group repos. App behavior is unchanged (apps shard at `<app_id>/`).
- group_files_repo: FsGroupFilesRepo keyed by group_id, blobs at
`<root>/files/groups/<group_id>/<collection>/<id[0:2]>/<id>` — a
`groups/` infix disjoint from the per-app subtree, so the existing
recursive orphan sweeper covers it with zero change.
Schema snapshot blessed (55 migrations); app-files fs tests still green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Data layer for extending shared group collections from KV to docs. KV behavior
unchanged (callers pass kind="kv").
- 0054_group_docs.sql: widen the group_collections kind CHECK to ('kv','docs')
and add the group-keyed group_docs store (clone of docs/0013, keyed by
group_id, CASCADE on group delete) + its (group_id,collection) and data GIN
indexes.
- group_collection_repo: thread a `kind` parameter through list_for_owner,
resolve_owning_group, insert/delete_collection_tx; add list_all_for_owner ->
(name,kind) for the kind-aware diff (D4). Relocate the injectable
GroupCollectionResolver trait + Postgres impl here (now shared by kv+docs;
resolve takes kind) — was in group_kv_service.
- group_docs_repo: a near-clone of docs_repo keyed by group_id; find reuses the
generalized build_find_query.
- docs_repo: generalize build_find_query(table, owner_col, owner_id, …) — both
are compile-time literals (no injection); app docs passes ("docs","app_id"),
group docs ("group_docs","group_id"). row_to_doc/build_find_query are now
pub(crate) for reuse.
Schema snapshot re-blessed (55 migrations).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Data layer for shared cross-app group collections (KV-only MVP), nothing wired
yet. Two migrations + two repos, modeled on the extension_points (0051) marker
and the kv_entries (0007) store:
- 0052_group_collections.sql: owner-polymorphic marker table declaring a
collection name group-shared, with a `kind` discriminator (CHECK 'kv' for
now; generalizes to docs/files/topics/queue later) and per-owner
partial-unique (owner, LOWER(name), kind) indexes. CASCADE — a marker is
config, not code.
- 0053_group_kv_entries.sql: the shared store, keyed by (group_id, collection,
key) — NO app_id (a shared row belongs to the group). CASCADE on group delete
(data dies with its group, like vars/secrets config; an app delete leaves it).
- group_collection_repo: list_for_owner, insert/delete_collection_tx, and the
load-bearing resolve_owning_group — walks the reading app's chain
(CHAIN_LEVELS_CTE) for the nearest ancestor group declaring the name
(nearest-wins via ORDER BY depth LIMIT 1). That walk IS the isolation
boundary; the join is on group_owner only.
- group_kv_repo: a near-clone of kv_repo keyed by group_id.
Schema snapshot re-blessed (53 migrations).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An extension point (§5.5) marks a module name a node offers for descendants
to provide/override — resolved dynamically against the inheriting app rather
than lexically sealed. Lay the storage:
- migration 0051: owner-polymorphic `extension_points(id, group_id?, app_id?,
name)` marker table — exactly-one CHECK + per-owner partial-unique LOWER(name)
indexes + lookup indexes (mirrors 0050). ON DELETE CASCADE (a marker is
config, not code). No default-body column — the optional default body is a
co-located kind=module script (Phase 4b stores/resolves/caches those).
- extension_point_repo: `list_for_owner` + idempotent `insert_extension_point_tx`
/ `delete_extension_point_tx`, keyed by the shared `ScriptOwner` (mirrors the
var/secret tx-fn style).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4-lite C1. Make script ownership polymorphic so a script can be owned
by a GROUP (a template inherited by descendant apps) instead of an app —
mirroring vars/secrets (0048/0049), but ON DELETE RESTRICT (code is not data).
Schema (0050): `scripts.group_id` (nullable FK→groups RESTRICT), `app_id` made
nullable, `scripts_owner_exactly_one` CHECK, and the per-app name index split
into two per-owner partial-unique indexes. Existing app-owned rows keep their
exact `(app_id, lower(name))` uniqueness.
Type: `Script.app_id` becomes `Option<AppId>`; add `Script.group_id` +
`ScriptOwner` / `is_owned_by_app()`. `NewScript` gains the same polymorphic
owner. The execution-context app (what a script runs *under*) is supplied by
the invoking route/trigger/caller, never read off the script — group scripts
have no single app.
Behavior is fully preserved for app-owned scripts (the only kind creatable
today): every isolation backstop and authz site now uses `is_owned_by_app`,
which is byte-identical for app owners and **fails closed** for group owners.
A group script therefore can't yet be run, route/trigger-bound, invoked, or
managed via the app-script API — those land in C2 (group-script creation) and
C3 (chain-membership resolution + binding). The `/execute/{id}` bypass 404s a
group script (no app context to run under).
Re-blesses expected_schema.txt; note the golden was last blessed at migration
0044, so this also captures the already-committed 0045–0049 schema.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migration 0049 reshapes `secrets` to the same polymorphic-owner contract
as `vars` (0048): a secret is owned by an app XOR an ancestor group,
carries an `environment_scope`, and the old PK `(app_id, name)` becomes
two partial-unique indexes `(owner, environment_scope, name)`. Existing
rows backfill to `app_id` + scope `'*'`; the v1 AAD does NOT include the
scope, so every current ciphertext keeps decrypting byte-for-byte.
`SecretsRepo` is generalised to be owner+scope keyed (`SecretOwner` moves
down from `secrets_service` and is re-exported for path stability). The
SDK read path now goes through `SecretsRepo::resolve`, which reuses the
shared `CHAIN_LEVELS_CTE` to walk app→ancestor-group→root, env-filters,
and takes the nearest level (`@E` beating `*` within a level) — returning
the winning owner so the value is decrypted under the AAD it was sealed
with. Runtime injection stays anchored to `cx.app_id`: an app only ever
resolves its own and its ancestors' secrets.
All callers updated to owner+scope (`SecretOwner::App(_)`, scope `'*'`):
the app-secrets admin API, the SDK service, and the apply email-secret
path. The 21 secrets unit tests (incl. the app/group AAD-disjointness and
cross-row swap proofs) stay green; the chain-walk was live-verified
against Postgres (nearest-wins + env-filter). Admin API for group secrets
and the masked-read endpoint land next (Step E).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 3 foundation (docs §3): env-filtered, proximity-first config
inheritance down the group tree.
- Migration 0048: `vars` (polymorphic group|app owner via two nullable FKs
+ CHECK exactly-one, env scope, JSONB value, explicit tombstone) +
`apps.environment` (the env marker the resolver filters on — 'an
environment is an app').
- config_resolver: a shared chain-walk CTE (mirrors effective_app_role) that
walks app → ancestor groups, env-filters in SQL, plus a pure Rust
resolution pass implementing the §3 semantics SQL can't — per-key
proximity-first, @E-over-* within a level, map deep-merge, tombstone
suppression, and provenance for --explain.
Verified: 8 unit tests (incl. the §3.2 proximity-beats-env-specificity call,
deep-merge, tombstone, boundary) + the candidate-fetch CTE against live
Postgres (env-filter drops a non-matching @production value; nearer level
shadows farther).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2 (blueprint §5): groups form a single-parent org tree ABOVE apps.
This migration adds the tree + membership tables and gives every app a
parent (§9 adoption):
- groups: id, parent_id (self-FK ON DELETE RESTRICT), slug (instance-
global UNIQUE, frozen at creation), name, description, structure_version
(bumped on structural mutation), owner_project (inert §7 seam).
- group_members: (group_id, user_id, role) with the SAME three role
literals as app_members so AppRole round-trips and the authz rank table
covers both.
- apps.group_id: nullable add → backfill every app under a seeded 'root'
group → promote to NOT NULL + FK RESTRICT.
No group-owned resources yet (scripts/secrets stay app-owned — Phase 3).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Schema groundwork for the trigger `name` identity (the manifest merge key
that will let `apply` Update a trigger in place rather than only
Create/Delete). A `gen_random_uuid()` default keeps the existing write
paths valid and unique without code changes; existing rows are backfilled
to the readable `{kind}-{n}` form; `UNIQUE(app_id, name)` is enforced.
No behavior change yet — the manager-core write paths and the apply diff
start using the name in the follow-up. Verified the migration applies and
the trigger journeys (which create triggers via the unnamed path → default
name) stay green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase-1 `enabled` three-state lifecycle (§4.3), data-model half. Triggers
already carried `enabled`; this adds it to scripts and routes and threads
it through the declarative project tool. Runtime honoring (disabled route
404 / script non-invocable / dispatcher fire-time re-check) is the next
commit — this change only stores and reconciles the flag.
- Migration 0045: `enabled BOOLEAN NOT NULL DEFAULT TRUE` on scripts +
routes. Default true ⇒ no behavior change on migrate.
- `Script`/`Route` (shared) gain `enabled` (serde default true via the new
`picloud_shared::default_true`); repos' SELECT/INSERT/UPDATE SQL, row
structs, `NewScript`/`NewRoute`/`ScriptPatch` all carry it.
- Apply diff treats `enabled` as a declarative field (omitted ⇒ active):
`script_update_reason` + `diff_routes` detect a toggle as an Update, and
the create/update/insert paths persist it. The bound-plan `state_token`
re-includes script/route `enabled` (removed in the earlier review fix
precisely because the diff didn't key on it yet — now it does).
- CLI manifest model + `build_bundle` + `pull` round-trip `enabled`
(serialized only when false; omitted ⇒ active). `pic init` scaffold and
the interactive script/route create paths default active.
Tested: manager-core lib 365 (incl. enabled diff + token sensitivity) +
cli bins 31 (incl. manifest skip-serialize) green; clippy -D warnings
clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
At-rest secrets were AES-256-GCM sealed with no Associated
Authentication Data, so anyone with Postgres write access could
ciphertext-swap rows across apps (or rename via row edit) and the
decrypt would silently succeed under the wrong identity, returning
attacker-chosen plaintext. This breaks the cross-app isolation boundary
the moment DB write access is achieved.
Adds an AAD-bound envelope (v1) alongside the legacy no-AAD layout (v0),
discriminated by a per-row `version` column:
* shared::crypto — new encrypt_with_aad / decrypt_with_aad using
aes_gcm::aead::Payload { msg, aad }. Originals retained for v0 reads.
Tests: AAD round-trip, AAD-mismatch fails, empty-AAD round-trip.
* migration 0042 — adds `secrets.version SMALLINT NOT NULL DEFAULT 0`
and `app_secrets.realtime_signing_key_version SMALLINT NOT NULL
DEFAULT 0`. Existing rows stay v0; new writes are v1.
* secrets (SDK + admin API) — seal() now binds AAD =
"secret:{app_id}:{name}" and emits v1; open() dispatches on version.
StoredSecret gains a `version` field; SecretsRepo::set takes it.
Both secrets_service::set and secrets_api::set_secret go through the
v1 path. Tests prove a cross-app swap and a cross-name swap both
surface Corrupted, and that a hand-built v0 row still decrypts.
* app_secrets (realtime signing key) — get_or_create_signing_key writes
v1 with AAD = "app_secret:{app_id}:realtime_signing_key"; decode
dispatches on version. Tests cover v0 decode, v1 round-trip, and v1
decode-under-wrong-app failing.
* email-trigger inbound secret — kept on v0 (seal_legacy/open_legacy)
and explicitly deferred: email_trigger_details has no version column
and the trigger_id isn't known at seal time. The audit classes the
email-trigger AAD gap as Medium; folded into v1.2's key-versioning
pass per SECURITY_AUDIT.md.
* expected_schema.txt re-blessed by hand (no local Postgres) for the two
new columns + migration 0042.
Also folds in a let-else clippy fix in auth_api.rs (login Argon2
semaphore acquire, from the H-B1 commit) and two cargo-fmt reflows.
No re-encryption sweep — v0 rows decrypt as-is; the sweep is deferred to
v1.2's key-versioning pass (audit "Notes on remediation methodology").
Audit ref: security_audit/03_crypto_secrets.md (H-D1).
Co-Authored-By: Claude Opus 4.8 (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>
No unique constraint on (app_id, lower(email)) for pending rows meant
calling users::invite("alice@…") N times created N rows with N valid
tokens. Combined with accept_invite returning Ok(None) silently when
the email already exists, an attacker who learned one token could
permanently consume it without effect.
Migration 0040 adds a partial unique index keyed on
(app_id, lower(email)) WHERE accepted_at IS NULL. Re-inviting after a
previous invite was accepted still works — the accepted row falls
outside the index.
The service-layer 409-conflict UX (the audit's secondary suggestion)
is a separate follow-up; this commit closes the data-shape hole.
AUDIT.md anchor: F-S-013.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two (encrypted, nonce) column pairs are each nullable independently in
the current schema:
- email_trigger_details.inbound_secret_encrypted / _nonce
- app_secrets.realtime_signing_key_encrypted / _nonce
A bug or partial write could leave one populated and the other NULL,
silently bypassing signature verification (receivers decrypt only if
the encrypted column is set).
Migration 0038 adds CHECK ((enc IS NULL) = (nonce IS NULL)) on both
tables — defence-in-depth: catches an invariant violation at the DB
boundary even if the writing code regresses.
AUDIT.md anchor: F-M-002.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migration 0017_cron_triggers.sql created idx_cron_triggers_due on
(last_fired_at) with a comment claiming it serves the scheduler. The
actual scheduler query has no last_fired_at predicate — it filters
purely on `t.enabled = TRUE FOR UPDATE OF d SKIP LOCKED`. The index
has been pure write amplification with no read payoff.
Migration 0037 drops it. Reversible by re-running 0017's CREATE INDEX
if the planner story ever changes.
AUDIT.md anchor: F-M-001.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
list_active_queue_consumers fires every 100ms from the dispatcher
queue arm and predicates on `WHERE t.kind='queue' AND t.enabled=TRUE`
with no app_id filter — but the only available index
`idx_triggers_app_kind_enabled` is keyed on `(app_id, kind)` and so
requires an app_id predicate to be useful. Without one, the planner
falls back to a sequential scan every tick.
Add migration 0036 with a partial index on `kind` (WHERE enabled =
TRUE) so the hot dispatcher query becomes an index-only lookup.
AUDIT.md anchor: F-P-010.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 0034_queue_messages.sql: per-app durable named queues. (app_id, queue_name)
is the identity tuple; no queue registry table. Partial indexes on
(claim_token IS NULL) for the dispatch hot path and (claim_token IS NOT NULL)
for the visibility-timeout reclaim scan. The queue table IS the outbox
for queue semantics — no double-buffering.
- 0035_queue_triggers.sql: widens triggers.kind CHECK to admit 'queue';
widens outbox.source_kind CHECK to admit 'invoke' (for invoke_async).
Adds queue_trigger_details(trigger_id PK, queue_name, visibility_timeout_secs,
last_fired_at). Retry policy lives on the parent triggers row — same
pattern as every other kind. One-consumer-per-queue is API-layer enforced
via pg_advisory_xact_lock (partial unique index can't span parent.app_id).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migration 0033 widens topics.auth_mode CHECK to include 'session'
alongside 'public' and 'token'.
TopicAuthMode enum gains a Session variant (as_str + from_db
extended uniformly).
RealtimeAuthorityImpl now takes Arc<dyn UsersService> as a third
constructor arg. The Session branch of authorize_subscribe
delegates to UsersService::verify_session_for_realtime(app_id,
token):
* Returns Some(user) → allow. The service bumps the sliding TTL
on success.
* Returns None → Unauthorized.
* Defense-in-depth: even though verify_session_for_realtime
already enforces cross-app isolation, the branch re-checks
user.app_id == app_id.
Tests added (4 new cases): valid session token allows; missing
token is Unauthorized; wrong token is Unauthorized; cross-app
session token is Unauthorized. All 12 realtime_authority tests
pass.
Dashboard: TopicAuthMode TypeScript union widened to include
'session'; the topic create + edit forms gain a third radio option
labeled "session — requires a per-app user session minted by
users::login (v1.1.8)".
picloud binary: construction order reshuffled so users is built
before realtime_authority. app_secrets_repo is now .clone()'d into
the pubsub realtime wiring so the original Arc can be re-used by
realtime_authority.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.1.7 added at-rest encryption for app_secrets.realtime_signing_key
plus a startup task that backfilled encryption over the plaintext
column. v1.1.7's CHANGELOG committed v1.1.8 to dropping the
plaintext column; this commit follows through.
Migration 0032:
* Guard query: refuses to apply if any row still has
realtime_signing_key IS NOT NULL but realtime_signing_key_encrypted
IS NULL. Forces operators who skipped v1.1.7 to apply it first.
* ALTER TABLE app_secrets DROP COLUMN IF EXISTS
realtime_signing_key.
app_secrets_repo:
* decode_signing_key now reads encrypted+nonce only; the plaintext
fallback is gone. (The schema still allows it via DROP IF EXISTS
semantics on replay; once dropped, the column doesn't exist —
the SELECT no longer requests it.)
* Removed migrate_plaintext_keys (the v1.1.7 startup sweep).
* Tests for the falls-back-to-plaintext path are gone with it; the
remaining tests cover the encrypted-only happy path, the
missing-columns None case, and the wrong-master-key Crypto error.
picloud/lib.rs: removed the migrate_plaintext_keys startup call
+ replaced with a comment explaining the upgrade-path requirement.
LOAD-BEARING: v1.1.8 requires v1.1.7 to have been applied first.
Operators upgrading directly from v1.1.6 or earlier must apply
v1.1.7 (which performs the encryption pass) before applying v1.1.8.
This is enforced both by the migration guard and by the CHANGELOG
(in a later commit).
Brief mentioned dropping a "realtime_signing_key_nonce_LEGACY_IF_EXISTS"
column — recon confirmed migration 0025 only added the plaintext
column + the encrypted/nonce pair, so no legacy nonce column exists
to drop. Documented in HANDBACK §7.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
migration 0031: app_user_roles table — composite PK (app_id, user_id,
role) so add is idempotent (ON CONFLICT DO NOTHING). v1.1.8 stores
strings only; permission matrices / hierarchies / role registry are
explicitly v1.2 work per the brief.
UsersServiceImpl wires roles: Arc<dyn AppUserRoleRepo>:
* fetch_roles() now actually queries the repo (replacing the empty
Vec stub from commit 4). Every AppUser returned from get /
find_by_email / list / update / verify / login now carries its
role list.
* users::add_role gated on AppUsersAdmin; first checks the user
exists in this app so a FK violation can't leak "no such user".
* users::remove_role gated on AppUsersAdmin; idempotent.
* users::has_role gated on AppUsersRead.
* accept_invite now applies pre-staged roles atomically with the
user creation; malformed role strings are skipped with a warn
rather than aborting the whole accept (the invitation was an
admin's promise — we honor as much of it as we can).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
migration 0030: app_user_invitations table — surrogate id PK + unique
token_hash, app_id FK cascading, pre-stages email + display_name +
roles for a user that doesn't exist yet. One-shot via atomic UPDATE
SET accepted_at = NOW() WHERE accepted_at IS NULL.
UsersServiceImpl gains invitations: Arc<dyn AppUserInvitationRepo>
plus a mint_session() helper factored from login() and reused by
accept_invite().
users::invite(email, opts) is gated on AppUsersAdmin (per brief —
the most senior of the three new capabilities). Optional
EmailTemplateOpts inside InviteOpts: omitting the template skips the
email send so an admin can stamp invitations for out-of-band
delivery (mailers, printed onboarding letters, etc.). If the template
is present and the email service isn't configured, surfaces as
NotConfigured; non-NotConfigured failures are logged but kept silent
so the invitation row remains valid for retry.
users::accept_invite(token, password, display_name?) atomically
consumes the invitation, validates the new password, creates the
user (returning () on DuplicateEmail — sign-up beat acceptance,
they'll log in normally), and mints a fresh session via mint_session
so the caller can return both the user and a working session token
in one round trip.
Pre-staged roles are stored on the invitation row but not yet
applied — the app_user_roles table arrives in commit 8 (migration
0031). For commit 7 the staged-but-not-applied case logs an info
record so an operator can audit the gap.
list_invitations + revoke_invitation (admin-mediated, gated on
AppUsersAdmin) ship in this commit and become reachable from the
HTTP surface later in the series.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
migration 0029: app_user_password_resets table — same shape as
verification (token_hash PK, app_id + user_id FKs, expires_at,
consumed_at). One-shot via atomic UPDATE WHERE consumed_at IS NULL.
Default TTL 1h (shorter than verification's 48h — reset tokens are
higher-risk).
UsersServiceImpl gains password_resets: Arc<dyn AppUserPasswordResetRepo>.
users::request_password_reset(email, opts):
* Returns Ok(()) regardless of whether the email matched — no
existence-leak signal in script-land (per brief).
* Email-not-configured surfaces as NotConfigured so scripts can
fall back to a synchronous reset path. Other email errors are
silently swallowed and logged server-side; surfacing them would
leak which addresses produced a "real send attempted" signal vs
a no-op.
users::complete_password_reset(token, new_password):
* Atomically consumes the token, updates the Argon2id hash, and
revokes EVERY active session for that user (anyone with a stale
token shouldn't be able to ride out the reset). Emits
users::password_changed.
* Returns the user on success, () on bad/expired/already-used.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
migration 0028: app_user_email_verifications table — token_hash PK,
app_id + user_id FKs cascading, expires_at, consumed_at. Single-use
via atomic UPDATE WHERE consumed_at IS NULL.
UsersServiceImpl gains:
* verifications: Arc<dyn AppUserVerificationRepo>
* email: Arc<dyn EmailService>
users::send_verification_email(user_id, opts) mints a 32-byte token,
stores SHA-256(token), and calls EmailService::send with the body
template's {link} placeholder substituted by link_base + ?token=raw.
EmailError::NotConfigured propagates as UsersError::EmailNotConfigured
so scripts already handling email-disabled mode (v1.1.7 email::send)
don't need new branches.
users::verify_email(token) atomically consumes the one-shot token via
the verifications repo, marks the user's email_verified_at = NOW(),
and emits a "users::email_verified" event for future triggers.
Internal email send uses a synthesized SdkCallCx with principal=None
so the email-service AppEmailSend authz check is skipped (the
users::* surface has already gated on AppUsersWrite — the internal
hop isn't the script's direct call). Documented in HANDBACK §7.
EmailTemplateOpts now requires `from` (the v1.1.7 email service needs
an envelope sender). The brief example omitted it; deviation logged
in HANDBACK §7.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
App-user session storage (migration 0027) mirrors admin_sessions but
adds three things v1.1.8 needs:
* app_id column + FK cascade — every v1.1+ table starts with app_id
so cross-app isolation is bright at the SQL layer (lookup keys
off the hash only, but defense-in-depth: a leaked row's session
still scopes to its app on every read).
* absolute_expires_at — hard cap on the sliding window (default 30d
via PICLOUD_APP_USER_SESSION_ABSOLUTE_HOURS). Beyond this the
user must re-login regardless of recent activity.
* revoked_at — explicit revocation by token (logout) or per-user
(admin revoke-sessions button, password reset). Lookups reject
revoked rows immediately so revocation takes effect before the
weekly GC sweep runs.
The repo's gc() uses FOR UPDATE SKIP LOCKED matching the dead-letter
and abandoned-executions sweep patterns; the GC wiring lands in a
later commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-app end-user table for the v1.1.8 users::* SDK. Distinct from
admin_users (control-plane operators) — same Argon2id password hash
shape but everything else (uniqueness scope, ownership, lifecycle)
independent.
- Uniqueness on (app_id, lower(email)) — case-insensitive within an
app; same email may exist across two apps.
- AppUserRepository trait + Postgres impl; every method takes app_id
explicitly so cross-app reads are unmistakable at the call site
(matches v1.1.3 cross-app discipline).
- Public AppUserRow never includes the password hash; the credentials
shape is its own struct returned only by the login lookup.
- Cursor-based list keyed on (created_at, id).
- Reserved a timing-flat dummy Argon2id PHC constant in auth.rs for
the upcoming login path so the bad-email and good-email branches
share wall-clock cost.
- Added AppUserId + InvitationId id types in shared::ids.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-phase encryption of app_secrets.realtime_signing_key:
- migration 0025 adds NULL-able realtime_signing_key_encrypted +
_nonce columns and drops NOT NULL on the plaintext column.
- PostgresAppSecretsRepo now holds the master key: new keys are written
encrypted-only; reads prefer the encrypted columns and fall back to
plaintext during the compat window.
- Startup task migrate_plaintext_keys() encrypts any pre-existing
plaintext rows (plaintext left in place for rollback safety).
- v1.1.8 will drop the plaintext column.
The RealtimeAuthority read path is unchanged (it calls signing_key),
so SSE keeps working throughout. Unit tests cover the
encrypted-wins / plaintext-fallback / post-drop precedence.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inbound email: a provider POSTs a normalized JSON message to
POST /api/v1/email-inbound/{app_id}/{trigger_id}; the public receiver
verifies the optional HMAC signature, builds a TriggerEvent::Email, and
enqueues an outbox row the dispatcher delivers like any async trigger.
Handlers see ctx.event.email = #{from,to,cc,subject,text,html,
received_at,message_id}.
- migration 0024: widen triggers.kind + outbox.source_kind CHECKs to
'email'; new email_trigger_details table.
- TriggerKind::Email, TriggerDetails::Email{has_inbound_secret},
OutboxSourceKind::Email, TriggerEvent::Email; dispatcher routes the
email row via the generic resolve_trigger path.
- Admin POST /apps/{id}/triggers/email (validate_trigger_target; module
+ cross-app rejection). inbound_secret is stored ENCRYPTED via the
master key (deviation from the brief's plaintext default; decrypted
per inbound request — see HANDBACK §7).
- Dashboard: email trigger form on the Triggers tab + webhook URL +
expected-payload help.
- 8 DB-gated e2e tests (202/401/404/422/cross-app/handler-fire) +
receiver unit tests (HMAC verify, secret round-trip, payload parse).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Encrypted per-app secrets, reachable from scripts as
secrets::{get,set,delete,list}(name) and managed from the dashboard
Secrets tab. Values are AES-256-GCM-sealed with the process master key
(picloud_shared::crypto) before they touch Postgres; the repo only ever
sees ciphertext + nonce. JSON round-trip preserves Rhai types.
- migration 0023_secrets.sql (PRIMARY KEY (app_id, name)).
- SecretsService trait (picloud-shared) + SecretsServiceImpl + repo
(manager-core), wired into the Services bundle and Rhai engine.
- Capability::AppSecretsRead/Write (→ script:read / script:write); no
new Scope variants (seven-scope commitment).
- Admin API GET/POST/DELETE /apps/{id}/secrets (list returns names +
updated_at, never values).
- build_app now takes a MasterKey, sourced from PICLOUD_SECRET_KEY in
main.rs; test callers pass a fixed test key.
- 64 KB value cap (PICLOUD_SECRET_MAX_VALUE_BYTES); no ServiceEvent
emission (secret writes don't fire triggers, by design).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Durable pub/sub through the universal outbox — the sixth trigger kind.
- `pubsub::publish_durable(topic, message)` Rhai SDK (no handle; topics
ARE the grouping unit). Message JSON-encoded; Blobs base64 at any
depth.
- `PubsubService` trait in picloud-shared with the topic matcher +
validator (exact / `<prefix>.*` / `*`; mid-pattern wildcards
rejected). `PostgresPubsubRepo` + `PubsubServiceImpl` in manager-core.
- Publish-time fan-out: one outbox row per matching enabled pubsub
trigger, all in ONE transaction (no half-fan-out on crash). No
matching trigger → publish succeeds silently, zero rows.
- `pubsub:*` trigger kind via Layout-E (0020: widen both CHECKs +
pubsub_trigger_details + partial index), TriggerEvent::Pubsub +
ctx.event.pubsub, dispatcher arm, admin endpoint POST /triggers/pubsub
(validates topic pattern + reuses validate_trigger_target).
- AppPubsubPublish capability → script:write (seven-scope held).
- Dashboard Pub/Sub trigger form on the Triggers tab + list rendering.
publish_ephemeral stays deferred to v1.2. ~18 new tests (service
in-memory incl. transactional-rollback, shared matcher, bridge
encoding). No DB required for the suite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Filesystem-backed blob storage as the fifth concrete trigger kind.
- `files::collection(c).{create,head,get,update,delete,list}` Rhai SDK
(blob in/out; metadata maps; missing-field throws naming the field).
- `FilesService` trait in picloud-shared; `FsFilesRepo` (atomic
write: temp→fsync→rename→fsync-dir→DB; single-pass SHA-256;
checksum-verified reads → Corrupted) + `FilesServiceImpl` in
manager-core. Metadata in Postgres (0018), bytes on disk under
PICLOUD_FILES_ROOT with 0o700 shard dirs.
- `files:*` trigger kind via the Layout-E pattern (0019: widen both
CHECKs + files_trigger_details), TriggerEvent::Files (metadata only,
no bytes), emit_files fan-out, dispatcher arm, admin endpoint
POST /triggers/files (reuses validate_trigger_target).
- AppFilesRead/AppFilesWrite capabilities → script:read/script:write
(seven-scope commitment held). AppPubsubPublish reserved for v1.1.6.
- Admin files API (list + delete) + dashboard Files view per app.
Cross-app isolation keyed on cx.app_id at every layer. ~45 new tests
(service in-memory, fs tempdir, bridge integration). No DB required
for the suite. publish_ephemeral and the orphan sweep stay deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HTTP (`http::*`):
- `HttpService` trait (picloud-shared) + reqwest-backed `HttpServiceImpl`
(manager-core), wired into the `Services` bundle.
- SSRF deny-list applied to the resolved IP via a custom reqwest
`dns_resolver` (covers every redirect hop + defeats DNS rebinding) plus
a literal-IP check at URL-parse time. Scheme/port restrictions, request
+ response body caps (stream-with-cap), layered timeout. Error reason is
a CIDR category, never the IP. `PICLOUD_HTTP_ALLOW_PRIVATE` dev override
(logs a startup warning).
- Rhai bridge with three-arg split `verb(url, body, opts)` (resolves the
brief's body-vs-opts contradiction; unknown opt keys throw). Body
dispatch by type; response `#{status,headers,body,body_raw}` with JSON
auto-parse; non-2xx does not throw.
- `Capability::AppHttpRequest` → existing `script:write` scope (no new
Scope variant). `SdkCallCx` gains `script_id` (attribution + User-Agent).
Cron triggers (4th trigger kind):
- Migration 0017 widens the kind/source_kind CHECKs and adds
`cron_trigger_details`. `cron`/`chrono-tz` parse + validate 6-field
schedules and IANA timezones.
- `spawn_cron_scheduler` polls due triggers and enqueues to the universal
outbox; the dispatcher delivers them (one-line match-arm extension).
Catch-up fires exactly once per trigger per tick, not once per missed
window. `ctx.event.cron` for handlers.
- `POST /api/v1/admin/apps/{id}/triggers/cron` reuses the v1.1.3
cross-app + kind!=module target check.
- Dashboard: admin-gated Triggers tab (cron create form + list).
Follow-ups: redact module backend errors at the resolver boundary (log
original at error level); pin `rhai = "=1.24"`; CHANGELOG incl. retroactive
v1.1.3 cross-app-trigger security note. Version bumps: workspace 1.1.4,
SDK 1.5, dashboard 0.10.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lays down the v1.1.3 plumbing:
- `ScriptKind` enum in `picloud-shared` ('endpoint' | 'module').
- `ModuleSource` trait + `ModuleScript` DTO + `NoopModuleSource` in
`picloud-shared`. Resolver lives in `executor-core`; Postgres impl
in `manager-core` (`PostgresModuleSource`).
- `Services::new` grows a fifth `modules: Arc<dyn ModuleSource>` arg.
- `ScriptValidator` returns `ValidatedScript { imports }` so the
manager can populate the dep-graph table on save. New
`validate_module` method on the trait gates module-shape rules.
- `Engine::execute_ast(&Arc<rhai::AST>, req)` lets the orchestrator's
script cache reuse compiled ASTs. `Engine::execute(&str, req)` is
preserved as a convenience that compiles inline. `Engine::compile`
exposes the AST for callers that want to cache.
- `PicloudModuleResolver` replaces `DummyModuleResolver` per-call.
Bridges Rhai's sync `ModuleResolver::resolve` to async
`ModuleSource::lookup` via `Handle::block_on`. Enforces:
- cross-app isolation (resolver captures `Arc<SdkCallCx>`),
- circular import detection (in-progress stack on the resolver),
- import depth limit (default 8 via
`Limits::module_import_depth_max`).
- Module-shape validation walks `ast.statements()` via `rhai/internals`
and accepts only `Var { CONSTANT }`, `Import`, and `Noop`. The
manager admin endpoint runs `validate_module` at save (primary
gate); resolver re-runs it at load (defense in depth).
- LRU cache `(AppId, name) -> (updated_at, Arc<Module>)` owned by
`Engine`. Size from `PICLOUD_MODULE_CACHE_SIZE` (default 512).
- Migration `0015_scripts_kind.sql` adds `scripts.kind` + composite
index + module-name shape CHECK.
- Migration `0016_script_imports.sql` adds the dep-graph table with
FK CASCADE on both columns.
- Repo: `kind` threaded through SELECT/INSERT/UPDATE. New
`count_routes_for_script` / `count_triggers_for_script` /
`list_imports` methods. `create`/`update` open a transaction and
call `replace_imports_tx` to populate the dep-graph.
- Admin endpoint: accepts `kind`; rejects reserved module names;
rejects `endpoint → module` transitions when routes / triggers
exist.
- SDK_VERSION 1.3 → 1.4.
Workspace builds; full test suite (~440 tests) green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrations 0013_docs.sql + 0014_docs_triggers.sql land the docs table
(JSONB body + GIN-on-jsonb_path_ops index, PK keyed on (app_id,
collection, id) for cross-app isolation) and widen the triggers.kind
and outbox.source_kind CHECK constraints to include 'docs', plus the
docs_trigger_details detail table mirroring kv_trigger_details.
picloud-shared grows the DocsService trait + DocRow/DocsListPage/
DocsError + NoopDocsService, the TriggerEvent::Docs variant with the
prev_data change-data-capture surface, the DocsEventOp enum, the docs
field on the Services bundle, and the SDK_VERSION bump 1.2 -> 1.3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Routes gain `dispatch_mode TEXT NOT NULL DEFAULT 'sync'` (migration
0012). Existing routes default to sync so the migration is
non-breaking. `DispatchMode` enum lands in `picloud-shared`.
The user-routes orchestrator handler now branches:
- `dispatch_mode = async` → write outbox row with `reply_to = None`,
return `202 Accepted` + `{accepted_at, execution_id}`. Dispatcher
fires the script in the background; retries / dead-letters via
the framework from commit 5.
- `dispatch_mode = sync` → register an inbox channel
(`tokio::sync::oneshot`), write outbox row with `reply_to =
inbox_id`, `.await` on the receiver with a timeout =
script.timeout_seconds + 2s buffer. Dispatcher hands the result
back; orchestrator maps `InboxResult` into the HTTP response per
the design-notes §3 status-code table (422/502/503/504/507/500).
`InboxRegistry` (orchestrator-core/src/inbox.rs) is the in-process
implementation of `InboxResolver`. Lock-free HashMap of pending
oneshot senders keyed by `inbox_id`. Tests cover register/deliver
round-trip, unknown-id is abandoned, dropped-receiver is abandoned,
explicit cancel. Cluster mode (v1.3+) swaps this for
LISTEN/NOTIFY-keyed lookup behind the same trait.
`OutboxWriter` trait lives in `picloud-shared` so orchestrator-core
can write to the outbox without depending on manager-core (which
would invert the dependency arrow). `PostgresOutboxRepo` implements
both `OutboxRepo` (dispatcher surface) and `OutboxWriter`
(orchestrator surface); the picloud binary clones the same concrete
Arc into both trait views.
The dispatcher's HTTP arm (commit 5 had a stub) now decodes the
`HttpDispatchPayload` off the outbox row, looks up the script,
synthesizes an `ExecRequest`, and runs it through the executor.
Outcome routing reuses the same path as KV triggers — sync HTTP
flows through the inbox, async dispatch gets dropped after
success (or DL'd on exhaustion).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>