RFDs/0007

Development UI and execution telemetry

State
discussion
Authors
Ryusen contributors
Discussion
chaba-dev/ryusen#1
Labels
uitelemetrydev-serverhistoryobservabilitysse
Implemented
89%Checklist

Goal

Ship the proof of concept and local development server with a useful UI from the first end-to-end workflow. Developers must be able to see events, runs, durable transitions, retries, transport behavior, and compensation without interpreting SQLite records or logs.

The UI is a view and control surface over the same durable execution model. It must not create a second best-effort history whose loss changes what the user can understand about a run.

Product surface

ryusen dev serves an embedded web application at its root URL. The initial UI provides:

  • a workspace overview of recent events and runs across applications with app, function, status, start time, and duration filters;

  • an event detail view showing its immutable workspace routing snapshot, referenced application manifests, fan-out progress, every evaluated trigger binding and match result, and links to every resulting run;

  • a run detail timeline showing discovery, queue wait, dispatch, forward attempts, retries, accepted results, cancellation, and compensation;

  • operational waiting reasons such as queued or waiting for a compatible worker without representing them as a generic Pending run phase;

  • an effective run deadline, whether it was explicit or inherited, and the server-owned timeout item and transition that enforced it;

  • work classification and authorization boundaries explaining why discovery was replayed or an effectful attempt became Unknown;

  • HTTP attempt diagnostics distinguishing proven pre-send failures, authenticated pre-start rejections, canonical callback results, and ambiguous post-dispatch outcomes without implying that an HTTP status proves a callback did not run;

  • a distinct DefinitionBlocked state showing the duplicate step ID, first and duplicate display names and source locations when available, and explicit confirmation that the second callback did not execute;

  • clear separation of execution outcome from recovery outcome, such as "timed out, fully compensated";

  • explicit NotRequired reasons distinguishing successful execution from an empty unwind, plus skipped-only, mixed, failed, and pending recovery states;

  • a distinct cancelling view that shows unstarted work fenced, authorized work classified as repeatable discovery or effectful callbacks, effectful work still settling, Unknown attempts blocking recovery, and whether a late or operator-supplied result resolved each attempt;

  • step input, output, structured errors, attempt numbers, stable operation IDs, lease/generation information, and the SDK/build that executed each attempt;

  • connected-worker and HTTP-endpoint views with language, SDK version, functions, authorized workspace/application scope, release group, server ordering, compatibility, capacity, in-flight work, mode, health, reconnect, and drain state;

  • a function page action that invokes one function directly with an input and opens the run it created, with invocation events labelled as addressed to that function in the event list and the run’s origin;

  • a workspace event submission form that may exercise subscribed functions in several applications;

  • event-submission feedback that reuses one key for transport retries, generates a new key for a deliberate new submission, links an identical replay to the original event, and explains conflicting key reuse;

  • operator controls for resolving Unknown attempts through the transitions allowed by RFD 1 and RFD 4, with idempotency status, stale-generation conflicts, and an explicit duplicate-effect warning before retry authorization;

  • definition-blocker controls to retry discovery now or cancel the run, recovery guidance for removing or renaming the duplicate, and release wake or probe history explaining each automatic rediscovery.

Compensation is visualized as part of the run rather than as an unrelated run. Skipped irreversible steps and missing current compensators remain visible, so a partially compensated result cannot look fully rolled back. The timeline shows both declaration-time compensation presence and the current target result, including a compensator added after forward completion, without implying code version affinity.

Telemetry authority

Every accepted engine transition already appends transactional history under RFD 4. That ordered history is the authoritative source for the run timeline. Each record includes a global commit cursor, event time, kind, workspace, application, function, and run identifiers, optional step and attempt identifiers, execution phase, transport, worker metadata, and versioned attributes.

The model distinguishes facts from derived telemetry:

  • state changes, accepted results, retry decisions, compensation decisions, and lease fencing are durable facts committed with execution state;

  • queue latency, execution duration, retry delay, compensation duration, and status counts are projections derived from those facts;

  • current socket health, heartbeat age, and instantaneous worker capacity are ephemeral live observations and are labeled as such.

Worker connect, ready, drain, and disconnect lifecycle changes are persisted as system history. Individual heartbeats are not persisted. Each application keeps its most recent 1,000 changes in a bounded history read newest first from GET /v1/workers/history: a connection with the worker’s SDK, runtime, release, manifest generation, functions, and capacity; a refused registration with its reason; and a disconnection with when the session connected and why it ended. A connection and its disconnection share a session key that stays distinct across restarts. Recording a change never ends a session, and a full history overwrites its oldest change in place rather than scanning or pruning. Attempt records retain the transport, connected release, and worker identity needed to explain where execution happened. Release metadata explains fleet selection but does not imply that later work from the run is pinned to that release.

Event telemetry is durable independently of run history. An event records the workspace routing generation selected at acknowledgement and its expansion state. The product calls this a "routing snapshot" because it describes what the event saw; storage and protocol records use "routing generation." Each evaluated trigger binding records its application, application-manifest generation, stable binding ID, and whether it matched; a match links the event and binding to a deterministic run ID. This allows the UI to distinguish pending fan-out, zero matches, cross-application runs waiting for workers, and completed runs. New manifest or routing generations never rewrite these associations.

The initial UI reads workspace routing and application-manifest generations from SQLite through the normal server API. A later ClickHouse archive may provide long-term search and generation diffs, but it is an asynchronous projection and is never required to explain a locally retained event or run.

Event E1Workspace snapshot W12App A / manifest A7App B / manifest B4Run A1durable decisionattempts, results, recovery routed againstmatchno matchassociation retained

Cancellation intent, settlement-token creation, late result acceptance, operator resolution, and the transition into compensation are durable timeline facts. The UI must not render Cancelling as terminal or imply that a delivered stop request prevented an external effect. It links each blocking Unknown attempt directly to the allowed resolution controls and explains that recovery cannot safely proceed until every authorized attempt settles. Resolution history shows which operator command or late result won without exposing the idempotency key as a user-facing workflow concept.

The run trace draws the first stop as a point on its axis and exposes its timestamp as accessible text, including root-only traces. A settlement whose durable sequence follows the stop is labelled as settling after it; wall-clock order does not establish authorization or settlement source. Operator-supplied settlements are distinguished. An open target is described by what holds it — in flight, awaiting an operator, or blocking the unwind — rather than as running. The run page offers to cancel a running run only after a confirmation that states what a stop does not interrupt, and reports the transition the server acknowledged without inferring the recovery outcome.

A definition-blocked run leads its page with the conflict: the duplicate step ID, what the run recorded for the first declaration (its name and whether it ran), where the SDK says the duplicate was declared or that it reported no location, the SDK’s message, and when the conflict was first seen and how many passes reported it. The protocol carries one location, the duplicate’s, so the first declaration is described by what the run recorded rather than by a location the page would have to guess. Retry with current code sits beside the fix guidance and requests one rediscovery, saying only that a pass is pending because whether current code still conflicts is that pass’s outcome; it is held while a requested pass is queued or leased. The page’s stable run action confirms that cancelling does not fix the conflict and withdraws a rediscovery still pending when cancellation takes effect; if rediscovery already authorized an effect, the ordinary authorized-work settlement rules apply. Keeping that action mounted across a blocked-to-running transition ensures a confirmed request is not abandoned.

