A WordPress-inspired CMS whose entire backend is PiCloud Rhai scripts deployed with pic apply, plus a SvelteKit frontend. Built to dogfood the project tool, CLI, and SDK; exercises docs/kv/files/users, docs+queue+cron +pubsub triggers, the transactional-outbox notification chain, a docs before-interceptor, set_if CAS, SSE, per-app CORS, env overlays, and a durable Workflow (validate -> enrich || seo -> publish -> finalize) started with workflow::start and polled with workflow::run_status, visualized live with Svelte Flow. FINDINGS.md / SECURITY.md capture every gap, surprise, and security issue found (each tagged [PiCloud] vs [CMS]); the platform fixes for the actionable ones ship in the preceding commit. Also folds in the read-only `pic` allowlist entries added to .claude/settings.json during the session (fewer-permission-prompts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
34 KiB
PiCloud CMS PoC — Findings Log
Running log of gaps, inconsistencies, surprises, and issues encountered while building a WordPress-inspired CMS on PiCloud. Each entry is tagged [PiCloud] (platform gap/footgun/bug) or [CMS] (our own code). Security findings live in SECURITY.md.
Severity: 🔴 blocker · 🟠 significant · 🟡 minor/ergonomic · 🔵 note/observation
Re-verification — fix/cms-poc-findings (2026-07-18)
A platform agent fixed the actionable findings. I rebuilt picloud + pic on
that branch, redeployed the CMS onto the same DB, ran a 12/12 regression
sweep (all existing flows green), then verified each fix live and re-ran the
async chains. All 7 fixes confirmed; no regressions.
A second round then closed the two actionable workflow findings (F-037
reachability, F-038 workflow::run_status). Both verified live, and the CMS
was refactored to adopt workflow::run_status — the wfruns KV / wf_lib
run-tracking workaround is now deleted (see the F-037/F-038 rows below).
| Finding | Fix | Live re-verification |
|---|---|---|
| S-01/S-12/F-011 502 info-leak | shared scrub_runtime_detail now also on the user-route (inbox) path |
✅ throw #{…"SECRET_INTERNAL_MARKER"…} on a user route → client got {"error":"script execution error (ref: <uuid>)"} (502), no marker / app-id / line / func; server log kept the full detail under the same correlation_id |
| F-015 docs array-membership | $contains (JSONB @>) |
✅ rewrote posts_list to tags: #{ "$contains": tag } — DB-side filter with real $limit: picloud→3, meta→1, comments→1, nope→0 |
| F-026 JSON-only bodies | content-type-aware bodies (form→object, text/*→string, binary→base64) + body_base64 responses |
✅ raw --data-binary PNG upload stored byte-exact; media_get now serves real image/png bytes (cmp = equal); form-urlencoded login → 200+token; bad JSON still 400 |
| F-030 no CORS | per-app allow-list (pic apps cors set), Origin echo + OPTIONS preflight; migration 0077 |
✅ default-off preserved; matching Origin echoed; preflight → 204 with allow-methods/headers/max-age; foreign origin not echoed |
| F-012 no role CLI | pic users add-role / rm-role + admin API |
✅ granted author to a reader and revoked it, verified via the users API |
| F-006 opaque apply 404 | actionable message | ✅ app not found: ghost-app-xyz — apply does not create apps; create it first with \pic apps create ghost-app-xyz`…` |
| F-021 interceptor "unreachable" warning | interceptor bindings count as reachability | ✅ plan no longer warns that comment_sanitize has no route/trigger |
| F-037 workflow-step "unreachable" warning | reachability set also counts [[workflows]] step functions |
✅ plan emits zero warnings for the five wf_* step scripts |
| F-038 workflow runs unobservable from a script | new read-only workflow::run_status(run_id) SDK (app-scoped) |
✅ adopted — deleted the wfruns/wf_lib workaround; pipeline_run reads run_status directly. Happy path → all succeeded/completed; gated path → skipped/blocked, post stayed draft; foreign run id → ()/404 |
New finding from re-testing (a small consequence of the F-026 fix):
F-035 🟡 [CMS] F-026 shifts body-shape validation into every script
Now that non-JSON bodies reach the script (text→string, binary→base64), an
endpoint that assumes ctx.request.body is a map will throw → 502 if a
client sends text/plain/binary. Observed: text/plain to /cms/auth/login
(which did body.email) → 502 (correctly scrubbed, but a needless error).
Pre-fix, the platform rejected the non-JSON body at 400 before the script ran.
Fix in-CMS: guard if type_of(b) != "map" { return 400 } on JSON endpoints
(applied to login; register/media already did). Attribution [CMS], but a
note for the platform: the extra flexibility moves content-type discipline onto
every author — a per-route "expected content types" declaration could help.
The remaining open items are the ones the fix agent deliberately deferred
(documented, not regressions): S-02 platform per-row authz (fundamental design;
its 502 amplifier is now removed), F-011 throw-as-envelope semantics (scrub-only
by choice), F-034 wildcard SSE topics, S-09/S-10 platform auth rate-limit &
httpOnly cookies, F-002 pic/groff name clash, F-022 version skew.
Summary — the findings that most affect building on PiCloud
Most significant friction (🟠):
- F-006
pic applycan't create an app — only groups; the first deploy needspic apps createfirst. - F-007
/api/(and/admin/) are reserved route prefixes — the obvious REST layout is rejected; namespace elsewhere. - F-011
throw #{statusCode}becomes a 502 that leaks the app id + script internals — the response-envelope convention is return-only; a footgun for authz aborts (and an info-leak — see SECURITY S-01). - F-012 No CLI/API to grant an app-user a role — role assignment is script-only; you must build a bootstrap endpoint.
- F-015 The docs filter DSL can't match membership inside an array field (no
$contains/@>) — "posts with tag X" isn't expressible. - F-026 User routes are JSON-in/JSON-out only — no form posts, no raw binary uploads, no binary responses.
- F-030 No CORS and no config for it — a cross-origin SPA can't call the API; you must reverse-proxy or bundle same-origin.
Ergonomic gaps (🟡): F-001 (SMTP env vars ≠ docs), F-004 (host_kind/path_kind enum values), F-005 (inline [vars.env] silently misparses), F-008 (kebab-only var keys), F-017 (docs envelope shape), F-019/F-020 (interceptor is per-service not per-collection, create-only), F-029 (DSL timestamp compare is lexical), F-034 (SSE topics are concrete-only).
Genuine strengths (🟢): F-010 (plan compiles Rhai server-side), F-013/F-014 (users:: + roles, module imports), F-016 (set_if CAS + kv index), F-018 (docs before interceptor transform), F-023/F-028 (docs→queue→email + cron trigger chains), F-031 (env overlays + safe --prune), F-032 (one script → many routes), F-033 (pub/sub → public SSE). Plus SDK security wins: S-06 (email header injection prevented), S-07 (SSRF blocked by default), S-08 (secrets never plaintext).
Headline security posture: a public route holds full app authority with no per-row authz (S-02), and uncaught errors leak internals via 502 (S-01). Every access decision is app code; one missing guard() is a silent full bypass. See SECURITY.md.
S0 — Infra & environment
F-001 🟡 [PiCloud] SMTP relay env vars differ from documentation
What: To relay email::send through a real SMTP server (Mailpit), the docs / CLAUDE.md and onboarding notes suggested PICLOUD_SMTP_HOST/PORT/FROM/USERNAME/PASSWORD/REQUIRE_TLS. The actual env vars (crates/manager-core/src/email_service.rs:208-236) are:
PICLOUD_SMTP_HOST,PICLOUD_SMTP_USER,PICLOUD_SMTP_PASSWORD— all three required or the relay stays disabled (falls back to dev capture sink).PICLOUD_SMTP_PORT(default 587/465 depending on TLS),PICLOUD_SMTP_TLS(implicit/starttls/none),PICLOUD_SMTP_TIMEOUT_SECS.- There is NO
PICLOUD_SMTP_FROM— the sender is taken from eachemail::send(#{ from })call.USERnotUSERNAME;TLSnotREQUIRE_TLS.
Impact: Setting PICLOUD_SMTP_HOST/PORT/FROM alone (as the docs imply) silently leaves email in dev-capture mode — the log line email DEV CAPTURE ... is the only signal. Confusing for a first-time operator.
Surprise/why it matters: the relay is all-or-nothing on three vars, and one of the "documented" vars (FROM) doesn't exist while the SDK requires from per-call. For Mailpit (no TLS, accepts any auth) the working combo is:
PICLOUD_SMTP_HOST=127.0.0.1 PICLOUD_SMTP_USER=x PICLOUD_SMTP_PASSWORD=x PICLOUD_SMTP_PORT=1025 PICLOUD_SMTP_TLS=none.
F-002 🔵 [PiCloud] CLI binary name collision with groff
What: The PiCloud CLI builds as pic, but /usr/bin/pic on this host is groff's pic (picture preprocessor). A naive pic ... invocation runs groff, not PiCloud. Had to use ./target/debug/pic explicitly. Minor, but a packaging/name-collision worth noting for a tool meant to be installed on developer machines.
F-003 🔵 [PiCloud] No per-kind docs/pubsub/email/queue/files trigger CLI wrapper
What: pic triggers only has per-kind create wrappers for some kinds; docs/files/pubsub/email/queue triggers must go through pic triggers create-from-json OR the declarative manifest. Fine for us (we use the manifest), but noted: the imperative CLI is not at parity with the declarative surface.
F-004 🟡 [PiCloud] host_kind / path_kind enum values are non-obvious
What: Route host_kind values are any / strict / wildcard (NOT exact), and path_kind values are exact / prefix / param (NOT pattern/regex). Several summaries/docs say host_kind = "exact" or path_kind = "regex", which fail deny_unknown_fields/enum parsing. Path parameters (:slug) require path_kind = "param" — using exact with a :slug in the path would match the literal string :slug, not capture it. (Source: crates/shared/src/route.rs:14-38.)
S1 — Skeleton apply & routing
F-005 🟡 [PiCloud] Env overlays are separate files; inline [vars.<env>] silently misparses
What: pic apply --env dev merges a separate picloud.<env>.toml overlay file. Putting [vars.dev] inline in the base picloud.toml does NOT create a dev-env overlay — because the base vars field is a map<string, toml::Value>, [vars.dev] parses as a var literally named dev whose value is a table. No error is raised; it just silently produces a bogus var. Overlay files may carry [app] (slug/name), [secrets], [vars]. Footgun: the natural inline form is silently wrong.
F-006 🟠 [PiCloud] pic apply cannot CREATE an app — only groups
What: The declarative tree apply (/tree/apply) reconciles the group tree SHAPE (creates groups) but for an app node calls resolve_app, which throws AppNotFound if the app doesn't exist (apply_service.rs:3358-3366). So the very first deploy of an app fails with HTTP 404: app not found: cms until you run pic apps create <slug> imperatively. pic init scaffolds the manifest but does not create the app. Surprise: contradicts the "declarative, one-command" framing — the app is a manual prerequisite; only groups are declaratively creatable. Also: a [project] block over a bare app (no group) shows ownership: unclaimed at plan and is effectively inert — apps are only claimed via a nearest-ancestor group, so single-app repos can't exercise ownership at all.
F-007 🟠 [PiCloud] /api/ and /admin/ are reserved route prefixes — a headless-CMS gotcha
What: User routes may not start with /api/, /admin/, /healthz, /version (422 path ... is reserved). The instinctive REST layout (/api/posts, /api/admin/posts) is rejected wholesale because /api/ is the platform control plane. Had to namespace the whole CMS under /cms/.... Impact: any app modelling itself as an HTTP API must avoid the single most conventional prefix; the reservation is a hard, instance-global rule not scoped per app/host.
F-008 🟡 [PiCloud] vars keys are kebab-case only ([a-z0-9-], no underscores)
What: A var key like comment_moderation is rejected: "must be 1–128 chars of lowercase letters, digits, and hyphens, starting with a letter or digit." So site_title, posts_per_page, etc. are all invalid — must be site-title, posts-per-page. Meanwhile Rhai map literal keys can't contain hyphens (parsed as minus), so the two namespaces diverge: vars::get("site-title") (kebab string) feeding a Rhai field site_title: (snake identifier). Mildly confusing; easy to trip on. (Secrets, by contrast — see later — and script/route names have their own rules.)
F-009 🔵 [PiCloud] A new app's routes 404 until it claims the request Host
What: Two-phase dispatch resolves Host→app first (most-specific domain claim wins; Any matches everything, Strict beats it). A freshly-created app has no domain claim, so every route 404s (no route matches GET /cms/hello) even though pic apply reported the route created. The seeded default app claims localhost (exact), so on a dev box localhost:8081 resolves to default, and a new app is unreachable there until you move or add a host claim. This IS documented in pic apps domains --help, but it's an easy-to-miss step between "apply succeeded" and "route works." Resolution for this PoC: pic apps domains rm default <id> + pic apps domains add cms localhost.
F-010 🟢 [PiCloud] positive: pic plan compiles Rhai server-side
What: pic plan/apply compile every script's Rhai source on the server and reject syntax errors at plan time (invalid script source: Expecting ':' ... (line 6, position 13)), with line/column. Nice — you catch a broken script before it's deployed, not at first request. (Minor CLI nit: pic routes ls takes a positional SCRIPT_ID, not --app, unlike most other ls commands which accept --app.)
S2 — Auth (app-users + roles)
F-011 🟠 [PiCloud] throw #{ statusCode } becomes a 502 that leaks internals — envelope convention is return-only
What: The response-envelope convention (#{ statusCode, headers, body }) applies ONLY to a returned value. Throwing the same map does not produce that HTTP status — the executor treats it as an uncaught runtime error and returns HTTP 502 with a body like:
{"error":"Runtime error: #{\"body\": #{\"error\": \"unauthenticated\"}, \"statusCode\": 401} @ 'app:2cced4ba-...-...' (line 43, position 18)\nin call to function 'require_user' ... (line 3, position 15)"}
Two problems: (1) the intuitive throw #{statusCode:403} for authz aborts is silently a 502, not a 403; (2) the 502 body leaks the app UUID, function names, and line/column of the script (info disclosure — see SECURITY S-01). Mitigation in this CMS: a non-throwing auth::guard() that RETURNS {ok,response}; the caller returns g.response. Any endpoint author reaching for throw to signal an HTTP error will get this wrong.
F-012 🟠 [PiCloud] No CLI/API to grant an app-user a role — role assignment is script-only
What: pic users can list/inspect users, mint reset tokens, revoke sessions — but there is no command (nor admin HTTP endpoint) to add/remove an app-user role. users::add_role exists only inside Rhai. So bootstrapping the very first admin/author requires writing a dedicated script endpoint (here auth_bootstrap, gated by a setup-token secret). An operator cannot promote a user from the outside; every role change must be mediated by app code. Ergonomic gap for an admin/RBAC-heavy app.
F-013 🟢 [PiCloud] positive: the users:: app-user system is solid & ergonomic
What: users::create/login/verify/logout/has_role/add_role/email_available all behaved exactly as documented, returning () for absent and tokens/bools cleanly. email_available is the anon-safe probe; login returned a session token; verify round-tripped the user with a sliding TTL. Roles composed nicely. This is the strongest part of the SDK for building an auth layer — the app-user tier being distinct from platform admins is the right model for a CMS's readers+authors.
F-014 🟢 [PiCloud] positive: import "<module>" as m + module funcs calling the SDK works
What: A kind=module script (auth) imported via import "auth" as auth; and its functions call users::* freely; ctx is passed in as a parameter (modules don't capture caller scope). The shared access-boundary module pattern works as intended.
S3 — Posts (docs + kv index + CAS)
F-015 🟠 [PiCloud] docs filter DSL cannot match membership inside an array field
What: docs::find builds SQL via jsonb_extract_path_text(data, 'field'), which for an array field returns the array serialized as compact JSON text (docs_repo.rs:378-380). So a filter like #{ tags: #{ "$in": [tag] } } compares the tag against the whole serialized array string (["intro","picloud"]), never element membership. There is no $contains / @> / array-element operator. Impact: the single most common blog query — "posts with tag X" — isn't expressible in the DSL. Worked around by fetching published posts and filtering in Rhai (breaks true pagination) — or you'd maintain a separate kv/docs index. A JSONB containment operator would close this cleanly.
F-016 🟢 [PiCloud] positive: set_if CAS + kv index pattern works well
What: The CAS view counter (kv::set_if(key, expected, new) in a bounded retry loop) incremented correctly under repeated hits (1→2→3), and the slugs kv collection as an O(1) slug→id index worked cleanly for public post URLs. $sort/$limit on scalar fields (status, updated_at, publish_at ISO strings) behaved as documented. Solid primitives for the read/index paths.
F-017 🟡 [PiCloud] docs stores return an ENVELOPE, not the bare doc — easy to double-nest
What: docs::get/find return #{ id, data, created_at, updated_at } with the user's fields under .data. It's easy to accidentally return the envelope (leaking the wrapper) or to double-nest. Combined with the response-map convention (a returned map with no statusCode becomes the whole body), a script that does return doc; ships {id, data:{...}, ...} to the client. Not a bug, but a shape mismatch you must normalize (shape_doc) on every read path.
S4 — Comments + sanitization interceptor
F-018 🟢 [PiCloud] positive: before docs interceptor transform works end-to-end
What: A [[interceptors]] service="docs" ops=["create"] phase="before" hook receives the op-context ({service, op, collection, key, value}) as its request body and returns #{ allowed: true, data: <rewritten value> }. Verified: a comment body containing <script>, <img onerror>, &, and an <b> author_name were all HTML-escaped before persistence, and the moderation status was set from a vars value — all transparently to the comment_create endpoint. Fail-closed verdict (only #{allowed:true} allows; a dangling/erroring hook denies) is the right default for a security control. This is a genuinely nice feature for centralised write-time sanitization/validation.
F-019 🟡 [PiCloud] interceptors bind per-SERVICE+op, not per-collection — needs an in-hook guard
What: The docs create interceptor fires for every docs collection (posts, pages, comments). The hook must branch on p.collection and no-op on the rest, or it would rewrite unrelated writes. Verified a post body if a < b && b > c {} is preserved only because the hook guards collection != "comments". Also every post/page create now pays an extra script execution. A per-collection binding (or a collection_glob like triggers have) would be more ergonomic and less error-prone.
F-020 🟡 [PiCloud] ops=["create"] doesn't cover UPDATE — sanitization gap on edits
What: The comment interceptor guards create only; a later docs::update to the same comment is not re-sanitized. Our admin moderation only flips status (safe), but any feature that edits a comment/post body through update would bypass the escaper. You must remember to also register a phase="before" ops=["update"] hook. Easy to overlook; the "sanitize on write" mental model doesn't map cleanly to create-vs-update.
F-021 🔵 [PiCloud] plan warns an interceptor script "has no route or trigger"
What: pic plan prints warning: endpoint 'comment_sanitize' has no route or trigger — only reachable via the execute-by-id bypass / invoke(). But it IS reachable — as an interceptor. The reachability check doesn't count an [[interceptors]] binding, so a legitimately-bound hook looks orphaned. Cosmetic, but could mislead you into "cleaning up" a live hook.
F-022 🔵 [env] the shipped pic/picloud 1.1.9 binaries lagged the source
What: The pre-installed target/debug/pic (self-reported pic 1.1.9) rejected the [[interceptors]] manifest block (unknown field 'interceptors') though the source at crates/picloud-cli/src/manifest.rs supports it. A rebuild fixed it — but the version string stayed 1.1.9 across both, so nothing signals the skew. A build/version that doesn't bump when the manifest schema changes makes "which features does my CLI actually support?" unanswerable from --version.
S5 — Notifications (docs-trigger → queue → email → Mailpit)
F-023 🟢 [PiCloud] positive: the docs-trigger → queue → email chain is robust and fast
What: Publishing a post fired the whole 3-hop async pipeline — posts_on_publish (docs trigger) → queue::enqueue → notify_drain (queue trigger) → email::send_html → Mailpit — delivering one email per reader (4/4) in ~1 second, from the configured notify-from-email. The transactional-outbox dispatch, the publish-transition detection (prev_data.status), chunked enqueue, and the SMTP relay all worked first try. This is the most complex integration in the CMS and it was the smoothest — the event/trigger model is a genuine strength. Retry/dead-letter config (retry_max_attempts) is declarative per trigger.
F-024 🔵 [PiCloud] ctx.event.docs.prev_data makes transition detection clean
What: The docs trigger event carries both data and prev_data, so "became published" is a simple data.status=="published" && prev_data.status!="published". On a draft create (prev_data == ()) and on non-status updates it correctly no-ops. Good event ergonomics.
F-025 🔵 [CMS] rate-limiter shares one bucket when x-forwarded-for is absent
What: Our register/comment rate limiter keys on ctx.request.headers["x-forwarded-for"], which is absent on direct curl to the origin, so every local client shares the "local" bucket — a 6th local registration in an hour is throttled (429). Behind Caddy the header would be set per-client. Attribution [CMS], but note [PiCloud]: there is no built-in client-IP surface in ctx (no ctx.request.remote_addr), so an app behind no proxy has no reliable per-client key for rate limiting — you depend entirely on a trusted upstream setting XFF. A spoofable x-forwarded-for from an untrusted client would also let an attacker rotate the bucket (see SECURITY).
S6 — Tags, media (files), cron scheduled-publish
F-026 🟠 [PiCloud] user routes are JSON-in/JSON-out — non-JSON request bodies are rejected
What: The dispatch path does serde_json::from_slice(&body_bytes) and returns 400 invalid JSON body for any non-empty body that isn't valid JSON (orchestrator-core/src/api.rs:261). So a user route cannot accept application/x-www-form-urlencoded, text/plain, or raw binary — a classic HTML <form> POST or a file upload with a binary body is rejected before the script runs. This contradicts the "body is a String if not application/json" description. Impact: file uploads must be base64-in-JSON (done here), inflating payloads ~33% and capped by the 10 MiB body limit; and you can't build a no-JS HTML form that posts directly to a route. Likewise responses: there's no way to stream raw bytes back (media is returned as base64 for the frontend to turn into a data: URL — fine for a PoC, wrong for real media serving).
F-027 🟢 [CMS] SVG upload rejected to prevent stored XSS
What: media_upload allowlists image/{png,jpeg,gif,webp} and rejects image/svg+xml (415), because an SVG served same-origin can execute embedded <script>. Verified. Attribution [CMS] — but note [PiCloud]: files:: performs no content sniffing/validation and media_get would happily hand back whatever content_type was stored, so the allowlist is entirely the app's responsibility.
F-028 🟢 [PiCloud] positive: cron + trigger-fires-trigger chain works
What: A 6-field cron trigger (0 * * * * *) flipped a due scheduled post to published within the minute; that docs::update then fired the posts_on_publish docs trigger → queue → 4 notification emails. The full cron→docs→queue→email chain (a trigger firing a trigger) ran cleanly and stayed within the depth bound. Both files:: (create/head/get round-tripped a 75-byte PNG byte-exact) and cron behaved as documented.
F-029 🟡 [PiCloud] docs DSL $lte on ISO-timestamp strings works only because ISO-8601 sorts lexically
What: posts_scheduled_publish filters publish_at: #{ "$lte": now } where now = time::now() (ISO string). This works because jsonb_extract_path_text compares text and ISO-8601 is lexically ordered — but it's an implicit contract: any non-lexicographic timestamp format (epoch millis as a number, time::now_ms()) would compare as text and silently mis-order. There's no typed/temporal comparison in the DSL; correctness rides on always storing zero-padded ISO strings.
S8/S9 — Frontend, env overlays, prune
F-030 🟠 [PiCloud] No CORS support — a decoupled SPA can't call the API cross-origin
What: User routes send no Access-Control-Allow-Origin (or any CORS) header, and there's no config to enable CORS. A browser SPA on a different origin (:5173) is blocked from calling the API on :8081. For a platform whose selling point is "upload scripts, get HTTP endpoints" consumed by a frontend, this forces either (a) same-origin bundling behind Caddy, or (b) a dev/prod reverse proxy. Worked around with Vite's server.proxy (/cms → :8081), which also sidesteps CORS preflight (same-origin to the browser). Also relevant: OPTIONS isn't a routable method here, so a genuine cross-origin preflight would 404 regardless. A cors_allowed_origins per-app setting would make the headless-CMS story first-class.
F-031 🟢 [PiCloud] positive: env overlays + prune are solid and safe
What: pic plan --env prod cleanly showed only the overlaid vars (site-base-url, signups-open) as update / value changed, leaving the rest noop; the dev overlay was live (site_base_url = http://localhost:5173). --prune correctly diffed a removed script+route as delete, and refused to run non-interactively without --yes ("prune deletes resources … cannot be undone. Review with pic plan, then re-run with --yes") — a good guardrail. After --prune --yes the throwaway route 404'd and the rest of the app was untouched. The declarative reconcile loop (plan → apply → prune) is the strongest part of the tooling.
F-032 🟢 [PiCloud] positive: one script bound to many routes works
What: The pages and comments_admin scripts are each bound to several [[routes]] (different methods/params) and dispatch internally on ctx.request.method + path — a clean way to build a REST resource without one script per verb. ctx.request.params populated correctly across param routes (/cms/pages/:slug, /cms/admin/pages/:id).
S7 — Live comments (pub/sub + realtime SSE)
F-033 🟢 [PiCloud] positive: pub/sub → SSE realtime works cleanly, incl. anonymous public topics
What: Approving a comment fires comment_on_approve (docs trigger, update) → pubsub::publish_durable("comments-feed", …) → the registered external, auth_mode=public topic streams to any SSE subscriber. Verified end-to-end via curl -N /realtime/topics/comments-feed (event arrived ~3s after approval) AND through the Vite dev proxy to the browser EventSource. pic topics create --external --auth-mode public makes a topic anonymously subscribable — exactly right for a public live-comment feed (no per-viewer token needed). The durable-publish + broadcast split (durable outbox fan-out, then live broadcast) is a nice model.
F-034 🔵 [PiCloud] SSE topics are concrete (no wildcards) — per-entity live feeds need a shared topic + client filter
What: Registered topics must be concrete names (no *), so a per-post topic (comments.<id>) isn't registerable dynamically. Used one shared public comments-feed topic carrying post_id and filtered client-side. Fine for public comments, but a per-entity private live feed would need auth_mode=token + a minted subscriber_token scoped per topic — and you'd still need a concrete registered topic per entity, which doesn't scale to unbounded ids. A wildcard/prefix topic registration would help. (subscriber_token also requires an authenticated principal, so anonymous scoped subscriptions aren't possible — it's all-public or authed.)
Workflows — editorial publish pipeline (added 2026-07-18)
Added a durable-DAG automation to dogfood the Workflows feature (the one major
PiCloud capability the original PoC skipped): validate → (enrich ∥ seo) → publish → finalize, authored as a [[workflows]] block, started from a script
via workflow::start, visualized live with Svelte Flow (@xyflow/svelte).
F-036 🟢 [PiCloud] positive: the Workflows engine is excellent
What: Declarative [[workflows]] DAG applied cleanly; the full 5-step run
executed in <1s. Verified from timestamps that enrich and seo ran in
real parallel (both fired ~15ms apart, after validate), then publish, then
finalize. Conditional when = "steps.validate.output.ok == true" gated the
branch: on a post with an empty body, validate failed and enrich/seo/publish
were skipped, leaving the post a draft. {{ input.x }} input templating and
steps.<name>.output references worked; pic workflows ls/runs shows the
history; a step's returned body becomes its output; steps freely called
docs/kv and the publish step chained into the existing notification
pipeline (docs-trigger → queue → 4 emails). This is a genuinely strong,
well-integrated feature — the highlight of the platform.
F-037 🟡 [PiCloud] workflow-step scripts trip the "no route or trigger" reachability warning
What: A script referenced only by [[workflows.steps]] function = "…" is
flagged at plan/apply: warning: endpoint 'wf_validate' has no route or trigger — only reachable via the execute-by-id bypass / invoke(). But it IS reachable —
as a workflow step. The F-021 fix taught the reachability check about
interceptor bindings but not workflow-step bindings, so all five step
scripts look orphaned. Same class of bug as F-021, one binding-type short.
✅ Resolved (platform): the reachability set now also counts [[workflows]]
step functions (and interceptor scripts). Verified live — pic plan on this
CMS now emits zero reachability warnings for the five workflow-step scripts.
F-038 🟠 [PiCloud] workflow runs are observable only via the platform-admin API — not from scripts
What: workflow::start(name, input) is the only workflow SDK call — it
returns a run id but there is no workflow::get_run/status from a script.
Run status/history lives behind the platform-admin API
(GET /api/v1/admin/apps/{app}/workflow-runs/{id}), which needs an instance-admin
bearer token — not an app-user token. So a data-plane app cannot surface its
own workflow progress to its own users without either borrowing platform-admin
credentials or rolling its own run tracking. I did the latter: each step writes
progress into a wfruns KV store (own key per step, so enrich ∥ seo don't
contend), and a /cms/admin/workflow/runs/:id endpoint reassembles it for the
UI. It works, but "startable from a script, not observable from a script" is an
asymmetry worth closing (even a read-only workflow::run_status(id) would do).
✅ Resolved (platform) + adopted: workflow::run_status(run_id) shipped —
returns #{ status, output, error, steps: #{ name: status } } or () when no
such run belongs to the app (app-scoped at the SQL, the isolation boundary; same
AppInvoke gate as start). The CMS now uses it: the wfruns KV workaround
(and the wf_lib progress-writer module) is deleted — pipeline_run reads
workflow::run_status directly, and the step scripts no longer thread a
run_id. Re-verified live end-to-end: happy path (all 5 steps succeeded,
overall completed) and gated path (empty body → validate succeeded but
output.ok=false → enrich/seo/publish skipped, overall blocked, post stayed
draft). A run id from another app resolves to () → 404.
F-039 🔵 [PiCloud] a skipped step does NOT block its dependents (skip ≠ fail)
What: When publish's when is false it is skipped, yet finalize
(depends_on = ["publish"]) still runs — a skipped dependency is treated as
satisfied, not as a barrier. Reasonable, but a footgun: my first finalize
marked the run "completed" even though nothing was published. Fixed by having
finalize inspect whether publish actually succeeded (→ reports
"completed" vs "blocked"). Authors must not assume "my dependents only run if I
ran" — model the skip case explicitly. Status: confirmed by design and
documented — the WorkflowStepDef.when contract is "skipped = satisfied-but-
empty"; gate a dependent with its own when if it must not run on a skip. (With
F-038 resolved, workflow::run_status now reports an explicit per-step
skipped status from the engine, so pipeline_run no longer has to infer
skips — it still falls back to skipped for a step absent from a terminal run,
but the engine supplies the real marker.)
F-040 🟢 [tooling] Svelte Flow (@xyflow/svelte v1) drops into Svelte 5 cleanly
What: @xyflow/svelte@1.6.2 (peer svelte ^5.25) integrated with the
SvelteKit SPA using bind:nodes/bind:edges on $state arrays; a small
longest-path layout + per-status node style gives a live-updating DAG that
colors each node as the run progresses (polling /cms/admin/workflow/runs/:id).
Good fit for visualizing a PiCloud workflow.