VexelRay
Framework docs

Threading conventions

The rules an application and this framework both keep, stated normatively and in one place.

architecture.md is the reasoning — why one component gets one thread, why placement is static, why a component never holds the baton. This document is the rules that fall out of it, written so they can be checked rather than read.

Each rule carries its enforcement, because a threading rule that nothing enforces is the exact condition @MainThread was written to cure — “true, load-bearing, and enforced by nothing but the reader’s memory.” A rule here is one of:

  Meaning
held enforced now, by a test or by the type system
processor the processor’s to make a compile error, and it does not yet — -processor exists and checks what a declaration can decide; these need generation, a method body, or a seam not built
upstream cannot hold until a sibling repo changes
open the rule is not yet decided, and the entry says what is missing
read true, load-bearing, and enforced by the reader — the state to get things out of

Most of what follows is assembled rather than invented: the constraint already existed as prose somewhere in the stack, and is quoted where it did. §3.4–§3.7 are the exception and say so.


1. Lanes

T1.1 — The lanes are a closed list. Five: the main thread, the timeline, component threads, the handler lane, the offload lane. A sixth lane is a design change, argued in architecture.md, not a convenience added at a call site. (read)

The timeline is a lane in the sense that matters here — a confinement domain, single-threaded by construction, entered from FrameStage.CLOCK. Whether it is physically the main thread or a carrier of Kronometer’s own is Kronometer’s business; what is normative is §3.2.

T1.2 — Each lane has exactly one name, used everywhere. “Worker thread” used to mean the handler lane in Gui’s Javadoc and, in practice, the offload lane as well — one name for two lanes with different rules, in documentation applications read. The name split when the pool did: vexelray-gui-handler and vexelray-gui-offload upstream, vexel-handler and vexel-offload in the container, and vexel-component-<name> for a placed one. (held, by LanesTest in both repos — and the name is load-bearing in the thread dump somebody reaches for when one lane is the problem)

T1.3 — Placement is decided in the wiring and never at runtime. No work stealing, no placement decision, nothing to tune while running. This is the restriction the rest of the model is bought with: static assignment is what lets the processor emit thread construction, the component-to-thread mapping and barrier participation as generated code, with no scheduler in the binary. (held, by PlacementTest.anApplicationCannotPlaceAComponentOrAddAMailboxByHand. A @Component’s lane is on its declaration, the generated wiring places each lane once, and Shell.place, Placement’s constructor, start and subscribe are package-private, so a part holding the Shell cannot place a component later or add a mailbox by hand. The one public door is Placements, which the generated wiring calls; it is guarded by its Javadoc and not by the compiler, because Java has no visibility between a package and everyone. Barrier participation is not built, and is something the rule makes possible rather than part of what it enforces)

T1.4 — Component and offload threads are platform threads, never virtual. Not stylistic: jdk.virtualThreadScheduler.parallelism is a global JVM property and JDK 25 has “no public per-thread scheduler”, so an application that takes Kronometer’s single-carrier flag has no second carrier to give — and virtual threads become the documented deadlock rather than merely slower. That flag is the application’s and correctness never depends on it, so the default has to be the one that is right when it is set. (held, by LanesTest.everyThreadIsAPlatformDaemon — which asks Thread.isVirtual() rather than reading the property, because the property is the application’s and this may not depend on it)

T1.5 — The offload lane is bounded. A pool that answers a full queue by growing has chosen the one policy the rest of this document refuses (§4.2). Gui built an unbounded newCachedThreadPool, so a wedged filesystem answered backpressure by spawning threads. Bounded in threads, not in queue, and the distinction is the rule rather than an implementation detail: a full lane should make work wait behind the work already on it, which is backpressure, whereas a bounded queue makes it fail — and choosing loss is §4.2’s decision, taken per channel by whoever knows what the channel carries, not a default a pool takes on their behalf. (held, by LanesTest.theOffloadLaneIsBoundedInThreadsRatherThanGrowing)