Each unresolved attempt offers the three commands rather than a generic resolve action, and states the consequence of each: a supplied success records the operator’s assertion and joins the unwind, a declared failure applies the owning policy or terminalizes recovery, and an explicit retry is labelled as the duplication risk it is. Explicit retry needs a deliberate confirmation and is unavailable while the run is cancelling, because stop intent has already refused new forward work; a form that was already open when the run started stopping closes with it, and a declared failure under stop intent says that it schedules neither another attempt nor rediscovery rather than describing the forward retry policy. The outcome of a command is reported by the page rather than by the attempt row, because a settled attempt leaves the projection: the page says whether the command was applied or replayed, links the successor generation an explicit retry created, and distinguishes a stale attempt, a reused key, a forbidden retry, a transport-ambiguous response, and a command that exceeded a limit. The page never inserts a timeline entry of its own, and it says where each attempt was dispatched without claiming that the worker it was sent to executed it.

The page keeps the operator’s intent across the state changes around it. An attempt that a command has settled accepts no further command until the authoritative projection drops it, so a second click cannot replace a receipt with a stale refusal. A projection read that fails after one has already been applied is reported beside the projection rather than replacing it, because replacing it would unmount the controls and discard an unresolved command’s idempotency key and draft. A conflict the client cannot read is reported as an unexplained refusal rather than as a specific durable-state fact, so the page never claims an attempt settled when the server said something else. And a reordered object member is the same command as the server sees it, so editing only the member order of an ambiguous command keeps its key instead of resolving twice.

For explicit retry, the timeline links generation G’s RetryAuthorized settlement to generation G+1 under the shared operation ID. It distinguishes the old generation’s stale late result from the successor’s authoritative result and does not imply that both committed the logical target. Repeated retry chains use the same presentation for G+2 and later generations.

A definition blocker is rendered separately from callback failure and Unknown. Its durable history shows the original conflict, diagnostic updates, preferred-release wakes, scheduled probes, manual retries, and the rediscovery that cleared it. The UI never implies that deploying a fix reran an accepted callback: it identifies reused step results and newly unique work explicitly.

DefinitionBlockedrelease wake / scheduled probe / manual retryaccepted result reused; new work shownduplicate ID: chargefirst: workflow.ex:14duplicate: workflow.ex:21second callback: NOT EXECUTEDFix: remove the duplicate or assign it a new stable ID.[Retry current code] [Cancel run]

Trace model

The run detail answers "what happened, to which step, in what order, and how long did each part take". Durable history already orders every transition, but an entry that names only its kind cannot say which step or attempt it belongs to, so a waterfall built from it would have to guess from kind and order. The trace is therefore built from history entries that name their subject, plus the step records and current work the run detail already reads. Nothing in the trace is inferred from wall clocks or from the order of unattributed entries.

Step-attributed history

A history entry keeps sequence, kind, and recorded_at_millis, and gains four optional parts, written in the same transaction as the transition from state that transaction already loaded:

subject

The step the entry concerns (step_id) and the target (forward or compensation). Absent for run-scoped entries: creation, stop intent, the terminal entry, and the start or end of recovery.

attempt

For an entry that settles, schedules, or resolves one attempt: the work ID, operation ID, attempt number, and target generation, with when the attempt’s work became ready (ready_at_millis, the work item’s vesting time) and when it was dispatched (dispatched_at_millis).

pass

For an entry that a discovery pass committed: the same facts for the discovery work. An accepted pass commits one entry of its own: the declaration it found (step-declared, timer-armed, wait-registered, invocation-declared, event-emitted), or the outcome it returned (completed, failed).

detail

Bounded, kind-specific facts, listed in the table. Payloads, outputs, and error messages stay in the step records and are never copied into history.

Kind Subject Attempt Detail

step-declared

step, forward

–

– (carries pass)

step-succeeded

step, forward

yes

–

step-failed

step, forward

yes

failure category and retryability

step-retry-scheduled

step, forward

yes

the failure’s category and the next attempt’s ready time

step-obsolete

step, forward

when an attempt reported it

–

unknown-resolved-*, unknown-retry-authorized

step, target

yes

the resolution command

timer-armed

step, forward

–

due time (carries pass)

wait-registered

step, forward

–

event name and deadline (carries pass)

invocation-declared

step, forward

–

child run (carries pass)

event-emitted

step, forward

–

emitted event (carries pass)

wait-matched

step, forward

–

the matched event

timer-fired, wait-timed-out, invocation-settled

step, forward

–

–

compensation-succeeded, compensation-skipped

step, compensation

yes

–

compensation-failed

step, compensation

yes

failure category and retryability

cancelling, timing-out, cancelled, timed-out

the forward step actually fenced, if any

for fenced effect work

also a run-level stop event

failed

–

–

failure category and retryability (carries pass)

completed

–

–

– (carries pass)

definition-blocked, definition-still-blocked

–

–

the conflict’s code and step ID (carries pass)

definition-retry-requested, definition-unblocked

–

–

–

emission-replayed

step, forward

–

the already accepted event ID (carries pass)

other run-scoped kinds

–

–

–

An explicit retry’s successor is not recorded on the resolution: the successor generation’s attempts carry their own generation, which is how the trace links them to the attempt the operator retried.

A lease acquisition already rewrites its work record, so the record also keeps the time the current attempt was first dispatched. This applies to discovery work and step work alike. Renewing the lease keeps it. The settling transition copies it into history with no additional read or write: attribution grows the history entry the transition was already writing and adds nothing else to the dispatch path.

Entries committed before attribution decode with none of these parts. The trace places them on the run root as unattributed points rather than assigning them to a step.

Attempt dispatch facts

The trace already draws every attempt that committed a fact, with its ready and dispatch times. It cannot say where the attempt ran. An operator looking at an ambiguous or slow attempt needs the transport and the worker that held it, and needs that answer to survive the worker disconnecting and the bounded worker history overwriting the session.

Where work goes is a property of a connected session or an HTTP endpoint, not of one attempt, so the server records it once as a dispatch:

transport

connected or http.

worker

For a connected session: the worker’s instance ID, SDK, runtime, release ID when it reported one, and the manifest generation it registered with, plus the worker-history session key when that history recorded the connection. The key joins an attempt to its connection and disconnection while the history still holds them; the dispatch stays readable after it does not.

endpoint

For an HTTP endpoint: the endpoint as the server shows it, without credentials or query string.

The worker reports every identity field, so each is bounded when recorded: a string longer than 256 bytes is cut at a character boundary and the dispatch is marked truncated. A dispatch is named by the first 8 bytes of SHA-256 over a fixed domain tag and its encoding, as 16 hexadecimal characters, and stored once under the application. Inserting that immutable record succeeds when the key is absent or already contains the same dispatch. If the key contains different content, the insertion reports a collision and never replaces either value.

