Wires the KV store into Rhai scripts via the handle pattern:
let widgets = kv::collection("widgets");
widgets.set("k", #{ n: 1 });
let v = widgets.get("k"); // value or () if absent
widgets.has("k") / widgets.delete("k")
let page = widgets.list(); // cursor-style pagination
`KvHandle` is a custom Rhai type holding `Arc<dyn KvService>` + the
per-call `Arc<SdkCallCx>`. Methods route async service calls through
`tokio::Handle::current().block_on(...)` — works because
`LocalExecutorClient` runs the script under `spawn_blocking` so a
runtime is reachable. The bridge surfaces `app_id` exclusively
through `cx.app_id`; no public-facing argument can spoof an app.
`TriggerEvent` lands in `picloud-shared` as the wire shape the
dispatcher will emit (KV + DeadLetter variants — KV exercised now,
DL hooks up with the dispatcher in commit 5/8). `SdkCallCx` and
`ExecRequest` grow `is_dead_letter_handler: bool` and
`event: Option<TriggerEvent>`. `engine.rs::build_ctx_map` flattens
the event into `ctx.event` for triggered handlers; direct ingress
leaves the key absent so scripts can `if "event" in ctx`.
Tests:
- 7 `sdk_kv.rs` integration tests covering the full Rhai surface
(round-trip, missing-key unit, has bool, delete was-present,
empty-collection rejection, cursor pagination, cross-app
isolation through the bridge).
- 3 new `engine.rs` tests pinning `ctx.event` shape per
design notes §4 (KV insert with value, delete with unit value,
direct invocations have no `event` key).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First v1.1.1 commit. Adds the KV store the design notes commit to:
`(app_id, collection, key)` identity with JSONB value and a per-app
index. Trait lives in `picloud-shared` so the executor-core Rhai
bridge (next commit), the Postgres impl, and tests all depend on the
same surface without coupling crates.
The `Services` bundle grows from empty to three fields: `kv`,
`dead_letters` (NoopDeadLetterService stub — replaced by the
Postgres impl in commit 8), and `events` (NoopEventEmitter until the
outbox emitter lands with the dispatcher). Tests use
`Services::default()` for an all-noop bundle.
New capabilities `AppKvRead` / `AppKvWrite` join the Capability
enum. They map onto the existing seven-value `Scope` (script:read /
script:write) — the scope vocabulary stays locked per the
`docs/versioning.md` commitment.
Script-as-gate semantics in `KvServiceImpl`: capability check runs
when `cx.principal.is_some()`, skipped when None (public HTTP).
Cross-app isolation is enforced independently by deriving every
row's `app_id` from `cx.app_id` rather than a script-passed argument.
In-memory `KvRepo` impl + unit tests cover the round-trips, the
cross-app isolation property, empty-collection rejection,
script-as-gate behaviour for both anonymous and authed contexts,
and cursor-style pagination. Postgres impl exists; integration
testing waits for a real DB harness (see HANDBACK).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the four internal-only fields every v1.1.x stateful service needs
to isolate by app and audit by caller:
- app_id — owning app for this invocation
- principal — Option<Principal>; data-plane is unauthenticated
today so the orchestrator passes None until the
opportunistic middleware lands in the next commit
- trigger_depth — 0 for direct invocations; the triggers framework
(v1.1.1) bounds runaway feedback loops via this
- root_execution_id — equal to execution_id for direct invocations;
preserved across trigger fan-out for audit grouping
ExecRequest stays serializable (cluster mode still has to ship it across
processes when v1.3+ arrives). principal is `#[serde(skip)]` because
shared::Principal has no wire derivation today — when cluster mode lands
the wire-Principal question gets revisited properly.
Engine now carries a Services bundle (empty in v1.1.0). Engine::execute
constructs an SdkCallCx from the request and hands it to sdk::register_all
just after the per-call Rhai engine is built. The hook is a no-op in v1.1.0;
v1.1.1 KV registers its first native fns there.
Adds ExecError::Overloaded { retry_after_secs } and the matching 503 +
Retry-After mapping in orchestrator-core's IntoResponse. The gate that
actually produces this variant lands in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pins the user-visible Rhai SDK behaviors to a concrete test file so
SemVer enforcement isn't aspirational. **Editing this file is an SDK
version bump event** — the file header documents the rule.
* 30 tests covering every documented SDK 1.0 + 1.1 surface:
ctx.sdk_version (format + feature-detection)
ctx.execution_id / request_id / script_id (UUID shape)
ctx.script_name (round-trip)
ctx.invocation_type (http / function / scheduled)
ctx.request.path / headers / body / params / query / rest
log::trace / info / warn / error (with and without data)
response convention: bare value → 200, structured map →
statusCode pass-through, missing statusCode → wrapped 200,
non-integer statusCode → InvalidResponse error
sandbox restrictions: imports blocked, print disabled,
log::debug rejected (Rhai keyword — use log::trace)
JSON type fidelity (string/int/float/bool/null/array/object/
nested round-trip)
* Separate from tests/engine.rs (which tests internal Engine
behaviors) — same crate, different audience: engine.rs is
"does the engine work right", sdk_contract.rs is "does the
public contract hold". Some overlap is intentional so the
contract is readable in one place.
* Plain cargo test --workspace runs all 30 (no infrastructure
needed); these are pure unit tests.
Wires up enforcement item (3) from docs/versioning.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>