Additive fixes surfaced by building a full CMS on PiCloud
(examples/cms-poc/). One migration (0077_apps_cors.sql); no destructive
changes.
Security:
- Close the 502 info-leak on user routes: an uncaught script error (incl.
a throw) leaked the app UUID + script fn names + source line/col. The
user-route (inbox) path now shares scrub_runtime_detail with the
execute-by-id path — raw detail is logged under a correlation id and the
client sees only "script execution error (ref: <uuid>)".
Added:
- docs::find $contains operator (array membership via JSONB @>), per-app
and group-shared — the inverse of $in.
- App-user role management from API/CLI: pic users add-role / rm-role,
backed by the apps/{id}/users/{user_id}/roles endpoints (AppUsersAdmin).
- Per-app CORS: pic apps cors set, apps.cors_allowed_origins; the
orchestrator echoes an allowed Origin and answers OPTIONS preflight.
- Non-JSON request bodies: form-urlencoded -> object, text/* -> string,
other -> base64; ctx.request.content_type added. Malformed JSON still 400s.
- Binary responses via #{ body_base64 } with a script-set Content-Type.
- workflow::run_status(run_id) SDK (F-038): the starting script can poll a
run — #{ status, output, error, steps } or () when no such run belongs to
the app (app_id is the isolation boundary); same AppInvoke gate as start.
Fixed:
- pic apply "app not found" is now actionable (points at pic apps create).
- Interceptor- (F-021) and workflow-step-bound (F-037) scripts no longer
warn "no route or trigger" at plan time — both count as reachability.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
54 KiB
PiCloud Changelog
Unreleased — CMS proof-of-concept remediation
Fixes surfaced by building a full CMS on PiCloud (examples/cms-poc/). Pure
additive surface + one migration (0077_apps_cors.sql), no destructive changes.
Security
- 502 info-leak closed on user routes. An uncaught script error (incl. a
throw) on a user route returned HTTP 502 whose body embedded the app UUID, script function names, and source line/col. The 2026-06-11 audit scrub had only covered the execute-by-id path; the user-route (inbox) path still leaked. Both paths now sharescrub_runtime_detail— the raw detail is logged server-side under a correlation id and the client sees onlyscript execution error (ref: <uuid>).
Added
docs::find$containsoperator — array-membership via JSONB@>, e.g.docs::find("posts", #{ tags: #{ "$contains": "rust" } })finds every post taggedrust. The inverse of$in. Covers per-app and group-shared docs.- App-user role management from the API/CLI —
pic users add-role --app <app> <user_id> <role>/rm-role, backed byPOST/DELETE /api/v1/admin/apps/{id}/users/{user_id}/roles[/{role}](AppUsersAdmin). Role assignment was previously reachable only from a Rhai script. - Per-app CORS —
pic apps cors set <app> <origins…>(or*), stored onapps.cors_allowed_origins. The orchestrator echoes an allowedOriginon every response and answers theOPTIONSpreflight. Empty = off (unchanged). - Non-JSON request bodies — a user route no longer 400s on a non-JSON body.
application/x-www-form-urlencoded→ctx.request.bodyobject,text/*→ string, other types → base64 string;ctx.request.content_typeadded as a convenience. JSON (or unspecified) is unchanged; malformed JSON still 400s. - Binary responses — a script may return
#{ statusCode, headers, body_base64 }; the platform decodes the base64 and returns raw bytes with the script-setContent-Type(defaultapplication/octet-stream). workflow::run_status(run_id)SDK (F-038) — the script that started a run can now poll it: returns#{ status, output, error, steps: #{ name: status } }or()when no such run belongs to the app. Read-only, same-app scoped (the run'sapp_idis the isolation boundary), sameAppInvokegate asworkflow::start. Closes the gap where run status was platform-admin-API only.
Fixed
pic apply"app not found" is now actionable — points atpic apps create <slug>(apply reconciles groups but never creates an app).- Interceptor- and workflow-step-bound scripts no longer warned "no route or
trigger" at plan time — an
[[interceptors]]binding (F-021) and a[[workflows]]stepfunction(F-037) now count as reachability.
Notes
- Workflow
skipped≠failed(F-039, by design). A step whosewhenevaluates false isskippedand counts as satisfied-but-empty for its dependents — so a step downstream of a skipped one still runs. To gate a dependent on an upstream actually having run, give it its ownwhen(e.g.when = "steps.publish.output.published == true"), or have the upstream step return an explicit outcome the dependent checks.
v1.1.9 — Durable Queues & Function Composition (unreleased)
Per-app durable queues + same-app function composition + caller- controlled retries. The final v1.1.x release before the v1.2 phase milestone. No upgrade-order constraint — pure additive surface, no destructive migrations.
Added — queue::* SDK (durable per-app named queues)
queue::enqueue(name, message)/queue::enqueue(name, message, opts)— write one message onto a per-app named queue.optsis a Map withdelay_ms(defer delivery untilNOW() + delay) andmax_attempts(clamped to[1, 20]; default 3). Message is any JSON-serializable value — Maps, Arrays, scalars, and Blobs (which base64-encode at any depth, mirroringpubsub::publish_durable). FnPtr / closures are rejected at the bridge.queue::depth(name)/queue::depth_pending(name)— read-only inspection.depthisCOUNT(*);depth_pendingfilters toclaim_token IS NULL AND (deliver_after IS NULL OR deliver_after <= NOW()).- Identity tuple is
(app_id, queue_name). Queue names are implicit — no queue registry table, no separate "create queue" ceremony. A queue exists once the first message is enqueued under that name. - No
peek/dequeue/purgescript-side surface. Consumers are triggers; exposing manual dequeue would force the platform to surface visibility-timeout semantics into script-land. - New capability
AppQueueEnqueue(AppId)(script:write scope, editor+).
Added — queue:receive trigger kind
- Exactly one consumer per
(app_id, queue_name)— enforced viapg_advisory_xact_lock(hash(app_id || queue_name))+ a SELECT-then- INSERT in one transaction (a partial unique index can't span the parent'sapp_idfrom the detail table). The second create returns422 Invalidwith the messagequeue 'X' already has a consumer trigger; remove the existing one first. - Per-trigger
visibility_timeout_secs(clamped[5, 3600]; default viaPICLOUD_QUEUE_DEFAULT_VISIBILITY_TIMEOUT_SECS). The dispatcher treats this as the lease window; a handler that holds the claim past this point loses it to the periodic reclaim task. - Retry policy lives on the parent
triggersrow (the sameretry_max_attempts/retry_backoff/retry_base_msevery other trigger kind reads); no duplication on the detail row. - Surfaced to handler scripts as
ctx.event.queue—{ queue_name, message, enqueued_at, attempt, message_id }— plusctx.event.source = "queue"andctx.event.op = "receive". - Trigger executes as the principal that registered it (matches design notes §4 — every trigger kind does this).
Added — Dispatcher queue arm + visibility-timeout reclaim task
- The queue table IS the outbox for queue semantics. The dispatcher
doesn't double-buffer through
outbox; it claims directly fromqueue_messagesvia the single-round-tripUPDATE … WHERE id = (SELECT id … FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING ….SKIP LOCKEDkeeps concurrent dispatchers (cluster mode v1.3+) safe. - Per tick (every 100ms), interleaved with the existing outbox arm:
list every enabled
queue:receiveconsumer and try one claim each. Bounded by the registered-consumer count, so one busy queue can't starve the rest. - Auto-ack: handler returns successfully →
DELETE FROM queue_messages WHERE id = $1 AND claim_token = $2. The token in the WHERE is the leasing guarantee. - Auto-nack: handler throws → if
attempt < max_attempts, clear the claim and setdeliver_after = NOW() + compute_backoff(...); else, write adead_lettersrow (the existingfan_out_dead_letterpath then fires registereddead_lettertriggers off it) and delete the queue row, in one transaction. - Visibility-timeout reclaim: a separate
tokio::spawntask ticks everyPICLOUD_QUEUE_RECLAIM_INTERVAL_MS(default 30000) and clears claims older than the per-queuevisibility_timeout_secs. A crashed consumer (or one whose handler hung past the visibility window) thus loses its lease and the message becomes claimable again.
Added — invoke() + invoke_async() (same-app function composition)
invoke(target, args)— sync, returns the callee's response body as a Rhai Dynamic.targetis a string (path → route trie, UUID →ScriptId, else →(app_id, name)lookup). Same engine instance andServicesare reused; only the per-callSdkCallCxchanges (callee getstrigger_depth + 1and a freshexecution_id, inherits the caller's principal androot_execution_id).invoke_async(target, args)— fire-and-forget. Writes anOutboxSourceKind::Invokerow the dispatcher fires once (no retry loop — callers who want retries wrap inretry::run). Returns the newExecutionIdas a string for caller tracking.- Cross-app invokes are rejected at the
InvokeService::resolvelayer withInvokeError::CrossApp("invoke: target script belongs to a different app"). v1.1.x maintains strict isolation; cross-app sharing arrives in v1.3+. - Depth-bound:
cx.trigger_depth + 1 > limits.trigger_depth_maxthrows "invoke: depth limit exceeded (max N)" — shared with the trigger fan-out cap so a misbehaving recursive script can't blow the stack. - FnPtr / closures in
argsare rejected (closures don't survive re-entry into a freshSdkCallCx).
Added — retry::* SDK (caller-controlled retry)
retry::policy(opts)— constructs aPolicyvalue (custom Rhai type). Validates + clamps:max_attempts ∈ [1, 20],base_ms ∈ [1, 60_000],jitter_pct ∈ [0, 100],backoff ∈ {"exponential" | "linear" | "constant"}.retry::run(policy, closure)— callsclosure(); on throw, sleeps per the policy and re-invokes; aftermax_attemptsexhausted, throws the last error. Sleeps viatokio::time::sleepthroughTokioHandle::block_on— the bridge is already inside the caller'sspawn_blockingthread.retry::on_codes(policy, codes_array)— returns a new policy with an error-string filter. When non-empty, only throws containing one of the codes retry; others surface immediately.- NOTE on the brief deviation: the brief asked for
retry::with, but bothwithANDcallare Rhai reserved keywords (rejected at the parser, before registration could matter). Shipped asretry::run(policy, closure)instead. Documented in HANDBACK §7.
Added — Admin HTTP endpoints
POST /api/v1/admin/apps/{id}/triggers/queue— create aqueue:receivetrigger. Body:{ script_id, queue_name, visibility_timeout_secs?, dispatch_mode?, retry_max_attempts?, retry_backoff?, retry_base_ms? }. Capability:AppManageTriggers.GET /api/v1/admin/apps/{id}/queues— list every distinct queue name in the app with(total, pending, claimed)counts. Capability:AppLogRead.GET /api/v1/admin/apps/{id}/queues/{queue_name}— drill-down: stats + registered consumer trigger (if any). Capability:AppLogRead.- No mutating queue endpoints (purge / requeue / delete-message stays at v1.2).
Added — Dashboard (v0.15.0)
- New
/apps/{slug}/queuesread-only list view (queue name + counts + drilldown link) and/apps/{slug}/queues/{name}drill-down (depth + registered consumer + visibility timeout + last_fired_at). - "Queues" link added to the app-level nav between Files and Dead letters.
- Triggers tab gains a "Queue:receive trigger (v1.1.9)" form alongside cron / pubsub / email. Trigger list renders queue triggers with the queue name + visibility timeout + last_fired_at.
- TypeScript types:
TriggerKindgains'queue';TriggerDetailsgains theQueuevariant.CreateQueueTriggerInput,QueueSummary,QueueConsumer,QueueDetailexported.
Changed
Services::newsignature gainsqueue: Arc<dyn QueueService>andinvoke: Arc<dyn InvokeService>positionally afterusers(mirrors v1.1.8's positional append ofusers).with_noop_servicesupdated.Limits(crates/executor-core/src/sandbox.rs) gainstrigger_depth_max: u32(default 8). The picloud binary syncs it fromTriggerConfig::from_env().max_trigger_depthso the dispatcher's trigger-depth cap and the invoke depth-bound stay aligned (env var:PICLOUD_MAX_TRIGGER_DEPTH).Enginegainsset_self_weak(Weak<Engine>)+self_arc(). The picloud binary callsengine.set_self_weak(Arc::downgrade(&engine))right after construction so the invoke bridge can re-enter the engine synchronously.Weakso the Arc-cycle stays loose.register_allgains two parameters:limits: Limitsandself_engine: Option<Arc<Engine>>. The invoke bridge surfaces a clear error ifself_engineisNone(only in harnesses that don't wire it).OutboxSourceKindgainsInvoke;TriggerKindgainsQueue;TriggerDetailsgains theQueuevariant;TriggerEventgains theQueuevariant withsource = "queue"discriminant.ScriptRepositorygainsget_by_name(app_id, name)for theinvoke()name resolution path.- SDK schema 1.9 → 1.10.
Migrations
0034_queue_messages.sql— thequeue_messagestable with three indexes: a partial(app_id, queue_name, enqueued_at) WHERE claim_token IS NULLfor the dispatch hot path, a(app_id, queue_name)for the depth queries, and a partial(claimed_at) WHERE claim_token IS NOT NULLfor the reclaim scan.0035_queue_triggers.sql— widenstriggers.kindto admit'queue', widensoutbox.source_kindto admit'invoke', adds thequeue_trigger_detailstable.
F1–F5 follow-ups (carry-forward from v1.1.8)
- F1 — integration test density: four new DB-gated test binaries
(
crates/picloud/tests/queue_e2e.rs×4,…/invoke_e2e.rs×4,…/retry_e2e.rs×3,crates/manager-core/tests/migration_queue_messages.rs×4) + 50+ net new inline unit tests. All skip cleanly whenDATABASE_URLis unset; all pass against the docker-compose postgres onlocalhost:15432. - F2 — schema snapshot re-blessed via
BLESS=1 cargo test -p picloud-manager-core --test schema_snapshot -- --include-ignored. Delta matches the plan exactly (no unrelated drift). - F3 — fmt + clippy attestation as literal output: see HANDBACK §8.
- F4 —
TIMING_FLAT_DUMMY_HASHdedup: inline copy incrates/manager-core/src/auth_api.rs::loginnow references the sharedcrate::auth::TIMING_FLAT_DUMMY_HASHconst.grep -rnfor the PHC literal returns exactly one hit. - F5 — clippy without cold cache: incremental cache used; HANDBACK §8 notes the explicit choice.
Notes
- New env vars:
PICLOUD_QUEUE_RECLAIM_INTERVAL_MS(default 30000) — visibility- timeout reclaim cadence.PICLOUD_QUEUE_DEFAULT_VISIBILITY_TIMEOUT_SECS(default 30) — visibility timeout fallback when the trigger create request omits one. Per-trigger column overrides this.
- SDK schema bumped to 1.10.
@picloud/clientunchanged — no client-library work this release.
v1.1.8 — User Management (unreleased)
Per-app data-plane user management — a users::* SDK for scripts
plus an admin HTTP surface and dashboard tab — together with three
load-bearing follow-ups from v1.1.7 (dropping the plaintext realtime
signing-key column, --all-targets clippy discipline, and adding a
session-based realtime SSE auth mode).
UPGRADE PATH IS 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 (and let its startup encryption sweep
complete) before applying v1.1.8 — migration 0032's guard refuses
to drop the plaintext realtime_signing_key column while any row
still needs encryption.
Added — users::* SDK (data-plane user management)
users::create(#{...})/users::get(id)/users::find_by_email(...)/users::update(id, #{...})/users::delete(id)/users::list(#{ "$limit", cursor })— CRUD on per-app end-users (distinct from the control-planeadmin_userstable). Identity tuple is(app_id, user_id); uniqueness is on(app_id, lower(email))so the same email can exist across two apps but not twice within one. Password hash is Argon2id PHC (same algorithm asadmin_users); the public AppUser record never carries the hash.users::login(email, password)returns a session token string, or()on bad credentials. The bad-email and bad-password branches share wall-clock cost via a timing-flat dummy Argon2id verify on email miss. Required for security, not polish.users::verify(token)returns the user on success (bumping the sliding TTL),()on missing / expired / revoked / cross-app.users::logout(token)revokes the session row.migrations/0026_app_users.sql+0027_app_user_sessions.sql.
Added — Sessions
app_user_sessionstable with SHA-256 token hash, sliding TTL (PICLOUD_APP_USER_SESSION_TTL_HOURS, default 24h), hard cap (PICLOUD_APP_USER_SESSION_ABSOLUTE_HOURS, default 720h = 30d), and explicitrevoked_atfor logout / admin revoke-all / password- reset side-effects. Lookups reject revoked/expired rows immediately, before the weekly GC sweep runs.
Added — Email verification
users::send_verification_email(user_id, #{ link_base, from, subject, body_template })mints a 32-byte token, stores its SHA-256 inapp_user_email_verifications, and dispatches viaemail::sendwith{link}in the body substituted forlink_base?token=<raw>.EmailError::NotConfiguredpropagates asUsersError::EmailNotConfiguredso scripts already handling the v1.1.7 email-disabled mode don't need new branches.users::verify_email(token)atomically consumes the one-shot token, setsemail_verified_at = NOW(), emitsusers::email_verified.migrations/0028_app_user_email_verifications.sql.
Added — Password reset
users::request_password_reset(email, #{ template })returnsOk(())regardless of whether the email matched — no existence-leak signal in script-land. Email-not-configured does surface (operators need to know); other email failures are silently swallowed and logged server-side.users::complete_password_reset(token, new_password)atomically consumes the token, updates the Argon2id hash, and revokes every active session for the user (stale tokens shouldn't ride out the reset). TTLPICLOUD_APP_USER_PASSWORD_RESET_TTL_HOURS(default 1h).migrations/0029_app_user_password_resets.sql.
Added — Invitations
users::invite(email, #{ template?, display_name?, roles? })gated onAppUsersAdmin. Omitting the template skips the email send so an admin can issue invitations for out-of-band delivery.users::accept_invite(token, password, display_name?)atomically consumes the invitation, creates the user with pre-staged roles applied, and mints a fresh session — caller can return both the user and the session token in one round trip. TTLPICLOUD_APP_USER_INVITATION_TTL_DAYS(default 7d).migrations/0030_app_user_invitations.sql.
Added — Per-app roles
users::add_role(id, role)/users::remove_role(id, role)/users::has_role(id, role)— string-tagged, per-app. The script app decides what"admin"/"editor"/"viewer"mean. Stored inapp_user_roles(composite PK soaddis idempotent).- The full
AppUserrecord returned by everyusers::*getter now carries arolesarray. migrations/0031_app_user_roles.sql.- Role permission matrices / hierarchy / role registry are explicitly v1.2 work — v1.1.8 does not pre-commit to a model that would lock in a wrong API.
Added — Admin HTTP surface
GET / POST / PATCH / DELETE /api/v1/admin/apps/{id}/usersand/users/{user_id}— list, get, create, update, delete.POST /users/{user_id}/reset-password— returns a one-shot reset token in the response body (TTL 1h). No email is sent — that's the SDK'susers::request_password_reset.POST /users/{user_id}/revoke-sessions— bulk revoke for that user.GET / POST / DELETE /api/v1/admin/apps/{id}/invitationsand/invitations/{invite_id}— list, create, revoke pending.- Capability gating:
AppUsersReadfor reads,AppUsersWritefor CRUD,AppUsersAdminfor admin-mediated verbs + invitations. All three map to existing scopes (script:read/script:write) — no new scope (seven-scope commitment). - DTOs never include password hashes, session tokens, or unrotated reset tokens.
Added — Dashboard Users tab
apps/[slug]/users/+page.svelte— list, create form, edit modal, revoke-sessions, reset-password (one-shot token displayed once), delete. Sub-routeusers/invitations/+page.sveltefor pending invitations (list, create with optional inline email template, revoke).- The main app-detail tab strip gains a Users entry above Files.
@picloud/dashboard0.13.0 → 0.14.0.
Changed — Realtime: drop plaintext signing-key column (F1)
migrations/0032_drop_plaintext_realtime_signing_key.sql— guard query +ALTER TABLE app_secrets DROP COLUMN IF EXISTS realtime_signing_key. The guard refuses to apply if any row still has plaintext but no encrypted counterpart.- v1.1.7's
migrate_plaintext_keysstartup task and its call frombuild_appare deleted; the read path now consults only the encrypted columns.
Changed — Realtime: auth_mode = 'session' (F3)
migrations/0033_topics_auth_mode_session.sqlwidens thetopics_auth_mode_checkCHECK to allow'session'alongside'public'and'token'.RealtimeAuthorityImplgains aUsersServicedependency; the Session branch ofauthorize_subscribedelegates toverify_session_for_realtime(app_id, token)(same DB lookup + sliding-TTL bump as the SDK'susers::verify, but app_id is taken from the topic row, not a script).- Dashboard Topics tab gains
sessionas a third radio option. token(HMAC) validator is unchanged; both coexist per topic.
Notes
- New env vars (all with documented defaults):
PICLOUD_APP_USER_SESSION_TTL_HOURS(24)PICLOUD_APP_USER_SESSION_ABSOLUTE_HOURS(720 = 30d)PICLOUD_APP_USER_VERIFICATION_TTL_HOURS(48)PICLOUD_APP_USER_PASSWORD_RESET_TTL_HOURS(1)PICLOUD_APP_USER_INVITATION_TTL_DAYS(7)
- SDK schema 1.8 → 1.9 (additive surface:
users::*+sessiontopic auth mode). - Workspace version 1.1.7 → 1.1.8.
@picloud/clientdoes NOT bump in v1.1.8 — itsauth.login/auth.logouthelpers already call dev-defined endpoints; nothing to change in the client.- New weekly GC sweep (
spawn_app_user_token_gc) prunes expired / revoked / consumed rows across the four new token tables.
v1.1.7 — Configuration & Email (unreleased)
The operational-config layer: encrypted per-app secrets, outbound email, and an inbound email trigger — plus the long-missing dead-letter handler wiring and at-rest encryption of the realtime signing key. All at-rest encryption uses a single process master key (AES-256-GCM); key rotation is deferred to v1.2.
Added — Encryption infrastructure
- Process master key from
PICLOUD_SECRET_KEY(base64 of exactly 32 bytes). REQUIRED at startup — an unset or malformed key is fatal. Generate one withopenssl rand -base64 32. A deterministic in-memory dev key is used ONLY whenPICLOUD_SECRET_KEYis unset ANDPICLOUD_DEV_MODE=true(with a prominent startup warning); there is no quiet unencrypted mode. picloud_shared::crypto—encrypt/decryptenvelope:Aes256Gcm, 96-bit CSPRNG nonce, 128-bit auth tag appended to the ciphertext (RustCryptoAeadlayout). Both ciphertext and nonce are stored.- Key rotation is out of scope. Changing
PICLOUD_SECRET_KEYbetween deploys renders all existing ciphertext undecryptable. v1.2+ adds key-version columns + a re-encryption pass.
Added — Encrypted per-app secrets
secrets::{get,set,delete,list}(name)SDK — collection-less, per-app.setaccepts a String/Map/Array (JSON-encoded then encrypted);getreturns the same Rhai type back; missing →(). 64 KB plaintext cap (PICLOUD_SECRET_MAX_VALUE_BYTES).migrations/0023_secrets.sql.- Admin API
GET/POST/DELETE /api/v1/admin/apps/{id}/secrets— list returns names +updated_atonly, never values. - Dashboard Secrets tab — list names + last-modified, create/update (masked value with a confirm-gated reveal), delete with confirm.
Capability::AppSecretsRead/Write(→script:read/script:write). No new Scope variants (seven-scope commitment). Secret writes deliberately do not emit trigger events.
Added — Outbound email
email::send/email::send_htmlSDK over an SMTP relay (lettre). Config fromPICLOUD_SMTP_HOST/PORT/USER/PASSWORD/TLS/ TIMEOUT_SECS; if HOST/USER/PASSWORD aren't all set the service runs in disabled mode (every send throwsNotConfigured, warned at startup). Requiredto/from/subject+ one oftext/html; RFC 5322-ish address validation; 25 MB per-message cap (PICLOUD_EMAIL_MAX_MESSAGE_BYTES);reply_todefaults tofrom. Per-call connection (pooling deferred to v1.2); per-appfromvalidation / SPF / DKIM are the operator's SMTP-relay concern.Capability::AppEmailSend(→script:write).
Added — Inbound email (email:receive trigger)
- Webhook receiver
POST /api/v1/email-inbound/{app_id}/{trigger_id}— a provider (Mailgun / Postmark / SendGrid / SES) POSTs the generic JSON shape{from,to[],cc[],subject,text,html,message_id}; the receiver verifies the optional HMAC signature, normalizes toTriggerEvent::Email, and enqueues an outbox row. 202 accepted, 401 bad/missing signature, 404 missing/wrong-kind/cross-app, 422 malformed. Handlers seectx.event.email.migrations/0024_email_triggers.sql. - Admin
POST /api/v1/admin/apps/{id}/triggers/email+ dashboard form (with the webhook URL + expected payload). The HMACinbound_secretis stored encrypted via the master key (deviation from the original plaintext design — see HANDBACK §7). - Provider-specific payload unmarshallers + inbound attachments → v1.2. Native SMTP listener → v1.3+.
Security/correctness fix (retroactive) — dead_letter handlers
The dead_letter trigger kind has been registerable since v1.1.1 but,
due to missing dispatcher wiring (list_matching_dead_letter had no
production caller), handlers have never fired. Any deploy running
v1.1.1 through v1.1.6 with dead_letter triggers configured has had
silently non-functional handlers. v1.1.7 fixes the wiring; existing
dead_letters rows remain (no migration needed) but only NEW
dead-letter events (post-v1.1.7) trigger handlers. To process older
rows, use the existing admin replay surface to re-enqueue them.
Changed — Realtime signing key encrypted at rest (two-phase)
app_secrets.realtime_signing_key was stored as 32 plaintext bytes. It
is now encrypted with the master key. migrations/0025_encrypt_realtime_keys.sql
adds NULL-able encrypted columns and drops NOT NULL on the plaintext
column; a startup task encrypts pre-existing rows; the read path prefers
the encrypted columns and falls back to plaintext during the compat
window. v1.1.8 will drop the plaintext realtime_signing_key
column — operators should upgrade through v1.1.7 (which performs the
encryption) before v1.1.8.
Notes
- New deps:
aes-gcm(RustCrypto AEAD),lettre(SMTP). - New env vars:
PICLOUD_SECRET_KEY(required),PICLOUD_DEV_MODE,PICLOUD_SECRET_MAX_VALUE_BYTES,PICLOUD_SMTP_HOST/PORT/USER/PASSWORD/ TLS/TIMEOUT_SECS,PICLOUD_EMAIL_MAX_MESSAGE_BYTES. - SDK schema 1.7 → 1.8; dashboard 0.12.0 → 0.13.0.
v1.1.6 — Realtime Channels & Client Library (unreleased)
The first external realtime surface and the first frontend
library, co-shipped per the §5/§6 design-notes decisions. Browser
clients can subscribe over SSE to per-app pub/sub topics that have been
explicitly externalized; everything else stays internal-only. The
@picloud/client TypeScript package wraps typed HTTP, SSE, auth, and
React/Svelte hooks. Plus three v1.1.5 follow-ups.
Added — Realtime
topicsregistry (migrations/0021_topics.sql) — pub/sub topics are internal-only by default; atopicsrow withexternal_subscribable = trueopts one into external SSE subscription.auth_modeis'public'or'token'.- Topic admin endpoints under
/api/v1/admin/apps/{id}/topics—POST(register),GET(list),PATCH /{name}(flip external/auth_mode — its own audited surface),DELETE /{name}(unregister + disconnect live subscribers). Gated by the newCapability::AppTopicManage→app:adminscope (no new scope; the seven-scope commitment holds). - SSE endpoint
GET /realtime/topics/{topic}— data-plane surface (deliberately not under/api/). ResolvesHost→ app, authorizes via theRealtimeAuthority(404 for missing/internal topics, 401 for bad/absent tokens), then streamsdata: {topic,message,published_at}events with a configurable heartbeat (PICLOUD_REALTIME_HEARTBEAT_SEC, default 30). Token viaAuthorization: Beareror?token=. RealtimeBroadcaster+RealtimeEvent+RealtimeAuthoritytraits (picloud-shared); in-processInProcessBroadcaster(tokio::sync::broadcast, per-channel capacityPICLOUD_REALTIME_BROADCAST_CAPACITYdefault 64, periodic empty-channel GC) and the DB-backedRealtimeAuthorityImpl(orchestrator-core / manager-core respectively). The publish path now also fans out to in-process SSE subscribers, best-effort, after the durable outbox fan-out commits — a broadcast failure never fails the publish.pubsub::subscriber_token(topics, ttl)Rhai SDK (SDK schema 1.6 → 1.7) — mints an HMAC-SHA256 subscriber token (URL-safepayload.signature) scoped to externally-subscribable topics. Requires an authenticated principal + the pub/sub publish capability. TTL clamped to[10s, 24h](default 1h), env-overridable viaPICLOUD_SUBSCRIBER_TOKEN_TTL_{MIN,MAX,DEFAULT}_SEC. Per-app signing keys persist in the newapp_secretstable (migrations/0022_app_secrets.sql), created lazily on first mint. No per-token revocation (rotation invalidates wholesale; short TTL is the safety mechanism).- Dashboard Topics tab — register/list/edit/delete topics with a prominent external/internal badge, auth-mode radio (conditional on external), and a confirmation when flipping a topic external.
Added — @picloud/client (TypeScript, v1.0.0)
- New top-level package
clients/typescript/(tsup dual ESM+CJS +.d.ts, vitest). Typed HTTP viaendpoint<Req,Res>(path).get()/.post()with auth-token injection and structured errors; SSEsubscribe(topic, cb, {token, onTokenExpired})with exponential-backoff reconnect, 401 token-refresh, andLast-Event-IDresume;auth.login/logout/tokenover dev-defined endpoints; React (useTopic/useEndpoint+PicloudProvider) and Svelte (topicStore/endpointStore) subpath exports. Optional zod/valibot runtime validation via a{ parse }adapter (no hard dep). Hybrid model: no direct service access from the browser.
Changed / Fixed — v1.1.5 follow-ups
- Empty blobs accepted —
NewFile::validate/FileUpdate::validateno longer reject zero-lengthdata; empty files are a valid stored state (sentinels, placeholders). Non-breaking. - Orphan
*.tmp.*sweeper — a startup tokio task (spawn_files_orphan_sweep) walks the files root everyPICLOUD_FILES_ORPHAN_SWEEP_INTERVAL_SEC(default 6h) and unlinks temp blobs older thanPICLOUD_FILES_ORPHAN_TMP_TTL_SEC(default 1h). No DB cross-check (that full reconciler is v1.3+). - Dispatcher end-to-end tests —
crates/picloud/tests/dispatcher_e2e.rs, one per trigger kind (kv/docs/cron/files/pubsub/dead_letter), DATABASE_URL-gated (skip cleanly when unset).
Notes
- New deps:
hmac(token signing, picloud-shared),tokio-stream(SSE body stream, orchestrator-core). - New env vars:
PICLOUD_REALTIME_HEARTBEAT_SEC,PICLOUD_REALTIME_BROADCAST_CAPACITY,PICLOUD_SUBSCRIBER_TOKEN_TTL_{MIN,MAX,DEFAULT}_SEC,PICLOUD_FILES_ORPHAN_SWEEP_INTERVAL_SEC,PICLOUD_FILES_ORPHAN_TMP_TTL_SEC.
v1.1.5 — Files & Pub/Sub (unreleased)
Two stateful services + two trigger kinds. files::* is
filesystem-backed blob storage (atomic writes, path-sharded layout,
single-pass SHA-256 with checksum-verified reads); the metadata row
lives in Postgres, the bytes on disk. pubsub::publish_durable is
durable pub/sub through the universal outbox, fanning out one delivery
row per matching subscriber at publish time inside a single
transaction. Both ride the v1.1.1 trigger framework as the fifth and
sixth concrete kinds via the established Layout-E extension pattern.
Added
files::collection(name).{create,head,get,update,delete,list}— blob storage SDK.create/updatetake a RhaiBlob;getreturns aBlob(or()if missing);head/listreturn metadata maps (id, name, content_type, size, checksum, created_at, updated_at).create/update/deletethrow on failure;get/headreturn()for a missing file;deletereturns a was-present bool. Missing required field oncreatethrows naming the field.- Atomic writes — temp file → fsync → rename → fsync parent dir →
DB row, so a crash never leaves a readable half-written file. SHA-256
is computed in a single pass during the write;
getre-verifies it and surfacesFilesError::Corrupted(logged with the path, never auto-deleted) on a mismatch. Shard dirs are created0o700. files:*trigger kind —ctx.event.filescarries the metadata only (never the bytes; a handler that wants them callsfiles::collection(c).get(id)).previs()on create, the prior metadata on update, the deleted metadata on delete.pubsub::publish_durable(topic, message)— durable publish. Message is any JSON-serializable Rhai value; Blobs encode as base64 (at any nesting depth). No matching subscriber → the publish succeeds silently with zero outbox rows.pubsub:*trigger kind — topic patterns are exact,<prefix>.*, or*; mid-pattern wildcards are rejected at trigger creation.ctx.event.pubsubcarriestopic,message,published_at.FilesService+PubsubServicetraits (picloud-shared) +FsFilesRepo/FilesServiceImplandPostgresPubsubRepo/PubsubServiceImpl(manager-core). Wired into theServicesbundle asfilesandpubsub.- Capabilities
AppFilesRead/AppFilesWrite→script:read/script:write,AppPubsubPublish→script:write. No newScopevariant — the seven-scope commitment holds. Script-as-gate: skipped when the script runs unauthenticated. - Admin files API (
GET/DELETE /apps/{id}/files) + dashboard Files view per app; Pub/Sub trigger form on the Triggers tab. - CI — first
.github/workflows/ci.yml(Postgres service, fmt + clippy +cargo test --workspace); the schema-snapshot guardrail now runs instead of being#[ignore]'d.
Changed
- Workspace version: 1.1.4 → 1.1.5
- Rhai SDK version: 1.5 → 1.6
- Dashboard version: 0.10.0 → 0.11.0
schema_snapshottest: no longer#[ignore]'d — runs againstDATABASE_URLwhen set, skips cleanly when absent.
Migrations
- 0018_files.sql —
filesmetadata table (bytes live on disk). - 0019_files_triggers.sql — widen kind/source_kind CHECKs + add
files_trigger_details. - 0020_pubsub_triggers.sql — widen kind/source_kind CHECKs + add
pubsub_trigger_details+ partial index.
New environment variables
PICLOUD_FILES_ROOT(default./data)PICLOUD_FILES_MAX_FILE_SIZE_BYTES(default 100 MB)
v1.1.4 — Outbound HTTP & Cron triggers (unreleased)
Two surfaces. http::* lets Rhai scripts make outbound HTTP
requests (Slack webhooks, Stripe, third-party REST) fronted by an SSRF
deny-list applied to the resolved IP (DNS-rebinding defense), with
scheme/port restrictions, request/response body caps, and a layered
timeout. Cron triggers add the fourth concrete kind on the v1.1.1
trigger framework: a scheduler task enqueues due triggers into the same
universal outbox the dispatcher already drains.
Added
http::{get,post,put,patch,delete,head,post_form,request}— outbound HTTP SDK. Body and options are separate positional args (verb(url, body, opts));optsis{headers, timeout_ms, follow_redirects, max_redirects}(unknown keys throw). Body dispatch by type: Map/Array → JSON, String → text/plain,()→ none. Response is#{ status, headers, body, body_raw }withbodyauto-parsed when the response isapplication/json. Non-2xx does NOT throw (fetch-style); network/timeout/SSRF/size errors throw with an"http: …"prefix.- SSRF deny-list — applied to the resolved IP via a custom reqwest
dns_resolver(so it covers every redirect hop and defeats DNS rebinding), plus a literal-IP check at URL-parse time. Blocks loopback, RFC1918 private, link-local (incl.169.254.169.254), carrier-grade NAT, multicast, reserved, IPv6 ULA/link-local/loopback, and IPv4-mapped IPv6 (re-checked against the embedded v4 address). The script-visible error carries a CIDR-category reason, never the IP.PICLOUD_HTTP_ALLOW_PRIVATE=truedisables it (dev-only; logs a startup warning). HttpServicetrait (picloud-shared) +HttpServiceImpl(manager-core, reqwest-backed). Wired into theServicesbundle ashttp: Arc<dyn HttpService>.Capability::AppHttpRequest(AppId)— maps to the existingscript:writescope (any outbound request can exfiltrate data, so the conservative write mapping is used). No newScopevariant — the seven-scope commitment holds. Script-as-gate: skipped when the script runs unauthenticated.- Cron triggers —
POST /api/v1/admin/apps/{id}/triggers/cron(script_id,schedule,timezone, optional retry overrides). 6-field cron expressions (with seconds) validated by thecroncrate; IANA timezones validated bychrono-tz. A scheduler task (spawn_cron_scheduler, poll cadencePICLOUD_CRON_TICK_INTERVAL_MS, default 30s) enqueues due triggers into the outbox; the existing dispatcher delivers them. Catch-up policy: a trigger that missed N windows fires exactly once on the next tick, not N times. ctx.event.cron—{ schedule, timezone, scheduled_at, fired_at }for cron-trigger handlers (ctx.event.source == "cron",ctx.event.op == "tick").- Dashboard Triggers tab — admin-gated cron trigger create form (target endpoint script, schedule, timezone dropdown) + triggers list showing schedule / timezone / last-fired.
Changed
- Workspace version:
1.1.3→1.1.4. - Rhai SDK version:
1.4→1.5(additive —http::*SDK +ctx.event.cron). TheServicesbundle constructor becomesServices::new(kv, docs, dead_letters, events, modules, http). - Dashboard version:
0.9.0→0.10.0. SdkCallCx— gains ascript_idfield (audit attribution + the default outboundUser-Agent,picloud/<version> (script:<id>)).- Rhai pin tightened — workspace dep
rhai = "1.19"→rhai = "=1.24"so future bumps of the non-semver-stableinternalssurface are deliberate. - Module backend errors redacted —
PicloudModuleResolvernow surfaces a stable generic ("module backend unavailable; check server logs") to scripts and logs the original at error level, instead of leaking the backend error verbatim (see v1.1.3 follow-up).
Migrations
0017_cron_triggers.sql— widenstriggers.kindandoutbox.source_kindCHECK constraints to include'cron'; addscron_trigger_details (trigger_id, schedule, timezone, last_fired_at)with alast_fired_atindex. Additive — applies cleanly on a fresh DB and on top of the v1.1.3 schema.
New environment variables
PICLOUD_HTTP_ALLOW_PRIVATE(default false; dev-only) — disable the SSRF deny-list.PICLOUD_HTTP_MAX_REQUEST_BODY_BYTES/PICLOUD_HTTP_MAX_RESPONSE_BODY_BYTES(default 10 MB each).PICLOUD_CRON_TICK_INTERVAL_MS(default 30000) — cron scheduler poll cadence (floored at 1s).
v1.1.3 — Modules (unreleased)
Real per-app Rhai module system. Scripts can import "<name>" as <alias>; other scripts in the same app as reusable libraries. The
v1.0 placeholder DummyModuleResolver is replaced by a per-call
PicloudModuleResolver that loads kind = 'module' scripts via a
new ModuleSource trait, compiles them into Rhai modules, caches
the compiled output, and enforces cross-app isolation, circular-
import detection, and an import-depth limit. Two LRU AST caches
(top-level script + per-module compiled module) eliminate the
per-invocation compile cost; both invalidate on updated_at change.
Added
scripts.kindcolumn —'endpoint' | 'module', default'endpoint'. Endpoints handle HTTP routes / trigger events; modules are libraries imported by other scripts. The dashboard scripts list + script detail page surface the distinction as a colored badge.script_importsdep-graph table — populated at script save- time from the literal-pathimport "<name>"declarations in the source. FK-CASCADE on both columns. No admin surface in v1.1.3 (drives a v1.2+ "Used by" dashboard panel and v1.3+ cluster-mode eager invalidation).ModuleSourcetrait —lookup(&SdkCallCx, name). Postgres implPostgresModuleSourcein manager-core.app_idderived fromcx.app_id(cross-app isolation boundary, mirrors KV / docs).PicloudModuleResolver— implementsrhai::ModuleResolver. Per-call instance ownsArc<SdkCallCx>, the in-progress imports stack, the depth counter. Bridges syncresolve()to asynclookup()viaHandle::block_on(safe under the executor'sspawn_blockingwrap). ReplacesDummyModuleResolverat line 139 ofexecutor-core::engine::build_engine.- Module-shape validation —
kind = 'module'source must contain onlyfndeclarations,constdeclarations, andimportstatements at top level (no executable expressions). Walksast.statements()viarhai/internals. Admin endpoint is the primary gate; the resolver re-runs the check at load time for defense in depth against DB-direct inserts. - Per-module compiled-Module cache —
LruCache<(AppId, name), (updated_at, Arc<rhai::Module>)>owned byEngine. Invalidated lazily onupdated_atmismatch. Size viaPICLOUD_MODULE_CACHE_SIZE(default 512). - Top-level script AST cache —
LruCache<ScriptId, (updated_at, Arc<rhai::AST>)>owned byLocalExecutorClient. Same staleness semantics. Size viaPICLOUD_SCRIPT_CACHE_SIZE(default 256). ScriptIdentity+ExecutorClient::execute_with_identity— new method on the trait; default impl forwards toexecutesoRemoteExecutorClient(and future transports) keep working.LocalExecutorClientoverrides it to consult the script cache and pass the resultingArc<rhai::AST>toEngine::execute_ast.Engine::execute_ast— companion toexecutethat takes a pre-compiled AST so callers (the orchestrator) can reuse one compile across many invocations.- Import depth limit —
Limits::module_import_depth_max(default 8). Not script-overridable. - Reserved module names — module-kind scripts cannot be named
log,regex,random,time,json,base64,hex,url,kv,docs,dead_letters,http,files,pubsub,secrets,email,users,queue. Defense against author confusion with stdlib namespaces.
Changed
- Workspace version:
1.1.2→1.1.3. - Rhai SDK version:
1.3→1.4(additive — every v1.3 script still runs unchanged; new surface:import "<name>" as <alias>;for endpoint scripts that consume modules in the same app). - Dashboard version:
0.8.0→0.9.0. Adds kind dropdown on script create + kind badges on the scripts list and detail page. Servicesbundle — grows amodules: Arc<dyn ModuleSource>field. Constructor signature becomesServices::new(kv, docs, dead_letters, events, modules).ScriptValidatortrait —validatenow returnsValidatedScript { imports: Vec<String> }so the repo can write dep-graph edges in the same transaction as the script row. Newvalidate_modulemethod enforces module-shape rules.- Trigger creation tightening —
POST /api/v1/admin/apps/{id}/triggers/{kv,docs,dead_letter}now load the target script and reject when (1) it doesn't exist, (2) it belongs to a different app (latent v1.1.1/v1.1.2 gap — closed in v1.1.3), or (3) it iskind = 'module'. - Route creation —
POST /api/v1/admin/scripts/{id}/routesreturns 400 when the target script iskind = 'module'.
Security fix
- Cross-app trigger target (CVE-class: broken access control). In
v1.1.1 and v1.1.2,
POST /api/v1/admin/apps/{id}/triggers/{kv,docs,dead_letter}validated only that the caller could manage triggers on{id}— it did not verify that the targetscript_idbelonged to that same app. A member with trigger-management rights on app A could therefore register a trigger in A pointing at a script owned by app B, causing B's script to execute on A's events (a cross-app isolation break). v1.1.3 closes this: every trigger-create handler now loads the target script and rejects it unlessscript.app_id == path app_id(and it is not a module). Upgrade recommendation: anyone running a pre-v1.1.3 multi-tenant deploy should upgrade and audit existingtriggersrows for any whosescript_idresolves to a script in a differentapp_id.
Migrations
0015_scripts_kind.sql— addsscripts.kindwith CHECKIN ('endpoint','module'), composite index(app_id, kind), and a module-name shape CHECK (^[a-zA-Z_][a-zA-Z0-9_]{0,63}$).0016_script_imports.sql— adds the dep-graph table with FK CASCADE on both columns, PK(importer, imported), and a reverse-edge index onimported_script_id.
Downgrade caveats
Rolling back v1.1.3 → v1.1.2 with module-kind scripts present
strands them (no kind column means everything looks like an
endpoint; modules will then succeed as route targets and immediately
fail to execute meaningfully). Migration 0016_script_imports.sql
is safe to drop (the table is auxiliary). 0015_scripts_kind.sql
must be reversed by DROP COLUMN kind only after manually re-homing
or deleting module-kind rows.
v1.1.2 — Documents (unreleased)
docs::* SDK — schemaless JSONB document storage with a first-cut
query DSL — plus docs:* triggers as the second concrete kind on the
v1.1.1 triggers framework. Sets the precedent for the v1.2 query DSL
expansion and dead_letters::list.
Added
- Docs store —
docstable keyed(app_id, collection, id)with JSONB values and a GIN-on-jsonb_path_opsindex. Rhai SDK exposes the handle pattern:docs::collection(name).{create,get,find,find_one,update,delete,list}. Cursor-style pagination onlist. Cross-app isolation enforced viacx.app_id(never script-passed). Document envelope shape returned by reads:#{ id, data: #{...}, created_at, updated_at }— explicit metadata + user-data separation (sets precedent for v1.2dead_letters::list). - Query DSL (v1.1.2 subset) — implicit equality at top level
(
#{ tier: "gold" }), operator-object form (#{ created_at: #{ "$gt": "..." } }), dotted field paths up to 5 levels ("user.email"), and operators$eq/$ne/$gt/$gte/$lt/$lte/$in. Filter modifiers$sort(single field) and$limit. Unsupported operators ($or,$regex, etc.) reject with a clear v1.2-pointer error. - Docs triggers (
docs:*) —docs_trigger_detailstable mirrorskv_trigger_details. Admin endpointPOST /api/v1/admin/apps/{id}/triggers/docsaccepts the same DTO shape as the KV endpoint withopsofDocsEventOp(create / update / delete). Dispatcher routesOutboxSourceKind::Docsthrough the same generic path as KV + dead-letter. ctx.event.docs.prev_data— change-data-capture surface for docs trigger handlers.prev_datacarries the document state prior to the mutation (Nonefor create), letting handlers see what changed. The repo reads the old row in the same SQL statement as the write so the trigger event has the prior value.Capability::AppDocsRead(AppId)+AppDocsWrite(AppId)— granted to Viewer / Editor respectively in the per-app role table. Same trust shape as KV'sAppKvRead/AppKvWrite.
Changed
- Workspace version:
1.1.1→1.1.2. - Rhai SDK version:
1.2→1.3(additive — every v1.2 script still runs unchanged; new surfaces:docs::collection(name).{...},ctx.event.docsfor triggered handlers). - Dashboard version:
0.7.0→0.8.0. Workspace alignment; no docs-specific UI in v1.1.2 (the dashboard's Rhai-mode hints don't list KV completions either — focused UX pass is a separate task). Servicesbundle — grows adocs: Arc<dyn DocsService>field. Constructor signature becomesServices::new(kv, docs, dead_letters, events).- Scope mapping: API keys with
script:readscope can calldocs::find/get/list;script:writecan calldocs::create/update/delete. Same trust shape as KV — honors the seven-scope commitment from v1.1.0.
Migrations
0013_docs.sql—docstable + per-(app_id, collection)index + GIN-on-jsonb_path_opsindex.0014_docs_triggers.sql— extendstriggers.kindandoutbox.source_kindCHECK constraints to include'docs'; addsdocs_trigger_detailstable.
Downgrade caveats
Rolling a deployment back from v1.1.2 → v1.1.1 with docs-source
outbox rows still queued will cause the v1.1.1 dispatcher to fail
deserialising TriggerEvent::Docs (#[serde(tag = "source")]
rejects unknown variants). Drain or delete
outbox WHERE source_kind = 'docs' before downgrading. Trunk-only
deployments don't hit this.
Known limitations
- Text-lex comparison for
$gt/$gte/$lt/$lteis incorrect for unpadded numbers crossing digit-count boundaries ('10' < '9'is TRUE under any text collation). Workaround: zero-pad numeric strings. v1.2's advanced query expansion adds numeric-aware operators. - Concurrent
update()s on the same doc may both emit the pre-updateprev_data(last-writer-wins). Inherited from KV'ssetpattern; documented for forensic-trace use cases. - v1.1.2 has no partial-update DSL — scripts that want partial
update do
get + modify + update. Planned for v1.2.
v1.1.1 — Storage & Events (unreleased)
The triggers framework — KV store + universal outbox + dispatcher + NATS-style sync HTTP + per-route async dispatch + dead-letter handling + dashboard surface. Every subsequent v1.1.x service module (docs, files, pubsub, …) hangs off the dispatcher built here.
Added
- KV store —
kv_entriestable keyed(app_id, collection, key)with JSONB values. Rhai SDK exposes the handle pattern:kv::collection(name).{get,set,has,delete,list}. Cursor-style pagination with opaque base64 cursors. Cross-app isolation enforced viacx.app_id(never script-passed). - Triggers framework (Layout E) — parent
triggerstable + per-kind detail tables (kv_trigger_details,dead_letter_trigger_details). Trigger CRUD admin endpoints (/api/v1/admin/apps/{id}/triggers/{kv,dead_letter}) +Capability::AppManageTriggers(AppId). - Universal outbox + dispatcher — single tokio task that polls
the outbox via
FOR UPDATE SKIP LOCKED, routes due rows to the executor through the sharedExecutionGate. Retry with exponential backoff + ±jitter; on exhaustion, dead-letter. - NATS-style sync HTTP via outbox —
InboxRegistry(in-process oneshot map) lets the orchestrator await dispatcher delivery on every sync HTTP request. Cluster mode (v1.3+) swaps this forLISTEN/NOTIFYbehind the sameInboxResolvertrait. dispatch_mode: asyncon routes —POSTto a route withdispatch_mode = 'async'returns202 Acceptedimmediately; the script runs via the dispatcher (with retries / dead-letter).- Dead-letter handling — separate
dead_letterstable per design notes §4.dead_letters::{replay,resolve}Rhai SDK + admin endpoints +Capability::AppDeadLetterManage(AppId). Recursion-stop rule: dead-letter handler failures annotate the original row asresolution = 'handler_failed'and never produce a new dead-letter or retry. - Dashboard surface for dead letters — unresolved-count red
badge on the apps list + per-app page; per-app dead-letters list
view at
/admin/apps/{slug}/dead-letterswith Replay + Mark resolved per-row actions and expandable payload detail. abandoned_executionstable — forensic row written by the dispatcher when it tries to resolve an inbox the orchestrator already abandoned (timed out). Counter metric path reserved.- Trigger-depth limit —
cx.trigger_depth > max_trigger_depth(default 8) skips execution + logs; does NOT dead-letter (depth-exceeded means "you built a loop"). - GC sweepers — weekly retention sweeps for
dead_letters(30 days) andabandoned_executions(7 days), both withFOR UPDATE SKIP LOCKEDfor cluster-mode safety. - Env-overridable trigger config —
TriggerConfig::from_envreadsPICLOUD_MAX_TRIGGER_DEPTH,PICLOUD_TRIGGER_RETRY_*,PICLOUD_DEAD_LETTER_RETENTION_DAYS,PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS.
Changed
- Workspace version:
1.1.0→1.1.1. - Rhai SDK version:
1.1→1.2(additive — every v1.1 script still runs unchanged; new surfaces:kv::*,dead_letters::*,ctx.eventfor triggered handlers). - Dashboard version:
0.6.0→0.7.0for the dead-letters UI. Servicesbundle — replaces v1.1.0's no-argServices::new()with explicitServices::new(kv, dead_letters, events). Tests useServices::default()for an all-noop bundle.SdkCallCxgrowsis_dead_letter_handler: boolandevent: Option<TriggerEvent>fields.ExecRequestmirrors the newSdkCallCxfields and growseventfor serializable trigger payload transport.- Routes table grows
dispatch_mode TEXT NOT NULL DEFAULT 'sync'(CHECK in {sync, async}). - Schema version: 6 → 12 (migrations 0007 through 0012).
Migrations
0007_kv.sql—kv_entriestable + index0008_triggers.sql—triggers+kv_trigger_details+dead_letter_trigger_details0009_outbox.sql— universaloutboxtable + due-row partial index0010_dead_letters.sql—dead_letterstable + unresolved partial index + GC index0011_abandoned_executions.sql— forensic table + GC index0012_routes_dispatch_mode.sql—routes.dispatch_modecolumn
v1.1.0 — Foundation & Standard Library
See docs/v1.1.x-design-notes.md §7 for the full v1.1.x roadmap.