A connected session makes one recording attempt after it becomes ready; failure does not end the session and every lease that session takes stays unattributed. The HTTP dispatcher attempts to record its endpoint before each lease until one attempt succeeds, then caches the identifier for the process. Storage transaction retries inside either attempt use the ordinary bounded retry policy. Exhausting it, an encoding failure, or an identifier collision degrades only telemetry: dispatch continues without a dispatch_id. Recording is outside the lease transaction, and no lease names an identifier unless its immutable record was confirmed first.

A lease acquisition already rewrites the work record, and that write is where the server decides who receives the work. The same write therefore records the dispatch_id beside dispatched_at_millis, adding no read, no write, and only the identifier’s bytes to the dispatch path. Work the server runs itself, such as a timer or a fan-out page, names none. Renewing a lease keeps the identifier, and so do Unknown and completion. Work that returns to the queue clears it with the dispatch time, so it always names the current attempt’s lease.

The settling transition copies the identifier into the history entry’s attempt or pass from the work record it already loaded, and a resolution entry copies it from the unknown work it resolves. The identifier appears wherever an attempt is read: the run detail’s current work, each listed Unknown attempt, and the trace’s attempt and pass spans. The run detail and the unknown-attempt list also carry each dispatch their attempts name, read once per distinct identifier in the same read transaction, so the cost grows with the sessions that served a run rather than with its attempts. Records and entries written before dispatches were recorded name none, and the page says the dispatch was not recorded rather than guessing.

Recorded dispatches grow with connected sessions and HTTP endpoints, not with attempts. This slice defines no dispatch-record collector; the development server keeps them indefinitely. A future hosted retention policy may remove one only after no retained history, current or Unknown work, live connected session, or running HTTP dispatcher can name its identifier.

There is no separate attempt endpoint. A step’s attempt history is the attempt spans under its step span, each with its generation, attempt number, status, dispatch and settlement times, and dispatch. The page reads readiness from the matching history attempt or current work’s vested time, never from the trace span’s start: the projector may use dispatch or settlement only as a drawing anchor when recorded readiness is absent. That history is bounded by the run detail’s history page and flagged as incomplete when the page is truncated.

A dispatch says where the server sent the work, not that callback code ran there or how long it ran. Ready-to-dispatch is queue wait. Dispatch-to-settlement includes delivery, execution, and the response, so it is not labelled as execution time. A release explains which part of the fleet received the work. It does not pin later attempts of the run to that release, and the page never implies affinity.

Some lease deliveries commit no history entry. When the run still permits the work, an effect delivery released for redelivery before callback start returns the same physical application attempt to the queue: its next delivery has a new lease generation but the same attempt number, so it leaves no numbered attempt gap. Other pre-start outcomes retain the retry, obsolescence, skip, and stop-intent semantics defined by RFD 4 and RFD 6. A discovery delivery whose response is lost is recovered after lease expiry as the next discovery attempt; only the later accepted pass is recorded, so its larger attempt number exposes the gap. Recording every such delivery would add a write on release and recovery paths, so it is not proposed unless those invisible deliveries prove confusing in practice. An effect attempt whose lease expires after start authorization becomes Unknown and keeps its dispatch until it is resolved.

The run page shows a step’s attempts in its Steps view, oldest first. For each it shows the generation and attempt, the status, when the attempt became ready, the queue wait, and the dispatch-to-settlement interval, and says where the attempt was dispatched in words. A connected worker is named by its instance and release, and the session links to the worker history. An HTTP endpoint is shown as recorded. An unresolved attempt also says where it was dispatched, beside the resolution controls. A selected attempt span in the trace shows the same facts. Activating an attempt with a pointer or keyboard opens Trace and moves focus to that visible detail heading.

The page does not turn missing evidence into activity. Current work supplies an exact queued, leased, authorized, unknown, fenced, or completed state; an open span without matching current work is only open, not in flight. If its ending may fall beyond a truncated history page, it is open in partial history. A server that supplies no trace makes attempt history unavailable, which is different from a complete trace with no recorded attempts. An incomplete trace qualifies its counts, empty state, and open endings as partial.

Copying the whole dispatch onto every lease and history entry made each lease, renewal, completion, and attributed entry grow by about 300 bytes, and an exploratory local comparison showed a material throughput regression. That prototype result selected the content-identifier design; it is not a portable performance claim. The implementation evaluates the final design instead: the lease and settlement paths are measured with the common seeded benchmark workloads against the revision before dispatch facts, with every lease naming a representative connected dispatch and the reproducible profile and raw results reported with the implementation.

Ambiguity causes

A dispatch says where an unresolved attempt went. It does not say why its outcome is unknown, and the operator choosing between a supplied success, a declared failure, and an explicit retry needs that: an endpoint that answered 500 after the callback was authorized, a connection that dropped while a worker held the attempt, and a worker that never answered are different facts about the same unknown outcome. Today the dispatcher observes each of them and discards it, and the attempt becomes Unknown only when its lease later expires, so the only durable trace of the cause is the expiry.

The dispatcher therefore records what it observed on the work record, as the attempt’s ambiguity: a cause and the server time it was observed. RFD 6 classifies HTTP outcomes by what they prove about callback execution; an ambiguity is recorded only for the class that proves nothing, after start authorization of effectful work. Repeatable discovery is re-vested after the same observations and records none, and the other three HTTP classes either return the attempt to the queue or settle it.

Cause Observed after start authorization

http_status

The HTTP endpoint answered with a non-success status, which the cause keeps. A bare 429, 500, 502, or 503 cannot prove the callback did not run: it may have committed its effect before middleware, a proxy, or response serialization failed.

http_transport

The HTTP exchange failed, and the dispatcher could not prove that no request bytes were sent. The request may have been delivered.

http_response_too_large

The HTTP response declared or streamed more than the response limit.

http_response_interrupted

The HTTP response body ended with a transport error before it was complete.

http_response_malformed

The HTTP response body was not a canonical protocol response.

response_refused

Either transport delivered a protocol response the server refused: it did not match the attempt, was invalid, or exceeded a limit.

connection_lost

The connected worker’s connection closed, failed, or could not be written while the worker held the authorized attempt.

protocol_violation

The connected worker sent a frame other than a valid result for the attempt, so the server closed the session.

lease_expired

Expiry recovery found an authorized attempt without an accepted settlement and without a more-specific ambiguity cause durably recorded. It records this cause when it moves that attempt into Unknown. The cause does not prove that the worker was silent, that no response arrived, or that the dispatcher observed no other failure: a more-specific observation may have failed to record.

Recording the ambiguity is one write on the failure path only, outside the lease transaction, and it degrades only telemetry: a recording failure leaves the attempt as it was, and expiry recovery still makes it Unknown with lease_expired. The write applies only while the record still names the dispatching lease with start authorized, or is Unknown under that same lease with lease_expired, because a slow HTTP response can return after expiry recovery. A recorded cause other than lease_expired is never replaced. The write publishes a run change, so an open run page shows the cause on the current work before the lease expires. Work that returns to the queue clears the ambiguity with its dispatch, and Unknown and completion keep it, so a late result that settles an unknown attempt still says why it had been unknown.

