feat(platform,server): match manifest execution kinds and serve custom chat rendering from a JS host - #459
Merged
Conversation
…m chat rendering from a JS host `ProductExecutionKind` named two surfaces while product manifests declare three, so a widget had to masquerade as an app. Rename the variants to match `["app", "widget", "worker"]`: `Spa` becomes `App`, `Widget` is new, and `Worker` replaces `Chat`. `App` and `Widget` are one capability class, since execution gating is exact equality and nothing requires either specifically. This moves the worker kind's SCALE discriminant from 1 to 2, so it is a wire change rather than a rename; the wasm entrypoint now rejects an unknown kind at construction instead of degrading silently. Add the two host-initiated Chat entry points a JS host was missing. `renderCustomMessage` streams product-drawn trees for one stored custom message, and `publishChatAction` carries a tapped action back to the product. Shipping only the first would render cells whose buttons do nothing, so both land together. `ChatConnection::publish_action` was excluded from wasm by a `cfg`, despite being in-memory buffering with nothing native about it. Give host-initiated subscriptions an error terminal. A product that cannot serve a render sends `_interrupt`; one that finishes sends nothing and leaves its last tree standing. Both previously ended the stream the same way, so a failed render reported completion and the host would show a partial tree as final. `HostInitiatedSubscription` now yields `Result`, and the interrupt payload reaches the stream instead of being dropped on the floor. Host-side cancellation stays a clean end, because it is not a product failure. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
… session
The Account Holder's answer for `//product//{product_id}` is fixed for a
paired session, but a fresh core kept only an in-memory map, so every restart
re-sent the remote message and waited on a phone that may be asleep. On a
dashboard with several products that is one round-trip per product, per launch,
for a value that cannot have changed.
`CoreStorageKey::ProductSubtree { session_id, product_id }` holds the 32-byte
key unframed. One slot per product keeps a persist to a single write, and lets
a host derive product account addresses from the slot it already stores without
decoding anything the core owns privately: the reviews a host draws can then
carry an address and a fee rather than a bare derivation path.
The lookup order is memory, storage, then the wallet. A stored key passes the
same currency check as a fetched one before it is served, and a persist runs
under the session-secret storage guard, rolling the entry back when the pairing
is lost mid-write. A slot that is not exactly 32 bytes reads as a miss, so a
damaged entry re-asks the wallet instead of deriving a wrong account.
Disconnect clears the slots this run knows about. A product never opened since
launch keeps its slot; those address session ids that cannot recur, matching
how per-product permission and AutoSigning slots already behave.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A host holding a product's subtree public key still could not name the account a review would sign with, so reviews carried a derivation path with no address and no fee. Reproducing the derivation host-side is not a safe alternative: the 32-byte chain code is `u32` little-endian followed by an index magic, and a host that rebuilds it wrongly gets a valid-looking wrong address rather than a failure, shown on a signing review beside a fee fetched for the wrong account. `deriveProductAccountPublicKey` takes a SCALE-encoded `DerivationIndex`, the same value a review already carries, so the chain-code layout stays core-owned and `Raw` selectors work as well as plain indices. `productAccountAddress` applies the prefix host-spec C.6 fixes rather than leaving each host to pick one. Both are pure, so a host calls them after `default()` with no runtime or session. The composition the export performs is pinned against the vector the mobile hosts share. `wasm.rs` compiles only for wasm32, so that test lives beside the derivation itself where the normal suite runs it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
filvecchiato
approved these changes
Aug 20, 2026
TarikGul
reviewed
Aug 20, 2026
TarikGul
approved these changes
Aug 20, 2026
TarikGul
left a comment
Member
There was a problem hiding this comment.
Nice job, one small nit - if we can get this merged soon that would be amazing
…wallet Hosts had no way to reach a product's subtree public key. Every path that pulled one started at the product or the wallet, so a host wanting to name the signing account on a confirmation review had to watch writeCoreStorage for a ProductSubtree key and keep its own mirror of core-owned state. CoreAdmin::get_product_subtree_public_key answers from the memory cache, the persisted slot, or a signing host's local derivation. It never sends the SSO request that the product path sends on a miss: wait_for_sso_remote_response has no timeout of its own, and every other CoreAdmin method is a local read, so a fetch here would be the first one able to block a host mid-render on a phone that is asleep. None means not known yet, and a host draws what it draws today. Splitting the consent-free half out of remote_product_subtree_public_key keeps one resolution order rather than two. Reached through the wasm bridge as productSubtreePublicKey and over the worker protocol, so a worker-runtime host calls it on its existing wasm instance instead of initialising a second one on the main thread to reach the pure derivation exports. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Y65XQWkbMjfxG43gKvVV1b
The main merge brought #453's chat work in against the renamed ProductExecutionKind. Git had no conflict to report: the enum definition came from this branch and the call sites came from #453, so App/Widget/Worker landed next to five uses of the Spa and Chat variants that no longer exist. Worker is the kind that serves Chat and App is the ordinary full-page product, so the mapping is a rename at each site. The host CLI keeps its own --execution spa|chat flag values, which name CLI roles rather than protocol kinds, so the batteries that pass them are unaffected. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Y65XQWkbMjfxG43gKvVV1b
Session teardown drops only the product-indexed slots the core is holding in memory, so a product never opened during a run keeps its entries. Clearing those is the host's job, and until now the only record of that was a comment on a private function inside the core, where no host implementer reads it. CoreStorage says it instead, covering all three product-indexed variants rather than the subtree alone, and points at describe_core_storage_key as the way to identify which product owns a slot. The trait doc flows into the generated TypeScript host-callbacks interface, so JS hosts get it too. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Y65XQWkbMjfxG43gKvVV1b
26 tasks
get_product_subtree_public_key resolves from the memory cache, then the persisted slot, then the Account Holder, and takes the deadline the caller is willing to block for. A host drawing a confirmation review can name the account it will sign with instead of showing a bare derivation path. The deadline drops the call rather than cancelling it and awaiting the result. Only the SSO response wait observes the cancellation token; the statement-store client acquisition and topic subscriptions that precede it do not, so a call parked there ignores a cancel and outlives any deadline that waits for it to finish. Dropping ends it either way. A test with an unanswerable wallet pins this, and hung four runs in five before the change. Passing no deadline keeps the timeout a product gets while awaiting a signature, which is three minutes and far too long to hold a render. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Y65XQWkbMjfxG43gKvVV1b
The `--execution-kind` flag takes `app` and `worker`, matching the kinds a product manifest declares and the core enforces. `spa` named nothing the protocol has, and `chat` named a modality rather than the executable kind that serves it, so a reader had to know both vocabularies to see that `--execution-kind chat` opens a Worker. The checked-in diagnosis reports keep their `spa/` and `chat/` directories. Those name a report's modality, not an executable kind, and the explorer matrix reads the paths. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Y65XQWkbMjfxG43gKvVV1b
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Six changes Polkadot Desktop needs to run its worker on the shared core.
Execution kinds match the manifest
ProductExecutionKindisApp | Widget | Worker, matching the kinds a product manifest declares.Workeris the only kind that serves Chat.AppandWidgetreach the same APIs and differ only in how a host presents them.SCALE indices are
App=0, Widget=1, Worker=2, so this is a wire change and not only a rename. A host still encoding 1 for its worker claims to be a widget and loses Chat. Paired Rust and TypeScript tests pin all three, and the wasm entrypoint rejects an unknown kind rather than falling back to a default.The host CLI's
--execution-kindtakesappandworker, the same names, so reading--execution-kind workerno longer requires knowing thatchatmeant a Worker. The checked-in diagnosis reports keep theirspa/andchat/directories, which name a report's modality rather than an executable kind, and the explorer matrix reads those paths.Custom chat messages reach a JS host
renderCustomMessagestreams the trees a product draws for one stored custom message.publishChatActioncarries a tapped button back to the product. A host with only the first draws buttons that do nothing, so both land together. Both sit behind the Chat access policy that governs every other Chat call.Host-initiated subscriptions report failure
A product that cannot serve a render sends
_interrupt. One that finishes sends nothing at all (js/packages/truapi/src/client.ts:405-433).HostInitiatedSubscriptionyieldsResult<Item, GenericError>so those two stay distinct.onCompletemeans the last tree delivered stands,onErrormeans it is partial. A dropped sender is a clean end, since that is the host closing the manager rather than the product failing.NativeCustomRendererObservergainson_error(reason), and the Swift conformer finishes throwing.Product subtree keys survive a restart
CoreStorageKey::ProductSubtree { session_id, product_id }holds the 32-byte key unframed. One slot per product keeps a persist to a single write rather than a read-modify-write of a whole session, and lets a host derive addresses from a slot it already stores.Lookup order is memory, storage, wallet. A stored key passes the same currency check as a freshly fetched one. A persist runs under the session-secret storage guard and rolls its entry back if the pairing is lost mid-write. A slot that is not exactly 32 bytes reads as a miss, so a damaged entry re-asks the wallet rather than deriving a wrong account.
Product accounts derive in a JS host
Both pure, exported from
@parity/truapi-host/wasm/web. The index crosses as a SCALE-encodedDerivationIndex, which keeps the 32-byte chain code core-owned. That is the reason for the shape. The chain code is au32little-endian followed by an index magic, and a host that rebuilds it wrongly does not fail. It gets a valid-looking wrong address, on a signing review, beside a fee fetched for the wrong account. The wire index also handlesRawselectors, which a hand-rolledu32path gets silently wrong.productAccountAddressapplies the prefix host-spec C.6 fixes. The composition is pinned against the vector the mobile hosts share.Hosts resolve a subtree key on their own deadline
Memory cache, then the persisted slot, then the Account Holder. The wallet answers without prompting the user, though it can wake the phone. A signing host derives locally and never waits.
Nonemeans no active session. Missing the deadline is an error.The deadline drops the call rather than cancelling it and waiting for it to finish. Only the SSO response wait watches the cancellation token. The statement-store client acquisition and topic subscriptions ahead of it do not, so a call parked there ignores a cancel and outlives a deadline that waits on it. A test with an unanswerable wallet pins this, and hung four runs in five before the call was dropped instead.
Passing no deadline keeps the timeout a product gets while awaiting a signature. That is three minutes, which is far too long to hold a render, so a host drawing one should pass its own.
Reached over the worker protocol as well, so a worker-runtime host uses its existing wasm instance instead of initialising a second one to reach the derivation exports.
Two crash-shaped bugs in the worker bridge
A tree the host's codec cannot decode, or a renderer that throws on one, ends that render through
onError. Such a throw escaping the worker message listener leaves the render with no terminal at all, so a caller awaiting one waits forever. Products built against a newer protocol than the host's bundled@parity/truapiare the realistic trigger.Render subscriptions are keyed by core on both sides of the worker boundary, and a disposed provider fails its outstanding renders. Desktop runs one provider per open product plus one per worker, so cross-product cancellation is reachable there.
CoreStorage documents the product-slot sweep
Session teardown drops only the product-indexed slots the core holds in memory, so a product never opened during a run keeps its entries.
CoreStoragesays clearing those belongs to the host, coveringPermissionAuthorization,AutoSigningKeyandProductSubtreerather than the subtree alone, and namesdescribe_core_storage_keyas the way to find a slot's owner. The trait doc reaches JS hosts through codegen.Notes
Merges from
maincarry chat work written against the oldProductExecutionKindnames, and git reports no conflict when the enum comes from this branch and the call sites come from the other. Compile after one rather than trusting it.remote_authority_call, which every product signing path uses, has the same gap: it cancels and then awaits, so its deadline is not a hard bound either. Left alone here, since fixing it touches every signing call.cargo test --workspace --all-features998 passed 0 failed,cargo clippy --workspace --all-targets --all-features -- -D warnings,cargo +nightly fmt --all --check,cargo check --target wasm32-unknown-unknown -p truapi-server, codegen goldens,sync-bindings.sh,@parity/truapi231 tests,@parity/truapi-host65 tests, explorer typecheck, playground build and lint.