From ce5ba01446321af1afc140e73a93080383cf6542 Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Thu, 20 Aug 2026 18:33:51 +0100 Subject: [PATCH 1/3] Add RFC 0027 for calling a product's worker from its app --- docs/rfcs/0027-surface-worker-calls.md | 184 +++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 docs/rfcs/0027-surface-worker-calls.md diff --git a/docs/rfcs/0027-surface-worker-calls.md b/docs/rfcs/0027-surface-worker-calls.md new file mode 100644 index 00000000..e9a5ac50 --- /dev/null +++ b/docs/rfcs/0027-surface-worker-calls.md @@ -0,0 +1,184 @@ +--- +title: "Calling a product's worker from its app" +owner: "@BigTava" +--- + +# RFC 0027: Calling a product's worker from its app + +| | | +| --------------- | -------------------------------------------------------------------------------------------------------------- | +| **RFC Number** | 27 | +| **Start Date** | 2026-08-20 | +| **Description** | A `Worker` method letting a product's rendered executable invoke a named export of that same product's worker. | +| **Authors** | Tiago Tavares | + +## Summary + +Add one method to a new `Worker` trait. `call_worker` takes an export name and a JSON payload, dispatches to the calling product's own worker, and returns the export's answer. The Host supplies the caller's product identity — the request carries no product field — and a worker opts in by declaring which rendered executables may reach it in its manifest `includes`. The call is request/response, one direction, and carries data only. + +## Motivation + +A product ships two kinds of executable: the web applications a Host renders (`app`, `widget`, `funding`) and a single background `worker`. They run in separate sandboxes, and no protocol connects them. The product-manifest RFC defines both and explicitly defers runtime APIs to per-modality contracts, so today nothing specifies how a rendered executable and a worker exchange anything at all. + +**Work that outlives the view has nowhere to live.** A funding surface starts a settlement that runs for minutes across several chain interactions. The user closes the sheet halfway through. The worker is the right home for that job — it holds the product's full Host-API surface and its lifetime is independent of any view — but the surface cannot start it. So the job goes in the page, where it dies with the view, competes with rendering, and runs outside the deadline, backoff and quarantine machinery a Host applies to worker code. + +**A surface cannot ask what its own product is doing.** The two executables already share one storage namespace and one product account, so a worker's results are readable by the surface once written. What is missing is the request. A surface reopened mid-job can only wait for the next write to land; it cannot ask for the current state, and cannot distinguish "still working" from "the worker was never started". + +**Products will otherwise ask for the unsafe version.** The obvious shortcut is to let the page hand the worker a function to run. That must not be built — see [Alternatives](#alternatives) — and the cheapest way to prevent it is to ship the bounded version first. + +## Detailed Design + +### `worker.callWorker` + +````rust +/// Invoke a named export of the calling product's own worker. +/// +/// The export must be declared by the worker module in the product's +/// verified archive. An undeclared name is `Invalid`; nothing is loaded on +/// demand. See [RFC 0027]. +/// +/// [RFC 0027]: https://github.com/paritytech/host-rust-core/blob/main/docs/rfcs/0027-surface-worker-calls.md +/// +/// ```ts +/// const result = await truapi.worker.callWorker({ +/// apiName: "startSettlement", +/// payload: JSON.stringify({ rail: "BANK", intentId }), +/// }); +/// assert(result.isOk(), "callWorker failed:", result); +/// console.log("job:", JSON.parse(result.value.payload)); +/// ``` +#[wire(request_id = 174)] +async fn call_worker( + &self, + cx: &CallContext, + request: WorkerCallRequest, +) -> Result>; +```` + +```rust +/// Request to invoke one worker export. +struct WorkerCallRequest { + /// Export declared by the worker module. Not a path, not a module + /// specifier — the Host resolves it against the already-loaded module. + api_name: String, + /// JSON arguments, bounded by the worker protocol's payload ceiling. + payload: String, + /// Per-call wall-clock budget. `None` selects the Host default; values + /// are clamped to the Host's own window. + deadline_ms: Option, +} + +/// The export's answer. +struct WorkerCallResponse { + /// JSON returned by the export, under the same payload ceiling. + payload: String, +} + +/// Error from call_worker. +enum WorkerCallError { + /// No worker is running for this product. Covers a product with no + /// worker, a worker the user stopped, a manifest that declares no + /// ceiling for this surface, a quarantined worker, and a host that runs + /// no workers at all. + Unavailable, + /// The call passed the manifest ceiling and failed a downstream + /// authorization check. + Denied, + /// No such export, or the payload is malformed or over the ceiling. + Invalid(GenericError), + /// The call outlived its deadline and was revoked. + Timeout, + /// The worker threw, or died handling the call. + Crashed, + /// Worker and Host disagree on the protocol. + Version, +} +``` + +**The request carries no product identifier, and that is the security property.** The Host knows which product's surface it is rendering. If the caller named the product, page JavaScript could address a different product's worker by passing a different string, and no downstream validation could recover the true caller. The worker's own execution context is Host-issued for the same reason: identity that JavaScript can state is identity JavaScript can forge. + +**Data crosses, never code.** `api_name` selects an export the verified archive already declared. The call cannot widen what the worker is able to do — it asks a worker that already holds its own authority to act, and the answer comes back as bytes. + +**`Unavailable` is deliberately undifferentiated.** A surface must not be able to tell "this product has no worker" from "the user stopped it" from "the manifest does not permit you". Splitting those would turn the method into a probe for a control the user owns. + +### Manifest declaration + +Reaching a worker is opt-in, declared on chain rather than requested at runtime, so "what can call this worker" is answerable from the manifest before any code runs. `includes` already names which surfaces a worker serves, and this extends the same vocabulary: + +```ts +interface WorkerIncludes { + chat: boolean; + pocket: boolean; + /** Rendered executables permitted to call this worker. Absent means none. */ + app?: boolean; + widget?: boolean; + funding?: boolean; +} +``` + +Per-surface rather than a single flag: a product may want its funding sheet to drive a settlement while its widget, rendered on a dashboard nobody is looking at, cannot. Collapsing the three into one boolean makes the narrower policy unexpressible, and widening a ceiling later is additive while narrowing one is breaking. Unknown keys stay ignored, so a record published before this RFC reads as "no surface may call me" rather than failing to resolve. + +Note the asymmetry with `chat`. That flag widens the worker's own authority — a worker declaring it is issued a trusted execution kind that unlocks the chat service surface. These three grant the worker nothing; they only admit a caller that otherwise has no path. + +### Semantics and invariants + +- **Same limits, new caller.** Surface calls reuse the worker protocol's existing bounds: capped payloads, per-call deadlines with typed expiry, serialized dispatch per worker, a bounded queue whose overflow is `Unavailable`, and crash backoff into quarantine. A surface calling in a loop degrades into typed errors rather than unbounded work. A surface call is a new _caller_, not a new _path_. +- **Starting is the Host's business.** A surface does not start or stop a worker. If the worker is not running and the manifest permits the call, the Host may start it; if the user has stopped it, the answer is `Unavailable` and stays that way until the user says otherwise. +- **No ambient state.** Two calls share nothing beyond what the worker itself persists. Callers that need continuity pass an identifier the worker minted. + +### Typical product flow + +```ts +const started = await truapi.worker.callWorker({ + apiName: "startSettlement", + payload: JSON.stringify({ rail: "BANK", intentId }), +}); +if (!started.isOk()) return renderUnavailable(); +const { jobId } = JSON.parse(started.value.payload); + +// Reopening the surface later: ask, rather than wait for the next write. +const status = await truapi.worker.callWorker({ + apiName: "status", + payload: JSON.stringify({ jobId }), +}); +assert(status.isOk(), "status failed:", status); +``` + +The job survives the surface closing because it never lived there. The surface holds an identifier, not a process. + +### Implementation shape + +The core does not own worker execution. A Host already implements the worker engine behind a platform seam, and the runtime that owns correlation, payload bounds, serialized dispatch and quarantine sits above it. This RFC adds a caller, so it follows the delegation pattern RFC 0026 uses: + +- `truapi-platform` gains one syscall carrying the calling product's identity, the export name, the payload and the deadline. +- `truapi-server` answers `call_worker` in-core by checking the manifest ceiling for the calling surface's kind and delegating to that syscall, mapping a missing worker, a refused ceiling and a stopped worker alike onto `Unavailable`. + +Hosts that already run workers implement one callback over machinery they have. Hosts that do not return `Unavailable` and are conformant. The change is purely additive: one new trait, one fresh wire id, no changes to existing calls or types. + +## Non-goals + +- **Push to the surface.** Request/response only. A surface follows a long job by polling a `status`-shaped export or by reading the storage the worker writes. A subscription needs its own cancellation and lifetime semantics and should not hold this up. +- **Worker-initiated calls.** The worker cannot call the surface. It has no view, the surface may not exist, and what it wants to say belongs in product storage where a surface can read it afterwards. +- **Cross-product calls.** A surface reaches its own worker and no other. Product-to-product interaction is the permission model's business, not this method's. +- **Worker lifecycle control.** Starting, stopping and disclosing workers is a Host concern with a user-facing control surface; this RFC neither extends nor bypasses it. + +## Drawbacks + +- Two executables that could not previously interact now can, which is new surface area for review. Authority does not move — the worker's capabilities are unchanged and the surface gains none of its own — but the interaction itself has to be reasoned about. +- The worker becomes a soft dependency of a surface's flows. A product must still work when the answer is `Unavailable`, because a user can stop the worker at any time. That burden is real, and it is why `Unavailable` is specified as an ordinary outcome rather than a fault. +- Polling for status is less efficient than a subscription, and this ships without one. The cost is bounded by the same queue and deadline limits as any other call. + +## Alternatives + +- **Let the surface pass the code to run.** This was discarded, and is recorded because it is the request this RFC pre-empts. Worker code resolves only from one pinned, verified archive; code arriving from a page has no CID and no signature. The worker's execution kind authorizes more than the surface's, so anything able to inject into the page — a cross-site scripting bug, a compromised dependency — would execute with worker authority. The bounded version costs a redeploy to change worker logic, which is the intended price. +- **A symmetric message channel between the two sandboxes.** This was discarded because it invites the worker to depend on a surface being present, which is the coupling the worker exists to avoid. The lifetimes are asymmetric, so the protocol should be too. +- **Product storage as the only mechanism** — the surface writes a request record and the worker picks it up. Products should still keep durable job state there, but as the sole mechanism it cannot start a stopped worker, gives no typed failure, and turns every request into a poll with no bound on latency. +- **A single `includes` flag rather than one per surface.** This was discarded because it cannot express "my funding sheet may, my widget may not", and a ceiling can be widened additively later but never narrowed. +- **Do nothing.** This was discarded because it is the status quo the motivation describes: long jobs run in pages, where they die with the view. + +## Unresolved Questions + +1. **Coordination with RFC 0024**, which also extends `WorkerIncludes`, with `onLoad`. That key is a lifecycle request rather than a surface, so `includes` would carry two different kinds of thing. Whether lifecycle belongs there at all is worth settling once rather than in two RFCs separately. +2. **Storage namespace for a modality subname.** The identity rules make the base product identity the storage namespace and name `worker.` and `card.` as resolution subnames that are never runtime identities. `funding.` is not listed. If a funding surface resolved to its own namespace it would not see what the worker wrote, and this method would hand back results the caller could not persist against. This should be stated wherever identity is specified. +3. **Whether `deadline_ms` should be caller-expressible** at all, given the Host clamps it regardless. Removing it is simpler; keeping it lets a surface distinguish "this is interactive, fail fast" from "this is a background settlement". +4. **Whether `Denied` is reachable in practice.** It is specified for downstream authorization failures, but if every such failure is better reported by the export itself the variant is dead weight and should go before the wire id is frozen. From 62ef7c743da42ed692edd886d67c69cdde558efa Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Fri, 21 Aug 2026 21:01:02 +0100 Subject: [PATCH 2/3] Fold the getcash prototype findings into RFC 0027 and index RFCs 0024 and 0027 --- docs/rfcs/0027-surface-worker-calls.md | 30 ++++++++++++++++++-------- docs/rfcs/_index.md | 2 ++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/docs/rfcs/0027-surface-worker-calls.md b/docs/rfcs/0027-surface-worker-calls.md index e9a5ac50..3c4e805d 100644 --- a/docs/rfcs/0027-surface-worker-calls.md +++ b/docs/rfcs/0027-surface-worker-calls.md @@ -64,7 +64,9 @@ struct WorkerCallRequest { /// JSON arguments, bounded by the worker protocol's payload ceiling. payload: String, /// Per-call wall-clock budget. `None` selects the Host default; values - /// are clamped to the Host's own window. + /// are clamped to the Host's own window. Caller-expressible because the + /// budgets genuinely differ: an interactive status check wants to fail + /// fast, a settlement start wants the full window. deadline_ms: Option, } @@ -81,9 +83,6 @@ enum WorkerCallError { /// ceiling for this surface, a quarantined worker, and a host that runs /// no workers at all. Unavailable, - /// The call passed the manifest ceiling and failed a downstream - /// authorization check. - Denied, /// No such export, or the payload is malformed or over the ceiling. Invalid(GenericError), /// The call outlived its deadline and was revoked. @@ -125,6 +124,7 @@ Note the asymmetry with `chat`. That flag widens the worker's own authority — - **Same limits, new caller.** Surface calls reuse the worker protocol's existing bounds: capped payloads, per-call deadlines with typed expiry, serialized dispatch per worker, a bounded queue whose overflow is `Unavailable`, and crash backoff into quarantine. A surface calling in a loop degrades into typed errors rather than unbounded work. A surface call is a new _caller_, not a new _path_. - **Starting is the Host's business.** A surface does not start or stop a worker. If the worker is not running and the manifest permits the call, the Host may start it; if the user has stopped it, the answer is `Unavailable` and stays that way until the user says otherwise. - **No ambient state.** Two calls share nothing beyond what the worker itself persists. Callers that need continuity pass an identifier the worker minted. +- **One identity beneath both.** Every rendered surface — `funding.` included — runs under the base product identity; the subnames are resolution names, never runtime identities. Caller and worker therefore share one storage namespace and one product account. A Host that gives a surface subname its own namespace breaks this method outright: the worker's answers name state the caller cannot read or persist against. ### Typical product flow @@ -153,7 +153,14 @@ The core does not own worker execution. A Host already implements the worker eng - `truapi-platform` gains one syscall carrying the calling product's identity, the export name, the payload and the deadline. - `truapi-server` answers `call_worker` in-core by checking the manifest ceiling for the calling surface's kind and delegating to that syscall, mapping a missing worker, a refused ceiling and a stopped worker alike onto `Unavailable`. -Hosts that already run workers implement one callback over machinery they have. Hosts that do not return `Unavailable` and are conformant. The change is purely additive: one new trait, one fresh wire id, no changes to existing calls or types. +Hosts that already run workers implement one callback over machinery they have. Hosts that do not run workers return `Unavailable` and are conformant. + +Two requirements on a conforming Host that runs workers, easy to miss because each fails far from its cause: + +- **Async exports need a reactor.** A Host embedding the core over UniFFI must export the worker-facing async callbacks with `async_runtime = "tokio"`; an export that reaches storage without one fails as a host-process abort, not a typed error. +- **Launch parameters must reach archive-served surfaces.** The reopen in [Typical product flow](#typical-product-flow) assumes a relaunched surface can carry an identifier back in through its launch query; a Host that drops the query for archive-served executables strands the caller with no way to name its job. + +The change is purely additive: one new trait, one fresh wire id, no changes to existing calls or types. ## Non-goals @@ -176,9 +183,14 @@ Hosts that already run workers implement one callback over machinery they have. - **A single `includes` flag rather than one per surface.** This was discarded because it cannot express "my funding sheet may, my widget may not", and a ceiling can be widened additively later but never narrowed. - **Do nothing.** This was discarded because it is the status quo the motivation describes: long jobs run in pages, where they die with the view. +## Prior Art and References + +- [RFC — Product Manifest Format](product-manifest.md) — the two-level manifest, the `worker.` subname, `includes`, and the one-worker-per-product rule this RFC extends. +- [RFC 0026 — Host chain discovery and name resolution](0026-supported-chains.md) — the platform-syscall delegation pattern [Implementation shape](#implementation-shape) follows. +- [RFC-0024 — Proof of Personhood as a Product](0024-personhood-as-product.md) — also extends `WorkerIncludes`, with `onLoad`; see [Unresolved Questions](#unresolved-questions). +- [RFC 0002 — Permission Model](0002-permission-model.md) — owns product-to-product interaction, which is why cross-product calls are a non-goal here. +- [Brevity pocket modality contract](https://github.com/paritytech/brevity-dozer/blob/main/docs/pocket-modality-contract.md) — §6 specifies the worker protocol's bounds this method reuses (payload ceiling, serialized dispatch, bounded queue, crash backoff into quarantine) and §7 the identity rules behind [Semantics and invariants](#semantics-and-invariants); neither is specified in this repo. + ## Unresolved Questions -1. **Coordination with RFC 0024**, which also extends `WorkerIncludes`, with `onLoad`. That key is a lifecycle request rather than a surface, so `includes` would carry two different kinds of thing. Whether lifecycle belongs there at all is worth settling once rather than in two RFCs separately. -2. **Storage namespace for a modality subname.** The identity rules make the base product identity the storage namespace and name `worker.` and `card.` as resolution subnames that are never runtime identities. `funding.` is not listed. If a funding surface resolved to its own namespace it would not see what the worker wrote, and this method would hand back results the caller could not persist against. This should be stated wherever identity is specified. -3. **Whether `deadline_ms` should be caller-expressible** at all, given the Host clamps it regardless. Removing it is simpler; keeping it lets a surface distinguish "this is interactive, fail fast" from "this is a background settlement". -4. **Whether `Denied` is reachable in practice.** It is specified for downstream authorization failures, but if every such failure is better reported by the export itself the variant is dead weight and should go before the wire id is frozen. +- **Coordination with RFC 0024**, which also extends `WorkerIncludes`, with `onLoad`. That key is a lifecycle request rather than a surface, so `includes` would carry two different kinds of thing. Whether lifecycle belongs there at all is worth settling once rather than in two RFCs separately. diff --git a/docs/rfcs/_index.md b/docs/rfcs/_index.md index a72a0cd1..2c337fb9 100644 --- a/docs/rfcs/_index.md +++ b/docs/rfcs/_index.md @@ -25,4 +25,6 @@ created: 2026-03-13 | 0021 | [Add Coins variant to PaymentTopUpSource](0021-payment-topup-coins.md) | accepted | @filippovecchiato | — | | 0022 | [Account key derivations](0022-account-derivations.md) | draft | Valentin Sergeev | — | | 0023 | [sr25519 VRF signing for product accounts](0023-account-sign-vrf.md) | draft | Valentin Sergeev | — | +| 0024 | [Proof of Personhood as a product](0024-personhood-as-product.md) | draft | Valentin Sergeev | [#360](https://github.com/paritytech/host-rust-core/pull/360) | | 0026 | [Host chain discovery and name resolution](0026-supported-chains.md) | draft | Valentin Fernandez | [#354](https://github.com/paritytech/host-rust-core/pull/354) | +| 0027 | [Calling a product's worker from its app](0027-surface-worker-calls.md) | draft | Tiago Tavares | [#468](https://github.com/paritytech/host-rust-core/pull/468) | From d4e6efe7fb1863154df38950eda3742390ac11da Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Sat, 22 Aug 2026 12:13:42 +0100 Subject: [PATCH 3/3] Tighten RFC 0027 prose to the browse documentation rules and add a call-flow diagram --- docs/rfcs/0027-surface-worker-calls.md | 161 +++++++++++-------------- docs/rfcs/_index.md | 2 +- 2 files changed, 72 insertions(+), 91 deletions(-) diff --git a/docs/rfcs/0027-surface-worker-calls.md b/docs/rfcs/0027-surface-worker-calls.md index 3c4e805d..3de57a77 100644 --- a/docs/rfcs/0027-surface-worker-calls.md +++ b/docs/rfcs/0027-surface-worker-calls.md @@ -1,76 +1,77 @@ --- -title: "Calling a product's worker from its app" +title: "Calling a product worker from its app" owner: "@BigTava" --- -# RFC 0027: Calling a product's worker from its app +# RFC 0027: Calling a product worker from its app -| | | -| --------------- | -------------------------------------------------------------------------------------------------------------- | -| **RFC Number** | 27 | -| **Start Date** | 2026-08-20 | -| **Description** | A `Worker` method letting a product's rendered executable invoke a named export of that same product's worker. | -| **Authors** | Tiago Tavares | +| | | +| --------------- | ---------------------------------------------------------------------------------------------------- | +| **RFC Number** | 27 | +| **Start Date** | 2026-08-20 | +| **Description** | A `Worker` method letting a product executable invoke a named export of the same product worker. | +| **Authors** | Tiago Tavares | ## Summary -Add one method to a new `Worker` trait. `call_worker` takes an export name and a JSON payload, dispatches to the calling product's own worker, and returns the export's answer. The Host supplies the caller's product identity — the request carries no product field — and a worker opts in by declaring which rendered executables may reach it in its manifest `includes`. The call is request/response, one direction, and carries data only. +Add one method to a new `Worker` trait. `call_worker` takes an export name and a JSON payload, dispatches to the worker of the calling product, and returns the export answer. The request carries no product field. The Host supplies the caller identity, and a worker opts in by declaring in its manifest `includes` which product executables may reach it. The call is request/response, one direction, and carries data only. + +```mermaid +sequenceDiagram + participant E as Product executable + participant H as Host + participant W as Product worker + + E->>H: callWorker(apiName, payload) + H->>H: check manifest ceiling, attach caller identity + H->>W: invoke declared export + W-->>H: JSON answer + H-->>E: response or typed error +``` ## Motivation -A product ships two kinds of executable: the web applications a Host renders (`app`, `widget`, `funding`) and a single background `worker`. They run in separate sandboxes, and no protocol connects them. The product-manifest RFC defines both and explicitly defers runtime APIs to per-modality contracts, so today nothing specifies how a rendered executable and a worker exchange anything at all. +A product ships rendered executables (`app`, `widget`, `funding`) and a single background `worker`. They run in separate sandboxes, and no protocol connects them. -**Work that outlives the view has nowhere to live.** A funding surface starts a settlement that runs for minutes across several chain interactions. The user closes the sheet halfway through. The worker is the right home for that job — it holds the product's full Host-API surface and its lifetime is independent of any view — but the surface cannot start it. So the job goes in the page, where it dies with the view, competes with rendering, and runs outside the deadline, backoff and quarantine machinery a Host applies to worker code. +**Work that outlives the view has nowhere to live.** A funding executable starts a settlement that runs for minutes. The user closes the sheet halfway through. The worker is the right home for that job. It holds the full Host API surface and its lifetime is independent of any view. But the executable cannot start it, so the job runs in the page, dies with the view, and escapes the deadline and quarantine machinery a Host applies to worker code. -**A surface cannot ask what its own product is doing.** The two executables already share one storage namespace and one product account, so a worker's results are readable by the surface once written. What is missing is the request. A surface reopened mid-job can only wait for the next write to land; it cannot ask for the current state, and cannot distinguish "still working" from "the worker was never started". +**An executable cannot ask what its own product is doing.** The two share one storage namespace, so worker results are readable once written. What is missing is the request. An executable reopened mid-job cannot distinguish still working from never started. -**Products will otherwise ask for the unsafe version.** The obvious shortcut is to let the page hand the worker a function to run. That must not be built — see [Alternatives](#alternatives) — and the cheapest way to prevent it is to ship the bounded version first. +**Products will otherwise ask for the unsafe version.** The obvious shortcut is to let the page hand the worker a function to run. That must not be built. See [Alternatives](#alternatives). Shipping the bounded version first is the cheapest way to prevent it. ## Detailed Design ### `worker.callWorker` -````rust -/// Invoke a named export of the calling product's own worker. -/// -/// The export must be declared by the worker module in the product's -/// verified archive. An undeclared name is `Invalid`; nothing is loaded on -/// demand. See [RFC 0027]. -/// -/// [RFC 0027]: https://github.com/paritytech/host-rust-core/blob/main/docs/rfcs/0027-surface-worker-calls.md +```rust +/// Invoke a named export of the calling product worker. /// -/// ```ts -/// const result = await truapi.worker.callWorker({ -/// apiName: "startSettlement", -/// payload: JSON.stringify({ rail: "BANK", intentId }), -/// }); -/// assert(result.isOk(), "callWorker failed:", result); -/// console.log("job:", JSON.parse(result.value.payload)); -/// ``` +/// The export must be declared by the worker module in the product verified +/// archive. An undeclared name is `Invalid`. Nothing is loaded on demand. #[wire(request_id = 174)] async fn call_worker( &self, cx: &CallContext, request: WorkerCallRequest, ) -> Result>; -```` +``` ```rust /// Request to invoke one worker export. struct WorkerCallRequest { /// Export declared by the worker module. Not a path, not a module - /// specifier — the Host resolves it against the already-loaded module. + /// specifier. The Host resolves it against the already-loaded module. api_name: String, - /// JSON arguments, bounded by the worker protocol's payload ceiling. + /// JSON arguments, bounded by the worker protocol payload ceiling. payload: String, - /// Per-call wall-clock budget. `None` selects the Host default; values - /// are clamped to the Host's own window. Caller-expressible because the - /// budgets genuinely differ: an interactive status check wants to fail - /// fast, a settlement start wants the full window. + /// Per-call wall-clock budget. `None` selects the Host default, and + /// values are clamped to the Host window. Caller-expressible because + /// the budgets differ: an interactive status check wants to fail fast, + /// a settlement start wants the full window. deadline_ms: Option, } -/// The export's answer. +/// The export answer. struct WorkerCallResponse { /// JSON returned by the export, under the same payload ceiling. payload: String, @@ -80,8 +81,8 @@ struct WorkerCallResponse { enum WorkerCallError { /// No worker is running for this product. Covers a product with no /// worker, a worker the user stopped, a manifest that declares no - /// ceiling for this surface, a quarantined worker, and a host that runs - /// no workers at all. + /// ceiling for this executable, a quarantined worker, and a host that + /// runs no workers at all. Unavailable, /// No such export, or the payload is malformed or over the ceiling. Invalid(GenericError), @@ -94,39 +95,37 @@ enum WorkerCallError { } ``` -**The request carries no product identifier, and that is the security property.** The Host knows which product's surface it is rendering. If the caller named the product, page JavaScript could address a different product's worker by passing a different string, and no downstream validation could recover the true caller. The worker's own execution context is Host-issued for the same reason: identity that JavaScript can state is identity JavaScript can forge. +**The request carries no product identifier, and that is the security property.** The Host knows which product executable it is rendering. If the caller named the product, page JavaScript could address another product worker. Identity that JavaScript can state is identity JavaScript can forge. -**Data crosses, never code.** `api_name` selects an export the verified archive already declared. The call cannot widen what the worker is able to do — it asks a worker that already holds its own authority to act, and the answer comes back as bytes. +**Data crosses, never code.** `api_name` selects an export the verified archive already declared. The call cannot widen what the worker is able to do, and the answer comes back as bytes. -**`Unavailable` is deliberately undifferentiated.** A surface must not be able to tell "this product has no worker" from "the user stopped it" from "the manifest does not permit you". Splitting those would turn the method into a probe for a control the user owns. +**`Unavailable` is deliberately undifferentiated.** Distinguishing a missing worker from one the user stopped from one the manifest withholds would turn the method into a probe for a control the user owns. -### Manifest declaration +## Manifest declaration -Reaching a worker is opt-in, declared on chain rather than requested at runtime, so "what can call this worker" is answerable from the manifest before any code runs. `includes` already names which surfaces a worker serves, and this extends the same vocabulary: +Reaching a worker is opt-in and declared in the manifest, so what can call a worker is answerable before any code runs. `includes` already names which surfaces a worker serves. This extends the same vocabulary: ```ts interface WorkerIncludes { chat: boolean; pocket: boolean; - /** Rendered executables permitted to call this worker. Absent means none. */ + /** Product executables permitted to call this worker. Absent means none. */ app?: boolean; widget?: boolean; funding?: boolean; } ``` -Per-surface rather than a single flag: a product may want its funding sheet to drive a settlement while its widget, rendered on a dashboard nobody is looking at, cannot. Collapsing the three into one boolean makes the narrower policy unexpressible, and widening a ceiling later is additive while narrowing one is breaking. Unknown keys stay ignored, so a record published before this RFC reads as "no surface may call me" rather than failing to resolve. - -Note the asymmetry with `chat`. That flag widens the worker's own authority — a worker declaring it is issued a trusted execution kind that unlocks the chat service surface. These three grant the worker nothing; they only admit a caller that otherwise has no path. +Per-executable rather than one flag: a product may want its funding sheet to drive a settlement while its widget cannot, and a ceiling can be widened additively but never narrowed. Unknown keys stay ignored, so a record published before this RFC reads as no executable may call me. Unlike `chat`, which widens what the worker itself may do, these flags grant the worker nothing. They only admit a caller. -### Semantics and invariants +## Semantics and invariants -- **Same limits, new caller.** Surface calls reuse the worker protocol's existing bounds: capped payloads, per-call deadlines with typed expiry, serialized dispatch per worker, a bounded queue whose overflow is `Unavailable`, and crash backoff into quarantine. A surface calling in a loop degrades into typed errors rather than unbounded work. A surface call is a new _caller_, not a new _path_. -- **Starting is the Host's business.** A surface does not start or stop a worker. If the worker is not running and the manifest permits the call, the Host may start it; if the user has stopped it, the answer is `Unavailable` and stays that way until the user says otherwise. -- **No ambient state.** Two calls share nothing beyond what the worker itself persists. Callers that need continuity pass an identifier the worker minted. -- **One identity beneath both.** Every rendered surface — `funding.` included — runs under the base product identity; the subnames are resolution names, never runtime identities. Caller and worker therefore share one storage namespace and one product account. A Host that gives a surface subname its own namespace breaks this method outright: the worker's answers name state the caller cannot read or persist against. +- **Same limits, new caller.** Executable calls reuse the worker protocol bounds: capped payloads, per-call deadlines with typed expiry, serialized dispatch, a bounded queue whose overflow is `Unavailable`, and crash backoff into quarantine. A caller in a loop degrades into typed errors, not unbounded work. +- **The Host owns starting.** If the worker is not running and the manifest permits the call, the Host may start it. If the user has stopped it, the answer is `Unavailable` until the user says otherwise. +- **No ambient state.** Two calls share nothing beyond what the worker persists. Callers that need continuity pass an identifier the worker minted. +- **One identity beneath both.** Every product executable, `funding.` included, runs under the base product identity. The subnames are resolution names, never runtime identities. Caller and worker therefore share one storage namespace and one product account. A Host that gives a subname its own namespace breaks this method outright: the worker answers name state the caller cannot read or persist against. -### Typical product flow +## Typical product flow ```ts const started = await truapi.worker.callWorker({ @@ -136,60 +135,42 @@ const started = await truapi.worker.callWorker({ if (!started.isOk()) return renderUnavailable(); const { jobId } = JSON.parse(started.value.payload); -// Reopening the surface later: ask, rather than wait for the next write. +// Reopening later: ask rather than wait for the next write. const status = await truapi.worker.callWorker({ apiName: "status", payload: JSON.stringify({ jobId }), }); -assert(status.isOk(), "status failed:", status); ``` -The job survives the surface closing because it never lived there. The surface holds an identifier, not a process. - -### Implementation shape - -The core does not own worker execution. A Host already implements the worker engine behind a platform seam, and the runtime that owns correlation, payload bounds, serialized dispatch and quarantine sits above it. This RFC adds a caller, so it follows the delegation pattern RFC 0026 uses: - -- `truapi-platform` gains one syscall carrying the calling product's identity, the export name, the payload and the deadline. -- `truapi-server` answers `call_worker` in-core by checking the manifest ceiling for the calling surface's kind and delegating to that syscall, mapping a missing worker, a refused ceiling and a stopped worker alike onto `Unavailable`. - -Hosts that already run workers implement one callback over machinery they have. Hosts that do not run workers return `Unavailable` and are conformant. - -Two requirements on a conforming Host that runs workers, easy to miss because each fails far from its cause: - -- **Async exports need a reactor.** A Host embedding the core over UniFFI must export the worker-facing async callbacks with `async_runtime = "tokio"`; an export that reaches storage without one fails as a host-process abort, not a typed error. -- **Launch parameters must reach archive-served surfaces.** The reopen in [Typical product flow](#typical-product-flow) assumes a relaunched surface can carry an identifier back in through its launch query; a Host that drops the query for archive-served executables strands the caller with no way to name its job. - -The change is purely additive: one new trait, one fresh wire id, no changes to existing calls or types. +The job survives the executable closing because it never lived there. The caller holds an identifier, not a process. ## Non-goals -- **Push to the surface.** Request/response only. A surface follows a long job by polling a `status`-shaped export or by reading the storage the worker writes. A subscription needs its own cancellation and lifetime semantics and should not hold this up. -- **Worker-initiated calls.** The worker cannot call the surface. It has no view, the surface may not exist, and what it wants to say belongs in product storage where a surface can read it afterwards. -- **Cross-product calls.** A surface reaches its own worker and no other. Product-to-product interaction is the permission model's business, not this method's. -- **Worker lifecycle control.** Starting, stopping and disclosing workers is a Host concern with a user-facing control surface; this RFC neither extends nor bypasses it. +- **No push.** Request/response only. A caller polls a status export or reads storage. +- **No worker-initiated calls.** The worker writes to product storage instead. +- **No cross-product calls.** An executable reaches its own worker and no other. +- **No lifecycle control.** Starting, stopping and disclosing workers stays a Host concern. ## Drawbacks -- Two executables that could not previously interact now can, which is new surface area for review. Authority does not move — the worker's capabilities are unchanged and the surface gains none of its own — but the interaction itself has to be reasoned about. -- The worker becomes a soft dependency of a surface's flows. A product must still work when the answer is `Unavailable`, because a user can stop the worker at any time. That burden is real, and it is why `Unavailable` is specified as an ordinary outcome rather than a fault. -- Polling for status is less efficient than a subscription, and this ships without one. The cost is bounded by the same queue and deadline limits as any other call. +- Two executables that could not interact now can, which is new surface to review. No authority moves. +- The worker becomes a soft dependency. A product must still work when the answer is `Unavailable`, because the user can stop the worker at any time. +- Status is polled. A subscription is future work. ## Alternatives -- **Let the surface pass the code to run.** This was discarded, and is recorded because it is the request this RFC pre-empts. Worker code resolves only from one pinned, verified archive; code arriving from a page has no CID and no signature. The worker's execution kind authorizes more than the surface's, so anything able to inject into the page — a cross-site scripting bug, a compromised dependency — would execute with worker authority. The bounded version costs a redeploy to change worker logic, which is the intended price. -- **A symmetric message channel between the two sandboxes.** This was discarded because it invites the worker to depend on a surface being present, which is the coupling the worker exists to avoid. The lifetimes are asymmetric, so the protocol should be too. -- **Product storage as the only mechanism** — the surface writes a request record and the worker picks it up. Products should still keep durable job state there, but as the sole mechanism it cannot start a stopped worker, gives no typed failure, and turns every request into a poll with no bound on latency. -- **A single `includes` flag rather than one per surface.** This was discarded because it cannot express "my funding sheet may, my widget may not", and a ceiling can be widened additively later but never narrowed. -- **Do nothing.** This was discarded because it is the status quo the motivation describes: long jobs run in pages, where they die with the view. +- **Passing code instead of data.** Worker code resolves only from one pinned, verified archive. Page-supplied code has no CID and no signature, and an attacker who can inject into the page would execute with worker authority. Changing worker logic costs a redeploy, which is the intended price. +- **A symmetric message channel.** Invites the worker to depend on an executable being present, which is the coupling the worker exists to avoid. The lifetimes are asymmetric, so the protocol is too. +- **Product storage as the only mechanism.** Durable job state should still live there, but alone it cannot start a stopped worker, gives no typed failure, and turns every request into an unbounded poll. +- **A single `includes` flag.** Cannot express that the funding sheet may call while the widget may not. +- **Do nothing.** The status quo the motivation describes. ## Prior Art and References -- [RFC — Product Manifest Format](product-manifest.md) — the two-level manifest, the `worker.` subname, `includes`, and the one-worker-per-product rule this RFC extends. -- [RFC 0026 — Host chain discovery and name resolution](0026-supported-chains.md) — the platform-syscall delegation pattern [Implementation shape](#implementation-shape) follows. -- [RFC-0024 — Proof of Personhood as a Product](0024-personhood-as-product.md) — also extends `WorkerIncludes`, with `onLoad`; see [Unresolved Questions](#unresolved-questions). -- [RFC 0002 — Permission Model](0002-permission-model.md) — owns product-to-product interaction, which is why cross-product calls are a non-goal here. -- [Brevity pocket modality contract](https://github.com/paritytech/brevity-dozer/blob/main/docs/pocket-modality-contract.md) — §6 specifies the worker protocol's bounds this method reuses (payload ceiling, serialized dispatch, bounded queue, crash backoff into quarantine) and §7 the identity rules behind [Semantics and invariants](#semantics-and-invariants); neither is specified in this repo. +- [RFC: Product Manifest Format](product-manifest.md): the two-level manifest, the `worker.` subname, `includes`, and the one-worker-per-product rule this RFC extends. +- [RFC 0024: Proof of Personhood as a Product](0024-personhood-as-product.md): also extends `WorkerIncludes`, with `onLoad`. See [Unresolved Questions](#unresolved-questions). +- [RFC 0002: Permission Model](0002-permission-model.md): owns product-to-product interaction, which is why cross-product calls are a non-goal here. +- [Brevity pocket modality contract](https://github.com/paritytech/brevity-dozer/blob/main/docs/pocket-modality-contract.md): §6 specifies the worker protocol bounds this method reuses, namely the payload ceiling, serialized dispatch, the bounded queue, and crash backoff into quarantine. §7 specifies the identity rules behind [Semantics and invariants](#semantics-and-invariants). Neither is specified in this repo. ## Unresolved Questions diff --git a/docs/rfcs/_index.md b/docs/rfcs/_index.md index 2c337fb9..11e1f864 100644 --- a/docs/rfcs/_index.md +++ b/docs/rfcs/_index.md @@ -27,4 +27,4 @@ created: 2026-03-13 | 0023 | [sr25519 VRF signing for product accounts](0023-account-sign-vrf.md) | draft | Valentin Sergeev | — | | 0024 | [Proof of Personhood as a product](0024-personhood-as-product.md) | draft | Valentin Sergeev | [#360](https://github.com/paritytech/host-rust-core/pull/360) | | 0026 | [Host chain discovery and name resolution](0026-supported-chains.md) | draft | Valentin Fernandez | [#354](https://github.com/paritytech/host-rust-core/pull/354) | -| 0027 | [Calling a product's worker from its app](0027-surface-worker-calls.md) | draft | Tiago Tavares | [#468](https://github.com/paritytech/host-rust-core/pull/468) | +| 0027 | [Calling a product worker from its app](0027-surface-worker-calls.md) | draft | Tiago Tavares | [#468](https://github.com/paritytech/host-rust-core/pull/468) |