The ambiguity travels with the dispatch identifier: on the run detail’s current work, each listed Unknown attempt, the history attempt of every settling or resolution entry that copies it from the work record, and the trace’s attempt spans. Records written before ambiguity was recorded decode with none, and the page says the cause was not recorded rather than guessing. An attempt that settles without any recorded ambiguity omits the field, so the ordinary success path writes no additional bytes. A late or operator-supplied success retains any ambiguity already recorded.

The page explains an unresolved attempt with its cause and dispatch in one sentence beside the resolution controls, such as "The HTTP endpoint answered 500 after the callback was authorized to start", followed by what that cannot prove. A resolution entry in the durable history, and the selected span of an unknown or resolved attempt, carry the same explanation, so the decision stays legible after the attempt leaves the projection. Where the attempt went over HTTP, the page lists the four RFD 6 outcome classes with what each proves and marks the ambiguous class as applied only when the attempt’s current state or retained history records ambiguity. An ordinary canonical result or a pre-start outcome carries no such marker. A pre-start delivery returned to the queue has no separate historical delivery entry, while a pre-start outcome that settles or skips a target keeps its normal attributed history; the page distinguishes those cases rather than implying either was observed for this attempt.

Trace projection

The run detail projection gains a trace, computed in the same read transaction as the rest of that projection, so it shares its cursor and its live updates. A span has a stable ID, a parent, a kind, a name, the step, target, attempt, and generation it describes, a status, and a start and an end. A history-backed boundary carries the entry’s sequence and recorded time. A work-backed ready or dispatch time has no history sequence; neither does a scheduled deadline. An open span has no end. Spans can carry links to other spans.

Missing provenance stays missing. An attributed settlement or current work may name a step whose declaration predates attribution. Its container is synthetic and marked incomplete, anchored to the earliest available attributed fact, not to an inferred declaration time. Legacy entries remain unattributed points on the root; they do not prove a start or a duration for that container.

The hierarchy is:

  • run: from creation to the terminal entry, or open while the run is live;

  • discovery pass: from the discovery work’s ready time to the entry the pass committed, with the dispatch time as an event inside it. A pass is a child of the run span, and a pass that found a declaration links to that step’s span. The pass bars show server-observed readiness-to-settlement time, including queueing and worker start-up, not measured time spent executing workflow code. A pass is error only when it reported an uncaught failure. A pass that reported a definition conflict is ok, because nothing failed in it; the conflict is a point on the run;

  • step: from declaration to the accepted forward outcome, or to step-obsolete, in declaration order;

    • attempt: from the work’s ready time to its settlement, with the dispatch time as an event inside it. Ready-to-dispatch is queue wait; dispatch-to-settlement is an unsettled interval, not measured execution;

    • retry delay: from a scheduled retry to the next attempt’s ready time. The deadline is a plan, not evidence that the delay completed. It stays open until a successor’s work or settlement proves that readiness, or closes as cancelled/obsolete at the recorded stop if the retry is fenced first;

    • an attempt the current projection lists as Unknown is open with status unknown. It closes at the resolution entry: ok for a supplied success, error for a declared failure, and retry_authorized for an explicit retry, with the command itself as resolved_by. Its duration can include operator wait; neither dispatch nor an operator resolution proves when, or whether, callback code executed;

    • an explicit retry closes generation G’s attempt as retry-authorized. Generation G+1’s attempts sit under the same step span, each with a link to G’s last attempt, so a stale late result from G is never drawn as G+1’s outcome;

  • timer: armed to fired;

  • wait: registered to matched or timed out, linked to the matched event;

  • invocation: declared to settled, linked to the child run’s root span. A child run is its own trace with its own retention, so it is linked rather than nested;

  • recovery: from compensation-started to the recovery outcome, with one compensation span per targeted step in unwind order and attempts beneath it as in the forward pass. A skipped step is a zero-length span whose status says why. compensation-not-required is a zero-length recovery span carrying its reason.

Stop intent (cancelling, timing-out) is an event on the run span, not a span: the run keeps settling work after it. Steps that the stop fences close as obsolete. The existing stop entry names the actual fenced target from its loaded work, without another history write; a retained settlement token is not fenced and therefore is not attributed as obsolete. That same entry closes a pending retry delay at the stop, never at its planned ready time.

A discovery attempt whose response is lost, or whose worker dies, commits nothing. Recovery requeues the work with a new ready time and a higher attempt number, and only the pass that commits is drawn. The time lost to the earlier attempt is a gap before that pass, and its attempt number shows that it was not the first, rather than the trace claiming an attempt it cannot prove.

Two other kinds of pass commit no entry of their own and are not drawn. A pass that re-declares a step or an emission the run already accepted is idempotent and writes nothing new. A pass that finds the run’s deadline elapsed commits the run’s timeout instead. Recording either would add a write, which is not proposed unless it proves common.

The trace ID is the first 16 bytes of SHA-256 over a fixed domain tag and the run’s organization, workspace, application, and ID. A span ID is the first 8 bytes of SHA-256 over the trace ID and the span’s path, such as step/charge/0/forward/g2/a1, where the number after the step ID counts the times the step was declared. An all-zero result has its low bit set. Both IDs are OpenTelemetry-compatible and stay the same across reads, restarts, and storage backends, so a later OTLP export can map the same spans without becoming the source of truth. A discovery pass’s path names its discovery work.

Spans are ordered by the sequence of the durable fact that opened or scheduled them, independently of their boundary timestamps. Work-only or synthetic containers use the latest available relevant history fact as an ordering anchor, not as proof of their start time. Recorded times give widths only. Every recorded time comes from the server that committed the transition, never from a worker. A span whose end time precedes its start is drawn at zero width, and its order still comes from sequence. When history_truncated is set, a span whose end lies beyond the page stays open and is marked truncated, and the trace says that it is incomplete.

The run detail draws the trace as a waterfall by default. Selecting a span shows that step’s input, output, or structured error from the step records and the attempt’s identity, so the trace and the existing step panels never disagree.

Attribution makes every history entry larger and the trace makes the run detail read do more work. Both are measured with the common seeded benchmark workloads, with revision-relative regression bounds on the settlement path and on the run detail read.

API and live updates

The server exposes versioned, role-filtered read APIs for workspace event lists, event detail and expansion, routing and application-manifest generations, trigger matches, associated runs, application run lists, run detail, history ranges, worker sessions, HTTP endpoints, and aggregate development metrics. GET /v1/workers reports the connected worker sessions — SDK, runtime, release, registered functions, capacity, current assignment, and when each was last seen — as one observation stamped with the instant the server read it. The sessions are ephemeral: the registry is rebuilt by reconnecting workers after a restart, and nothing in the engine reads it. GET /v1/functions joins the active manifest’s functions and trigger bindings with how many of those sessions registered each function against that manifest. A run’s input is read on demand from GET /v1/runs/{run_id}/input, with the event or parent step it came from. It never changes and can approach the response bound on its own, so it is not carried in the run projection that live updates re-read. A run with a deadline also carries its timeout: the server-owned RunTimeout work item that enforces the deadline, read in the same transaction as the rest of the projection. It is queued until the deadline vests it, completed when it committed the run’s TimedOut stop, and fenced when anything else consumed it first: terminalization, a cancellation, a parent’s propagated stop, or any other transition evaluated at or after the deadline that committed the TimedOut stop itself. The projection does not record which transition fenced it, so the UI never names one. A terminalizing stop or an explicit retry advances the run’s generation, so the projection reads the newest item within a bounded number of generations of the current one and omits it rather than walk further. An event list item carries an addressed event’s target and target miss, and a run list item and run detail say whether the run’s source event is a direct invocation addressed to its function. Both read the event’s durable target, which only the server mints; a trigger binding’s name is not an origin discriminator, because an ordinary trigger may carry the name an invocation’s synthetic binding uses. GET /v1/server describes the running server as one observation: its version and platform, when it started, its storage engine, data directory, the database files in it, and their size on disk, the address it listens on, whether it was deliberately made reachable beyond this machine, whether a host supplied an authenticator, the HTTP worker endpoint without credentials or query string, and how many workers are connected. A query string can carry a token, so the description says only that one was left out. When the host did not say how it opened the store or where it listens, those values are absent rather than guessed, and a data directory whose path is relative or not UTF-8 is not named, because a command built from a lossy rendering would name another path. Every value comes from how the process was started, so the UI shows them and changes none; a reset names the database files, never the data directory, which can hold other things.

