VexelRay
Framework docs

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:

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:

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):

Callers by hand:

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) { ... }
}

What the processor checks, all of it declarations (so the processor reads declarations stays true, and no Trees API is needed):

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.

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.

  1. Remove the imperative path (rulings 1 and 2), and make T1.3 held. A deletion, so it comes first.
  2. The cycle check (T4.4) is done, 2026-10-02, on the data Channels already 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’s BUS_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.
  3. The copier (T2.4). The check half is done, 2026-10-02 (Shareable): a @Subscribe payload 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.
  4. 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 Executor and a Callable, 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.
  5. Supervision’s missing halves: the main-thread heartbeat that knows a dialog is up, and Overrun per lane.
  6. 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.