Server-side realtime SSE on per-app pub/sub topics, plus the three
v1.1.5 follow-ups and the version bumps.
Realtime:
- topics registry (0021) + admin endpoints + Capability::AppTopicManage
(-> app:admin; no new scope).
- GET /realtime/topics/{topic} SSE endpoint (orchestrator-core data
plane): Host -> app, RealtimeAuthority gate (404 missing/internal,
401 bad/absent token), broadcast::Receiver stream + heartbeat.
- RealtimeBroadcaster / RealtimeEvent / RealtimeAuthority traits
(picloud-shared); InProcessBroadcaster + GC (orchestrator-core);
DB-backed RealtimeAuthorityImpl (manager-core). Publish path fans out
to in-process subscribers after the durable outbox commit (best-effort,
panic-isolated).
- HMAC subscriber tokens (subscriber_token.rs) + app_secrets table (0022)
+ pubsub::subscriber_token SDK (schema 1.6 -> 1.7). TTL clamp + env
overrides.
- Dashboard Topics tab (register/list/edit/delete, prominent external
badge, flip confirmation).
v1.1.5 follow-ups:
- Empty blobs accepted (NewFile/FileUpdate::validate) + round-trip test.
- Orphan *.tmp.* sweeper (spawn_files_orphan_sweep).
- Dispatcher e2e tests, one per trigger kind (DATABASE_URL-gated).
Versions: workspace 1.1.6, SDK 1.7, dashboard 0.12.0. Schema-snapshot
golden re-blessed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
27 KiB
PiCloud Changelog
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.