From 8133c08fcd84e9aa88c5a60f51b2e2098a51d8c7 Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Tue, 8 Sep 2026 12:10:10 +0200 Subject: [PATCH 1/4] docs(rfc): add Subscription Typed Interrupt Payload --- .../subscription-typed-interrupt-payload.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/rfcs/subscription-typed-interrupt-payload.md diff --git a/docs/rfcs/subscription-typed-interrupt-payload.md b/docs/rfcs/subscription-typed-interrupt-payload.md new file mode 100644 index 000000000..82c192e52 --- /dev/null +++ b/docs/rfcs/subscription-typed-interrupt-payload.md @@ -0,0 +1,72 @@ +--- +title: "Subscription Typed Interrupt Payload" +owner: "@johnthecat" +status: draft +--- + +# RFC — Subscription Typed Interrupt Payload + +## Summary + +Every subscription names its interrupt type, and its stream can end with a value of that type at any point. The +`_interrupt` frame carries the value; a normal end is an empty frame, which the client delivers as `complete`. +`Subscription` replaces both `Subscription` and `Result, CallError>`, +because a start-time failure is an interrupt with no items before it. + +```ts +truapi.theme.subscribe({ + next: (theme) => apply(theme), + error: (error) => console.warn("theme stream failed:", error.reason), + complete: () => {}, +}); +``` + +## Motivation + +A live subscription cannot fail. `Subscription` yields items only, so when the platform stream behind +`theme.subscribe` errors, the runtime drops the error and the product's theme freezes on its last value. A chain-head +follow whose connection drops ends with the same frame as one that finished cleanly. + +The one place a typed reason exists, it is inconsistent. Methods declared `Result, CallError>` send +a typed `_interrupt` when the start fails, and the generated TS client surfaces it as +`error(SubscriptionError { reason })`. Plain methods send an empty `_interrupt` for the same failure, and the client +reports `complete`, so a host that does not support the method looks like a stream that ended normally. When a +result-kind stream ends normally, the empty frame is fed to the typed decoder, which throws. + +Host-initiated methods need the same in the other direction. A product that streams items to the host has no way to say +that it is done, or why it stopped, other than ending the stream or declining with a bare `[0]` byte that the host sees +as `GenericError { reason: "product interrupted the host-initiated subscription" }`. A stream that finishes with an +outcome has to encode that outcome as an error or lose it. + +## Approach + +`_interrupt` with a payload carries the method's interrupt value, encoded as declared and without further wrapping. +`_interrupt` with an empty payload is a normal end, which the TS client delivers as `complete`. `_stop` is unchanged. + +`Subscription` is a stream whose items are `Result`; the first `Err` is terminal. The +dispatcher encodes it as the `_interrupt` payload and drops the rest of the stream. A stream that ends without an `Err` +produces the empty frame. A method chooses the shape of `Interrupt`: `CallError` for a failure-only end, or +`Result<(), CallError>` when a normal end carries meaning of its own. Methods with a domain error declare +`CallError`, so their interrupt bytes are unchanged. Methods without one declare `CallError`, which is +what their platform streams yield, so the runtime forwards the platform error instead of dropping it. +`Subscription::interrupted(value)` is a stream that ends with the given interrupt and replaces `Subscription::empty()`. + +Codegen emits `ObservableLike`, `SubscriptionError` and an interrupt decoder for every +subscription; the decoder treats an empty payload as `complete`. A product that reads `error.reason` gets the declared +interrupt value. + +A product handler for a host-initiated method returns `Subscription`. When its observable errors with +the method's interrupt value, the client encodes the value into the `_interrupt` frame, the server preserves the bytes, +and the host's stream ends with `Err(value)`. A product-side stream whose end carries an outcome declares it as the +interrupt type instead of encoding it as an error. + +The frame format is unchanged, so there is no protocol version bump. A client without a decoder for the interrupt type +delivers any interrupt as `complete`, and a host that sends an empty frame on failure is read as `complete`. + +## Trade-offs + +- Every subscription implementer changes its return type, on all hosts, in one pass. +- A normal end is an empty `_interrupt` frame, not silence, so a product can distinguish a finished stream from a + waiting one. +- `CallError` carries a string, not a discriminated enum. A method that needs a richer reason declares its + own interrupt type. From be6c82057c86c28b6360dbd7f0da1a8da9175cdf Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Tue, 8 Sep 2026 12:10:10 +0200 Subject: [PATCH 2/4] docs(rfc): add Worker Lifecycle --- docs/rfcs/worker-lifecycle.md | 82 +++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/rfcs/worker-lifecycle.md diff --git a/docs/rfcs/worker-lifecycle.md b/docs/rfcs/worker-lifecycle.md new file mode 100644 index 000000000..d6b5e640e --- /dev/null +++ b/docs/rfcs/worker-lifecycle.md @@ -0,0 +1,82 @@ +--- +title: "Worker Lifecycle" +owner: "@johnthecat" +status: draft +--- + +# RFC — Worker Lifecycle + +## Summary + +A product has one worker. The host runs it while a reference to it is held and may stop it when none is. Modality work +takes a reference for as long as it is on screen or in flight. Acknowledging a product grants one short run so the +worker can set up before anything references it. + +## Motivation + +The [Product Manifest Format](product-manifest.md) defines the worker as the product's single background process and +does not say when it runs. A worker that runs for the life of the host is a signing-capable process per product running +unobserved. A worker that runs only with the product's app view cannot serve a chat room while the view is closed. Every +modality that calls a worker needs the same rule. + +### Requirements + +1. **Single.** A product has one worker process, however many modalities call it. +2. **Demand-driven.** The worker runs only while the host has work that only the worker can do. +3. **Stoppable.** The host may stop an unreferenced worker at any time and the product keeps working. +4. **Setup.** An acknowledged product gets a run before anything calls its worker. +5. **Additive.** No wire change; the host starts and stops the worker executable. + +## Approach + +The host keeps one reference count per product worker. The first reference starts the worker. When the count returns to +zero the host may stop it. Products do not ask for a worker to run. + +The design has three parts: + +- **References**: what holds the worker. +- **Acknowledgement grant**: the one run without a reference. +- **Product rules**: what a worker must tolerate. + +### References + +A reference is held for exactly as long as its work is on screen or in flight. Several references of one product hold +one worker. A modality names its holders in its own RFC. App and Widget executables hold no reference; their lifetime is +their screen. + +Reference holders, by modality: + +| Holder | Held while | +| ------- | ----------------------------------------------------------------------------------------------------- | +| Chat | A room the product serves is on screen, or a message addressed to the product is in flight. | +| Pocket | An artifact the product contributed is on screen. | +| Funding | The product is the selected provider of a funding flow, from selection until the flow settles. | +| Input | An input surface is open, or a `Custom` candidate is on screen ([Input Modality](input-modality.md)). | + +### Acknowledgement grant + +When the host acknowledges a product, it takes a time-limited reference on the worker. Acknowledgement may be a pin, an +addition to widgets or pocket, or the adoption of a new deployment of an acknowledged product. Nothing calls the worker +during the grant. The window is host policy, and the host releases the reference whether or not the worker is done, so +setup is idempotent and resumes on the next start. + +### Product rules + +- A worker tolerates being stopped whenever nothing references it, including mid-setup and while its output is on + screen. State that must survive goes through host storage. +- A worker does not read being started as user intent. +- A worker does no background work of its own. Proactive wakeups are the host's to schedule and are a separate RFC. + +Nothing crosses the wire. The worker learns it is needed by receiving a modality call. + +## Trade-offs + +- Setup that outlives the grant completes on a later start. +- A worker may start and stop several times in one pocket scroll. The host may keep an unreferenced worker warm; the + product may not rely on it. +- Considered and dropped: an always-on worker, a start per call without counting, one worker per modality. + +## Open questions + +- Whether a worker is told what started it, a grant or a reference. Nothing distinguishes the two from inside the + worker. From 18b6a6ac82f29e64de939f77baae6d901132756e Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Tue, 8 Sep 2026 12:10:10 +0200 Subject: [PATCH 3/4] docs(rfc): add Unified Renderer --- docs/rfcs/unified-renderer.md | 465 ++++++++++++++++++++++++++++++++++ 1 file changed, 465 insertions(+) create mode 100644 docs/rfcs/unified-renderer.md diff --git a/docs/rfcs/unified-renderer.md b/docs/rfcs/unified-renderer.md new file mode 100644 index 000000000..e3fdd7e31 --- /dev/null +++ b/docs/rfcs/unified-renderer.md @@ -0,0 +1,465 @@ +--- +title: "Unified Renderer" +owner: "@johnthecat" +status: draft +--- + +# RFC — Unified Renderer + +## Summary + +One `Renderer` service draws every body a product renders itself and reports the presses inside it: a host-initiated +`render` stream and a product-side `action_subscribe`, each carrying a `RenderContext` that says where the body lives. +The body is a `RendererNode` tree over a closed vocabulary. `Chat::custom_message_render` and the renderer-tree half of +`ChatActionPayload::ActionTriggered` fold into it. The input modality uses it for product-drawn candidates, the pocket +modality for card faces, and any later surface adds a context variant instead of a rendering pair. + +## Motivation + +The only product-drawn body is a chat message with `ChatMessageContent::Custom`, and drawing it is a pair of chat +methods. The host calls `Chat::custom_message_render` with `message_id`, `message_type` and the stored payload; a press +on a `Button` node in the returned tree comes back on `Chat::action_subscribe` as +`ActionTriggered { message_id, action_id, payload }`. Both are chat-shaped: the request is keyed by message, and the +press item does not say whether the button was one the host drew for an `Actions` message or one the product drew in a +tree. + +The input modality needs the same thing for a candidate the product draws, and the pocket modality for a card face. +Copying the pair per surface gives a product one render callback and one action stream per surface, at four wire ids +each. + +## Requirements + +- **Surface-neutral.** A body is drawn and its presses reported the same way on every surface. +- **Closed.** A product names layouts and tokens from a fixed vocabulary, never markup, stylesheets or URLs. +- **Correlated.** A press carries enough for the product to find the body and the handler without a registry. +- **Live.** A product redraws a displayed body in place, and no stream is open for a body off screen. +- **Framed.** The host draws identity, bounds and dismissal around every body and interprets the tree itself. + +## Approach + +The design has three parts: + +- The `Renderer` service and the `RenderContext` that binds its two methods. +- The `RendererNode` tree. +- The action pipeline from a rendered node to the product. + +### Service + +`Renderer` is a worker service beside `Chat` in the canonical `truapi` crate, at `api/renderer.rs`. Its types live in +`v01::renderer` and are re-exported through `truapi::latest`. + +```rust +/// Where a product-rendered body lives, and the id that names it there. +pub enum RenderContext { + /// A message in a chat room. + ChatMessage { + /// Room the message was posted in. + room_id: String, + /// Message id, as returned by `Chat::post_message`. + message_id: String, + /// Product-defined discriminator, as stored in `ChatCustomMessage::message_type`. + message_type: String, + }, + /// A candidate answered to an input query. + InputWidget { + /// Candidate id, as the product answered it. + candidate_id: String, + }, + /// A card face in the host's Pocket collection. + PocketCard { + /// Card id, as declared in the product's worker manifest. + card_id: String, + }, +} + +/// A body the host needs drawn. +pub struct ProductRendererRenderRequest { + /// Where the body lives. + pub context: RenderContext, + /// Product-defined payload, opaque to the host. + pub payload: Vec, +} + +/// An action triggered inside a product-rendered body. +pub struct HostRendererActionSubscribeItem { + /// Where the body lives. + pub context: RenderContext, + /// Remote actor who triggered it. Absent when the local user did. + pub peer: Option, + /// Which action was triggered, as named in the renderer tree. + pub action_id: String, + /// Data the node attached to the action. Absent for a `Button` press. + pub payload: Option>, +} +``` + +```rust +/// Product-rendered bodies and the actions triggered inside them. +pub trait Renderer: Send + Sync { + /// Streams renderer trees for one product-rendered body. Each item + /// replaces the previous tree. The stream stays open while the body is + /// displayed so the product can redraw in place. + fn render( + &self, + _cx: &CallContext, + _request: ProductRendererRenderRequest, + ) -> Subscription> { + Subscription::interrupted(CallError::unavailable()) + } + + /// Subscribe to actions triggered inside this product's rendered bodies. + async fn action_subscribe( + &self, + _cx: &CallContext, + ) -> Subscription> { + Subscription::interrupted(CallError::unavailable()) + } +} +``` + +`RenderContext` has one variant per surface, holding the ids that surface uses to name a body. The host scopes an id to +the product that minted it, and for `InputWidget` also to the round the candidate was answered in. A render request and +the actions inside it carry the same context verbatim, so a product correlates by equality. A new surface adds a variant +and nothing else on the wire. + +A product registers one `render` handler, and the host calls it for every context of a surface the product's manifest +`includes`. A product that draws both chat messages and input widgets matches on `context`. Contexts for surfaces the +product does not include are never sent. + +### Render Tree + +A body is one `RendererNode`. An absent `OptionBool` leaves the default to the host. + +```rust +/// A size in logical pixels, SCALE-encoded as `Compact`. +pub type Size = Compact; + +/// Edge dimensions. `bottom` defaults to `top` and `start` to `end` when absent. +pub struct Dimensions { + pub top: Size, + pub end: Size, + pub bottom: Option, + pub start: Option, +} + +/// Typography presets, resolved by the host's design system. +pub enum TypographyStyle { + HeadlineLarge, + TitleMediumRegular, + BodyLargeRegular, + BodyMediumRegular, + BodySmallRegular, +} + +/// Button emphasis. +pub enum ButtonVariant { + Primary, + Secondary, + /// No background. + Text, +} + +/// Semantic color tokens, resolved by the host's theme. +pub enum ColorToken { + FgPrimary, + FgSecondary, + FgTertiary, + BgSurfaceMain, + BgSurfaceContainer, + BgSurfaceNested, + FgSuccess, + FgError, + FgWarning, +} + +/// Placement of content within a `Box`. +pub enum ContentAlignment { + TopStart, + TopCenter, + TopEnd, + CenterStart, + Center, + CenterEnd, + BottomStart, + BottomCenter, + BottomEnd, +} + +/// Cross-axis alignment of `Column` children. +pub enum HorizontalAlignment { + Start, + Center, + End, +} + +/// Cross-axis alignment of `Row` children. +pub enum VerticalAlignment { + Top, + Center, + Bottom, +} + +/// Main-axis distribution of children. +pub enum Arrangement { + Start, + End, + Center, + SpaceBetween, + SpaceAround, + SpaceEvenly, +} + +/// Outline of a background or border. +pub enum Shape { + Rounded(Size), + Circle, + Square, +} + +pub struct BorderStyle { + pub width: Size, + pub color: ColorToken, + pub shape: Option, +} + +pub struct Background { + pub color: ColorToken, + pub shape: Option, +} + +/// How a node composites with what is behind it. The values are those common to CSS +/// `mix-blend-mode`, SwiftUI `BlendMode` and Compose `BlendMode`. +pub enum BlendingMode { + Normal, + Multiply, + Screen, + Overlay, + Darken, + Lighten, + ColorDodge, + ColorBurn, + HardLight, + SoftLight, + Difference, + Exclusion, + Hue, + Saturation, + Color, + Luminosity, +} + +/// Layout and styling applied to one node. +pub enum Modifier { + /// Outer spacing. + Margin(Dimensions), + /// Inner spacing. + Padding(Dimensions), + Background(Background), + Border(BorderStyle), + Height(Size), + Width(Size), + MinWidth(Size), + MinHeight(Size), + FillWidth(bool), + FillHeight(bool), + /// 0 is transparent, 100 is opaque. + Opacity(u8), + BlendingMode(BlendingMode), +} +``` + +```rust +pub struct BoxProps { + pub content_alignment: Option, +} + +pub struct ColumnProps { + pub horizontal_alignment: Option, + pub vertical_arrangement: Option, +} + +pub struct RowProps { + pub vertical_alignment: Option, + pub horizontal_arrangement: Option, +} + +pub struct TextProps { + pub style: Option, + pub color: Option, +} + +pub struct ButtonProps { + /// Button label. + pub text: String, + pub variant: Option, + /// Whether the button accepts presses. + pub enabled: OptionBool, + /// Whether the button shows a loading state. A loading button accepts no + /// presses. + pub loading: OptionBool, + /// Action triggered on press. A button without one is inert. + pub click_action: Option, +} + +/// Where image bytes come from. The host fetches them; the tree carries no URL. +pub enum ImageSource { + /// A Bulletin chain blob, addressed by its CID. + Bulletin(String), + /// A file inside the product's executable archive, as a path relative to + /// the archive root. + Archive(String), +} + +pub enum ImageFit { + /// The image is not resized. + None, + /// Resized to fill the container without preserving the aspect ratio. + Fill, + /// Preserves the aspect ratio and fills the container, cutting overflow. + Cover, + /// Preserves the aspect ratio and fits inside the container, leaving empty space if needed. + Contain, + /// Whichever of `None` or `Contain` yields the smaller image. + ScaleDown, +} + +pub struct ImageProps { + pub source: ImageSource, + /// Defaults to `Fill`. + pub fit: Option, +} + +/// A visual effect. Each variant names one effect and carries its parameters. +pub enum Effect { + Rainbow, +} + +pub struct EffectProps { + pub effect: Effect, +} + +pub struct TextFieldProps { + /// Current value. + pub text: String, + /// Shown when the value is empty. + pub placeholder: Option, + pub label: Option, + /// Whether the field accepts input. + pub enabled: OptionBool, + /// Action triggered on every value change, carrying the new value. + pub value_change_action: Option, +} + +/// A node in a product-rendered tree. Container variants recurse through +/// `children`. +pub enum RendererNode { + /// Draws nothing. + Nil, + /// A text run. + String { text: String }, + /// Generic container. + Box { + modifiers: Vec, + props: BoxProps, + children: Vec, + }, + /// Vertical layout. + Column { + modifiers: Vec, + props: ColumnProps, + children: Vec, + }, + /// Horizontal layout. + Row { + modifiers: Vec, + props: RowProps, + children: Vec, + }, + /// Flexible space. + Spacer { + modifiers: Vec, + }, + /// Styled text. + Text { + modifiers: Vec, + props: TextProps, + children: Vec, + }, + Button { + modifiers: Vec, + props: ButtonProps, + children: Vec, + }, + /// Single-line text input. + TextField { + modifiers: Vec, + props: TextFieldProps, + }, + /// Image, sized by modifiers. + Image { + modifiers: Vec, + props: ImageProps, + }, + /// Applies its effect to its children. + Effect { + props: EffectProps, + children: Vec, + }, +} +``` + +The host draws every node from its own design system. + +An `ImageSource` is fetched from the Bulletin IPFS gateway or the product's executable archive. An image that cannot be +fetched draws as empty space. A tree nested deeper than the host's bound is a decode failure of the stream. + +### Actions + +An action id is product-chosen and opaque to the host. `Button::click_action` and `TextField::value_change_action` are +the action sites. + +Action payload by node, as the host sends it in `HostRendererActionSubscribeItem::payload`: + +| Node | Trigger | `payload` | +| ----------- | ------------ | ---------------------------------------------- | +| `Button` | Press | Absent | +| `TextField` | Value change | UTF-8 bytes of the new value, no length prefix | + +The host fills `context` from the render request whose tree the node belongs to. Only the current tree of an open +`render` stream is a source of actions. Actions published before the product subscribes are buffered until it does. +`peer` is absent for an `InputWidget` or `PocketCard` context. + +### Streams + +The host opens a `render` stream when the body comes on screen and closes it when the body leaves; the same body coming +back opens a fresh stream with the same request. Each item replaces the whole tree. A stream that ends cleanly leaves +the last tree on screen. After an error the host shows the product's identity and no body. A product that cannot draw +the body ends the stream with an error. + +An open `render` stream is one worker reference in [Worker Lifecycle](worker-lifecycle.md) terms, whichever surface +opened it. + +### Chat + +`Chat::custom_message_render` is removed. A `Custom` message is rendered through `Renderer::render` with a `ChatMessage` +context and the stored message payload as `payload`. Presses inside its tree arrive on `Renderer::action_subscribe` with +`peer` set to the room member who pressed. `ChatActionPayload::ActionTriggered` carries only a press on a button the +host draws for a `ChatMessageContent::Actions` message. + +## Compatibility + +`Renderer` is not additive for chat products. A product rendering custom messages moves its handler from +`chat.onCustomMessageRender` to `renderer.onRender` and its tree-button handling from `chat.actionSubscribe` to +`renderer.actionSubscribe` and re-encodes its trees as `RendererNode`. A product that posts no `Custom` messages is +unaffected. Hosts implement `Renderer` in the same change that removes the chat method. + +## Trade-offs + +- Chat products that render custom messages break once. +- A string `surface` field instead of the enum was dropped; it moves the id set into an untyped payload and loses + correlation by equality. + +## Open questions + +- The `Effect` variants beyond `Rainbow`, and the parameters each carries. +- Whether a pocket card drawn through `Renderer` needs a viewport signal beyond the stream closing. From 8e738637224ffaf7918d8e411e041656e95cbe56 Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Tue, 8 Sep 2026 12:10:10 +0200 Subject: [PATCH 4/4] docs(rfc): add Input Modality and amend the worker manifest includes.input shape --- docs/rfcs/input-modality.md | 304 ++++++++++++++++++++++++++++++++++ docs/rfcs/product-manifest.md | 12 +- 2 files changed, 310 insertions(+), 6 deletions(-) create mode 100644 docs/rfcs/input-modality.md diff --git a/docs/rfcs/input-modality.md b/docs/rfcs/input-modality.md new file mode 100644 index 000000000..030212170 --- /dev/null +++ b/docs/rfcs/input-modality.md @@ -0,0 +1,304 @@ +--- +title: "Input Modality" +owner: "@johnthecat" +status: draft +--- + +# RFC — Input Modality + +| | | +| --------------- | ------------------------------------------------------------------------------------------------- | +| **Start Date** | 2026-07-28 | +| **Description** | Route user input to the products already on screen, as a contextual surface over another modality | +| **Authors** | Sergey Zhuravlev | + +## Summary + +Input is a modality with no screen of its own. The host opens it as a surface over the view the user is on, and **the +screen underneath dictates who is guaranteed to receive the input**. Those products are always asked, provided they +accept the input's shape, and their answers rank first. A host may ask others at its own discretion; their answers rank +below. Each product answers with candidates or declines. A candidate is text, an attachment, or a widget the product +renders itself. Widgets are drawn through the `Renderer` trait of [Unified Renderer](unified-renderer.md). A navigation +is the one exception: it names a product and a surface, and replaces the context instead of acting within it. + +## Motivation + +A view offers the interactions its product designed into it. A user may want something the view did not anticipate. + +Two things are missing: + +- **A product cannot be handed anything.** The only way to reach one is to open it, so every input is an entry point. + There is no channel to a product the user is already looking at. +- **A product cannot answer.** A product holds state the host cannot see, so a string may mean something to it and to + nothing else. The set of such strings is open, so the host cannot close the gap by learning formats. + +Making input contextual closes both: the screen names its products, and those are the products asked. + +### Requirements + +1. **Contextual.** Input reaches the products the user is looking at, without leaving the view. Products the user cannot + see are not the default recipients. +2. **Products interpret, the core does not.** The host carries input uninterpreted. Which strings mean something is a + product's knowledge, and a new meaning must not require a protocol or host change. +3. **Answers, not just delivery.** A product can respond to input, and the host presents responses from several products + in one place, each under its own identity. +4. **Bounded disclosure.** Input goes to no product without either the screen or the host's stated policy putting it + there. +5. **Confirmed external input.** Input authored outside the host is routed only after the user confirms it. + +## Explanation + +The user opens the input surface over the screen they are on. That screen may be an app view, a chat view, or a pocket +view. The host reads it for its **context set**: the products present in it that accept the input's shape. It asks all +of them at once, ranks them first, and each answers with candidates or declines. Beyond them the host may ask further +products at its own discretion, ranked below. A navigation names a product and a place inside it outright, and moves +there instead. + +The design has five parts: + +- **Registration**: what a product publishes to take part. +- **Data types**: what an input is. +- **Context**: who is guaranteed to receive it. +- **Query answering**: what comes back. +- **Worker lifecycle**: when workers run. + +### Registration + +A product takes part only if its worker manifest says so. The [Product Manifest Format](product-manifest.md) defines the +worker executable at `worker..` and its `includes` key, which names `input` beside `chat` and `pocket`. +`includes.input` is an object listing the shapes the worker accepts; the Product Manifest Format is amended to match. A +worker without it is never asked anything. + +```json +{ + "kind": "worker", + "entrypoint": "./worker.js", + "includes": { + "chat": true, + "pocket": false, + "input": { + "supports": ["text", "image", "audio", "video", "file"] + } + } +} +``` + +**`supports`** lists the input shapes the worker accepts: `text`, `audio`, `video`, `image`, and `file`. A text query +goes only to workers listing `text`; an attachment only to workers listing its category. An unrecognized member is +ignored. An empty array means the worker is never asked. + +### Data types + +#### The routed input + +A routed input says one of two things: **open this surface of this product**, or **here is a query for the products in +context**. Only a query reaches a product. + +```rust +/// What the host resolved a user input to. +pub enum RoutedInput { + /// Open the product's app view at a path within it. + App { product_id: String, pathname: String }, + /// Open one room of the product's chat view. + Chat { product_id: String, room_id: String }, + /// Open one artifact in the product's pocket view. + Pocket { product_id: String, artifact_id: String }, + /// Something to be answered, uninterpreted by the host. Text the host + /// resolved to no product falls through to here. + Query(Query), +} + +/// An input no product was named for. +pub enum Query { + /// The user's text, exactly as they entered it. + Text(String), + /// Something the user handed to the host, carried whole. + Attachment(Attachment), +} + +/// A user-supplied payload, categorized for handling. +pub enum Attachment { + /// Audio the host can play inline. + Audio(AttachmentData), + /// Video the host can play inline. + Video(AttachmentData), + /// An image the host can render inline. + Image(AttachmentData), + /// Anything else, such as a document or an archive. + File(AttachmentData), +} + +/// The bytes of an attachment and what they are. +pub struct AttachmentData { + /// Name as the user knows it. + pub file_name: String, + /// MIME type. + pub mime_type: String, + /// The bytes themselves. + pub content: Vec, +} +``` + +Every input goes through three steps in the host before any product hears of it: + +1. **Capture.** The host takes the input from its own surface: a typed string, a scanned code, or a payload handed over + by the operating system. +2. **Classify.** If the input is a deeplink to a modality, it becomes `App`, `Chat`, or `Pocket` according to its + content. Anything else becomes `Query`: a string is `Query::Text`, a payload is `Query::Attachment`. +3. **Navigate or ask.** A navigation is executed by the host: it opens the named surface and delivers nothing to any + product. A `Query` is put to the context set of the screen it was entered on, as one round. + +`Query` is the only variant that crosses the wire. `Text` crosses unmodified: every product receives exactly what the +user entered, and interpreting it is the product's job. The `Attachment` category is derived, not declared: the host +maps `image/*`, `audio/*`, and `video/*` to their variants and everything else to `File`. `mime_type` is never empty. +When the attachment arrives without one, the host may derive it; if that fails, it falls back to +`application/octet-stream`. + +#### When routing happens + +Input the user composed inside the host is routed when the host decides: on a pause, on submit, or otherwise. Rounds +supersede, so the user sees the answer to the string currently in the field. + +Input authored outside the host is routed only after the user confirms it. For a scanned code the host shows what it +decoded and what it will do with it. For input from the operating system a share sheet or file picker already showed the +user the payload and counts as confirmation; a bare tap on a link does not, and the host confirms it again. + +### Context + +The context set is the products on the screen the input surface opened over whose `supports` lists the input's shape. +Every product in it is queried, and its answers rank first. The host may also ask products outside the set, if they +declare `includes.input`; which ones, and whether any, is host policy. Their answers rank below every context candidate. +Ranking within each band is host policy. + +Context set examples by modality underneath: + +| Modality | Context set | +| -------- | -------------------------------------------------- | +| App | The one product whose app view is on screen. | +| Chat | Every product with a room registered. | +| Pocket | Every product with an artifact in the pocket view. | + +### Query answering + +The host calls the worker with the query, and the return value is the answer. + +```rust +/// One query the host routed to this product. +pub struct ProductInputRequest { + /// What was routed. + pub query: Query, +} + +/// What a product has to say about one query. +pub enum InputResponse { + /// The product has nothing to offer. Terminal: the stream ends after it. + NotHandled, + /// Zero or more answers, ordered by the product's own confidence. + Candidates(Vec), +} +``` + +#### Candidate content + +```rust +/// Body of a candidate. `Text` and `Attachment` mirror `Query`. +pub enum InputCandidateContent { + /// Plain text. + Text(String), + /// A payload the candidate offers, carried whole. + Attachment(Attachment), + /// A candidate the product draws itself. + Custom(InputCustomContent), +} + +/// A candidate whose body the product renders and whose controls it handles. +pub struct InputCustomContent { + /// Identifies this candidate among the ones this product answered with. + /// Correlates the render call and any action triggered in it. + pub candidate_id: String, + /// Product-defined discriminator used to select a renderer. + pub content_type: String, + /// Product-defined payload, opaque to the host. + pub payload: Vec, +} +``` + +Every candidate renders under the answering product's `displayName` and `icon` from its root manifest. `Text` and +`Attachment` are drawn by the host. + +A `Custom` candidate is drawn by the product through the `Renderer` trait of [Unified Renderer](unified-renderer.md). +Input adds one variant to its `RenderContext`: + +```rust +/// A candidate answered to an input query. +InputWidget { candidate_id: String, content_type: String }, +``` + +The host hands `payload` back under that context, with the candidate's `content_type` in it, and asks for a renderer +tree, which it draws inside its own frame. Controls in the tree deliver actions to the product. + +#### The `Input` trait + +```rust +/// Contextual input routed to the product's worker. +pub trait Input: Send + Sync { + /// Route one query to this product's worker and take its answer. + fn request( + &self, + _cx: &CallContext, + _request: ProductInputRequest, + ) -> Subscription>> { + Subscription::empty() + } +} +``` + +`Input::request` is `host_initiated`, the existing primitive for the host calling a product and taking a stream. The +product streams `InputResponse` items and the host appends each one's candidates to that product's list. The stream ends +with an interrupt carrying `Result<(), CallError>` +([Subscription Typed Interrupt Payload](subscription-typed-interrupt-payload.md)): `Ok` means the product is done, `Err` +means it failed. Either way the host keeps the candidates received. `NotHandled` is terminal too; the host closes the +stream after it. The host ends the call itself on supersession or the response deadline. + +### Worker Lifecycle + +Workers are reference-counted; the rule is [Worker Lifecycle](worker-lifecycle.md). Input adds two references: + +- An **open input surface**, on every product it queries, for as long as the round is open. +- A **displayed `Custom` candidate**, on the product drawing it, for as long as its render call is open. + +## Drawbacks + +- The same string typed over two screens gets two answer sets, not cachable. +- How far beyond the screen a query travels is host policy, so privacy differs between hosts. +- A large pocket or room queries every product in it, with no upper limit. +- A host that routes while the user types discloses prefixes. + +## Performance, Ergonomics, and Compatibility + +A query costs one concurrent call per product asked, bounded by the response deadline, and at most one round is in +flight. A `Custom` candidate costs a second call that stays open while it is drawn. + +A product participates by setting `includes.input` and implementing `Input::request`. `Text` candidates are enough for a +working answer; `Renderer` is needed only to draw custom ones. What a product answers needs no manifest change. + +`Input` is additive on the wire: a product that does not implement it receives no queries. The manifest change is +breaking: `includes.input` becomes an object, a boolean value is malformed, and the Product Manifest Format is amended +to match. A worker manifest without `includes.input` stays valid. + +## Prior Art and References + +- [Product Manifest Format](product-manifest.md) +- [Worker Lifecycle](worker-lifecycle.md) +- [Subscription Typed Interrupt Payload](subscription-typed-interrupt-payload.md) +- [Unified Renderer](unified-renderer.md) + +## Unresolved Questions + +1. Widget context: which products a dashboard contributes. Until settled, the input surface does not open over one. +2. Whether a product is told if it is alone on screen or one of several. +3. Input syntax: the textual form that produces `App`, `Chat`, and `Pocket`. This blocks anything printed or shared + between hosts. +4. Ranking within a band, given that any product can return a plausible candidate for every query. +5. Whether a host that asks beyond the screen must let the user exclude a product. +6. Whether ranking outside products by selection history or answer rate is acceptable profiling. diff --git a/docs/rfcs/product-manifest.md b/docs/rfcs/product-manifest.md index 8e2279661..d6969962a 100644 --- a/docs/rfcs/product-manifest.md +++ b/docs/rfcs/product-manifest.md @@ -170,10 +170,10 @@ type WidgetManifest = CommonExecutableFields & { type WorkerManifest = CommonExecutableFields & { kind: 'worker'; entrypoint: string; // Path to the worker entry module inside the executable directory. - includes: { // Surfaces served; an omitted key means `false`. + includes: { // Surfaces served; an omitted key means not served. pocket?: boolean; chat?: boolean; - input?: boolean; + input?: { supports: ('text' | 'audio' | 'video' | 'image' | 'file')[] }; // Input shapes the worker accepts. }; }; @@ -314,10 +314,10 @@ type WorkerConfig = { root: string; // Path to the executable directory on disk. appVersion: SemVer; // Same SemVer tuple as the matching ExecutableManifest. entrypoint: string; // Path to the worker entry module inside the executable directory. - includes: { // Same shape as WorkerManifest.includes; all may be false for a background-only worker. + includes: { // Same shape as WorkerManifest.includes; all may be omitted for a background-only worker. chat?: boolean; pocket?: boolean; - input?: boolean; + input?: { supports: ('text' | 'audio' | 'video' | 'image' | 'file')[] }; }; }; ``` @@ -373,7 +373,7 @@ The returned record has shape `{ extent: { transactions_allowance, transactions, - `expiration > currentBlock`, and - the remaining capacity (`transactions_allowance − transactions`, `bytes_allowance − bytes`) covers the planned upload (chunks-needed and total-bytes for the icon + every executable in this publish operation). -Granting is out-of-band via `TransactionStorage.authorize_account({ who, transactions, bytes })`. On testnet, the well-known `//Alice` keypair holds the authorization authority and a testnet publisher MAY self-grant. On mainnet `//Alice` has no such authority; authorization MUST come from a production-role account, and the request mechanism (portal, governance proposal, operator extrinsic, …) is **TBD** alongside the mainnet Bulletin deployment. +Granting is out-of-band via `TransactionStorage.authorize_account({ who, transactions, bytes })`. On testnet, the well-known `//Alice` keypair holds the authorization authority and a testnet publisher MAY self-grant. On mainnet `//Alice` has no such authority; authorization MUST come from a production-role account, and the request mechanism (portal, governance proposal, operator extrinsic, …) is decided alongside the mainnet Bulletin deployment. #### Step 4 — Upload assets to the Bulletin chain @@ -404,7 +404,7 @@ All payloads start with `$v: 1`. Before any dotNS write, the publisher: 1. Parses each composed JSON back through the v1 JSON Schema to confirm conformance. -2. Computes the UTF-8 byte length of each manifest and rejects any that exceed the dotNS text-record budget (the exact figure is still TBD — see [Unresolved Questions](#unresolved-questions)). +2. Computes the UTF-8 byte length of each manifest and rejects any that exceed the dotNS text-record budget (the exact figure is an unresolved question — see [Unresolved Questions](#unresolved-questions)). Either check failing aborts the publish before on-chain writes begin (see [Security § Size cap at publishing](#security)).