The event list, the run list, the unknown-attempt listing, and the workspace bootstrap and its change feed take an optional application. The run list and the unknown-attempt listing then read that application instead of this server’s; there is no workspace-wide run list, so without a filter they are this server’s application’s, and the bootstrap names it as served_application. The event list keeps the events that matched one of the application’s trigger bindings or were addressed to it, including an addressed event that created no run; it examines a fixed window of the event index per page, so a filtered page can be short or empty and still continue. Measured on a file-backed SQLite store with 100 applications and 5,000 events none of them matched, a filtered workspace bootstrap that examines the whole window takes about 8.5 ms against 1.8 ms unfiltered, and the overview pays that once per re-read. A filtered bootstrap’s cursor is bound to the application, and its feed still delivers every event change, which the overview re-reads rather than misses. The bootstrap also names the applications in the active routing generation, up to a fixed bound, as the filter’s choices. A publication is not itself an overview change, so a newly published application joins the choices on the overview’s next re-read, refresh, or reload; a filter already in the URL is offered whether or not it is listed.

A listed run can belong to any application of the workspace, so every run read, live view, and command — GET /v1/runs/{run_id} and its input, the run bootstrap, its catch-up and stream, and cancellation — locates the run through the durable run-location record written when the run is created and acts in the application that owns it. The overview’s filter never decides ownership, and a run another workspace holds stays as missing as one that does not exist. The UI keeps the filter as ?app= on every internal link, including a run or event page, Jump, Manage, and Settings, so a reload, history, or a new tab keeps it, but a run or event page reads its resource by identifier alone. Mutating APIs cover workspace event submission, explicit Unknown resolution, and run cancellation, and rediscovery of a definition-blocked run. POST /v1/runs/{run_id}/cancel records cancel intent for a Running or DefinitionBlocked run; a blocked run stops without its conflict being resolved, and a pending rediscovery is fenced with it. Repeating the same cancellation while Cancelling or terminal Cancelled changes nothing; the acknowledgement can differ after recovery progresses, and Compensating returns a conflict. Another cancel after a lost response is safe, but a later refusal does not establish whether the earlier request committed. RunCancelled does not prove an empty unwind. Other incompatible phases are refused as a conflict. POST /v1/runs/{run_id}/rediscover schedules one discovery pass of a DefinitionBlocked run’s current code and reports scheduled with its work, or already_scheduled with the pending pass’s work when an earlier request’s pass has not run yet, so a retry after a lost response schedules nothing new. It refuses a run that is not blocked, or whose deadline elapsed, as a conflict. The request reports only that a pass is pending: whether current code still conflicts is the pass’s outcome, which the run detail shows when it commits. While the run is blocked, its detail carries definition_blocker: the SDK’s code, step ID, message, and source location for the conflict, when it was first and last observed, and how many passes reported it. Event submission reports whether it created or replayed an event and returns a structured idempotency conflict without exposing stored fingerprints.

Operator resolution is a versioned command envelope as well. The request names the attempt, its fencing generation, one idempotency key, and exactly one command. An optional run query parameter names the run the attempt belongs to; the server then resolves the attempt in the application that owns that run, and refuses an attempt of any other run as stale, a replay included. Without it, the attempt is looked up in this server’s application. An idempotency key is scoped to the application the attempt is resolved in, so one key names one command per application. The acknowledgement reports applied or replayed, the resolved generation, the transition, and the successor an explicit retry created. A refusal names a stable code — stale for an attempt that is no longer unresolved at that generation, idempotency_key_reused, retry_forbidden, deadline_elapsed, unsupported_version, invalid, limit_exceeded, or server — so a client never parses a message, and no envelope carries the key, a fingerprint, a lease token, or another scope’s attempt. Authorization runs before lookup, so an unauthorized caller cannot distinguish an existing attempt from a missing one.

Every endpoint authorizes the credential’s workspace role or exact application capability before resource lookup. Workspace event readers can inspect the event’s cross-application routing graph; app-only run readers see only authorized run projections unless a workspace role grants the broader view. SSE applies the same filter before snapshot and catch-up, preventing unauthorized existence disclosure through live updates. The development server’s one credential carries the workspace view, so a run is read and controlled in whichever application owns it. The owner lookup for a run, and the application an overview, run list, or unknown-attempt listing names, are where an application-only credential will have to be checked; none of them consults the caller today.

Authentication is injected

The server does not decide on its own who may call it. An Authenticator is injected into the server state, and middleware asks it about every API and gateway request before a handler can look a resource up; an admitted request carries the resulting Principal. A refusal is 401 for a caller with no acceptable identity and 403 for one that may not use the server. A hosted deployment injects its own implementation — API keys, an identity provider — without changing the handlers, and the "authorize before lookup" guarantee above is a property of that seam rather than of one credential format.

ryusen dev injects LocalAuthenticator, which admits every caller: the local development server has no identities, so the UI, the API, and the worker gateway need no credential, and a header from an earlier credential is ignored rather than refused. It is protected by its network boundary instead. It binds loopback by default, and a guard in front of every route, including the UI, refuses the two browser requests that can reach a loopback server from elsewhere: an Origin that is not a loopback host, which is another site’s page calling the server from the developer’s browser, and a Host that is not a loopback host, which is a DNS-rebinding attack. A request with neither header is not a browser request and is admitted, and any loopback port is admitted so the Vite dev server’s proxy works. Binding a non-loopback address requires --allow-remote and prints a warning; the guard then admits any Host, and an Origin that is a loopback host or the same origin as the request’s Host. It still refuses another site’s page by its origin, but no longer one whose hostname was rebound to this server’s address.

The tests that prove authorization before lookup inject a bearer Authenticator, so they exercise the seam a hosted deployment uses rather than a code path the development server no longer has.

Every live page starts from one page-level bootstrap response containing all initial projections needed by that view and a snapshot_cursor. The server reads the projections and cursor in one database transaction. A page never combines the maximum cursor from unrelated requests; if a view needs several resource types, its composite bootstrap endpoint returns them together. This avoids persistent snapshot sessions and cross-request database transactions.

