RFDs/0007
Development UI and execution telemetry
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
Pendingrun 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
DefinitionBlockedstate 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
NotRequiredreasons 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,
Unknownattempts 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
Unknownattempts 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.
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.
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 (forwardorcompensation). 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, forward |
– |
– (carries |
|
step, forward |
yes |
– |
|
step, forward |
yes |
failure category and retryability |
|
step, forward |
yes |
the failure’s category and the next attempt’s ready time |
|
step, forward |
when an attempt reported it |
– |
|
step, target |
yes |
the resolution command |
|
step, forward |
– |
due time (carries |
|
step, forward |
– |
event name and deadline (carries |
|
step, forward |
– |
child run (carries |
|
step, forward |
– |
emitted event (carries |
|
step, forward |
– |
the matched event |
|
step, forward |
– |
– |
|
step, compensation |
yes |
– |
|
step, compensation |
yes |
failure category and retryability |
|
the forward step actually fenced, if any |
for fenced effect work |
also a run-level stop event |
|
– |
– |
failure category and retryability (carries |
|
– |
– |
– (carries |
|
– |
– |
the conflict’s code and step ID (carries |
|
– |
– |
– |
|
step, forward |
– |
the already accepted event ID (carries |
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-
connectedorhttp. 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 |
|---|---|
|
The HTTP endpoint answered with a non-success status, which the cause keeps.
A bare |
|
The HTTP exchange failed, and the dispatcher could not prove that no request bytes were sent. The request may have been delivered. |
|
The HTTP response declared or streamed more than the response limit. |
|
The HTTP response body ended with a transport error before it was complete. |
|
The HTTP response body was not a canonical protocol response. |
|
Either transport delivered a protocol response the server refused: it did not match the attempt, was invalid, or exceeded a limit. |
|
The connected worker’s connection closed, failed, or could not be written while the worker held the authorized attempt. |
|
The connected worker sent a frame other than a valid result for the attempt, so the server closed the session. |
|
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 |
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
erroronly when it reported an uncaught failure. A pass that reported a definition conflict isok, 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
Unknownis open with statusunknown. It closes at the resolution entry:okfor a supplied success,errorfor a declared failure, andretry_authorizedfor an explicit retry, with the command itself asresolved_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-startedto 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-requiredis 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.
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 Gonewith a JSON body whosecodeisresync_required, naming nothing that was removed. A stream that is already open when its position falls below the floor ends with anevent: resyncframe 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 |
|---|---|
|
The most streams the server holds open. |
|
Streams open now. |
|
Streams refused at the limit since the server started. |
|
Open streams with something left to deliver: their last scan found more, or their client has not read what was queued. |
|
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. |
|
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
applicationparameter, 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.