Components and Bindings
strata_component defines typed headless components. It targets Erlang and
JavaScript. It does not own a socket, persistence, presence, or a UI
framework.
A host owns those services. The host sends component commands through
strata_client. The host also routes document events to the correct component
instance.
Define a component
Section titled “Define a component”Build a component with strata_component.definition. The function takes nine
arguments:
strata_component.definition( kind: "strata/intake", version: 1, config_encoder: encode_config, config_decoder: decode_config, initial_document: make_initial_document, init: do_init, update: do_update, receive: do_receive, owned_document_ids: do_owned_document_ids,)The result is a typed
Definition(root, context, config, model, message, output). Each type
parameter has one purpose:
rootis the schema tag for the root document.contextcontains host-local values such as a replica id.configis the persisted component configuration.modelis the headless runtime state.messagerepresents local operations.outputrepresents typed local events.
The constructor returns InvalidDefinition when kind is empty or version
is not positive.
Use kind, version, and config
Section titled “Use kind, version, and config”Use a stable kind string for each component contract. Increase the version when persisted config or behavior becomes incompatible.
The definition stores a config encoder and decoder. Use these accessors when a host stores or loads component metadata:
let kind = strata_component.kind(definition)let version = strata_component.version(definition)let config_json = strata_component.encode_config(definition, config)let decoded = strata_component.decode_config(definition, config_dynamic)decode_config returns InvalidConfig when the component rejects the value.
The current Intake, Profile, Board, Checklist, and Composite examples use an
empty JSON object as config.
Use strata_component.initial_document when the host creates a new component
root. This call returns the initial typed document. The host can send it in a
Create command.
Run init, update, and receive
Section titled “Run init, update, and receive”Call strata_component.init with the definition, host context, config, and
typed root id.
case strata_component.init(definition, context, config, root_id) { Error(error) -> report_component_error(error) Ok(strata_component.Init(model, commands)) -> send_commands(model, commands)}Init returns the initial model and client commands. A component normally
returns one Join command for its root document.
Call strata_component.update for a local component message:
case strata_component.update(definition, context, model, message) { Error(error) -> report_component_error(error) Ok(strata_component.Update(model, commands, outputs)) -> { send_commands(model, commands) route_outputs(outputs) }}Keep the returned model. Send every returned command. Route each output after the local update succeeds.
Call strata_component.receive for a document event from strata_client:
case strata_component.receive(definition, context, model, event) { Error(error) -> report_component_error(error) Ok(strata_component.Receive(model, commands)) -> send_commands(model, commands)}receive does not return outputs. A remote delta must not repeat work that a
local output already started.
The functions report_component_error, send_commands, and route_outputs
in these snippets are application helpers.
Handle lifecycle status and errors
Section titled “Handle lifecycle status and errors”Each example component stores a strata_component.Status:
Loadingmeans that the component waits for a usable root snapshot.Readymeans that local operations can run.Failed(error)preserves a decode, schema, or merge failure.Deletedmeans that the root received aDeletedevent.
Return InvalidState when an operation cannot run in the current status. Do
not replace invalid state with a new empty document.
ComponentError also reports invalid definitions and config, initial document
failures, schema failures, document merge failures, and ownership failures.
Report these errors through the host.
Route events by ownership
Section titled “Route events by ownership”Call strata_component.owned_document_ids after init, update, and receive. The
result contains the root id and any child document ids that the component owns.
Map each owned document id to one component root. Use that map to route
Snapshot, RemoteDelta, and Deleted events. Return or report NotOwned
when no component owns the event id.
The host must handle Welcomed, presence events, ServerError, and Pong.
The component examples reject these host events in receive.
The workspace example uses this host model. It owns one WebSocket connection,
one root PageStore, a dictionary of component models, and the ownership map.
It uses closed sum types for all supported component models, messages, and
outputs. It does not use a runtime registry.
Host-author contract
Section titled “Host-author contract”The generic core delegates owned_document_ids and receive to component
callbacks. It does not enforce host-wide uniqueness. The workspace already
rejects duplicate routing claims; a new host must provide the same check.
| Obligation | Required host behavior |
|---|---|
| Routing index | Rebuild claims after init, local update, and receive. Map each document ID to one instance. Repeated claims by the same instance are harmless; claims by different instances are errors. |
| Atomic local adoption | Validate the new index before retaining the proposed model or sending its commands. A duplicate must leave the prior models, index, and outbox intact. |
| Unknown events | Report an unknown document ID. A host can discard late events for an explicitly retired ID; keep this retirement policy separate from an unknown ID. |
| Page demand | Pair each open page that needs remote state with Join. page_store.open does not join. Close a page and send Leave when demand ends. Multiple consumers must share demand before the final leave. |
| Resume | Keep the connection's join set consistent with demand. Use frames_for_resume, send joins before queued document commands, and reject stale socket-generation callbacks. |
| Command delivery | Preserve command order, including create-before-join. Retain unsent commands on a send failure. Browser send completion does not prove store acceptance or persistence. |
| Local outputs | Dispatch typed outputs only from a successful local update. A Receive has no output list. Do not synthesize local integration output from remote state. |
| Lifecycle | Define loading, usable state, failure, and deletion policy. Keep errors visible. Do not replace damaged state with an empty model. |
Loading, Ready, Failed, and Deleted describe model state. They do not
create a restart policy, durable queue, replay engine, or recovery process.
Each host must state whether failure requires retry, rejoin, model recreation,
or user action. The workspace keeps network connectivity separate from these
component states. Selecting another component does not close the old model.
Its fixed workspace root is a special host-owned document.
Conformance cases
Section titled “Conformance cases”Use packages/strata_component/test/host_contract_test.gleam as a small
page/join fixture. Run the component suite on Erlang and JavaScript.
The workspace reducer suite exercises duplicate claims, unknown events,
retired-event races, root retirement and leave, reconnect command order, and
remote receive without local workflow dispatch.
Before you extract runtime code, run the same cases against a second real
component host. The standalone Todo and Kanban apps provide useful
single-document reconnect comparisons, but they do not host
strata_component.Definition instances. They cannot validate component-index
or binding-dispatch behavior. No shared runtime is extracted here. Preserve
the typed public contracts, closed component sums, and application-owned
adapters until a second host demonstrates duplicated mechanisms.
Define ports
Section titled “Define ports”An OutputPort(output, payload) selects an optional payload from a component
output:
port.output_port( component_kind: "strata/intake", key: "request_submitted", version: 1, select: fn(output) { case output { RequestSubmitted(submission) -> Some(submission) } },)An InputPort(message, input) converts a typed input into a component
message:
port.input_port( component_kind: "strata/board", key: "create_card", version: 1, to_message: fn(input) { Ok(CreateCard(input)) },)Both constructors validate the component kind, key, and positive version.
Bind ports
Section titled “Bind ports”A Binding(output, message, payload, input, config) connects one output port
to one input port. It also defines a stable kind, a positive version, config
codecs, and a mapping function.
port.binding( kind: "strata/intake-to-board", version: 1, output: intake_component.request_submitted_output(), input: board_component.create_card_input(), config_encoder: encode_config, config_decoder: decode_config, map: intake_to_card,)Use port.encode_config and port.decode_config to persist binding config.
Use binding_kind and binding_version to read binding metadata. The module
also provides component-kind, key, and version accessors for each port.
Call port.route(binding, config, output) for each local output. The function
returns:
Ok(None)when the output port does not select a payload or the mapping skips it;Ok(Some(message))when the route creates a target message;MappingFailedwhen the mapping rejects the payload;InputFailedwhen the target port rejects the mapped input.
Apply the returned message to the target component with
strata_component.update. Queue the returned client commands. Route any new
local outputs through the next bindings.
The package does not provide a stored binding graph or a type-erased binding collection. The workspace example keeps an exhaustive set of supported routes.
Workflow delivery boundary
Section titled “Workflow delivery boundary”The workspace's direct dispatcher is session-local and best-effort.
port.route produces a target message, and a successful local update
produces commands and outputs. Neither result proves server acceptance.
The host dispatches downstream outputs before it sends the source and
target commands. Those commands do not form one atomic workflow.
Routes, outputs awaiting dispatch, and the outbox are in browser memory. Closing or reloading the tab, or a browser crash, loses them. Server-stored component state can survive, subject to checkpoint timing, without enough information to recover unfinished workflow actions.
| Interruption point | Current behavior |
|---|---|
| After a source output, before target command submission | Browser termination loses the pending local work. A later snapshot does not emit the source output again. |
| After target submission, before server acceptance is known | WebSocket.send success only means the browser accepted the send. Disconnect or termination leaves the operation's outcome unknown; it may still complete. |
| After target acceptance, before the browser learns the result | The target may have changed even though the browser has no confirmation. Repeating the source action can create a new effect. |
| After acceptance, before a durable workflow completion record | No such record exists. Another tab or runtime cannot identify and resume incomplete work. Acceptance in memory can also be lost before the server checkpoint. |
A surviving tab retains unsent outbox frames during a disconnect and sends joins before that queue on reconnect. It drops frames after browser send success, not after server acknowledgement. This cannot recover every lost operation. The workspace uses legacy commands and does not request correlated operation receipts.
The opt-in operation API from #26 distinguishes rejection, acceptance in memory, and unknown outcomes. Its records are session-local. A request ID correlates replies; the server does not retain an ID-based deduplication cache. Do not treat send success, disconnect, or an acceptance receipt as durable workflow completion. Reuse the exact request and payload only under the documented operation-specific retry rules; replaying a component message can generate new IDs and effects.
Use the lightweight dispatcher where partial execution and manual reconciliation are acceptable. Work that must survive browser loss needs a durable record of source identity, target identity, intended action, idempotency rule, and completion state, plus an owner that resumes retries. Strata has no built-in intent store or recovery runtime. Those choices and browser-termination recovery tests remain deferred in #27. Persisting binding configuration alone would not provide this guarantee.
Study the examples
Section titled “Study the examples”Intake
Section titled “Intake”examples/intake
defines a headless single-document component. Submit writes a request,
returns SendDelta, and emits RequestSubmitted. Its
request_submitted_output exposes that payload to a binding.
Profile
Section titled “Profile”examples/profile
defines a headless fixed-record component. save_profile_input converts a
whole profile into SaveProfile. profile_saved_output exposes successful
local saves. This example has no host or workflow bindings.
Workspace
Section titled “Workspace”examples/workspace
hosts Intake, Board, Checklist, and Composite components in one browser app.
It routes document events with an ownership map. It sends all component
commands through one connection.
The workspace also defines four direct typed bindings. One binding maps an Intake submission to a Board card. Other bindings connect Board and Checklist outputs. These routes are session-local and best-effort. The app does not persist or replicate them, and it cannot resume workflow work after browser loss. See the delivery boundary.
Use Composing Documents when you only need
typed document references and PageStore. Add strata_component when you
also need a reusable lifecycle, typed local messages, outputs, ports, and
bindings.