The handler lane is deliberately not bounded yet, and that is a gap with a reason rather than an oversight. Bounding it is safe only once nothing blocking runs on it, and the census that would settle that has not been taken — the one application checked, text-editor-vexel-demo, turned out to be doing its file I/O on the main thread rather than on a handler, which is a different defect and is now fixed. So the bound waits on knowing, not on a known blocker. (open — and the thing it wants is a census)

2. Colour

T2.1 — Every value has exactly one colour: the main thread, one named component thread, or shareable. Colour is a property of the value, not of the moment. The main thread being its own colour rather than a lane with a reserved name is now a decision rather than an accident of how this sentence was first written — see architecture.md. Lanes does not mint it, there is exactly one of it, and what may happen there differs in kind; naming it as a lane would make a string the discriminator for all of that. (held, by VexelProcessorTest.aComponentIsNotAlsoMainThread — a @Component that is also @MainThread is a compile error. The third colour, shareable, is not declared but checked: a @Subscribe payload must be deeply immutable (T2.4, Shareable), so the only way to give a value two colours today is the one that is refused)

T2.2 — A main-thread value may not be injected into anything that is not itself main-thread. One rule, one direction. The inverse is deliberately allowed: handing a main-thread component an immutable model or a settings record violates nothing, and requiring an annotation for it would put @MainThread on most of an application. (held, by VexelProcessorTest’s T2.2 cases — into a component’s constructor, and into a provider whose value is not main-thread, with the colour read off the type’s @MainThread or off a @MainThread provider’s, including from a class file. Only as good as where it is applied: see T3.1)

T2.3 — A direct reference between two components is permitted only where they share a thread. Decidable because placement is static: the colour of a value is the thread it was placed on, and dynamic placement would have made this check undecidable. Static is not yet the same as visible, and this rule as written assumed it was. a hand-written shell.place("compose") was a call in a wiring method body, which a processor — reading declarations, not bodies — cannot see. So placement moved onto the declaration: @Component(lane = "compose"), optional since 2026-09-29: a component naming none shares the reserved default lane, one thread that is not the main thread. The lane is a string, and that is safe here in a way it would not be for the main thread: all two spellings agreeing permits is a direct reference, so a misspelling can deny one and never grant it, and the error names both spellings side by side. (held, by VexelProcessorTest.componentsOnDifferentLanesMayNotHoldEachOther and aMisspelledLaneDeniesAReferenceAndNamesBothSpellings)

T2.4 — A value crossing a lane arrives as if it had crossed a wire: no shared reference to the sender’s heap, anywhere in the reachable graph. Sharing a deeply immutable value is indistinguishable from copying it, so the copy is elided exactly then — an optimisation the rule cannot observe, not an exception to it. Everything else crosses by a copier the processor emits from a declaration, elektroq’s pattern, so the declaration is a request that compiles or does not rather than a promise taken on faith. The reader’s form of the rule is could this survive a network hop — which a Vulkan handle, a window, a Gui node and an open file cannot, and those are the same values T2.2 confines. Identity does not survive a crossing: a == b is false afterwards.

This replaces a closed set of three. The set was not closed: State<T> and Versioned<T> are wrappers, shareable only as far as T is, and State’s own Javadoc says what T must be in a parenthesis — “should be immutable”. The reasoning is in architecture.md, including why Serializable is the semantics here and never the mechanism. (held for @Subscribe payloads, the check half, by VexelProcessorTest’s T2.4 cases: a payload must be primitives, strings, enums, records, final classes of those (not inner ones), or a sealed interface or class of them, and the first thing that stops it is named with its path. The copier is not built: rather than copy a mutable payload the processor refuses it, so every collection, array and mutable class is refused until it is. That accepts less than the ruled design and building the copier later only accepts more. Only a declared mailbox is a checked crossing; a send nobody declared is not)

T2.5 — A lambda that crosses a lane may capture only what T2.4 lets cross. This is what makes the offload lane a policed edge of the model rather than a hole in it: an offloaded task that captures a component’s state becomes a compile error rather than a race. Capture is the case a copier cannot be slipped into invisibly — a lambda closes over a reference — so here the rule is a refusal rather than a copy, and the fix at the call site is to pass the value rather than close over it. (processor)

