The component model
What a component is, what the framework owes it, what exists, and what is still to be decided. This is the spec the declaration seam is built against. It does not repeat the other three documents it summarises:
- architecture.md is the reasoning (why one thread, why static placement).
- threading.md is the rules, each with what enforces it.
- v1.md is the contract v1 freezes, including the liveness guarantee.
Where this page and one of those disagree, that is a bug in one of them; say which in the commit.
Status of this page, 2026-10-02. The nine rulings below were put to the user together on 2026-10-01 and
accepted as a batch, so most are labelled Accepted as recommended: adopted from a recommendation without
discussion, valid but unexamined, to be re-checked when one matters. Ruled is kept for what the user decided
with a reason of their own: that a full FAIL mailbox halts the process (ruling 5), that superseded() is deleted,
and that the designer is not ported. Every ruling states its reason, and a new one must; see TODO.md, A ruling
carries its reason. Rulings 1 and 2 are complete.
What a component is
A @Component is a thread and a mailbox: a class constructed once, before the loop runs, by its one
constructor, that runs on a lane and is reached by publishing, never by holding. A value that needs only a
lifetime and swappable behaviour is a @Provides; a value is neither. The main thread is not a lane and a
component is never @MainThread.
What a component is owed
| The framework owes it | Held by | Rule |
|---|---|---|
A platform thread of its lane, named vexel-component-<lane>, never virtual |
LanesTest |
T1.2, T1.4 |
| One shared default thread when it names no lane, so a plain service costs no thread | Component.DEFAULT_LANE |
ruled 2026-09-29 |
A mailbox per @Subscribe, with a declared loss policy and bound |
processor, Placement |
T4.1, T4.3 |
| Construction before the loop; start only after everything is built | PlacementTest |
T6.1 |
| A wake of the frame loop after any delivery, with nothing to call | PlacementTest |
T5.3 |
| Drain-then-stop at shutdown, inside a total bound after which the process halts | PlacementTest, Disposer.beforeClose |
T6.2, liveness |
Its own ThreadGroup, so the watchdog can interrupt everything the lane started |
Lanes.interruptLane |
liveness |
| A stall report naming its lane, and a policy the application can override | Watchdog, LivenessPolicy |
T6.5 |
| That no other lane holds a reference to it, and no provider does | processor | T2.3, T3.7 |
| That a value crossing into it shares nothing with the sender’s heap | processor, for @Subscribe payloads (deeply immutable, Shareable); a captured lambda is not checked |
T2.4, T2.5 |
| That no blocking cycle runs through it | processor, for declared sends | T4.4 |
And what it owes the application: it never blocks the main thread, never touches the timeline (T3.2), and returns work through one of the two doors (T5.1). A wedged component loses its own lane and nothing else; a component that throws ends the application, because nothing declared that failure (section 10).
What exists, what is decided, what is not
| Built | Decided, unbuilt | Undecided | |
|---|---|---|---|
| Threads | Lanes, Placement, default lane, platform-only, bounded offload |
handler lane bound (waits on a census) | |
| Declaration | @Component(lane), @Subscribe, @Publishes |
what the nine rulings below (accepted 2026-10-01) ordered and is not built: the capture rule | |
| Colour | T2.1 to T2.4 (T2.4 as a check: payloads must be deeply immutable), T3.1, T3.7 | the copier’s pattern (annotate a record, processor emits the copy) | its annotation’s name; the capture check’s mechanism |
| Channels | T4.1, T4.3, T4.4, T4.7 for declared sends | the graph as generated data (additive) | |
| Completion | Lanes.offload(), app.post |
two doors | kronometer offload and the timeline door unbuilt |
| Liveness | watchdog, busy gauge, shutdown bound, policy seam | main-thread heartbeat that knows a dialog is up | |
| Tree | T3.5: the tree does not restrict messaging | T3.4, T3.6, T6.3 |
The rulings owed
Eight were named in TODO.md when the component model was ruled frozen at v1; a ninth turned up in drafting this page. They are ordered by how much each one blocks, not by importance.
1 and 2. The imperative path, and a public Shell.place — one change
Placement.subscribe(...) and Shell.place(name) are the original, imperative way to make a mailbox and a
placement from inside a wiring method body. @Subscribe and @Component(lane) replaced them for everything a
declaration can say. Kept beside them, the imperative form is a second way to do one thing
(v1.md), and Shell.place is a public call any part taking the Shell can make
at any time, which is exactly what T1.3 (placement is decided in the wiring and never at runtime) says
nothing may do. T1.3 is half held for that reason alone.
Accepted as recommended 2026-10-01, and carried out at the user’s direction (steps a to g): remove both from the surface an application sees. @Subscribe is the one way. The generated
wiring still has to call something, so what survives is one entry point that generated code uses and
application code has no reason to; Java has no friend visibility, so say so in its Javadoc and keep it off
Shell. This makes T1.3 held rather than half held, and it deletes the cheapest way to build a cross-lane
reference by accident.
What it costs and what to check first. @Subscribe cannot express everything the imperative form can: its
payload is one class or interface with no type arguments, and a mailbox is one per method. Before removal,
list what the designer, W2 and the test fixtures subscribe with by hand, and either widen @Subscribe or
decide the missing shape is not a component. VexelProcessorTest fakes both calls, so it moves with this.
Inventory, taken 2026-10-01. Every use of Shell.place, Placement.subscribe and Placement across this
repo and the designer. Nothing in vexelray-gui, mainframe, vexplore or the template names any of them.
Generated code, which stays and is the one residual entry point:
Generator.java:713and:753emitshell.place(<lane>);:755emits<lane>.subscribe(Topic.of(..), ..)for each@Subscribe. TheFoldoverload is not emitted by anything.ComponentsWitnessTest(lines 79 to 82) andLongRunWitnessTest(81) assert the generated text containsshell.place(...), so they move with whatever the generated call becomes.
A third path the rulings did not name, and the one that changes the answer. A @Component constructor may
take a Placement, handed its own lane’s (Framework.PLACEMENT, LaneArg, Graph.java:258), and subscribe
on it by hand. Component’s Javadoc (line 53) documents it, and the processor test’s Worker
(VexelProcessorTest:812) uses it. That is the imperative path reached without Shell.place at all, so
deleting Shell.place alone leaves it open. Ruling 1 therefore has to cover it: an application component
takes no Placement for subscribing, and the injectable-root table loses the entry or narrows it.
What Placement was for besides subscribing: superseded(), now deleted (ruled 2026-10-01). It asked “is my
current message obsolete?” and answered “is anything queued on this lane?”, which agree only when every message
replaces the last outright and the lane carries nothing else, and nothing said or checked either. The framework
does not pretend to know more than it does: what a message means to the next one is the component’s to handle
while it drains, by draining more often and by raising @Subscribe(capacity). Its one caller was the untracked
designer (Viewport:331). With it gone nothing component-facing is left on Placement, so (c) is unblocked.
Test seams in the public surface (a legacy-sweep entry in its own right):
new Placement(name, bus, lanes)is public so the designer’sTheLastEditIsTheOneOnScreenTest(lines 101 and 124) andViewport’s constructor (221) can build one without a container. It is documented as a test seam.Placement.start()andonWake(..)are public for the same reason and are documented as not the application’s.
Callers by hand:
- This repo:
PlacementTest(eight sites, lines 49 to 202) andLivenessTest(55, 88, 122) callshell.place(..).subscribe(..). These test the mechanism, not an application, so they move to whatever the residual entry point is. vexelray-designer, whichCLAUDE.mdsays is untracked and nothing here is shaped to keep compiling:DesignerWiring:162(shell.place("compose")),Viewport:182, 221, 257(holds aPlacement, subscribesEDITSwithCOALESCE_LATESTat capacity one, which@Subscribecan express). It breaks on removal; the call is to leave it broken or port it. No ruling on that yet.- No hand-written use exists in the template,
mainframe,vexploreor either witness.
Docs that describe the imperative path as supported: Shell.place’s and Placement’s own Javadoc,
Component (18 to 20 and 52 to 55), Subscribe (13), Channels (29), Generator (542), and threading.md
T1.3 and §5.3.
Progress, 2026-10-01: (a) done. The residual entry point is Placements in -shell (of(shell, lane)
and mailbox(placement, topic, subscriber, capacity, policy)), public because the generated wiring lives in the
application’s package, and said in its Javadoc to be generated code’s. The generator emits it, the processor
test’s fakes and the two witness assertions follow, and Shell.place is now package-private, which is ruling 2.
Unit tests pass (278); the acceptance run is the check that a generated project compiles against it.
(b) and (c) done. superseded() is deleted, and a component constructor taking a Placement is now a compile
error (Graph.componentParameter), the generator no longer resolves one, and the processor test’s Worker declares
a @Subscribe instead. Still to do: (e) to (g) below. (d) is done: Placement’s constructor, start and both subscribe overloads are package-private, so only
Shell, Placements and the in-package tests reach them. onWake stays public because WakeSource requires it, and
name() and close() are public as before.
Order, smallest first: (a) move the generated call and the two witness assertions to the residual entry
point; (b) delete superseded() (done); (c) stop injecting Placement into components (done); (d) make Placement’s constructor, start and the subscribe overloads
non-public to an application (done); (e) the designer (not ported, ruled 2026-10-01: the up-to-date witnesses are MainFrame, Vexplore and the fluid simulator, and none of them uses Placement, Shell.place or superseded()); (f) move PlacementTest and LivenessTest (nothing to move: they are in the package and pass);
(g) rewrite the prose, then flip T1.3 to held in threading.md (done, held by
PlacementTest.anApplicationCannotPlaceAComponentOrAddAMailboxByHand). Rulings 1 and 2 are complete.
3. lane
Ruled already: an optional string, defaulting to Component.DEFAULT_LANE, whose grammar
([A-Za-z][A-Za-z0-9._-]*) excludes the default by construction. Accepted as recommended 2026-10-01: confirm and freeze, and
the grammar is contract, because a lane’s name is also its thread’s name and what a stall report calls
it. The main thread stays a separate colour for the reason architecture.md gives. Changes nothing.
4. Group = lane
T3.6 says a subtree is the default thread grouping, so that which grouping is late is answerable. With no
tree (ruling 8) there is nothing else for a group to be. Accepted as recommended 2026-10-01: for v1 a group is a lane, and
supervision, Overrun and reports are per lane. A separate group concept, if a tree later wants one, is an
optional attribute with a default, which is additive. Changes nothing today; it stops T3.6 being read as a
promise of a second axis.
5. The @Subscribe defaults
overflow = FAIL, capacity = 64, registered at construction and started with the lane. FAIL is T4.2, held,
and the stack’s own precedent. Accepted as recommended 2026-10-01: freeze all three, and write capacity into the contract.
Capacity looks arbitrary, but a full FAIL mailbox resolves through Fatal (T6.2’s note says exactly that),
which ends the process, so changing 64 later changes whether an application dies under a burst, with no
compile error.
Checked, then ruled 2026-10-01: a full FAIL mailbox halts the process, and that is the contract. Checked:
PumpedReg.doDeliver (atchung) runs the process-wide Fatal policy when a FAIL mailbox is full, and Faults
installs Fatal.HALT (exit 70, no shutdown hooks, no save), from the publisher’s thread, before the 10 s
watchdog threshold could matter. On the shared default lane it is reachable from a neighbour: a slow component
holds the lane and a fast one’s mailbox on it overflows. Ruled: it should fail and it should halt, because a
single missed message is potential data corruption and must never happen silently. A developer who knows a channel can lose a message
says so per mailbox, with overflow = DROP_OLDEST, DROP_NEWEST or COALESCE_LATEST. So the sentence in
TODO.md, “a component’s failure never stops the others”, is corrected: a component that
cannot keep up with an edge channel ends the application. What stays true is that a wedged component never
freezes the window, since this is a halt and not a hang. The default lane’s cost is stated as exactly this.
Scope, decided 2026-10-01: nothing more is built for this. Two things are wanted and both exist. It crashes
when a mailbox overflows: a full FAIL mailbox halts the process (above). Depth can be increased:
@Subscribe(capacity = ...) sets it per mailbox, and the generator passes it through. A mailbox dump and a
per-mailbox overflow handler were designed and dropped as more engineering than this is worth; the dump is
kept as a post-v1 TODO entry because it is additive and its format would not be frozen, and the handler is not
kept, since a developer who can tolerate loss already has DROP_OLDEST, DROP_NEWEST and COALESCE_LATEST on
the same attribute. The advice that survives is the cheap half: a sample belongs on COALESCE_LATEST (T4.1),
and a bound that is simply too small should be raised.
6. Supervision
Ruled: report only, and a policy the application can override. The three open points have answers in code
already. Accepted as recommended 2026-10-01: record them. The threshold lives in LivenessPolicy (10 s by default, the policy’s
to override). Lanes an application names are supervised the same way as the default lane, because the
mechanism is per lane. A report is what Context is handed (exit, interrupt, later restart) and never an
enum return. What is not done and is not a ruling: the main thread’s heartbeat, and surfacing Overrun per
lane. Both are build items.
7. The landing rule for processor checks
v1.md already says a new compile error is breaking, and may land after v1 only if it rejects something that was already a bug. Accepted as recommended 2026-10-01: confirmed, and made operational in two sentences. Before v1, every planned check lands enabled (T2.4 and T2.5’s, T4.4, T4.5), which is the reason to build them first. After v1, a check lands without an opt-in only if what it rejects is a race, a deadlock or a dropped message at runtime; anything stricter than that is behind a processor option and listed in the release notes. Applies the existing rule; changes no code.
8. The tree
T3.4 (components form a tree) is still open, and T3.6 and T6.3 hang off it. Its argument was that
supervision needs a structure to live in. Lanes turned out to be that structure: the watchdog supervises a
lane and names it, and nothing in the built model needed a parent.
Accepted as recommended 2026-10-01: v1 ships a flat set of components under an implicit root, and the tree is additive. The
extension test passes if the tree arrives as an optional attribute (parent, defaulting to the root) with
two consequences that apply only where it is used: a child shares its parent’s lane unless it names one, and
shutdown is child before parent (T6.3). A flat application’s behaviour under that default is today’s, so
nothing that compiled changes. Spiked 2026-10-02, and the count is zero (TODO, Spike the extension test): a throwaway parent changed no
existing signature and none of the existing processor tests, so the tree stays additive. With the recommendation accepted, threading.md’s own note applies: re-argue
§3.4 rather than keep it, and move T3.4, T3.6 and T6.3 to after v1. Not done yet: threading.md still lists them as before.
9. Is a @Subscribe off a component a thing?
The TODO lists “a @Subscribe that is not on a component (an extension or a macro, which is the whole point
of the entry)” as unbuilt. It has an unanswered question under it: a mailbox is drained on a lane, and an
extension declares none. Accepted as recommended 2026-10-01: it is not a thing. An extension is a @Component, with a lane like
any other, and the extension API is @Subscribe on it. That keeps the container is the only source of a
reference (T3.7) true for extensions as well, and it removes an item from the build list rather than adding
one. Reactive automation (a user script that reacts rather than drives) is a different shape, because that
script is not compiled into the application: it is the wire protocol growing an event stream, which is
additive. Say so, so the two are not built as one seam.
10. Offload as a request with a reply-to (proposed 2026-10-18, not ruled)
Where it came from. The capture rule (T2.5) was stuck between a heavy check and no check (build order, step 4),
because offload today is callbacks: GuiApp.offload(work, landed, failed) takes three lambdas, and Lanes.offload()
and Gui.offload() are Executors taking a Runnable. A lambda can close over a component’s mutable fields, and Java
has no type that refuses one that does. The user asked why this is not a reply-to queue. It can be, and then there
is nothing left to capture: the question goes away rather than being checked.
The shape. Slow work is a declared job, not a lambda, and its result is a message, not a callback.
@Job(topic = "file.read", reply = "file.opened")
final class ReadFile { // no mutable state: checked
ReadFile(FileSystem files) { ... } // injected like any part; must be Shareable or a declared service
Opened run(OpenFile request) throws IOException { ... } // runs on the offload pool
}
record OpenFile(Path path) {} // request: deeply immutable, as any payload
record Opened(Path path, String text) {} // reply: deeply immutable, carries what the caller needs
@Component
final class Documents {
@Publishes("file.read") ... // asks
@Subscribe(topic = "file.opened") // hears back, on its own lane, in order
void opened(Opened o) { ... }
}
- The request is published on the job’s topic like any message, so it is a declared send and in the channel
graph. The job runs on the bounded offload pool (
Lanes.offload()), not on a lane: it has no state to confine and no order to keep, which is why a pool is right for it (the reasoning under Nothing in the model covers work that outlasts a frame). - The reply is the method’s return value, published on
reply. Whoever subscribes receives it on their own lane, so the “landedalways runs on the main thread, and a callback written on the handler lane races” gap closes too. The reply carries what the caller needs to correlate it (above, the path), so there are no request ids. - A main-thread consumer hears back through the timeline door: the reply topic folded into a
CellbyKronBridge, which is build step 6 and whichkronometernow tests (KronBridgeTest.topicDrivesACellInADrivenLoop).
What the processor checks, all of it declarations (so the processor reads declarations stays true, and no Trees API is needed):
- the request and reply types are deeply immutable (
Shareable, the check@Subscribepayloads already get); - the job class holds no mutable state: final fields only, each
Shareableor a part the wiring provides; topichas one job,replyhas at least one subscriber (or the job is for its effect; see below);- the job’s reply is a declared send, so the blocking-cycle check (T4.4) sees it, which it cannot do for a lambda
today (TODO, a send from
workis invisible to T4.4).
What it settles. T2.5 is held by construction on the framework’s path: a job cannot capture, because it is not a
closure. The completion path (step 6) gets its API: the reply topic. Failure has one route, below, instead of a
failed callback.
What it does not settle, and the proposal says so.
- The raw executors stay reachable.
Gui.offload()andGuiApp.offload(...)arevexelray-gui’s, used below the framework (widgets, MainFrame, which is hand-wired), and an application holding theGuican still call them. The processor cannot see a call. So they become the documented, unchecked escape hatch, and the framework stops handingLanes.offload()to application parts. WhetherShell’sGuishould hideoffloadis the whatShellhands over decision, per accessor. - Failure. Ruled 2026-10-18: every failure is either declared and handled, or it ends the application. The
user’s words, and the reason is ruling 5’s: a failure nobody handles is a lost result, and a lost result that the
application runs on past is the silent corruption a full mailbox already halts for. So there is no framework
failure type and no
failedtopic. A failure a job expects is part of its reply, a type the application declares (record Opened(Path path, String text, Problem problem)), so the compiler makes the subscriber look at it, andrundeclares nothrows(checked by the processor), so javac makes the job catch what it expects. Anything else that escapesrun, anErrorincluded, goes to the process-wide fault policy (Atchung.fatal()), which halts. Already applied to components (2026-10-18): a@Subscribethat throws halts the same way rather than being logged and drained past, except while its lane is stopping, when the throw is the framework’s interrupt. TheGuiApp.offloadcallbacks arevexelray-gui’s escape hatch and keep theirfailedroute. - Cancellation (a newer request makes an older one pointless) is the caller’s: it drops a stale reply while it
drains, as
superseded()’s deletion already ruled. A cancellation token is additive. - A job for its effect (write a file, reply nothing) is a
void run, and wants the same answer as a recipe run for its effect.
Why now and not after v1. Under ruling 7 a check that rejects code v1 accepted is breaking, and applications
written against v1 would write offload as lambdas into Lanes.offload(). Moving them later is the refactor v1 rules
out. Adding @Job alone is additive; withdrawing the executors from application parts is not, so that half is the
part to rule on before the freeze.
To rule on: adopt this as the v1 offload API (@Job, the reply topic, and the executors withdrawn from
application parts). Failure’s shape is ruled (above).
Build order once the rulings are taken
Each step decides what the next bakes in, as v1.md orders the whole.
- Remove the imperative path (rulings 1 and 2), and make T1.3 held. A deletion, so it comes first.
- The cycle check (T4.4) is done, 2026-10-02, on the data
Channelsalready had: nodes are components, an edge is a declared send to a mailbox, and a cycle with a blocking edge is a compile error. The graph is exposed as generated data (the rest of T4.5), 2026-10-19, now that something consumes it: the generated wiring’sBUS_GRAPH, in vexelray-inspector’s bus-graph text (docs/bus-circuit.md), which the Inspector draws as a circuit. The stated limit stands and is on@Publishes: only a declared send is in the graph. - The copier (T2.4). The check half is done, 2026-10-02 (
Shareable): a@Subscribepayload must be deeply immutable, and anything else is refused, naming the first thing that stops it. The generated copier is parked as additive. It would annotate a record and have the processor emit the copy, so collections and mutable classes could cross; its annotation’s name is a Maven-level public name and is undecided. - The capture rule (T2.5), which is the only one that wants a method body. Not decided. This page first
said to make capture impossible by construction, with an offload API whose function cannot close over
anything. That is wrong: offload is an
Executorand aCallable, and Java has no type that refuses a capturing lambda. What is left is a Trees-based check that resolves each captured name (accurate, and the heaviest piece in the processor) or a rule the reader keeps. See TODO, Decide now. Two promises need a method body. Proposed 2026-10-18 instead: section 10, which makes slow work a declared job with a reply topic, so there is no lambda to capture with and the check needs only declarations. - Supervision’s missing halves: the main-thread heartbeat that knows a dialog is up, and
Overrunper lane. - The completion path: route offload completions into the timeline through
KronBridge.
Steps 2 to 4 are where new compile errors come from; all of them land before v1 under ruling 7.
What this page deliberately does not decide
The handler lane’s bound (it waits on a census of what still blocks on a handler), kronometer’s own offload,
and a restart policy beyond relaunching the process. None of them changes what a component is.