The UI then subscribes to server-sent events strictly after snapshot_cursor. Any commit racing between the snapshot transaction and subscription is replayed from durable history before live delivery, and duplicate projection updates are idempotent. SSE is sufficient because browser updates are one-way; UI commands remain normal HTTP requests. On reconnect, the server replays durable history after the client’s last applied cursor before sending live changes. Polling is the fallback and missed SSE notifications never hide committed transitions.

database transactiondurable SSE catch-upcommit 101 may race herepage projectionssnapshot_cursor = 100commits after cursor 100101, 102, then live

Ephemeral connection observations carry a separate session sequence and may be refreshed from a snapshot when continuity is lost.

Implemented live-update contract

The bootstrap endpoints are GET /v1/bootstrap/workspace, GET /v1/bootstrap/events/{event_id}, and GET /v1/bootstrap/runs/{run_id}. Each reads its projections and one snapshot_cursor in a single storage transaction. The cursor is the inclusive upper bound of the commit positions that transaction observed, so it bounds exactly the projections returned with it: a commit that races the page is always strictly after the cursor.

Catch-up is available as a bounded poll (GET /v1/changes/workspace, /v1/changes/events/{event_id}, /v1/changes/runs/{run_id}) and as a server-sent event stream (…​/stream). Both take the same cursor and apply the same view filter and authorization, so a client that cannot hold a stream reads the same changes from the poll endpoint. Polling and streaming therefore never disagree about what is missing.

A change names the projection that changed rather than shipping it: a kind (event or run), the resource identifier, and, for a run, the event it was created from. The client re-reads the authoritative projection, so applying a change twice is safe and a change that no longer resolves is simply stale. The next_cursor of a page is always present, so a client that receives nothing still knows where it resumed from, and a bounded page advances it even when one page cannot deliver everything.

A cursor names one journal entry, not one commit: the commit position plus the entry’s append order. A transaction routinely appends several relevant changes — a run materialization appends a dispatch change and a run change for each matched run — so a cursor that named only the commit would skip the rest of that transaction on reconnect. A bootstrap cursor is the position after every entry of the commit its snapshot observed, so it still bounds exactly the projections returned with it.

Cursors are authenticated with a per-authority key that is created once and read back on restart, and bound to one view, organization, workspace, application, and optional resource. The signature is verified before the payload is parsed, so a client cannot edit the position a cursor names, rewrite its scope, or replay it against another view, and the accepted/rejected difference is not an oracle for the authority’s committed bound. A malformed, oversized, unknown-version, cross-view, cross-scope, or ahead-of-the-journal cursor is rejected before any journal or resource lookup, so a cursor can never disclose whether another workspace or application holds state. Two things are necessarily read first: the signing key, without which no cursor can be authenticated, and the current snapshot bound, which the ahead-of-the-journal check compares against. The server resolves the key once before it starts serving, so a rejected cursor never creates it; a library or router instance that is built without the server resolves it lazily on first use instead. A stream validates its cursor before it upgrades, so a rejected cursor is an ordinary HTTP error instead of a stream that fails on its first frame.

Authentication is not confidentiality. A cursor is signed, not encrypted, so a client that decodes its own token can read the commit bound it carries. That bound is authority-wide and attributable to no workspace or application, so it reveals only that the authority’s position moved; two cursors compared over time reveal aggregate movement, and a bound change does not prove that a journal entry was appended, because creating the signing key also commits a position. Hiding the bound as well would need authenticated encryption with the same durable key, which this slice does not implement.

The stream is ordinary text/event-stream, which is what the authenticated streaming fetch client consumes. The first frame is event: ready and echoes the cursor the stream started from. Each delivered change is an event: change frame whose id field and data payload both carry the cursor to resume from. Idle periods emit a comment heartbeat. No bidirectional carrier is required.

Durable scans are authoritative and the in-process notification is only a wake-up hint: every step re-scans strictly after the cursor the scan reached, so a missed notification delays a change until the next scan instead of losing it. Two cursors move at different times. The scan cursor advances when a page is read and its frames are queued, which is what lets a view whose page was filled by entries it filters out make progress instead of re-reading the same window. The client-applied cursor advances only as frames are read, so a client that stops reading stalls its own stream and applies backpressure instead of dropping changes.

Three implementation discoveries are worth recording against the proposed design:

  • A page can mix scopes. The workspace overview reads a workspace-wide event list and one application’s run list, so its cursor is unfiltered for event changes and application-scoped for run changes, exactly mirroring the read endpoints it bootstraps. The journal is partitioned per workspace so a workspace reader gets one commit-ordered cross-application stream; an application-scoped view filters that stream, so it may scan entries it does not deliver. That scan is bounded per request and always returns a continuation cursor, so the filter costs bounded work rather than correctness. A page filled entirely by entries the view filters out still advances the cursor and still returns a continuation, so the view may need one bounded round trip per non-matching page rather than one per change.

  • A cursor below the workspace partition’s retained floor (RFD 3) is refused rather than advanced past removed changes. A poll or stream from such a cursor answers 410 Gone with a JSON body whose code is resync_required, naming nothing that was removed. A stream that is already open when its position falls below the floor ends with an event: resync frame carrying the same code. A cursor exactly at the floor still catches up normally. A cursor ahead of the committed journal is rejected rather than answered with an empty page. The UI reads a fresh bootstrap on either signal, says why in its status line, and never polls or streams from the expired cursor again. The local development server runs no retention policy, so in practice its floor stays at the origin.

  • A run-scoped work item records its own dispatch state, so the run detail’s current-work and unresolved-attempt fields update from a lease acquisition, authorization, release, completion, fence, or expiry recovery, not only from an engine transition. The run a work item belongs to is resolved from the record itself rather than from an optional field, because a record written before the association existed would otherwise go silent for exactly the work that needs operator attention. A dispatch-state change names no source event, so it is delivered to a run detail and not to an event detail, whose projection does not include dispatch state.

A review of the implemented slice found four defects that the initial fixtures did not reach, all now fixed with regression tests:

  • A cursor named only a commit position, so a page that cut a multi-entry commit skipped the rest of that transaction on resume. The change cursor is now an entry position, and the shared conformance suite pages one transaction at every boundary.

  • A stream adopted the cursor of a page only when that page delivered changes, so a view whose window was filled by filtered-out entries re-read the same window forever while polling advanced normally.

  • Run ownership was an optional field, so an upgraded database with in-flight work stopped journaling its dispatch state, and a duplicate enqueue could silently disagree about the run.

  • A retried authorization or completion appended a change for a projection that had not moved, and the engine appended a second, event-tagged change on dispatch-only activity, invalidating the event detail for state it does not show.

A cursor is a position in the authority’s commit order, so it survives a server restart: after restart the same cursor resumes the same feed with nothing repeated and nothing skipped. The signing key is durable for the same reason, so a cursor issued before a restart is still accepted afterwards.

Stream limit and live-update observations