3. Ownership

T3.1 — Vulkan, the window and present are the main thread’s. The stack’s constraint, not this framework’s invention — vexelray-gui/CLAUDE.md files it under constraints not visible in the code. (held, via §2.2, by VexelProcessorTest.aComponentAskingForTheWindowIsAnError. GuiApp carries no @MainThread and should not learn this repo exists, so the mark comes from the processor’s own table of what the framework hands out — Framework, held against Shell by FrameworkTableTest — which is where the generated wiring gets the GuiApp from as well)

The one exception, ruled 2026-10-08: a compute queue lent to one component. A device made with a compute-only queue beside the one that draws lends that queue, as a ComputeQueue (Shell.computeQueue), to the one @Component that takes it, and that component’s lane alone submits to it. A VkQueue must be externally synchronised, and a queue nothing on the main thread submits to needs no lock to be another thread’s: this is what lets a simulation run its steps back to back, however long each takes, without a frame ever waiting for one (vexelray-sim-rigid’s docs/physics-timing.md). The queue that draws, the window and present stay here. The generated wiring asks for the queue (Wiring.computeQueue) exactly when a component takes it, so every other application gets the device it always had. Ordering what the lane computes against what a frame reads is the two queues’ business, by what they signal and wait for: a buffer one writes while the other reads is a torn picture, and settling who may do which is not this rule’s. (held for the exception by VexelProcessorTest.theComputeQueueIsLentToOneComponent and aProviderIsNotLentTheComputeQueue, and by GuiApp.lendComputeQueue lending once)

T3.2 — The timeline graph is the baton’s. A component thread never holds the baton and never reads or writes a Signal, Cell or Effect. KronBridge exists because the two systems have incompatible threading models: the bus publishes on whatever thread published, “while the timeline is single-threaded by construction, because that is what makes it ordered”, and delivering on the publisher’s thread “would mean mutating the graph from off the timeline, which is the one thing the design does not permit.” (read)

T3.3 — A component’s state is its own thread’s. Nothing else reads it, writes it, or holds a reference to it. (processor, via §2.3 and §2.4 — the first is held, so the container can no longer hand a component’s object across a lane, and T2.4’s check half means a message can no longer carry a mutable reference out of a component’s state through a declared mailbox; what it cannot stop is the same thing through a send nobody declared)

T3.4 — Components form a tree. A component may own child components; the application is the root. (open — new to this stack, see the note below)

T3.5 — The tree is for lifecycle, supervision, grouping and naming. It does not restrict who may message whom. Keeping these apart is the whole of the rule. A supervision tree is about failure and lifecycle; the message graph is about data flow; they rarely have the same shape, and forcing them to coincide gives one of two bad outcomes — the tree gets contorted to match the traffic, or parents become relays, and a relay parent inherits all of its subtree’s traffic along with being the single point where backpressure bites. Restricting messaging to peers and children does not buy acyclicity either: a sibling group is a complete graph, and parent↔child is itself a cycle the moment a child may answer. The real hazard is narrower, and §4.4 states it. (read)

T3.6 — A subtree is the default thread grouping. A child shares its parent’s thread unless the wiring places it elsewhere. This is what makes which grouping is late an answerable question: slip is a property of the one timeline and cannot be made per-domain, but a static mapping can name the grouping that is overrunning. (processor)

T3.7 — The container is the only source of a cross-component reference. A component’s internals are unreachable unless the wiring exposes them, and what the container hands out is a channel or a shareable value, never the object. This is the sharp form of a mismatch already recorded for the processor’s brief: @Component is a DI contract, and constructor injection handing A a direct reference to B is the thing a mailbox exists to prevent. (held, by VexelProcessorTest.aProviderMayNotHoldAComponent with T2.3: a component reaches another component’s object only on its own lane, and a provider never does — a provided value holding a component could be injected anywhere, carrying it across lanes with no check seeing it)

