Files
PiCloud/examples/cms-poc/FINDINGS.md
MechaCat02 813bc46640
Some checks failed
CI / Rust — fmt, clippy, test (push) Failing after 39m53s
CI / Dashboard — check (push) Successful in 10m15s
docs(examples): add the CMS proof-of-concept dogfooding artifact
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>
2026-07-19 20:15:29 +02:00

34 KiB
Raw Blame History

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 apply can't create an app — only groups; the first deploy needs pic apps create first.
  • 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_PASSWORDall 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 each email::send(#{ from }) call. USER not USERNAME; TLS not REQUIRE_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 1128 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::enqueuenotify_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 deletedpipeline_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.