A server holds at most a configured number of streams open at once, 64 unless ryusen dev --max-live-streams says otherwise. A stream is admitted after its view, scope, and cursor are validated, so a rejected cursor is still reported as one rather than hidden behind a full server. A stream over the limit is refused at once with 503 Service Unavailable, a Retry-After of five seconds, and a JSON body whose code is stream_limit and whose limit is the limit. It is never queued behind a runtime thread. The refusal is correctness-neutral: the polling endpoint takes the same cursor and returns the same changes at the same cursors, which a test proves against a held stream. A slot is released when the stream’s response body is dropped, so a finished stream, a drain, and a client that disconnects each free one.

The UI treats the refusal as a mode rather than a fault. It polls, says in the status line that the server is at its limit of streams, and asks for a stream again no sooner than the Retry-After delay. Any other refusal stays an ordinary stream failure.

GET /v1/server reports live_updates, an instantaneous observation of this process that the Settings page shows beside the connected workers:

Field Meaning

stream_limit

The most streams the server holds open.

open_streams

Streams open now.

rejected_streams

Streams refused at the limit since the server started.

catching_up_streams

Open streams with something left to deliver: their last scan found more, or their client has not read what was queued.

max_catch_up_depth

The most changes any open stream has read and queued without its client reading them. Backpressure bounds it by one change page. A backlog a stream has not scanned yet is not counted here; it shows as a catching-up stream and as lag.

max_lag_millis

How long the furthest-behind stream has gone since it last had nothing left to deliver, or zero. A stream learns of a commit at its next scan, so this includes its notification wait.

Every figure comes from what each stream reports after its own steps, so reading them never touches storage. None is durable: a restart starts every count again.

Known limitations of this slice

Two gaps are deliberate here and are not hidden by the endpoints:

  • There is no separate application-only credential kind. The existing credential carries the workspace view: it may name any application of the workspace as an overview filter, and it reads and controls any of the workspace’s runs in the application that owns it. Views stay separate rather than isolated: an overview, a run detail, and their cursors are bound to one application, which the tests prove directly. Minting a credential that is scoped to one application without carrying a workspace role is follow-up work, and it must be enforced at the run owner lookup and at every application parameter, not only by the views.

  • Streams are limited per server, not per workspace or credential. RFD 5 records the response, buffering, and read cost each open stream carries.

The journal growth limit and the single-machine FDB evidence limit are recorded with the storage contract in RFD 3.

Implemented event-submission contract

POST /v1/events accepts an optional caller event identifier, an optional workspace-scoped idempotency key, the event name, the payload as bytes, an optional caller timestamp, and optional routing metadata. The acknowledgement is versioned, so a client can rely on fields rather than on a message: submission_version is 1, and disposition is Created for the submission that committed the event or Replayed for an identical retry under the same key. The remaining fields are the durable event identity, the workspace routing generation the event was routed against, and the expansion state the acknowledging transaction observed.

{
  "submission_version": 1,
  "disposition": "Created",
  "event_id": "event-…",
  "routing_generation": "…",
  "expansion": "Pending"
}

A refused submission is a versioned JSON body rather than plain text, so a client can act on a code:

{ "submission_version": 1, "code": "idempotency_key_reused", "message": "…" }

idempotency_key_reused is the only code that forbids a retry: the key already names a different canonical request, and the body carries neither the key nor the stored fingerprint. conflict covers the environment refusals a retry cannot fix either — no active routing generation, or a duplicate event identifier — while invalid, limit_exceeded, and server keep their own statuses. The codes are distinct because presenting an environment conflict as a reused key would tell an operator to abandon a submission that was never claimed.

Both envelopes are read only at the version this client implements: an acknowledgement or refusal whose submission_version is missing or unknown, or whose code is not one of the documented ones, is not described as if it were the contract. The HTTP status still classifies the outcome, and the client uses its own wording for the rest rather than quoting a body it cannot attribute.

The embedded UI models one deliberate submission as one intent. The key is generated when the form is submitted and reused by every retry of the same canonical request — the event name and the payload bytes the server fingerprints — so reformatting a byte array or moving the same bytes between the text and byte-array modes does not rotate it. It is rotated when that request changes, when the operator starts a new submission, or once the intent has completed. A double activation is one intent even when the first acknowledgement returns between the two clicks of the gesture, because the form guards on the activation’s own click count as well as on an in-flight flag, not only on the disabled button.

A transport failure — the request left the browser and no response came back — is reported as ambiguous, and so is a successful status whose acknowledgement cannot be read, because the event may be durable while its identity is unknown. The browser’s own transport message is shown only as a labelled local diagnostic stating that it does not determine the server outcome, so it cannot be read as a second, contradictory verdict on the submission. A non-success status is classified from the status itself even when its body cannot be read, and the server-error wording claims only that the submission was not acknowledged, because a proxy failure does not prove the event was not committed. The ambiguous and server states keep the key so that a retry replays an event that may have committed. The form inserts no row of its own, so a created event reaches the overview through the same durable live updates as any other change and the page cannot show a best-effort history that disagrees with the server.

Payloads are collected in the wire format the endpoint accepts. The text mode sends the typed characters as their UTF-8 bytes and the byte-array mode reads the literal JSON array of integers from 0 to 255, so an opaque payload can be sent exactly; the encoded byte count is shown next to the field. Local validation refuses only a request that cannot be canonical at all — an empty event name, or a byte array that is not one — and never replaces the server’s own refusal.

The idempotency key lives only as long as the intent it was generated for. It is never rendered, logged, placed in a URL, or written to browser storage, and a page reload discards the form rather than resubmitting it, which the form states. Server-provided diagnostic text is treated as untrusted for the same reason. The response document is classified exactly as it arrived — redacting it before it is parsed would corrupt a protocol field that happens to contain the key — and redaction is applied only to the decoded values the form renders: the message of a refusal envelope, and the browser’s own transport message. Matching is case-insensitive, because an echo is free to change the case of what it repeats, and an identity field that carries a request secret is refused rather than rewritten, because a rewritten identifier would link to a different event. No other foreign text is rendered: an unstructured response body is reported through its status and the client’s own wording, never quoted, and the credential is captured when the request is sent, so a session that rotates or clears it while a response is outstanding cannot leave what was sent unredacted. What remains outside this guarantee is a responder that fabricates the versioned envelope itself and embeds a partial secret in its message: a partial echo cannot be matched exactly, which is why the client renders no text it cannot attribute to a documented field. Two limits are deliberate here: the form does not persist unsent edits, because persisting them would either keep a request-scoped key beyond its intent or introduce a second storage contract for a development form; and it does not expose the caller event identifier, caller timestamp, or routing metadata, which remain documented API fields exercised by the server and benchmark fixtures rather than by this form.

Frontend and packaging

The initial frontend is a TypeScript single-page application built with Svelte and Vite. Svelte keeps the event, run, and timeline views component-based while compiling them to a small static application without requiring a UI runtime choice elsewhere in the system. Vite is compatible with the TypeScript SDK toolchain and produces hashed static assets that the Rust server embeds at build time. Running the binary requires no Node.js process, external asset directory, CDN, or network access to load UI assets.

React, Vue, and other Vite-compatible frameworks could satisfy the API and packaging boundaries. React’s larger ecosystem does not outweigh adding its runtime and component ceremony for this focused embedded development UI. Svelte is a product implementation choice rather than a server protocol dependency; the documented HTTP and live-update APIs remain framework-neutral.