On §3.4–§3.7. These are new to the stack — there is no hierarchy, parent or supervision concept in atchung, its documentation, or elektro-Q, whose Actor is a marker and whose Conduit is flat. By this repo’s own rule that is grounds for suspicion, so the argument has to be made rather than assumed: the tree is not added for tidiness, it is added because supervision has no structure to live in without it. The recorded gap is that “nothing notices that a component has stopped draining, or names it”, and a parent is the thing with standing to notice and to name. The grouping in §3.6 is the second thing it pays for. If supervision is ever answered another way, §3.4 should be re-argued rather than kept.

It has been. Supervision is per lane (Watchdog, T6.5, and components.md ruling 4: for v1 a group is a lane), so the tree’s first reason is gone and §3.4 is owed that re-argument. components.md records that T3.4, T3.6 and T6.3 should move to after v1 as a consequence; not done yet.

4. Channels

T4.1 — A channel carries one loss class. A sample — pointer position, window size, a clock reading — is COALESCE_LATEST, because the next one supersedes it. An edge — a keystroke, a command, a tree mutation — is FAIL, or BLOCK where the publisher can safely wait. A channel carrying both “cannot be given a correct policy — every choice is wrong for half the traffic. That is not a policy problem to be solved here; it is a signal to split the channel.” (held, by the processor reading @Subscribe: one topic that is an edge to one subscriber and a sample to another, or one topic name with two payload types, is a compile error — VexelProcessorTest’s T4.1 cases)

T4.2 — FAIL is the default, and needs no justification; loss does. “Choose FAIL unless you can say why loss is harmless on this channel” — a lost event is not an error anywhere, it is an absence, and the bug is then looked for in the consumer, which is working perfectly. (held, by Backpressure’s default)

T4.3 — A component gets a mailbox per loss class, not one mailbox. §4.1 applied to a component rather than to a channel in isolation. (held, by construction: each @Subscribe method gets a mailbox of its own, with its own overflow, so a component’s loss classes cannot share one)

T4.4 — No cycle in the message graph may contain a blocking edge. Cycles are not the hazard — request/response is a cycle, and with asynchronous mailboxes it is ordinary. A cycle containing a BLOCK edge is a deadlock, and it is not hypothetical: a mailbox bounded at 65,536 with BLOCK, filling because nobody drained it, is the freeze this repo shipped for three commits. Because placement is static and the wiring is compile-time, the processor sees the entire graph — every channel, its loss class and its policy — and can reject the loop. (held for declared sends, by VexelProcessorTest’s T4.4 cases: two components blocking on each other, a component blocking on its own mailbox, a send declared on a method of the component, and the cycles that are fine. It is the rule as written, which is wider than the strict deadlock condition: a cycle with a non-blocking hop does not deadlock, it fails when that hop’s mailbox fills, and is rejected anyway because the blocking hop turns a full mailbox into a stall instead of a named failure. A send nobody declared with @Publishes is not in the graph)

