The per-element Rhai sandbox caps (max_string_size 64 KiB, max_array_size /
max_map_size 10 000) do NOT bound the *materialized* size: Rhai shares strings
and arrays by Rc, so a 10 000-element array of one aliased 64 KiB string is
cheap to build (~10 000 ops, far under the 1 M op budget) yet dealiases to
~640 MiB of distinct JSON. Every Dynamic→JSON conversion deep-copies the
aliases; the HTTP response body path had NO size cap at all, and the KV/docs
value caps run only AFTER full materialization — so a handful of anonymous
requests could OOM the node (32 concurrent × ~640 MiB ≈ 20 GiB) on the stated
consumer-hardware target.
Add a byte-budgeted materializer that bails the moment the budget is exceeded,
so the transient allocation is bounded regardless of aliasing:
- bridge.rs: `dynamic_to_json_capped(value, max) -> Result<Json, JsonSizeError>`
(charges a running budget) + `MAX_JSON_MATERIALIZE_BYTES` (16 MiB hard rail,
far above the 256 KiB business caps). `dynamic_to_json` stays infallible for
best-effort paths (logging) but is now internally bounded — it collapses an
over-limit value to a marker string instead of OOMing.
- Route every user-value boundary through the fail-closed capped form: KV
set/set_if (+ the CAS `expected`), docs create/update/find, secrets set,
http request body, workflow input, `json::stringify`, and the HTTP RESPONSE
body (previously the one fully-uncapped exit → now a 500). `invoke` args and
the pubsub/queue message materializers get the same byte budget in place.
- `impl From<JsonSizeError> for Box<EvalAltResult>` so SDK sites protect
themselves with a bare `?`.
Pinned by bridge unit tests: an aliased 10 000×64 KiB array errors on the
budget rather than materializing, and the infallible wrapper yields a marker
instead of OOMing. Workspace 914 + journeys 157/157 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`http.rs` hand-rolled its own `block_on` + `map_http_err`, and `secrets.rs` /
`email.rs` / `users.rs` each carried a byte-identical `runtime_err` — five
`#[allow(clippy::unnecessary_box_returns)]` across four files for one pattern.
Move `runtime_err` to `bridge.rs` beside `block_on` (single home, single allow)
and have the three bridges import it. `http.rs` now calls the shared
`bridge::block_on("http", …)` (same "http:"-prefixed error, so messages are
unchanged) and its `err` validation helper delegates to `bridge::runtime_err`.
Deletes 4 duplicate fns and 3 of the 5 allows; no behavior change (pinned by
the executor-core SDK contract tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace ten near-identical `block_on` helpers (one per SDK module)
with a single shared `sdk::bridge::block_on(service: &str, fut)`.
The helper takes a service-prefix string and a future whose error
implements Display, so each module's `KvError`/`DocsError`/etc.
still self-formats.
Migrated:
- kv, docs, pubsub, users, dead_letters, secrets, files, email
- queue.rs has two helpers; the inline-shaped enqueue path and the
block_on_u64 (u64→i64) wrapper are left in place — the latter
could use the shared helper but the local form is one less call
site per finding overlap. Will revisit on a later pass if needed.
- http.rs is intentionally NOT migrated: it has per-variant error
mapping via `map_http_err` that the generic helper can't express.
Net: -218 / +97 lines across the 9 migrated files. Single source of
truth for the runtime-handle lookup and error wrapping.
AUDIT.md anchor: F-Q-002.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- sdk/bridge.rs: drop #[must_use] on the bridge fns — `Dynamic` and
`serde_json::Value` are both #[must_use] already; the wrapper
attribute is double-must-use noise.
- api.rs IntoResponse: hoist `use ApiError as E;` above the early
Overloaded branch so `E::Exec(...)` works in the if-let too
(clippy::items_after_statements).
- gate.rs test: bind the returned permit with `let _ =` so the
OwnedSemaphorePermit doesn't trip unused-must-use.
No behaviour change. Caught by `cargo clippy --all-targets
--all-features -- -D warnings`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hoist the json_to_dynamic / dynamic_to_json helpers out of engine.rs
into a new sdk/bridge.rs so the v1.1.x service modules (KV, docs, …)
can use them without engine.rs being the sole owner. No behavioural
change — the sdk_contract round-trip test pins the observable JSON
fidelity.
Also lands the structural shape that subsequent v1.1.x PRs hook into:
- sdk::register_all(engine, services, cx) — single per-call hook
every stateful service registers through. Body is a no-op for
v1.1.0; SdkCallCx construction inside Engine::execute lands in
the next commit alongside the new ExecRequest fields it reads.
- sdk::cx re-exports picloud_shared::SdkCallCx so SDK callers don't
cross-import shared for one type.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>