The visual foundation is shadcn-svelte: Tailwind CSS v4 utilities over bits-ui headless primitives, with each component copied into ui/src/lib/components/ui and owned by the repository rather than imported from a package, so it can be restyled and audited like the rest of the UI. The theme maps the components' semantic tokens and Ryusen’s run-state tones onto one palette for both color schemes, and fonts are bundled into the build rather than fetched, so the embedded UI still loads offline. Hand-written styles that predate the foundation live in a legacy cascade layer that yields to the components, and pages leave it as they are rebuilt. The same foundation is meant to carry a hosted console, so moving from the development server to production does not change the interface’s vocabulary.

The UI consumes only documented server APIs; it does not read SQLite directly or import Rust-generated internal domain structures. Generated protocol/API clients may be used from a language-neutral schema.

Live telemetry continues to use server-sent events rather than WebSockets. The browser receives projection updates in one direction, while event submission, operator resolution, and other commands remain ordinary authenticated HTTP requests. A bidirectional carrier would not remove the durable cursor, replay, or reconnect requirements and would duplicate machinery without improving this interaction model. The Svelte client consumes the SSE response through a streaming fetch rather than native EventSource, so a stream fails through the same classification as every other read, and reconnects from its last applied durable cursor. Polling remains the correctness-preserving fallback.

The UI presents no credential, because the development server needs none, so there is no sign-in step and nothing goes stale when ryusen dev restarts. A server with an injected authenticator may still answer 401; the UI then replaces the page with an explanation that the server requires authentication this UI does not provide, rather than rendering a workspace that looks empty. How a hosted UI signs in is that deployment’s concern.

Embedding fixes the asset contract. The production build’s content-hashed output under assets/ is served immutably, the entry document is revalidated, and every other GET/HEAD path is answered with the entry document so a deep link survives a refresh. The /v1, /health, and /connect prefixes are never answered with that document, so an unmatched API path stays an HTTP error instead of becoming HTML that a client would try to parse as JSON.

Payload handling

Inputs, outputs, and errors are useful in development but may contain secrets. The server enforces payload size limits and marks truncated or blob-backed values. The UI escapes rendered content and displays structured values without executing embedded markup.

The local server shows payloads by default. It has no authentication, so a non-loopback listener requires the explicit --allow-remote option, and a configuration option disables payload display. Automatic field-level secret detection is not claimed.

Separately, each browser can hide values on screen for screen sharing. This is a display preference kept in that browser’s storage, not access control: the values still reach the page. When hiding is on, the UI hides event payloads, run inputs, step outputs and compensation results, recorded history entries, and recorded application error messages and details, including detail keys, and workers' pre-start rejection reasons, each behind a one-time reveal that applies to that value in that place only and lapses when another span is selected or hiding is turned off and on again. Names, identifiers, error categories, timings, routing metadata, and server-produced explanations stay visible. An action that loads a hidden value into an editor says it reveals the value, and copying a command copies the value without showing it. A choice saved in one tab applies to the browser’s other open tabs at once. When the browser refuses storage, the page says the choices last only until it is reloaded. A chosen theme is applied by a small script in the entry document before the first paint, so the stored theme never flashes the other one.

Verification

UI tests use deterministic seeded histories for pending and completed event fan-out across applications, zero and multiple trigger matches, successful, retried, failed, cancelling, unknown, late-resolved, empty-unwind, skipped-only, compensated, partially compensated, and compensation-failed runs. Browser-level acceptance drives a real local server and connected worker, submits an event, watches trigger associations and the run timeline update, restarts the server, and verifies that durable telemetry reconstructs the same event-to-run graph. A handoff test pauses after the bootstrap transaction, commits another transition, opens SSE after the returned cursor, and proves the raced transition appears.

Live-update coverage that exists today, on SQLite: the raced transition, cursor reconnect and reconstruction after dropped notifications, cursor survival across a server restart, a slow client that withholds reads, every cursor rejection class, authorization before lookup for existing and missing resources alike, and application isolation including a cursor edited to name a neighboring application’s run. Review-driven cases were added with the fixes above: a page cut inside one commit, an empty page that still advances, a stream reaching a change behind more than one scan window of filtered-out entries, an idempotent replay that commits nothing, a rewritten pre-upgrade work record that still journals its dispatch state, a conflicting duplicate enqueue, a forged or edited cursor, the longest accepted scope, and a real client whose open stream does not prevent serve from returning.

The fixtures that page and reconnect use short histories, and only the newest paging fixture deliberately commits several changes in one transaction. The shared conformance suite does not exercise concurrent snapshot interleaving or a backend restart mid-scan. FDB coverage is the single-machine conflict-semantics check in RFD 3, not a restart or interleaving proof.

Event-submission coverage that exists today: server tests prove that a matching retry is acknowledged as Replayed while a fresh submission is Created, that a conflicting key reuse returns the idempotency_key_reused code with neither the key nor the stored fingerprint in the body, and that a workspace with no active routing generation returns conflict instead, so the two refusals cannot be confused. Frontend unit tests cover the intent model — key reuse for an unchanged canonical request including a reformatted byte array or an equivalent text and byte-array payload, rotation on a changed name or payload, an explicit new submission, and a completed intent — plus payload encoding, local validation, every outcome the typed client classifies, the version gate on both envelopes, the redaction of a reflected key from a refusal message and from a thrown error including an escaped and a case-varied echo, an identity field that carries a request secret, an unreadable acknowledgement after a successful status, and a refusal whose body cannot be read. Browser acceptance submits through the form against a real server and connected worker and proves that the created event reaches detail and its run; that a double activation inside one task, and the second click of one gesture whose first acknowledgement returned in between, are each one request with one key; that a lost response retries under the original key and is reported as a replay of the same event; that reformatting a byte array after a lost response keeps the key and sends identical bytes; that a key claimed by a different request is explained as a conflict with the key absent from the document, the title, the URL, and the console; that a plain-text proxy error echoing the key does not render it, and that the same echo inside a structured refusal is redacted rather than displayed; that editing the request after an ambiguous response rotates the key; that a malformed byte array never reaches the server; that a submission a server refuses with 401 is explained as a server that requires authentication; and that the form does not overflow a narrow viewport.

Workspace routing tests show one event’s frozen snapshot, each referenced app manifest, and matched and unmatched bindings across apps. A deployment race proves the UI continues to display the event’s old snapshot while current routing moves forward and execution remains unpinned. Transport tests show both connected and HTTP attempts and distinguish durable attempt state from ephemeral worker liveness. They also distinguish safe discovery redispatch from definition blocking and forward or compensation ambiguity. HTTP fixtures explain why a bare 500 or malformed response after an effect becomes Unknown, while an authenticated pre-start rejection can retry safely.

Non-goals

The initial UI is not a complete hosted administration console. It visualizes workspace and application boundaries needed by the local product, but does not include billing, user management, long-term analytics, alerting, custom charts, or an external telemetry warehouse. OTLP export and production retention policy are follow-up work.

Implementation

Telemetry records, APIs, embedded frontend, and acceptance coverage are tracked in RFD 7 implementation checklist.