T4.5 — The message graph is declared in the wiring. An emergent graph cannot be checked, rendered, or reasoned about; a declared one can. This, rather than any restriction on the graph’s shape, is what keeps the design from being cornered: §4.4 catches the blocking cycle that a topology rule would miss. (processor — the declarations are the graph and Channels reads them to run T4.1, T4.4 and T4.7. Exposed 2026-10-19: the generated wiring carries the graph as a constant, BUS_GRAPH, answered by Wiring.busGraph(), in the text vexelray-inspector’s docs/bus-circuit.md fixes (version 1), so the Inspector can draw it as a circuit; the automation module declares it to BusGraph, which answers the socket’s bus graph, and each mailbox is named on its pump by its class#method, so the bus’s probe rows say which pin a message reached. It holds what the checks hold plus one thing they drop: a type that declares a send and is not a component, which is a sender all the same)

T4.6 — Two Gui trees may not share a bus. Gui’s topics are static final class-level names, so every instance subscribes to the same vexelray.gui.mutations and two trees on one bus each receive the other’s mutations. Share a bus with anything that is not a tree — input publishers, workers, components and their mailboxes — and never with a second Gui. It is also the ceiling on one inspectable fabric per application, and on any scoped addressing §3.4 would want. (upstream — the fix is topics named per instance)

T4.7 — Main-thread code sends only on channels that never block. A blocking send from the main thread to a lane that has stopped draining is how a window freezes, and the freeze T4.6’s Gui topics shipped was exactly that. @Subscribe declares each mailbox’s policy and @Publishes declares what a sender sends on, so the processor rejects main-thread code (a @MainThread type or method, or a @BeforeFrame hook) that declares a send on a topic any mailbox BLOCKs. A declared graph, not a proof: a send nobody declared is not checked, and a subscriber in another module is not seen. (held for what is declared, by VexelProcessorTest.mainThreadCodeMayNotDeclareASendOnABlockingChannel)

5. Completion

T5.1 — A result never lands in place. Work done off a lane comes back through one of exactly two doors: published on a Topic and folded into a Cell by KronBridge, or dropped on a queue drained in FrameStage.APP. The first is for anything the timeline should see, the second for main-thread-only effects. (read)

T5.2 — FrameStage.APP is for draining, not for working. “The right place for reading a queue a worker filled, and the wrong place for the work the worker was doing” — whatever runs there is inside the frame budget, on the main thread, every frame the loop wakes for. (read)

T5.3 — Every mailbox owes a WakeSource, and nothing has to pay it. A component that finishes work has produced something the loop cannot predict, which is WakeSource’s definition. The symptom of omitting the wake is the one the GUI already paid for: a window that is responsive except for the interactions that happened to arrive that way. A Placement is a WakeSource, the container connects it when it starts the component, and it wakes the loop itself after any drain that delivered something — hung on the one thing every path has in common, which is that a delivery ran. There is no call to make and therefore none to forget, and the processor has nothing to emit for this at all. (held, by PlacementTest.aDeliveryWakesTheLoopWithoutTheComponentAskingItTo, with anIdleComponentDoesNotWakeTheLoop holding the other side — a park expiring is not work, and a component that woke on one would turn a render-on-demand loop back into a polling one while looking like a fix)

This rule was held twice, and the first time was not good enough. The first version gave the component a published() to call after publishing a result. That is a real improvement on the designer’s hand-written original — which met the obligation by accident, because announcing a phase wrote to a node and a node mutation wakes the loop — and it is still the same bug one step along: omittable, silent, and producing exactly the symptom the rule exists to prevent. A rule enforced by an API you must remember to call is not enforced. Worth keeping here as the shape of the mistake, because it is the one this document is most likely to make again: held is a claim about whether something can go wrong, not about whether the framework has written down that it shouldn’t.

T5.4 — One drain period of latency is the price of the ordering guarantee, and it is not a defect. “It is the same price every retained-mode GUI pays.” Named here so it is not re-litigated later as a fault of the component model. (read)

6. Lifecycle

T6.1 — Start order is distinct from construction order. A mailbox must not pump before its publishers exist. The container builds a component with nothing running on it and starts every placement together, after the wiring’s ATTACH has returned — so the rule is a moment in the framework rather than a line a wiring is trusted to put last. The hand-written first component had to say it for itself: “last, so nothing the component touches is still half-built when its thread starts”, which is an ordering rule enforced by where one line happened to sit in a constructor. (held, by PlacementTest.nothingRunsUntilTheFrameworkStartsIt and aMailboxCannotBeAddedOnceTheComponentIsRunning)

T6.2 — Shutdown is drain-then-stop, with a timeout. Not cosmetic: Backpressure.FAIL is the default and resolves through Fatal, so getting shutdown ordering wrong kills the process rather than dropping a message. Placement.close is that order exactly — the flag goes down, Pump.wake() ends the park, the thread does one last drain and returns, and only then are the mailboxes closed. Closing them first would drop what is queued on the floor; interrupting first would abandon a delivery mid-flight. Bounded at one second per component and two for the lanes, because a process that refuses to quit is worse than one that quits having abandoned a message — the same trade §6.4 makes, for the same reason. (held, by PlacementTest.stoppingDrainsWhatWasQueuedBeforeItCloses and shutdownStopsTheComponentThread)

T6.3 — Shutdown runs depth-first: a child stops before its parent. A parent that stopped first would have nothing left to answer a child’s last publish. (open, with §3.4)

T6.4 — A bus fault halts, and the framework does not save first. Atchung.onFatal is process-wide and wants calling “once, at the application edge”, which is VexelApplication. Saving window placement first is refused for upstream’s reason: “running application code on a thread that is mid-publish, holding a mailbox lock, with a full queue behind it, is how a crash becomes a hang.” A lost placement is the cheaper loss. (held, in Faults)

T6.5 — A wedged component is not a bus fault. It is a mailbox filling, answered by the Backpressure of its channel rather than by a process policy. What neither half covers is supervision — noticing that a component has stopped draining, naming it, and deciding what the rest of the application does about it. (held, by Watchdog in -core and LivenessPolicy in -shell: every component lane carries a busy gauge (Lanes.busy), a delivery inside it longer than the policy’s threshold is reported once by lane name, and the policy decides, report-then-exit by default — WatchdogTest.aLaneInsideOneDeliveryTooLongIsReportedOnceByName. On a timeout only: Overrun surfaced per grouping (§3.6) is still the better signal, and is not built)


The matrix

The point of the column is that the unenforced rules are a list rather than an impression.

  held processor upstream open read
1 Lanes T1.2, T1.3, T1.4, T1.5       T1.1
2 Colour T2.1, T2.2, T2.3, T2.4 T2.5      
3 Ownership T3.1, T3.7 T3.3, T3.6   T3.4 T3.2, T3.5
4 Channels T4.1, T4.2, T4.3, T4.4, T4.5, T4.7   T4.6    
5 Completion T5.3       T5.1, T5.2, T5.4
6 Lifecycle T6.1, T6.2, T6.4, T6.5     T6.3  

Three readings worth taking from it. Three of the thirty-three rules still say processor, down from fourteen, and what the remaining three have in common is the useful thing to know about them: none can be decided from a declaration alone. T3.6 waits on the component tree (§3.4), T2.5 wants a method body, and what is left of T3.3 is a send nobody declared, which no declaration shows. open is two rules, and §2 is now empty of them — the colour section was the one place where an unanswered question would have been classified into every application type by the processor, and T2.4 is answered. What is left open is the component tree and its teardown (T3.4, T6.3), which is where the remaining design work actually is; supervision left the column when the watchdog landed, per lane rather than per subtree. And the upstream column is down to one, which is the useful surprise: T1.2 and T1.5 were filed as things this repo could not fix, and both turned out to be one change in vexelray-gui — the worker pool ceasing to be a field initializer — rather than two problems.

What moved, and what made it move. Nineteen rules are held (T4.1, T4.3 and T4.7 when @Subscribe put a channel on a declaration, T4.4 when the processor joined senders to mailboxes, T2.4’s check half when it learned what is immutable, and T1.3 when placement left the public surface); thirteen were before those. The first eight were paid for by two objects: Lanes, which owns the application’s threads instead of a Gui owning one set per tree, and Placement, which owns a component’s thread and the drain-then-stop around it. None of those needed the processor — they needed something to be the rule, which is available a long way before the compile error is. The next four — T2.1, T2.2, T2.3 and T3.7 — are the processor’s checks, and they arrived before it generated anything: the checks read declarations. The precondition T2.3 named for itself, placement on the declaration, turned out to be one required annotation member. The thirteenth, T3.1, came with generation, and not because of it: the generated wiring needed a table of what the framework hands out, and a table of the framework’s values is the one place a type from another repo can be marked main-thread without that repo knowing.

Promoting a rule

A rule moves left when something checks it, and the entry changes in the same commit that makes it true. A rule that cannot be checked should say what seam it would need, the way the TODO’s dropped-capability entry does — “recorded so the gap is a decision.” Deleting a rule is allowed and preferable to keeping one that nothing believes; re-argue it in architecture.md first, since this file holds rules and that one holds reasons.