Architecture
The thesis
Spring Boot’s contribution was never dependency injection. It was the claim that the wiring most applications write by hand has one right answer nearly always, and that the answer should be overridable rather than retyped.
This stack has already reached the point where that claim is testable, and has already written the
evidence down. mainframe-template’s scaffold — the file a new VexelRay application starts from —
is ~350 lines of wiring whose own Javadoc says:
The application edge — the part a client of vexelray-gui has to write for itself… Every one of those is a decision the framework deliberately does not take on an application’s behalf, which is why they are all in one file rather than scattered.
Four applications now hold near-identical copies of that file. They have drifted from each other in
ways that are individually small and collectively the argument for this repo: the flags parse in
different orders, --capture’s optional path is read three different ways, and two of them carry a
comment warning the reader not to open Settings twice, because nothing stops them.
So the target is not “Spring Boot, ported”. It is: the decisions in that file, taken by default, checked at compile time, and overridable one at a time.
Why none of Spring Boot’s mechanisms come along
GraalVM native-image removes exactly the four capabilities Spring Boot is built on — a runtime classpath, reflection without prior declaration, runtime bytecode generation, and the freedom to spend a second or two on startup. Spring Boot 3’s AOT engine exists to claw back the first three, and it is a large piece of machinery devoted to undoing a design decision.
Starting after that decision is cheaper. Every mechanism has a compile-time replacement, and the
house already uses this pattern — atchung/elektroq generates message codecs from annotated records
and states the payoff as “native-image needs no reflect-config.json”.
| Spring Boot mechanism | Why it cannot come | Replacement here |
|---|---|---|
| Runtime classpath scanning | No classpath in the binary; costs the startup you bought native-image for | Processor walks the annotated set while compiling |
| Reflective instantiation | Needs reachability metadata per bean | Generated new calls |
| CGLIB / JDK proxies | No runtime bytecode generation | Generated decorators, or nothing |
@ConditionalOnClass |
Class.forName at runtime |
ConditionalOnType — Elements.getTypeElement at processor time |
@ConditionalOnMissingBean |
Registry state during population; hence ordering rules | Default — every provider visible to the processor at once, so there is no order to depend on |
| Reflective property binding | Metadata for every property class | Setting — generated calls to Settings’ typed accessors |
| Actuator over HTTP/JMX | Wrong shape for a desktop app | A diagnostics overlay; vexelray-diagnostics and vexelray-gui-automation already exist |
The cost is real and worth stating once: every conditional is frozen at the application’s build. Adding a jar to the runtime classpath contributes nothing until recompilation. For a framework whose output is one native binary, this is not a cost.
There is no scan, at runtime or at build time
Moving a classpath scan from startup to compile time is not a fix, and it is the mistake this design is most likely to be talked into. A build-time scan is still proportional to the size of the classpath rather than to what the application uses, is still paid on every compile, and still leaves nobody able to say which jar contributed what.
What a processor actually gets is the elements of the compilation it was handed —
RoundEnvironment.getElementsAnnotatedWith — which does not include annotated types sitting in
dependency jars. Everything else this framework reads, it resolves by name:
| Needed | Obtained by |
|---|---|
| the application’s own components and configurations | getElementsAnnotatedWith, current round |
| a starter’s configuration in another jar | the class literal in @VexelApp(starters = …) |
| whether an optional type is present | Elements.getTypeElement("fqcn") — a lookup, not a walk |
@MainThread / @Default / @Setting on a compiled element |
read off the element once resolved above |
The house precedent agrees: atchung/elektroq’s ElektroProcessor reads
roundEnv.getElementsAnnotatedWith(Message.class) and nothing else — no getResource(CLASS_PATH, …),
no ServiceLoader, no class-file walk.
Naming starters as class literals is what makes this possible, and it pays for itself: javac resolves
them, so a renamed or absent starter is a compile error rather than a component missing at
startup. It is also the house position on implicit defaults, stated in TextEditorApp: “a default
is not a choice anyone can read.”
One caution taken from the same precedent: ElektroProcessor writes its registrar at a fixed
FQN (sibarum.elektro.queue.generated.ElektroRegistrar), which collides if two modules on one
classpath both generate one. The wiring class here is named after its @VexelApp type
(TextEditorAppWiring) for that reason.
The framework’s own defaults are handed back, not backed off
Default is the back-off rule for providers, and the plan was for the framework’s own input backend and
clipboard to become @Default providers in a starter, so that an application’s @Provides would back them
off like any other. They are not, because the framework’s answer has to exist without a wiring that says
anything: a hand-written Wiring is still supported, and one that did not name the starter would start
with no input. A @Default in a starter and a fallback in -shell would then be two definitions of the
same default, kept in agreement by hand.
So the default lives in -shell, beside a setter that replaces it — Shell.appearance, Shell.input,
Shell.clipboard — and the processor hands a provided value to that setter, the same way the look always
worked. What the processor adds is the timing: each setter is accepted only up to the phase before the
framework reaches for its own (CONFIG, TREE and WINDOW respectively), so a provider built later is a
compile error naming the phase. The table of these is Framework.HAND_BACKS, and FrameworkTableTest holds
it against the setters’ own phase gates.
Default keeps the case it was written for: a starter’s provider that an application may back off, where the
framework does not consume the value itself. AutomationStarter is the first of those — -shell cannot name a
Driver, since -automation depends on it, so there is nothing to hand one back to. An application is driven
because it depends on -automation and names the starter: not a @ConditionalOnType guard that makes the
dependency the whole decision, because that is the add-a-jar-get-behaviour arrangement starters gives up on
purpose, and a starter named and missing is a compile error, so the two cannot drift apart silently.
What Spring has no answer for
These are the parts that are not a port of anything.
1. Phases — construction order is a correctness constraint, not a scheduling detail
Spring is free to instantiate in any order the graph allows. Here the graph is not the only
constraint: several seams are valid only inside a window of time, and every one of those windows is
currently a comment in a hand-written main, enforced by nothing.
Phase turns them into a type. A component never declares its phase — it is inferred as the
latest phase of anything it depends on, so the phase is a consequence of the code rather than a
second thing to keep in agreement with it. What the processor rejects is a backwards dependency.
| Phase | The constraint, and where it was already written down |
|---|---|
CONFIG |
One Settings per application. “two instances over the same file each hold their own copy of it, so the second one to save would drop whatever the first had added” |
MODEL |
“everything the application knows is in Model”; the edge “holds no state of its own” |
GUI |
Theme: “a role resolves at the moment a widget writes a prop”. Clock: “a widget that animates is handed its timing at construction” |
TREE |
Buildable before a window exists — which is what makes headless capture possible at all |
WINDOW |
The main-thread boundary. GuiApp exists; a window handle is real |
ATTACH |
Everything needing the handle: input, clipboard, chrome controls, window memory, dialogs, automation |
RUN |
The loop, and the reverse-order shutdown after it |
Note what the GUI and CONFIG failures have in common: they are silent. A half-themed window,
an animation that never runs, a preference that saves and then vanishes. None throws. This is the
class of defect that survives a green suite and is found by eye months later — and the reason a
container here earns its keep on correctness, not on convenience.
2. Thread affinity is a type-level concern
vexelray-gui/CLAUDE.md lists this under constraints that are not visible in the code:
Vulkan, the window and present stay on the main thread.
MainThread makes it visible. One rule, one direction: a main-thread value may not be injected
into anything that is not itself main-thread. A worker-safe component asking for a GuiApp is a
compile error naming both types, instead of a Vulkan call from a worker that happens to survive
testing on one driver. The inverse is allowed — handing the render thread an immutable model violates
nothing, and requiring an annotation for it would put @MainThread on most of an application.
Two colours is the whole of it, and that is deliberate for now. @MainThread says main-thread or
not-main-thread, which is exactly enough to protect Vulkan and not enough to describe where the rest
of an application runs. The model it is a first instalment of — one component, one thread, one mailbox
— is The concurrency model below, along with what a third colour would have
to be before the processor can check it.
3. The frame loop is the lifecycle
A request-scoped container can afford a reflective dispatch; a frame loop cannot afford an
allocation. FrameHooks resolves stage order at build time into one flat Runnable[] walked by a
counted loop. Stages exist in the source; at runtime they have already been spent.
FrameStage is a closed enum of four positions, not @Order(int). Integer priorities make every
hook’s position a negotiation with every other hook’s, decided by numbers whose meaning lives
nowhere — and the bug that produces is one frame of latency, which is invisible in a screenshot and
nearly unattributable after the fact. The order and its reason are already documented on this stack:
Input first, then the clock: the tick returns with its batch complete, so anything an animation posts this frame is on the bus before
Gui.framereconciles it — the frame that presents a value is the frame that computed it.
That closed enum is the main thread’s frame and nothing else’s, which is a scope worth stating
before somebody reads it as the stack’s general answer to “when does my work run”. A component says
that with a Kronometer Rate, and the two do not compete: see the frame is the main thread’s, and a
Rate is everyone else’s.
A hook that throws is stopped, not fatal (ruled 2026-10-08). The first ruling let a throwing hook take
the loop down. The reasoning was that a loop that keeps presenting while part of the application has stopped
is subtly wrong, while a program that has stopped is at least honest, and that hooks are the application’s
own code, documented not to throw. The calculator disproved that assumption. Closing its settings window left
WindowMemory.poll, which is framework code, reading the bounds of a destroyed window. GetWindowRect
failed, and the application went down with the user’s tape in it. The loop is the one place where one part
failing takes every other part with it, so FrameHooks now catches a RuntimeException per hook, replaces
that hook with a no-op for the rest of the run, and hands it to the shell. The shell logs it once at ERROR
with the stack and records it through Diagnostics. An Error still propagates. A bare FrameHooks with no
listener still rethrows, because swallowing an exception with nobody to tell is silence. The try is free on
the path that does not throw, so the no-allocation rule holds.
That one crash had three causes, and each is now closed where it lives. That matters more than the ruling itself:
- The lifetime was the application’s to pair.
WindowMemory.watchheld the window, andforgetlet go of it, but nothing anywhere calledforget. Every earlier watch was on a main window, which only closes when the application does.WindowMemory.remember(key, spec, w, h)now wires both ends from the spec’sonCreatedandonClosed. It is the same moveTitleBar.commandsmade for a bar’s controls (“forgetting either is silent”) andGuiApp.releasemade for textures: a lifetime only the framework can see is the framework’s to end. - A dead window threw.
Win32Windowalready knew it had been destroyed, but its geometry did not ask. Every query now answers with what the window last was, every command does nothing, a secondcloseis a no-op, and onlycreateVulkanSurfacerefuses.NativeWindow’s class comment states this as the contract. - Nothing exercised it. The component witness (W2) now opens a remembered tool window, closes it with the control its title bar’s X uses, and does that twice. It then checks that the run’s log holds no contained hook and no uncaught exception.
4. Deadlines and wakes are contributed, not enumerated
Render-on-demand means the loop parks, and parking correctly requires knowing every deadline the application holds. Today that is one expression:
app.pacing(() -> Math.min(krono.kron().sleepTimeout().nanos(), memory.nanosUntilSettle()));
gui.onWork(app::postWake);
krono.kron().onWork(app::postWake);
Correct, and the wrong shape: it must be edited by whoever adds the next deadline holder, and the
failure when they forget is that the loop parks past a deadline. Nothing throws — the window
placement is not written, or an animation resumes late, on a machine that was idle at the time.
DeadlineSource and WakeSource make a component bring its own; Pacing reduces over whatever the
graph produced.
The GUI’s own record of the wake half: five missing wakes shipped past a green suite, because a test that draws its own frames cannot notice a wake that never came.
5. Chrome placement is the framework’s; chrome appearance is the application’s
This direction is easy to get backwards, and vexelray-gui/docs/automation.md §7 already argues it:
A title bar is window chrome, and chrome belongs to whoever owns the window. An application contributes identity — its title, an icon as identity — and never controls… The moment one app puts its own button in the caption, the strip is app-addressable, and no framework instrument can rely on the space existing or on its meaning being the same from one window to the next. “Screenshot this window” is only free if the framework owns the place it lives.
So the framework builds the TitleBar, supplies identity from @VexelApp, and hands it real
WindowControls at ATTACH — the seam that exists because “a native window cannot photograph
itself… only GuiApp owns a window’s render bundle, so only GuiApp can make working controls.”
The look stays entirely the application’s. Appearance.theme() is whatever the application says,
including a Theme of its own construction with its own nine-anchor Palette, Shading and
Relief; the chrome reads that same theme rather than one of its own. An application that wants to
draw things differently is not fighting a default — it is supplying the only value there is.
Instruments follow §7’s rule: the framework supplies a default set and a window may take fewer or
none. Free to enable, not present unconditionally. The default is WindowInstrument.standard() with
one substitution: the screenshot is vexelray-gui-nfd’s SaveScreenshot, which opens a native save
dialog parented to the clicked window, rather than core’s, which files a timestamped PNG in the working
directory. A desktop application’s working directory is wherever it was launched from, and nothing on
screen names it, so a person clicking the camera could not find the picture. Core’s stays for a script.
Two knobs an application will want are not yet expressible, and neither gap is the framework’s to close:
- Corner radius is a per-node prop (
Node.corner(Length)) with no theme-level default. Making it convenient meansThemegaining a radius anchor besideRelief, invexelray-gui. Backwards compatible if added as adefaultmember. - Light direction is hardcoded in the generated shader —
CanvasShader.java,unit top-left light dir, baked into the SPIR-V. Making it app-controllable means a uniform threaded through the SDF uber-shader invexelray. That is the one change here that touches the path every pixel in the stack goes through.
6. Scopes are application / window / frame
Not singleton / request / session. Window scope is already real and already hand-maintained — the
text editor binds the clipboard to every window in a loop, and remembers each window under its own
WindowMemory key.
The concurrency model
The reasoning is here; the rules it produces are in threading.md, stated normatively and each carrying whether anything enforces it yet.
The intended shape of a VexelRay application is one component, one thread, one mailbox, and it is
the most load-bearing constraint in this design that has never been written down. It is why
atchung-core exists — its own README routes “one process, many components (input, graphics, GUI,
workers meeting on a bus)” to the bus — and it is why tactroller does not expose a poll but publishes
every device frame onto a topic. Until now this document’s only statement about threads was
§2, which is @MainThread and the two-colour rule.
That is precisely the failure @MainThread was created to fix. vexelray-gui/CLAUDE.md files the
main-thread rule under constraints not visible in the code, and the annotation’s own Javadoc says
why that was worth fixing: it was “true, load-bearing, and enforced by nothing but the reader’s
memory.” The concurrency model has been in the same condition, one level up.
Sequence matters. The processor generates the wiring, so whatever @Component means when the
processor is written is what gets baked into every generated application. Its contract today is a DI
one — “a container-managed singleton, constructed once, by its one constructor, with its parameters
supplied” — and constructor injection hands component A a direct reference to component B, which is
the thing a mailbox exists to prevent. Writing the processor first would freeze a vocabulary the
concurrency model then has to fight. So this is settled first, and the mismatches it names are part of
the processor’s brief.
The mapping is static, and that is the load-bearing simplification
A component lives on a thread that is not the main thread. Whether each component gets its own thread or several share one is decided in the wiring and never at runtime: there is no work stealing, no placement decision, and nothing to tune while running. This is not a pool and not a scheduler.
That one restriction is what makes everything below fall out.
The processor can emit the whole thing. Static assignment means thread construction, the component-to-thread mapping and barrier participation are all generated code, with no scheduler in the binary. It is the same trade the container already makes everywhere else — a compile-time replacement for a runtime mechanism — so it fits the thesis rather than straining it.
Platform threads, not virtual ones, and the argument is measured rather than stylistic. Virtual
threads exist to multiplex many blocking tasks onto few carriers dynamically, which is exactly the
thing being declined. More sharply: Kronometer’s kernel already owns the virtual-thread scheduler in a
desktop application that follows its advice. kronometer/docs/architecture.md §3.1 recommends
-Djdk.virtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=1 — worth 3× on
the baton handoff — and then states the sharp edge of that decision:
The precompute pool and
offload()must run on their own executors, never the kernel’s carrier. One carrier means one runnable virtual thread. Pure evaluation scheduled onto that carrier would not merely be slow, it would deadlock against the serialization that makes the baton fast.
Component work is in the same category as that precompute pool. Placing components on virtual threads would put every component in the application onto the one carrier the baton needs in order to be answered — so the choice is not “platform threads are a little simpler” but “virtual threads are the documented deadlock”. A platform thread is outside that scheduler entirely. Pinning inside a Vulkan call would be a hazard for no benefit on top of that.
Placement is semantically neutral, and Kronometer is the reason. Order over effectful work is “a
total order, one baton, strictly at now” with “no two shreds can race”, and a Rate is “an
independent sampling grid over the timeline” carrying its own priority — the tie-break “for shreds
of different domains waking at the same moment”. So a component keeps its schedule and its ordering
wherever it is placed. Regrouping costs capacity, not meaning. And capacity is a reading rather
than a guess: slip is a debt, wall(m) = m + slip, and Overrun says which kind of trouble it is —
“a slip that drains is a hiccup, a slip that plateaus is a capacity problem, and a slip that climbs is
a system heading for a RESYNC.”
The component thread does not touch the timeline
This is the part the phrase “in sync with Kronometer” hides, and getting it wrong would make the model
unbuildable. KronBridge — the module that exists to join the bus to the timeline — states the
constraint plainly:
Two systems with incompatible threading models, which is the whole problem. The bus publishes on whatever thread published — that is what makes it fast — while the timeline is single-threaded by construction, because that is what makes it ordered.
and rules out the shortcut: 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.”
So a component thread never holds the baton, and never reads or writes a Signal, Cell or Effect.
What “in sync with Kronometer” means is a round trip through the seam the stack has already built:
| Step | Where it runs |
|---|---|
The component declares a Rate — its grid, its maxCatchUp, its priority |
The wiring, at compile time |
| The rate steps, and the step publishes to the component’s mailbox | The timeline, on the baton |
| The mailbox drains and the component does its work | The component’s own platform thread |
The result is published on a Topic |
The component’s thread |
The bridge folds that topic into a Cell, and the graph sees it |
The timeline, on the baton |
That last step is KronBridge’s stated purpose — a topic driving a cell is “the natural way live
input enters the graph (with horizon == now, as it should be)” — so a component’s output enters the
predictable world by the same door tactroller’s input does.
The cost is one drain period of latency, and it is already priced rather than waiting to be discovered: “input latency is bounded by the draining domain’s period… The frame of latency is the price of the ordering guarantee, and it is the same price every retained-mode GUI pays.” Naming it here is what stops it being re-litigated later as a defect of the component model.
What the framework owes the model, and what it already has
Two of the three obligations are seams that already exist, written for a single-threaded edge without anyone noticing they were the multi-threaded answer as well:
- The deadline is already covered.
Kron.sleepTimeout()is “how long a host may block before it should tick again — the whole render-on-demand condition, as one number”, composed over the timeline with its rate domains in it. The framework already registers that as aDeadlineSource, so a component whose rate is due in 20 ms is a loop that parks for 20 ms, with no new API. That matters more than it sounds: the timeline is driven from the main thread’s frame, so a parked window would otherwise stop a 50 Hz component dead.DeadlineSource’s own Javadoc already lists “a component waiting out a cue” among its implementors. - The wake was a real gap, and it is now closed by construction. A component that finishes early and
publishes a result has produced work the loop cannot predict — which is
WakeSource’s definition — and no wake existed for it.gui::onWorkcovers a node mutated off the frame thread andkrono.kron()::onWorkcovers the timeline; a component’s own publish is a third path, and the symptom of omitting it is the exact one the GUI already paid for — a window that is responsive except for the interactions that happened to arrive that way. Each component mailbox owes aWakeSource, so aPlacementis one: the container connects it when it starts the component, and a component publishes throughpublished(). The obligation is no longer something the processor has to remember to emit — what it will emit is the call site, which is a smaller job than the registration and a much smaller one to get wrong. The designer’s hand-written component is the argument for doing it this way round: it met the obligation by accident, because announcing a phase wrote to a node and a node mutation wakes the loop, so a real wake was hanging on an unrelated line of reporting. Overrunwants surfacing per thread. Slip is a property of the one timeline and cannot be made per-domain, but which grouping is late is a question a static mapping makes answerable — so an overloaded grouping should name itself rather than show up as a global plateau.
Measurement is still worth doing, and its purpose is narrower than it looks: a synthetic barrier
against Kronometer at the N a real application reaches says where the tail starts to hurt, since frame
jitter is max() over participants rather than the mean. That chooses a sensible default grouping.
It does not decide the architecture, because the reasoning above already did.
What is actually wired today
The model above is the design. This is an inventory of the code as it stands, taken so that the gap between the two is a readable distance rather than an impression. Everything here is a grep over the four modules’ main sources.
Both of it have since been built, and the paragraphs below are struck through where they have stopped being true. The container owns the application’s bus —
Shell.bus(), handed to the framework’sGui— and now its threads as well:Shell.lanes()is the handler lane, the offload lane and the component threads, and the generated wiring puts each@Componenton one of the last with its mailboxes and its wake. The two constructor arguments named at the end of this section are both taken. This section is now a record of where the model started rather than an inventory of the present, which is what it was written to become.
The framework contains no concurrency primitives at all. Zero occurrences of Thread, Executor,
java.util.concurrent, volatile, synchronized or Atomic. The only matches for those searches are
the string @MainThread inside Javadoc.Lanes is in -core and Placement in -shell, and the
module split held under the change: Lanes is pure JDK, because -api and -core stay JDK-only and a
pool is exactly the kind of thing that keeps the container testable with no GPU; Placement is in
-shell because a mailbox is atchung’s and that dependency edge already falls there. Its thread-aware API
surface was one Runnable:
| Seam | What it says about threads |
|---|---|
WakeSource.onWake(Runnable) |
the runnable is “safe to call from any thread — that is its purpose” |
DeadlineSource.nanosUntilNextFrame() |
“Called on the main thread… must be cheap and must not block” |
FrameHooks |
“Not thread-safe and not meant to be”, and sealed before the first frame |
Disposer |
reverse construction order; no timeout, no drain, no start order distinct from it |
@MainThread |
inert — there was no processor, so the two-colour rule was enforced by nobody |
Two rows of that table have moved. Disposer now has a component half — Placement.close is drain then
stop, on a timer, and a placement’s start is a separate moment from its construction, which is the row’s
“no start order distinct from it” answered rather than restated. @MainThread stayed inert through all of
that, because it is the colour rule and the colour rule is the processor’s — and it has since moved too:
vexelray-framework-processor makes T2.2 a compile error, and marks GuiApp main-thread in its own table of
what the framework hands out, since GuiApp lives in a repo that must not learn this one exists.
The stack is already multithreaded, and none of the threading is the framework’s. The threading is
now the framework’s, which is the whole of what changed. Gui used to own an
Executors.newCachedThreadPool per instance and run every input handler on it. The applications make
almost no threads of their own: one daemon reporter in the editor’s FpsProbe, and none at all in the
calculator or the designer. So the concurrency an application has today is a property of how many
It is now a property of what the wiring placed, which is the sentence the
whole model turns on: the container builds one set of lanes, hands them to every tree it makes, and
Guis it happens to hold.Gui.close() shuts down only the lanes that Gui built itself — so a dialog closing cannot take the
application’s threads with it.
Atchung is named once, and only as a fabric. No — sibarum.atchung.* import appears anywhere in
the four modulesShell now imports Atchung, creates one in its constructor and hands it out as
Shell.bus(); an application no longer reaches a bus as shell.gui().bus(), which was that Gui’s
own. That is the whole of the change. No Topic, Pump, State, Backpressure or Fold appears in
any framework signature, so what the container owns is the fabric and not one thing published on it.
Beyond that the bus still reaches this repo only transitively through tactroller-atchung, of which
exactly one type is used — TactrollerInputBridge. elektro-Q is absent entirely, from the
framework and from all three ported applications.
Kronometer is never named either. No sibarum.kronometer.* import — only
dev.vexelray.gui.krono.KronoGui. The framework owns the clock’s lifecycle and none of its
vocabulary: it constructs it in GUI, ticks it at FrameStage.CLOCK, parks on
kron().sleepTimeout(), wakes on kron()::onWork, and hands it over through the single accessor
Shell.krono(). Rate, Settlement, Overrun and Moment appear in no framework signature.
Applications reach them directly instead — the calculator’s Motion imports Kron, Rate, Cell,
Curve and Animator, and its Model is built on Atchung’s State<T> and Committer.
Every window was an island, and half of that is now closed
new Gui() is this(Atchung.create()) — a private bus per Gui, and a private worker pool with
it. Nothing on this stack shared one. A calculator, which looks like a one-window application, ran two:
the framework’s main Gui, and the one Modals builds for the dialogs. The editor has that plus its
file drawer and its editor windows; the designer has that plus its viewport.
The seams for fixing it already existed, upstream:
public Gui(Atchung bus) // one shared fabric
public Gui(Atchung bus, java.util.concurrent.Executor handlers) // and who runs the handlers
The first is now taken, and it reaches less far than it first appeared to. Shell owns an
Atchung and VexelApplication builds its Gui on it, asserted headlessly by
OneBusPerApplicationTest. The dialogs do not join it, and neither does a second window’s tree —
see the constraint below, which was found by putting them there and watching an application stop.
One Gui per bus, and everything else on it
Gui’s topics are static: vexelray.gui.mutations is one name for every instance. So two trees on
one bus each receive the other’s mutations, into a mailbox bounded at 65,536 with
Backpressure.BLOCK — and a tree that is not being presented never drains it. The second Gui fills
up and then blocks the first one’s node setters permanently. An application that freezes after some
tens of thousands of edits, with no exception and nothing in a log.
It was found the hard way: putting the designer’s viewport window on the application’s bus stopped the
viewport marching at all, and moving that one line back brought it straight back. The framework had the
same defect for three commits, because Modals builds a tree of its own and the dialogs’ mailbox is
drained only while a dialog is on screen.
So the rule the bus is actually offering today:
| May share a bus | May not |
|---|---|
| Input publishers, workers, components and their mailboxes, anything that is not a tree | A second Gui |
Gui(Atchung)’s Javadoc now carries the constraint, where it previously carried an invitation. And
this is the ceiling on the wider ambition — one inspectable fabric carrying every application
semantic — because it is exactly what a second window is barred from joining. Lifting it means Gui
naming its topics per instance rather than per class, which is a real change in vexelray-gui and not
a line in this repo.
The second is now taken too, and what it cost is worth recording. Gui’s worker pool was a field
initializer, so a Gui built a newCachedThreadPool whatever it was handed and Gui.async submitted to
that pool rather than to the executor. Passing a handler executor therefore redirected handlers and did
not remove the per-Gui pool — so one bus was not one thread, and this was correctly filed as an
upstream change rather than another argument at this call site.
It was one change upstream and it closed three things at once. Gui now takes both lanes, null means
build that one and close it, and close() shuts down only what that Gui built. The lanes are named
apart, because “worker thread” meant both of them; and the offload default is bounded, because the
unbounded one answered a wedged filesystem mount by spawning a thread per blocked call. Two of those
three were filed here as upstream rules that this repo could not fix, and they turned out to be one
problem rather than two.
So the distance between this section and the model above is no longer the substrate or the seam.
Pump, State, Fold, Backpressure, Rate and KronBridge were all built and none of them was
reached from here; a placed component reaches the first four now. The container owns the bus a component
publishes on and the threads it runs on, which is where the model starts. What remains is the
colour rule — which is the processor’s, and is the one thing on this list that a runtime object cannot
be made to hold.
The first component, written by hand
vexelray-designer’s shader composition now runs as one: Mailbox<T> — one platform thread, one slot,
latest wins — with Composed as the immutable result it publishes. Nothing was extracted here, on
purpose. What two of these have in common is what the framework should own, and one of them is not a
census.
It found a defect, which is the answer to whether the exercise was worth doing. Viewport.show
hand-rolled the same mechanism — an AtomicInteger revision, a submit to Gui’s pool, and a check
that this task was still the newest — and made the check before composing rather than around it. So
an edit that passed it and then lost its place kept composing beside the edit that replaced it, and
both wrote five independently volatile fields:
reports, in the order they finished:
6,188 B in 17 ms the sphere — asked for last, finished first
2,662,152 B in 171 ms the heavy design — superseded after 40 ms, composed anyway, and landed last
The viewport settled on a design the user had already replaced, and the frame pump could build a pipeline from one compose’s modules and another’s push size. A slider emits an edit every few milliseconds and a compose takes tens, so that is the ordinary case of dragging one.
Four things the hand-written version turned up that the model above does not say:
- The mailbox is the bus’s, after atchung grew the one thing it was missing.
COALESCE_LATESTwas always the right policy and its capacity is one; whatPumphad no answer for was waiting, since a pump is drained by an owner that already has a wake — a frame loop.Pump.drain(long)is that answer: it parks on a pump-level monitor, holds no mailbox lock while parked, and costs a publisher one field read when nobody is waiting.Pump.wake()ends a park for shutdown. There is deliberately no untimed form, because a drain that waits forever on a thread that also publishes is a hung application rather than a slow one. The designer’s hand-writtenMailboxwas deleted rather than moved: an edit is now an ordinary message ondesigner.viewport.edit, which a debug port can watch and a script can send — which a private queue could never have been. - The wake obligation is real, and was being met by accident. A compose landing woke the loop
because announcing the phase writes to a
Nodeand a node mutation wakes the loop. A real wake hanging on an unrelated line of reporting: stop announcing and the window parks. It is nowshell.wake(viewport::onWork), which isWakeSourceused as written. - Drain-then-stop is an edge rule, not a universal one. It exists so nothing downstream loses what it cannot reconstruct. A coalescing mailbox holds a sample by construction, and the sample it holds at shutdown is a picture nobody will see — so this one stops.
- Coalescing cannot cancel work already started, and the framework does not say whether a message is
obsolete. Nothing here interrupts a compose in flight. This note used to describe a
superseded()a component could ask before publishing; it was deleted because it answered “is anything queued on this lane?” to the question “is my message obsolete?”, and the two agree only for a lane with one mailbox of full replacements. What one message means to the next is the component’s to settle while it drains: drain more often, and raise the mailbox’scapacity.
Three mismatches this leaves for the processor
None of these is a defect today. Each is a place where a vocabulary written for one thread has to grow one more axis, and writing them down is what stops the processor freezing the current shape.
| What | Why it does not fit yet |
|---|---|
@Component is a DI contract, not an actor one |
Constructor injection hands A a direct reference to B. Under this model most parameters should resolve to a Topic or an address, not to the object |
@MainThread is two-coloured; the model needs one colour per thread |
Main-thread versus worker-safe cannot express “confined to this component”, so a graph that passes today’s check can still be two worker components racing. Because placement is static, the colour of a value is the thread it was placed on — known while compiling — so the processor can allow a direct reference between two components sharing a thread and reject one that crosses, alongside a shareable set (immutable, Versioned, State<T>). Dynamic placement would have made that check undecidable; this does not |
FrameHooks is the barrier’s degenerate case |
A flat Runnable[] walked on one thread, “not thread-safe and not meant to be”, is the N=1 answer. The model wants release-at-tick, drain, await quiescence, reconcile — and saying so in the file is what stops its no-allocation rigour being defended into a shape that cannot grow |
The vocabulary, decided before the processor emits anything
The three mismatches above say what does not fit. These are the decisions taken about what the annotations mean, settled here rather than discovered in generated code, because the processor bakes whatever they mean into every application that compiles against it. Each one narrows the vocabulary, which was the useful surprise: the question that moved them was not what else should the container do, but what is the container’s business at all.
@Component is a thread and a mailbox. Not a container-managed singleton — that is @Provides. A
component is the actor: it owns a platform thread, it has an inbox, and it is reached by publishing
rather than by holding. Everything that has one is constructed before the loop runs, which is what
makes the placement static and is the simplification the whole model rests on. A worker thread that
allocates resources and takes no mailbox is not a component, and neither is anything built per window
— the designer already builds its viewport window in TREE rather than resolving it from a container,
and that is now the rule rather than an accident of how that window happened to be written.
This settles the first mismatch by choosing the actor contract over the DI one, and it settles
§6 as a side effect. Window scope is real and stays
hand-maintained; what it is not is a component scope. There is no @Component lifetime shorter than
the application’s, and so nothing about scope for the processor to track.
@Provides returns an interface, and a concrete class is a compile error. The cheapest check in
the whole brief — a return-type kind test at the declaration, no graph analysis behind it — and it
buys the one thing that is expensive to add later. Generated code can swap what it constructs without
touching a call site, but only if the call sites were written against something that can have a second
implementation. Front-loading an actual proxy instead would cost more than it saves: it would hide the
crossing the colour rule exists to catch, since a reference through a proxy always looks local, and it
would turn what should be a compile error into a message delivered at runtime. An interface costs
nothing at runtime and keeps the slot open.
It binds an application’s own types, and not somebody else’s. Taken literally the rule forbade the
case @Configuration exists for: its own Javadoc names Tactroller, Clipboard and Settings as what a
provider is the recipe for, and all three — like GuiApp and Gui — are public final class. The rule’s
whole purpose is that a call site can be handed a second implementation, and for a final class from
another jar the application cannot write one; wrapping each in an interface of its own would be ceremony
in service of a slot nobody can fill. So the processor holds the rule for types compiled from source in
this build and exempts a type that arrives as a class file. That is a clean distinction only because the
build is always a clean one — a module’s own types are all source in the compilation that checks them, so
a class file is always somebody else’s.
And public ones, not package-private ones. Generating the template’s wiring found the second edge. Its
Model and Ui are the application’s own concrete classes, and neither a component nor a value, so the rule
as it stood would have made the witness wrap each in an interface nothing else implements. The same argument
exempts them: a call site that could be handed a second implementation has to be able to see the type, and
outside its package nobody can see a package-private class — swapping it is always the application’s own edit.
A record stays refused whatever its visibility, because that objection is that it is a value.
A value is not the container’s business. The question that made the other two fall out: why would
an instance of a record need dependency injection? It does not. A record has nothing the container
provides — no disposal, no phase ordering beyond its own constructor arguments, no behaviour to swap —
and this framework already agrees in code. Launch, AppInfo and Appearance are reached as Shell
accessors rather than injected, and the one thing an application might want from them — a different
answer than the default — is configuration: @Setting, or an attribute on @VexelApp. So a record
behind @Provides is itself the smell, and the rule above needs no exception carved for one.
The three are one test applied three times, which is why they are worth stating as a set:
| Ask | And it is |
|---|---|
| Does it have a thread and a mailbox? | a @Component — an actor, application-scoped, built before the loop |
| Does it have behaviour worth swapping, and a lifetime? | a @Provides, returning an interface |
| Is it a value? | not the container’s; a Shell accessor if anything needs to reach it |
One correction this forces, and it is to the table above. The second mismatch argues the colour
rule is decidable because “placement is static… the colour of a value is the thread it was placed
on — known while compiling.” Static it is, in the sense that it is decided once and never changes
afterwards. Known while compiling it is not. A placement was a call in a wiring method body —
shell.place("compose"), in the designer’s tree — returning a Placement at runtime, and a
processor reads declarations rather than bodies. That call is no longer public (see T1.3). The two senses of static carrying that sentence are
not the same sense, and the check it promises cannot be run against the code as written.
Three ways out, and one of them is in keeping with the rest of this document. Reading wiring bodies
contradicts there is no scan. Leaving it means
thirteen of threading.md’s rules stay enforced by the reader’s memory. So placement belongs on the
declaration — @Component(lane = "compose") or its equivalent — which makes the annotation that does
not exist yet a precondition of the colour checker rather than an output of it.
It exists now, as exactly that: lane is a member of @Component, and T2.3 is a compile error
naming both lanes. Required at first, defaulted since 2026-09-29: the original argument was “a default is
not a choice anyone can read”, and it lost to the ruling that multithreaded-by-default has to be cheap. A
component naming no lane shares Component.DEFAULT_LANE, one thread that is not the main thread, so the
thread count does not grow with the component count. The default is now contract. When §3.4’s tree lands,
a child shares its parent’s lane is the other default worth arguing against it. A string, and safe as one,
which is not a contradiction of the paragraph below: the only thing two lane spellings agreeing permits is
a direct reference, so a misspelling can deny one and never grant it, and the error it produces prints both
spellings side by side. A misspelled main thread would decide what may touch Vulkan.
And that annotation does not absorb @MainThread. Both are confinement colours, so the obvious
economy is one axis with the main thread as a lane like any other — lane = "main". It is the wrong
economy, and Lanes’ own Javadoc says why before the question is asked:
Three of the five lanes live here: the handler lane, the offload lane, and the component threads… The other two are not this class’s to make — the main thread is the one the process started on, and the timeline is single-threaded by construction inside Kronometer.
The main thread is not a lane in the sense the others are. It is not minted, it cannot be closed,
there is exactly one of it, and what it may do is different in kind — Vulkan, the window and present
live there and nothing else may. Folding it into the lane axis would make a string the
discriminator for all of that, and a string that has to be spelled right is one that compiles when it
is spelled wrong. That is the same objection this document already makes to qualifiers on @Provides,
applied to the rule with the most to lose by it.
The separation costs no extra rule, which is what makes it cheap rather than merely defensible. It
is tempting to think two colourings need a third rule for the boundary between them — whether a
component on one lane may hold a @MainThread value. They do not, because the crossing rule is already
one rule for every lane and it is not about colours at all: behaviour is reached by publishing, never
by holding, and a value crosses only under the rule in what may cross a
lane. A component that holds another’s state is
the thing the mailbox exists to prevent,
whichever lane either of them is on. So the boundary between the two colourings is the same boundary
the model already had, and keeping them apart adds an annotation rather than a rule.
What may cross a lane, and what crossing does to it
threading.md’s T2.4 said the shareable set is closed — immutable values,
Versioned<T>, State<T> — and left open how an application declares a type of its own. Reading the
code sharpened the question before it answered it. The set is not closed, because two of its three
members are generic wrappers:
public record Versioned<T>(T value, long version, long timestampNanos) { }
State<T> and Versioned<T> are shareable only as far as T is, and State’s own Javadoc says what
T must be in a parenthesis — “the state type (should be immutable)”. Unenforced prose. So the rule
as written admits State<MutableThing>, which is a race with a blessing on it. There was no house
precedent to inherit either: elektroq’s processor validates package structure and code generation and
says nothing about what a payload may contain.
The rule, and it replaces the set. 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. Immutability stops being the thing that has to be proved, because copying makes mutability irrelevant. That is the actor model’s own answer, and it is why Erlang has never needed a shareable set.
One elision, which is not an exception. Sharing a deeply immutable value is indistinguishable
from copying it — no observation separates them — so the copy may be skipped exactly then, and
Composed or a settings record crosses without being duplicated. That is what keeps this one rule
rather than two tiers: the optimisation is invisible to the rule, which is the only kind a correctness
rule may carry.
The test is one a reader can apply without knowing this framework. Could it survive a network
hop? A Vulkan handle, a window, a Gui node, an open file: no. That is the same set which must never
cross a thread anyway, so the rule derives
§2’s restriction rather than sitting beside it — a value
that cannot cross a wire has no business crossing a lane, and the reason is the same one.
Serialization is the semantics, not the mechanism. Serializable is the obvious route and is
rejected. It is reflection: GraalVM needs serialization metadata per participating class, which is the
reflect-config.json this stack exists not to need, and nothing reflects is not a rule with an
exception in it. It is also far too permissive as a marker — ArrayList and HashMap implement it, so
it means “somebody once thought about persistence” rather than “safe to cross”. The mechanism is the
house one instead: annotate the record, and the processor emits the copier — elektroq’s pattern,
which is why that repo is named in CLAUDE.md as the precedent for this one.
That also disposes of the objection to an application-declared shareable, which would otherwise be the
one annotation in the vocabulary that can be wrong and still compile. An annotation the processor
trusts is a promise. An annotation that makes it emit the copy is a request, and it either compiles
or it does not — the same move @Component and @Provides already make.
Identity does not survive a crossing. After it, a == b is false. That is correct and intended,
and it is written here because it is the surprise: anything resting on reference identity across a lane
was resting on a shared heap, which is the thing being removed.
A consequence worth recognising rather than promising. If crossing a lane already has network
semantics, then a lane boundary and a process boundary are the same boundary, and moving a component
out of process stops being a semantic change — no calling code differs. elektroq has been sitting in
this stack with Actor, Conduit and four transports, reachable by nothing here. This rule does not
build that, and nothing below depends on it. What it removes is the reason cross-process would have had
to be a second model.
Nothing here touches the frame budget: a crossing happens off the frame thread by construction, and
FrameHooks.run and Pacing.nanosUntilNextFrame cross nothing.
What a full mailbox does, and where survivability actually lives
Settled before the first component rather than after, because a default chosen once something depends on it is not a choice. It divides in two, and keeping the halves apart is the whole of the answer.
A bus fault is the framework’s to answer, and the answer is still a halt. Atchung.onFatal is
process-wide and says “call it once, at the application edge” — which is VexelApplication, for
every application on this stack, and nothing called it. So the answer to what does this application do
when the bus cannot continue honestly was Fatal.HALT by inheritance: the right answer, and nobody’s
decision. Faults now installs one. The obvious framework addition — save the window placement first,
since the Disposer and WindowMemory.save are right there — is refused, and the reason is upstream’s:
“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. What the framework
adds is the sentence naming the application, and then upstream’s report and upstream’s exit code,
called rather than copied.
A wedged component is not a bus fault, and reading it as one would put survivability in the wrong
place. A component that stops draining is a mailbox filling up, and the answer to that is the
Backpressure chosen for that channel — which atchung has already reasoned out, per channel, by the
loss class of what it carries:
| The payload | Policy | Because |
|---|---|---|
| A sample — a pointer position, a window size, a clock reading | COALESCE_LATEST |
the next one supersedes this one, so dropping it costs nothing that could still have been drawn |
| An edge — a keystroke, a command, a tree mutation | FAIL, or BLOCK where the publisher can safely wait |
nothing supersedes it and nothing downstream can reconstruct it |
And the rule that falls out of it, which is the one a component author will meet first: “a channel carrying both classes 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.” So a component does not get one mailbox with one policy; it gets a mailbox per loss class, and the processor’s job is to make that structural rather than remembered.
What is still open is the part neither half covers: an application staying responsive while one
component is wedged. Coalescing keeps its publishers running and the loop alive, which is most of it —
but nothing yet notices that a component has stopped draining, names it, or decides what the rest of
the application does about it. That is supervision, and it wants Overrun surfaced per grouping
(above) before it can be built on anything but a timeout.
Disposer needs the matching answer too. An actor’s shutdown is drain then stop, with a timeout, and
a start order distinct from construction order — a mailbox must not pump before its publishers exist.
Not cosmetic: Backpressure.FAIL is the default and resolves through Fatal, so getting shutdown
ordering wrong kills the process rather than dropping a message.
The frame is the main thread’s, and a Rate is everyone else’s
FrameStage and Rate.priority read as two answers to “when does my work run”, and settling the
concurrency model means saying which is which before somebody makes one match the other.
They are not rivals once you look at what actually registers in a FrameStage: input::pump,
krono::tick and memory::poll are all framework-owned, and APP is an empty slot held for the
application. Four stages is not a scale anybody could need a fifth point on — it is the list of things
one thread does between waking and presenting. So the enum is the main thread’s frame recipe, and
its internal order is causal rather than ranked: CLOCK follows INPUT because the tick reads
what the pump delivered, not because it outranks it.
A Rate is the other thing entirely — independent grids over one timeline, with priority breaking a
tie when two come due at the same moment. That is where a component says when its work runs, and
no component enters the frame pipeline.
FrameStage |
Rate |
|
|---|---|---|
| Scope | The main thread, which is special because Vulkan makes it special | Every component |
| What the order means | Causal — each stage reads what the last produced | A tie-break between grids due at the same moment |
| Who declares one | The framework, four times; the application, in APP |
The component, in the wiring |
| Open to more positions | No. It is a recipe, not a scale | Yes. That is what a grid is for |
FrameStage’s argument against integer priorities wants a scope, not an answer. “An integer
priority makes every hook’s position a negotiation with every other hook’s” holds for an open hook
list, where unrelated parties pick numbers with no shared meaning. It does not hold for
Rate.priority, where one author declares a few grids whose relationship is real — physics before
render, because render displays what physics computed. Recording that distinction is what stops the
next reader collapsing one into the other, and there are two collapses to refuse:
- The four stages as four
Rates at priorities 0–3 loses the causality and makes “equal priority” both expressible and meaningless. - Everything as a
FrameStagecannot express independent rates at all, which isRate’s whole reason to exist — “an animation framework with one frame rate is a toy.”
The meeting point is APP, and it is narrower than it was
The two scopes meet where FrameStage.APP narrows from “the application’s own per-frame work” to
the main thread draining what a worker left for it. Its own Javadoc was already uneasy about the
wider reading — “the right place for reading a queue drained by a worker, and the wrong place for the
work the worker was doing” — and vexelray-gui names the same hook from the other side of the seam,
describing what an input handler does when its effect is neither a tree mutation nor a clock
operation:
it drops a request on one of the application’s own queues — a history to restore, a file to open, a preview to render — each drained once per frame from the host’s beforeFrame hook.
The designer is the worked example on this stack today. Its viewport marches on the GPU, and “a
VkQueue is not thread-safe, so a drag handler running on a worker must only move the camera and
raise a flag — pump() is called from the frame loop and is the only place the GPU is touched”, with
that pump() given exactly one home, one hook in APP.
And the framework registers nothing there, which is a correction to how this was first planned.
The obvious move — have the framework own a Pump on the Gui’s bus and drain it in APP — is wrong
for a reason the GUI already documents: Gui owns a pump on that bus and drains it inside
Gui.frame, folded by cell and lossless, with navigation arriving on the same mailbox so that
everything enters the tree “at the same point in the frame the tree’s own edits do — before the
drain, never in the middle of one.” A framework hook draining that bus would be a second drain point
landing at a different moment than the edits it has to agree with. What gets drained in APP is the
application’s queues, and only the application knows what they are — so APP stays an empty slot,
and what changed is the sentence saying what the slot is for.
Notably, the designer’s witness is a flag and a GPU submission rather than an Atchung mailbox. The shape is the same and the plumbing is not, which is the evidence that the mailbox half belongs with the component model rather than ahead of it.
A wedged component cannot freeze the window
Status: layers 2 to 4 are built for component lanes (the group, the supervisor, LivenessPolicy, the watchdog and
the shutdown bound); the main thread is not watched, and the processor check is not written. See the TODO entry. v1.md states it as the
contract v1 freezes.
The point of running components off the main thread by default is that a component that loops forever, blocks or deadlocks takes only its own lane with it. The window keeps painting and taking input, the user can always close the application, and the application can end itself. Four layers, and each catches what the one above cannot:
- Isolation. Components without a
laneshare one default lane that is not the main thread; a component that names one runs there. The main thread is never a lane and never waits on one, which is why main-thread code may send only on channels that coalesce, drop or fail: a blocking send to a wedged lane’s full mailbox is exactly how a window freezes (the upstream-topics entry in TODO is a real instance). - Supervision. The default lane is watched always on, as
Stallswatches the main thread, and a lane that stops draining is reported by name. What happens next is a policy the application can override, handed back toShelllike the look and the input backend, told what happened and acting through a context object. The default is not contractual: today it reports and then exits, and the target is a main-thread fallback offering the user a restart. A future default may be better, and may not weaken the guarantee. - Reaching the threads. A lane’s thread runs inside its own
ThreadGroup. Any thread the lane’s code creates joins that group without being told to, andThreadGroup.interrupt()sweeps all of them at once, so there is no tree of children to walk and noThreadLocalto keep in step. Tested on the JDK this repo builds against (25.0.3, HotSpot): anew Threadmade inside the lane inherited the group, an executor created inside it ran its workers in the group, and oneinterrupt()reached the lane thread, the child and the pool task, while an executor created outside the lane was untouched. Not tested: native-image. - Ending the process. A watchdog thread that belongs to no lane calls
Runtime.halt, which the operating system finishes whatever the threads are doing. This is the layer that does not depend on anything cooperating, and it is why the other three can be best-effort. It ishaltrather thanSystem.exitbecauseexitruns shutdown hooks, which a wedged thread can deadlock.
What the interrupt can and cannot do. interrupt() sets a flag and wakes a thread from an interruptible
blocking call; a thread spinning in a loop that never checks it ignores it, and Java cannot safely stop such a
thread (Thread.stop is gone), so it can be abandoned but not reclaimed, and a spinning one burns a core until the
process ends. Components share no state, so an abandoned one can touch only its own. Killing the process is what
reclaims everything, which is why relaunching it is the restart the target state uses and restarting components in
place is left to an opt-in policy.
Where it does not reach: threads created outside the group, such as a library’s shared executor,
CompletableFuture’s common pool, a Timer, or JDK ForkJoin workers; threads the operating system creates for a
native callback; a thread blocked on a synchronized monitor or in a native call; and a wedged main thread,
where the window is itself unresponsive and the way out is the watchdog ending the process once the stall
threshold has passed, guaranteed and delayed. Work the framework itself runs (Lanes.offload()) is tied to the lane
that submitted it by a cancellation token, not by thread identity, since the pool is shared.
Later, additively: an opt-in build step that weaves an interrupt check into loop back-edges in component code, so that a lane is cancellable even from a loop that never asked. Native-image forbids weaving at run time and not at build time, so it is possible; it reaches only the classes it is applied to, and it is not needed for the guarantee, since the policy context takes new actions as new methods.
The absorption boundary
The decision taken for this repo is absorb the app shell: GuiApp shrinks to the render seam and
the shell moves here. Reading the actual dependency edges sharpens where that line falls.
Confirmed movable. GuiApp’s only two mentions of Settings are Javadoc prose — {@code
Settings}, not even {@link} — so there is zero compile coupling from the frame loop to the
settings store. WindowMemory already inverts its one dependency behind a seam (Desktop PLATFORM =
GuiApp::workArea). AppHome and Settings are JDK-only.
Not movable, and not shell. AppWindow, WindowSpec, Standing, OpenWindow, CloseGate and
ModalScrim are referenced 7–11 times each from inside GuiApp. They are its multi-window
machinery, not the application edge. They stay.
The blast radius is wider than the demos. mainframe-vexel-gui — a library, consumed by
text-editor-vexel-demo — imports WindowMemory in three files and Settings in Desktop, where it
carries its own copy of the two-instances comment and threads both through a factory SPI
(List<ConsoleApp> of(Settings settings, WindowMemory memory) — dependency injection by parameter
threading). Moving those types puts mainframe-vexel-gui downstream of this framework.
That is the correct end state rather than an obstacle: mainframe’s console becomes a starter, contributing window-scoped console components, which is precisely how Spring Boot absorbed its ecosystem. But it makes the move a coordinated sweep across four repos, so it is sequenced last:
- Absorb what no library depends on — CLI parsing, capture dispatch, app icon, the wiring recipe. (Done.)
- Port one demo. Prove the claim against real code. (Done twice:
calculator-vexel-demoandtext-editor-vexel-demo— see What porting the text editor found, below.) - Move
WindowMemory,Settings,AppHomein one atomic sweep, in dependency order with anmvn installbetween each repo. Java has no type aliases, so there is no deprecation window available — this is a single coordinated change or it is a broken build. mainframe-vexel-guibecomesvexelray-framework-starter-mainframe.
What porting the text editor found
The calculator was the first port and it went through the seams as designed, which is a weak test: a one-window application with no files to lose exercises the parts of an application edge that are easiest to get right. The text editor was chosen second for the opposite reason — three windows, an OS clipboard on all of them, unsaved documents, an application mark, and its own headless capture — and it is the first thing on this stack the framework could not run.
The gaps were all one shape. In each case the decision had already been taken, written down in prose, and copied by hand into every application; the framework had absorbed the statement and not the seam. Four of them:
| What was missing | What it cost | Where the decision already was |
|---|---|---|
| The application’s mark | VexelApplication built the main window’s WindowConfig and never named an icon, so no framework application could wear one |
AppIcon’s own Javadoc, and automation.md §7: an application contributes “identity — its title, an icon as identity”. The README listed the app icon among the defaults, and it did not exist |
| The clipboard, past the main window | ClipboardBackend was opened, installed on the one Gui, and dropped. An application’s second window had no way to reach it |
ClipboardBackend’s own Javadoc quotes the editor’s loop — “copy out of the terminal’s prompt has to reach the same place copy out of a tab does” — and then made it unreachable |
| The close gate, and the dialogs | GuiApp.onCloseRequest had no seam at all, so an application with unsaved work could not be asked before it quit |
Phase.ATTACH lists “dialogs installed, the close gate armed” among the things that happen in it. Neither did |
A build that stops at TREE |
Phase.TREE exists to make a headless tree possible, and no entry point produced one |
Phase.TREE: “A tree that cannot be built without a window could not be captured headlessly.” |
The pattern is worth naming, because the processor will not catch it: a phase can document a
capability the runtime does not offer. Phase.TREE’s Javadoc is a complete and correct argument for
something no method returned. Nothing type-checks the claim that a phase’s list of contents is the
list the phase actually builds.
So every member’s list was then read against VexelApplication line by line, and the enum now
carries that warning on the type itself. The audit found three more, of two different kinds:
| Claim | Kind | Resolution |
|---|---|---|
Phase.GUI: “theme, minimum size, zoom range, and the frame clock attached” |
A capability that should exist | Taken — Appearance.ZoomRange, applied beside minSize. See below for why the range moves and the chords do not |
Phase.ATTACH: “the close gate armed” |
A seam deliberately left empty | Text corrected. A gate is registerable there (Window.onClose since 2026-10-11, Shell.onClose before) and the framework installs none: the default has to be that closing closes |
Phase.ATTACH: “the automation socket bound” |
Bound by an optional module | Text corrected. -automation’s Driver, called from the application’s own attach — see the two reserved keys |
The second kind is the one worth having a name for, because it is not a defect and reads like one: a phase is the right place for something the framework will never do itself, and a list of contents that does not distinguish the two invites somebody to close a gap that is a decision.
What the port did not need is the more interesting half. Pacing, wakes, the frame stages, ordered
shutdown, the one Settings, the window memory and the framework’s title bar all took the editor’s
real requirements without modification — including the two the editor had hand-written most
carefully, the min over two deadlines and the pair of onWork calls. A 541-line main became a
67-line wiring class, and the --verbse case works as advertised:
$ text-editor --verbse
unknown option: --verbse (known: automation, profile, terminal)
usage: text-editor [--key=value] [frames]
settings: terminal
framework: automation, profile
That list is the point — terminal is on it because the application declared one setting key. The
second line is the application’s own keys and the third is the names the framework reserves, kept
apart because they are not the same promise: see the two reserved keys
below. terminal is also hand-typed for now, and AppInfo.settingKeys says so — the claim that the
list “cannot fall out of date, because it is not written by anybody” is the argument for the
processor, not a description of today.
What the port left as the application’s
Three things looked like framework candidates and are not, recorded here so they are not re-proposed:
-
The zoom chords — but not the zoom range, and the split is the point.
CalculatorWiringhas ruled on the chords: “which chord zooms, or whether zooming exists at all, is not something a framework should be choosing.” The drift there is real and visible (the calculator binds the numpad chords, the editor does not) but it is drift between two applications’ decisions rather than between two copies of one. How far the zoom goes is a different question, and it was answered identically in all five places —gui.zoomRange(0.5f, 3f, 1.25f), with no reason given at any of them, which is what a default looks like before anybody has taken it. That half is nowAppearance.ZoomRange, applied whereminSizealready is, defaulting to the numbers everybody chose; the framework had already owned the other half, that the zoom is remembered. Owning both is what makes them agree, because the remembered factor is restored throughGui.zoom, which clamps to the range.Taking it turned up the clipboard’s problem in a second guise. The framework dresses the one
Guiit built, and the editor callszoomShortcutson three — so a range set only on the main window would leave the others on different bounds. HenceAppearance.applyTo(Gui): the theme and the zoom range, in one call, for a window the framework never saw. Not the minimum size, becauseGui.minSizeis “not an OS window minimum” but the smallest canvas one tree can be laid out on, and the main window’s floor is the wrong answer for a tool window beside it. The same call is whatVexelApplicationuses on its ownGui, so there is one definition of what applying an appearance means rather than two to keep in agreement — and it is the seam that stops theModalsdefect below from being every multi-window application’s as well.And then the editor corrected it. Applying that method to the editor’s other two windows is wrong: its file drawer is deliberately a different hue — “they are different machines… hue is the cheapest thing a glance resolves” — and the MainFrame console it opens brings its own palette, because “a window that had to be themed by whoever embedded it would look different in every application that used it.” So the two facts are not the same kind after all, and the split is worth stating exactly:
Fact Whose Why How far the zoom goes Every window on the desk A second window that disagreed is not making a point; it is inconsistent. ZoomRange.applyToThe theme The application, unless a window has its own A departure can be a decision, and overwriting one with a default is the defect, not the fix. Appearance.applyToThe minimum size One tree It is a floor for a layout, not for an application ZoomRange.applyTois also what a library window reaches for.EditorWindowandFolderWindowboth run under MainFrame as well as under this framework, and under that host there is noShellto ask — so they state their own look and take the range offZoomRange.DEFAULT, which keeps the three numbers in one place on the stack even where the container is not running. --captureand its two siblings. The editor has three headless entry points and two of them photograph a window that is not the main one.Launchalready says an application with its own capture tooling intercepts its own flag first; what was missing was only a tree to point it at.FpsProbe. Generic enough to move and specific enough not to: it deliberately pokes the loop — a timeline post, a node mutated from a worker, a handler that changes nothing — to prove each wake path is still alive. That belongs in-diagnosticswhen there is one, and until thenLaunch.FRAMEWORK_KEYSadvertisingprofileis a reserved name rather than a promise: the flag parses, and the application still owns the probe it is supposed to turn on.
The two reserved keys
Launch.FRAMEWORK_KEYS accepts --profile and --automation without the application declaring
them, and neither is consumed by -core or -shell. The socket is bound by -automation’s
Driver, from the application’s own attach; the probe is the application’s until -diagnostics
exists. So an application depending on neither can be given --automation=7654, have it parse, and
have nothing happen — which is the failure Launch exists to prevent, sitting inside Launch.
It is kept, because both alternatives are worse:
| Alternative | Why not |
|---|---|
| Refuse the key unless something consumes it | Requires asking at runtime whether Driver is linked — a Class.forName on the startup path, and nothing here reflects |
| Make each application declare the key | Returns the stack to what it had: the scaffold read System.getProperty("automation", "off") inside a factory method, and every application spelled the switch for itself |
Reserving the name is what buys the one property worth having — the same instrument is asked for the
same way in every application on the desk — and the honest fix is a compile-time one that does not
exist yet. @ConditionalOnType makes the dependency decide, and a key with no consumer becomes a
build question rather than a quiet launch. Until then the gap is documented on FRAMEWORK_KEYS
itself, and Launch.usage prints the reserved keys on their own line so the two categories are not
presented as one list.
What porting the designer found
The third port, and the first with two windows on one device and one frame loop — a tree window
and a ray-marched viewport, each with its own Gui. It was chosen for that: the calculator was one
window, the editor was three windows that were three separate applications’ worth of Gui with one
device between them, and neither exercised a second window whose content is rendered by the
application into a target the host mints.
It went through the seams. DesignerApp was 558 lines; about 130 of them were edge — run, two
input backends, the flag parsing behind two automation sockets, the shutdown ordering, and a main
that decided what --capture meant — and that is now roughly 40 lines of DesignerWiring, most of
it the one thing the framework could not take. The file total went up, to 611 across two files,
because the rest of DesignerApp was always the toolbar, the properties panel and the tree source,
and the phase methods now carry as Javadoc the reasoning that used to be comments inside run().
Counting lines is the wrong measure here; what changed is that none of the remaining lines are about
starting an application.
Three things it confirmed rather than found:
InputBackend.perWindow()was already exactly right. The designer’sattachWindowInput— open a backend, attach it to the new window’s handle, settleCLIENT, bridge it to that window’s bus, close it with the window — is the framework’s method line for line, including the comment about the two unrelatedNativeWindowtypes. A second window’s input needed nothing.- The
CLIENTcensus.Appearance.decorationsdefaults toCLIENTon the grounds that “three of the four applications on this stack draw their own frame”. This is the fourth. It saysDecorations.SYSTEM, gets no framework title bar and therefore no instrument strip — which is right, because the screenshot that matters in this application is the viewport’s and it comes off the driver. Appearance.applyTo, found in the editor’s threeGuis, has its second witness here: the viewport’s window is the application’s, so itsGuiis too, and without dressing it that window disagrees with the one beside it about the theme.
And two that were new. Neither is a defect in something the framework does; both are the framework declining to do something, which is the second kind of gap the phase audit above had to name:
| What | Why it stayed the application’s |
|---|---|
| A second automation socket | -automation’s Driver binds one, for the Gui the framework built. Two window trees need two — tree on the first does not list the viewport and shot on it photographs the wrong window. The application binds the second at Driver.port() + 1, so it still never parses the flag; what it cannot do is ask the framework for it. One application needs this, which is not yet enough to grow the API — see docs/TODO.md |
| A named window’s controls | WindowSpec.onControls hands them down when the window opens, and the driver starts before the frame loop creates it, so they have to be resolved per command. That is a fact about a window that can be closed and reopened, not about starting an application |
A phase is decided by a component’s listeners, not only by its data
The one genuinely new thing the port taught about Phase, and it is a trap worth naming. The
designer seeds a starting design so the first frame is not an empty sky, and a seed is model data, so
it reads as MODEL. It is not: Design.silently coalesces the four edits into a single onChange,
and that one announcement is what refreshes the tree view and compiles the first Surface. Both
listeners have to exist before it fires, so the seed belongs in TREE — after the things it wakes
up, not beside the model it edits.
Phase’s own rule already covers this correctly and says so in a way that is easy to read past: a
component’s phase is “the latest phase of anything the component depends on”, and an announcement
depends on its listeners. The processor will infer that from constructor parameters and get it right
without anybody thinking about it. A hand-written wiring has to think about it, and putting the seed
one phase too early is a first frame that draws nothing with no error anywhere.
The witness is the project builder, not an application
Three applications were ported onto this framework and all three are gone. That was the plan rather than an accident, and the reasoning decides how everything after it gets verified.
They were built to find out what the framework needed, and they did — what porting the text editor found and what porting the designer found are that work, and the findings outlived the code. What they were not good at was staying ported. Each carried its own conventions, so every framework change was paid for three times in three idioms, each with problems that resisted being fixed one at a time, and repeated attempts to make them cohere made no progress. A witness that has to be hand-maintained is a tax on every change to the thing it witnesses, and the tax was being paid in the phase where the architecture is still moving.
A generated project is a better witness for the same reason it is a worse application. It has no conventions of its own to defend, because it has no history — it is the framework’s own statement of what an application looks like, re-emitted from one place. A change that breaks it breaks it at generation time rather than in somebody’s checkout months later. And rebuilding a real application from it costs about a day, which is the whole trade: a day once, rather than a tax per change forever.
What moves, and what it already is
The builder exists and is not a sketch. It is mainframe-template, and its vexel-desktop template is
already framework-shaped: the tree it writes has a VexelApplication entry point and a Wiring
with one method per phase, importing dev.vexelray.framework.shell and
dev.vexelray.framework.automation. So this is a relocation rather than a port, and the note elsewhere
that the scaffold is “still longhand” has stopped being true.
The split it needs is already drawn. dev.mainframe.template — Scaffold, Blueprint, Template,
Manifest, Answers, Transform, Checks — imports nothing from mainframe; it is pure JDK, which
is the same constraint -api and -core carry and for the same reason: a thing that only computes a
tree of bytes needs no terminal and no GPU to be tested. Only dev.mainframe.template.shell, five
classes, binds to Builtin, Args and Plan. The engine comes here; the shell commands stay in
mainframe as an adapter over it.
A template is declared rather than coded — the manifest carries slot definitions with a kind, a
preset, a match and a validation rule, then fill lines for what is worked out rather than asked, then
the file list. That last part is not tidiness: a bundled template’s files are classpath resources, and
a classpath directory cannot be enumerated in a native image, so the manifest is the only record of
what is in the folder. The same constraint that shapes everything else here.
The acceptance loop
| Step | What it proves |
|---|---|
| Drive the builder API with a set of answers | the vocabulary a project is described in is reachable without a terminal |
Scaffold.of returns a Blueprint |
the emitted tree can be asserted in memory, without touching a disk |
| Write it and build it | it compiles against the framework as published, not as imagined |
Run it under --automation and drive it |
it is an application, rather than a tree that happens to compile |
The engine is already shaped for the first two. Scaffold.of(Template, Answers, Catalogue) returns a
Blueprint — a list of path-and-bytes with paths() and text(path) — and never touches a filesystem,
so most of the loop is an ordinary headless test. Blueprint.Writing is transactional, begin / write
/ undo, for the part that has to land on disk.
Built, as vexelray-framework-acceptance, and only under -Pacceptance. A profile rather than a
script, because the build that is always run is mvn clean install, and one flag on it is the cheapest
trigger there is; a module rather than a test in -template, because building the generated project needs
every module installed first, and a reactor orders by dependency. It builds the project with the Maven
running the reactor, against the same local repository, and drives it with the command the template tells
its user to type — mvn compile exec:exec — so the loop runs what a person would run rather than a
cleaner equivalent of it.
The last step is the one that is not optional, and the reason is circularity. If the builder lives
in this repo and this repo is verified by what the builder emits, then a mistake present in both the
template and the framework is invisible: the tree compiles, the shapes agree, and what they agree on is
being wrong. Driving the generated application through the automation socket is what breaks the circle,
because did it run is the one question neither side can answer by agreeing with itself. That is the
argument Driver was built on, and it is why -automation is load-bearing to this plan rather than an
extra.
What it does for the processor
The generated project is the processor’s fixture, and it solves the problem that the annotations are
today declared in -api and applied by nothing. The scaffold already emits the output side — a hand
Wiring, one method per phase — so the processor’s acceptance test states itself: generate a project,
and the wiring the processor emits from its annotations is the wiring the template would have written by
hand. Two sides of one transformation, both owned here, neither of them somebody’s application.
It also keeps the fixture honest about conventions. A fixture hand-written to suit the processor will
quietly be shaped by what the processor found easy; one that has to survive mvn compile and an
automation script in a generated project will not.
What the component witness found
The counter every earlier acceptance run drives has one thread of its own and no component, so the concurrency
model had only ever been checked by an application that does not use it. W2 is a generated application with the
parts the model is for — a worker on a lane of its own, two components sharing the default lane, another lane
with a wedge on it, a BLOCK mailbox published to from the handler lane — and a button that wedges a component.
It is the builder’s vexel-desktop tree with its application swapped (vexelray-framework-acceptance, test
resources, witnesses/components), and no wiring anywhere. Since October 2026 the builder writes a text editor
rather than a counter, so every witness keeps only the parts of the builder’s tree that every application shares —
the entry class, the look, the pom — and takes its counter from witnesses/shared (Support.generate): the components are annotated and the processor writes
the rest. Two launches of one build, driven through the socket.
What it confirmed, on the first run. This is the honest headline: nothing broke.
| Claim | How it was checked |
|---|---|
A component that only declares @Subscribe gets a thread, a mailbox and a wake |
the worker’s result reaches the tree with no wake written anywhere |
| Four components on three lanes are three placements | the generated wiring, counted |
| A wedge on a lane of its own leaves the window and every other lane working | count, work and echo all answer while isolated is wedged |
The watchdog names the stalled lane, and the policy’s interrupt frees it |
stall isolated, then isolated freed, after the 3 s threshold |
| The default lane is shared, so a wedge on it holds up its neighbours | a queued echo is not delivered until the lane is freed, and then it is |
| The window and the other lanes still work while the default lane is wedged | count and work answer |
| The framework’s default policy ends the application through the window’s own close route | under exit, the process is gone within the grace, after a report naming <default> |
The last row is the one no unit test could reach: Context.exit posts a close through GuiApp’s controls, and
the acceptance run is the first thing that has ever done that for real.
Two observations that are not failures. A @Component that declares no @Subscribe and takes no
Placement is constructed and given no thread at all, because a lane’s placement is made by the first thing
that asks for one. The annotation’s own text says a component has a thread and a mailbox, and that is true of a
component that declares a mailbox and not of one that does not; nothing in W2 has one to show it, so it is
recorded and not tested. And the default policy’s report goes through Diagnostics.dropped, whose vocabulary is a
capability that was silently lost (*“the component lane
What it did not exercise. The halt backstop: the JVM exits on its own once the window closes, because every
lane is a daemon, so Runtime.halt never fired and the total shutdown bound is asserted only by the watchdog’s
unit tests. A wedge that ignores interrupts, which the policy cannot free and only the exit path answers. The main
thread, which is not watched.
What the two-window witness found
Every generated application so far is one window plus dialogs, and the three second windows that exist — the
calculator’s settings, Vexplore’s viewer and W2’s tool window — each claimed its window by hand: its own Gui on its
own bus and the application’s lanes, the look, a TitleBar with commands, GuiApp.window, WindowMemory.remember
and the window mark. Doing that took the GuiApp, which made the recipe main-thread (T2.2) for a handle that only posts
(calculator FN-1), and the calculator’s first attempt put its tree on the host’s bus and drew it in the main window
(FN-2). W1 was meant to shape the seam that replaces all of that, so the seam and the witness were built together.
The seam. Windows is a framework root from GUI, not main-thread. A recipe declares a window by name with the
trees — windows.named(NamedWindow.of("settings", "Settings", 500, 640).satellite()) — and gets a Window. The
framework builds that window’s tree when it is declared (a Gui on a bus of its own and the application’s lanes, the
application’s look or the one declared, and a title bar unless the frame is the OS’s). At ATTACH it claims every
declared window: placement and zoom remembered under its name and forgotten when it closes, the window mark on the
taskbar and in the bar, the bar’s controls and instruments, and the clipboard. show, hide, toggle and close
are safe from any thread, and a show before the claim is held until the claim. A declaration after TREE, a second
one under the same name, and "main" are all refused. The three hand-built windows now each take one declaration,
and none of their recipes takes the GuiApp (W2’s ToolWindow, the calculator’s SettingsWindow, Vexplore’s
Viewer, whose only late binding left is its textures, which are the device).
W1 itself is a generated application with a main window, a peer (notes) and a satellite (palette), all views
of one model (witnesses/windows, WindowsWitnessTest). Two launches share one settings home.
What it confirmed.
| Claim | How it was checked |
|---|---|
No recipe takes the GuiApp and nothing is main-thread |
the generated wiring has shell.windows() and no shell.app() |
A show from TREE, before there is a device, is carried out |
-Dwitness.open=notes opens the window at the first frame |
| Each window draws its own tree | a window’s driver finds none of the other windows’ landmarks |
| One model, three trees | a count in the palette appears in the notes window and the main one |
| Each window has its own zoom and size | zooming the notes window to 1.5 leaves the main one at 1 |
| A listener on the window runs when it opens | the notes window’s opened 1, written only by onOpened |
| A window closed by its own button comes back to the same tree | the palette reopens showing the count it closed on, and a second press opens no second window |
| Its placement and zoom survive a restart, apart from the main window’s | resized to 520x380 and zoomed to 1.5, the notes window reopens at both in the next launch |
One finding, and it was upstream: with two windows and none focused, the loop could park for good with a wake
outstanding. Fixed in vexelray-gui 809c584. The driver’s click on the main window was dispatched and never counted; in the next run, a click on
Palette did nothing and the palette never opened. The trace shows why. The driver’s wake arrived during a frame:
the tree’s frame owed flag was set and a wake message was posted. Then the loop parked forever, and no frame ever
ran again. Every later input found the flag already set and posted nothing (“a frame is already owed, and saying so
twice does not owe two”). The loop parks on the thread’s message queue, and in an iteration with more than one
window each window pumps that queue for its own frame. On Windows a pump is PeekMessage with no window filter, so it
removes every message on the thread’s queue, the main window’s wake included: the wake message was consumed by a pump
later in the same iteration, after the tree had already been drained, and the park then waited for a message that
had already gone. GuiApp already records the fact it needed (wakePosted, set by every wake and cleared at the
top of each iteration), but it reads it only to hold the frame ceiling, not to refuse the park. Two things make it
look like a multi-window bug, and only one is: the extra pumps widen the window for the race, and the 5 Hz idle floor
that would have ended the park within 200 ms applies only while a window is focused, so a run the test machine had
not focused waited forever. For a person it is a missed wake that ends at the next mouse move. For a component’s
result arriving into an unfocused application, nothing ends it. The fix: GuiApp’s loop does not park while
wakePosted is set, in the same place it already declines to park on a queued task. The harness had hidden it, because
its wake was a flag only the wait cleared; every window’s pump now takes it, as the real queue does, and
TwoWindowsWakeTest in vexelray-gui-harness hangs in its first round without the fix and passes four hundred with
it. W1 then passed whole, three runs in a row, including the click straight after a close that first showed it.
Two smaller ones, found on the way.
settleanswers before the first frame. The socket opens atATTACH, so a client that connects at once can ask before anything is drawn.Gui.onWorkre-arms the frame owed flag when the wake is wired, so the edits the tree was built with are owed nothing, andsettlesaidok v0over an empty tree. W1 waits on a landmark instead. The flag is right that no wake is outstanding and wrong that the tree has caught up, which is the questionsettleasks it. Fixed upstream the same day (vexelray-gui1a40f59: a new tree owes its first frame); what remains is the moment before the main window is bound, when every verb answers that no window is open.- A
Windowcannot be injected by name. A recipe that wants the window’sshowfor a button takes the part that owns the button (Ui) and hands it over, as W2 and the calculator already did. That is the@Window("settings")question from FN-1, which is a processor feature; nothing in three applications has needed more than the handover.
Not yet exercised. A window declared with a theme of its own (unit-tested only).
Then the main window. Windows.main() is the main window as a Window, and W1’s Quit button is
windows.main()::close in a recipe that takes no GuiApp. Both launches now end that way, with the other windows
still open, and wait for the application to stop by its own loop; the second launch then finds the placement the
first wrote on its way out. With it, Driver reads the windows from Windows.views() rather than the GuiApp, and
memory() is gone. app() stays, as the device: every remaining use on the stack was textures, viewports, compute or
a dialog (see the Shell surface ruling in TODO.md), and the dialogs went next.
Then the dialogs. Dialogs is a framework root from CONFIG, not main-thread: a Modal, and the native open,
folder and save pickers. What a part is handed is the same value in every phase, and it holds a question until
something can answer it — the framework’s own from ATTACH (Modals and NFD, parented to the main window), or the
application’s, handed back through a @Provides Dialogs, which is how a test answers in a headless run. It replaced
three copies of one class, the text editor’s NativeDialogs, Vexplore’s Chooser and Pix’s Opener, each of which took
the GuiApp for a window handle it only passed along, and the ask fields the text editor and the template had grown
so a test could answer (Vex FN-6). W1’s fourth launch asks from TREE, before there is a window: the question comes
up in a window of its own once there is one, Enter takes its default button and the count moves, and a second one,
asked from a button and cancelled with Escape, leaves it where it was.
Then the close gates. A gate belongs to a window, so it is Window.onClose(CloseGate), on any window including
the main one, once per window, and read when the close arrives rather than when it is set. CloseGate is two
callbacks, proceed and cancel, rather than gui-core’s CloseRequest, which nothing outside gui-core can construct:
the template and the text editor had each kept a guardClose(Runnable, Runnable) beneath the real gate so a test could
call it (Vex FN-6’s other half), and now that is the gate. Shell.onClose is deleted. W1’s palette has a gate that
asks through Dialogs before it closes: its own Close button brings the question up as a fourth window, Escape keeps
the palette, and Enter lets it go. One window-management use of the GuiApp was left on the stack — Pix’s viewer
closed the main window through app.controls() — and it is windows.main().close() now, so a close gate would be
asked there too. Since then, the only uses of GuiApp left on the stack are the device.
Then undo. After 0.1.0 it was plain that undo worked only where an application had wired a History by hand
(vexelray-gui/docs/plans/undo.md). Every Window now owns one, built and bound in its constructor so there is no
window without it: Ctrl+Z, Ctrl+Shift+Z and Ctrl+Y anywhere in the tree, and every drop and paste recorded into the
same stack. Window.history() is where an application records its own commands and marks a save; an Undo button
calls gui().undo(history()), which takes the user to what will change before changing it. A narrower history — a
settings page’s, a document’s — still outranks the window’s by claim scope, so MainFrame’s workspaces page, which
binds its own at VISIBLE, is unchanged. Each window also gets UndoFeedback on Gui.onUndo: a refused control is
ringed where it stands, and anything else that fails is an error prompt through Dialogs, the same seam a test
answers. The ring is timed by the main window’s clock for every window, because only the main window has one; a
named window’s cue relies on that clock’s loop being the one that draws it, which is so today (one GuiApp, one
loop) and is not yet witnessed.
What the long-running witness found
W2 and the counter are event-driven: they do something and go quiet. The parking and wake machinery — the loop
that sleeps when nothing is owed, and the onWork calls whose omission is a parked loop rather than an
exception — is only exercised for real by something that keeps producing work, so W3 is a generated application
with a metronome: a component on its own lane that ticks to itself a hundred times a second and rewrites a readout
each time. The frame count, thread count and heap are copied into a line by a button, never drawn continuously,
because a readout that changed every frame would owe the next frame itself and a loop that wakes itself cannot be
measured for whether it parks. (vexelray-framework-acceptance, witnesses/longrun.)
| Measured | Result | Reading |
|---|---|---|
| Frames in 3 s, nothing running | 22 | parked: about 7 a second, the idle refresh |
| Frames in 5 s, metronome running | 521 | a component’s thread does wake the loop, with nothing else asking |
| Frames in 3 s, after Stop | 18 | it goes back to parking, so nothing is still waking it |
| Threads, over ~20 s of steady load | 17 → 17 | no thread per tick |
| Heap after a collection, same run | 5 MB → 5 MB | nothing accumulates |
| Input while flat out | a click is answered | the loop is not starved by its own wakes |
Two findings, both real.
The frame ceiling does not cap wake-driven frames. The loop ran about 104 frames a second against a
60 Hz maxFrameRate, which is the tick rate and not a coincidence. The ceiling is implemented as a timed park
(waitEvents(budget)), and a wake a component posts is a message on the same queue, which ends the park early —
the ceiling’s own javadoc says as much for OS input (“the wait ends early on OS input regardless”), and a
component’s wake is indistinguishable from it. This first reading was incomplete and one claim in it was
wrong: the pacing measurements below show the ceiling holds nothing that wakes the loop, animations included,
and that the presenter, not the ceiling, is what stops a wake storm. It is why the entry is now a question about
intent and not a bug report.
settle cannot report a run with nothing to settle. It answered ok while the metronome was ticking. That is
what its javadoc says — “exact about the frame loop and the clock, and blind to application work still in
flight” — and a component ticking to itself is exactly that work, so in the gap between two ticks nothing is owed
and settle is right about the frame loop and wrong about the application. The socket has no way to say this
application never goes quiet, and a script that calls settle before a photograph of an animating window gets one
taken mid-change. The remedy the javadoc already names is to wait on a landmark the application publishes, which is
what every assertion in W3 does.
What it did not exercise. A run of minutes or hours: twenty seconds rules out a thread or a heap that grows
per tick and nothing that grows per minute. The pulse and the clock’s own repeating cues, which are the path
settle’s “a repeating cue is reported by name” covers and a component’s ticks are not.
What the pacing measurements found
The GUI’s design is a hybrid: retained, drawn on demand, parked when nothing is owed, and running at the display’s
rate the moment something animates. W3 suggested the wake half worked and the ceiling did not; three measurements
on a generated application (witnesses/pacing, PacingMeasurementTest) test the design directly, and a fourth
(the Idle probe) tests the parked half. The numbers are from one machine, whose display appears to run at 144 Hz;
they were re-taken on 2026-10-17, after the ceiling began holding against wakes, and the first measurements are
kept in brackets where they differ.
| Question | Result |
|---|---|
| Frames across one 600 ms pulse (three runs) | 87, 88, 88 in ~610 ms: ~144 a second |
| Wake to the first frame from a parked loop (15 trials) | median 0.6 to 1.3 ms across two runs, p95 1.0 to 2.0 ms (was 1.03 ms) |
| Frames in 3 s under 35 to 41 million writes, one wake behind each | 402 to 430, 134 to 143 a second, 0 failures (was 434) |
| Frames in 4 s with nothing owed, unfocused | 0 |
| Frames in 4 s with nothing owed, focused | 20, 5.0 a second: the idle floor, exactly |
| Frames earned by one write from a parked loop, counted over the next 100 ms (10 trials, two runs) | 1, every time |
| Process CPU over those 4 s | 31 to 125 ms, 0.8 to 3 % of one core; see below |
What it says.
- Animation runs at the display’s rate. ~143 against ~145 under a storm is the same number, so the presenter
is what blocks and animate at vsync speed is already true. The 60 Hz
maxFrameRateis not what holds it: an ordinary pulse, with no component involved, ran at 143. The krono clock’s wake every tick ends the ceiling’s timed park exactly as a component’s does. - A wake from a parked loop is answered in about a millisecond, not in an idle refresh’s 200. That is the half of the design that was most feared, and on this path it is not where the latency is. It measures to the start of the next frame, not to the glass: with FIFO present the picture is further behind by the swapchain’s queue, which is not measured here.
- A storm cannot spin the loop. Eleven million writes a second plateau at the refresh rate. This is also why
the message-queue overflow worry did not materialise:
Guicoalesces its wakes (“the wake is a cell, not an event”), so the queue sees about one message a frame however many writes there are. A component that calledpostWakeitself, per delivery, is not covered by that and was not measured. - Parked means parked. Unfocused, the loop ran no frame in four seconds; focused, it ran the floor’s five a second and nothing more. A write from a parked loop earned exactly one frame in all twenty trials, so neither a text change nor the paint fades keeps the loop awake after the frame that drew it. Which of the two a run gets depends on whether the launched window happened to come up in front; the probe reports either.
- The idle CPU is small and not zero, and the instrument is coarse. Windows accounts thread CPU in 15.6 ms
ticks, so four seconds resolve to a few ticks: the readings are 2 to 8 of them. Of the Java threads,
mainspent two ticks across the 20 floor frames (about 1.5 ms a frame) and the watchdog, the prober and a pool worker a tick each; the process total in the second run was about 80 ms above the Java threads’ sum, which is the JVM’s own threads or the drivers’ and is not broken down further here. A minute-long run would be needed to put a number on the unfocused residue that the ticks cannot hide.
Which changes an earlier claim. The ceiling was written up as a bug whose fix was a wake object, on the reading that a wake storm would run the loop flat out. It would not. What the numbers leave open is the intent: a ceiling that worked would hold a 144 Hz display to 60, which is the opposite of the goal the design states.
The click path, and a theory that was wrong. The first suspect for felt input latency was Tactroller’s 125 Hz
polling thread, which samples the pointer and keys with GetCursorPos and GetAsyncKeyState and would add up to
8 ms between an OS event and its publish. It is not in the framework’s path. Nothing calls Tactroller.start():
TactrollerInputBridge takes a snapshot() once per frame, at FrameStage.INPUT, and a real OS input message
ends the parked wait. Measured through that path (twenty clicks, each after the loop had parked): the time from the
frame that took the click to the frame that drew its result, through the handler lane and back, is a median
6.5 ms (p95 7.4 ms, two runs), of which the hop to the handler is 0.1 to 0.2 ms. That is one refresh, and it
is the ceiling. The first measurement here was 1.3 ms, taken while any wake could end the ceiling’s park; since
the loop holds the ceiling against wakes (render-on-demand.md, the ceiling is a real ceiling), the handler’s
write lands about 0.2 ms into a frame and the frame that draws it waits out the rest of the 6.94 ms gap from that
frame’s start. render-on-demand.md names this as the price of a ceiling that holds: at most one gap. Whether it
costs anything on the glass is not measured: with FIFO present the earlier frame would have queued behind the click’s
own frame, so the difference may be less than a refresh. (Tactroller’s own 125 Hz loop is still what a consumer that
calls start() gets; the framework is not one.)
Smoothness, which is the goal, and one hiccup. Across three pulses (about 260 frames) the frame gap has a
median of 6.94 ms, which is 144 Hz and is steady. But every pulse has one or two late frames, gaps of 20 to
30 ms (three or four frames dropped at that rate), and they are not scattered: they fall at about 30 ms and 60 ms
after the animation starts, and the remaining ~550 ms is clean. So the hiccup belongs to the transition from a
parked loop into an animation, not to animating. It repeats on the third pulse, so it is not first-run class
loading or a cold JIT. A run with atchung-probe on (-Dprobe=all -Dprobe.format=csv, and
PacingMeasurementTest’s probed runs) shows it has two separate stalls at fixed offsets, and one is explained:
- A, about 29 ms, 1 to 3 ms after the click’s handler: explained, in two parts.
- The 60 Hz ceiling, taken at the wrong moment. The handler’s
krono.rampposts to the timeline during the frame that took the click, after that frame’s CLOCK stage has passed, so nothing ticks it until the next frame. That frame is then held back:sleepTimeout()says work is pending, which is a budget of zero, andmaxFrameRatereplaces a zero budget with 16.6 ms, parked after the present, with no wake coming (the post’s wake was consumed by the frame already running). The kronometer tick log shows it:time tick 1on the main thread happens at the start of the frame after the park, never before. The clock is not late; the loop has not asked it yet. This is the design’s documented 60 fps ceiling (render-on-demand.md) doing what it says, at the one moment it costs an animation its start. - Windows throttling the timer. That 16 ms wait returned after 28 ms. Windows 11 ignores a timer
resolution request from a process with no visible, foreground window unless it opts out, and a test window
launched by a build is such a process. Across 266 timed parks that ran to their timeout, 264 were on time and
these were the only overshoots, both by about 12 ms. Confirmed by experiment: with
SetProcessInformation(ProcessPowerThrottling, IGNORE_TIMER_RESOLUTION)plustimeBeginPeriod(1)at startup the stall drops from ~29 ms to ~18 ms in every pulse (18.1, 18.0, 17.7). The same run withtimeBeginPeriod(1)alone is unchanged, which is why it looked ruled out. This is now built (vexelray-os-windows,TimerResolution, held per window) and asserted by the pacing test, and the plain default run reproduces the ~18 ms with no experiment switch.
- The 60 Hz ceiling, taken at the wrong moment. The handler’s
- B, about 21 ms, 27 to 60 ms in: not explained. The wait is instant; the frame then spends 14 to 19 ms in
gpu wait fence, waiting on the previous frame’s GPU work. The opt-out leaves it exactly where it was (21.5, 20.3, 21.2 ms).
Ruled out for both: a JVM pause. With -Xlog:gc,safepoint, the only pause of 3 ms or more in a whole run
is one 3 ms young collection at startup. The uncommitted GuiApp re-park draft, which was installed for the first
measurements: the committed one reproduces it exactly. The wake path, which is about a millisecond.
Which leaves. A’s remaining ~18 ms is the ceiling, so it goes when the ceiling does (TODO, maxFrameRate’s
60 Hz is not in force): the frame after a click would then start at once. B wants a run with the GPU held busy
beforehand, to see whether it is an idle power state, and the present queue’s depth looked at, since a burst after
a stall can fill three FIFO images and then wait on vblanks. One caution on scope: the timer half is from a window
that was not in front, and a focused window may not be throttled at all; the opt-out is cheap and harmless either
way, but the size of A for a real user in front of the window is not measured.
What the stall probes found
The pacing section left stall B unexplained (a 14 to 19 ms gpu wait fence 27 to 60 ms into every animation).
Once the stack had one logger, the stall was instrumented rather than guessed at, and everything below reads out of
one probe trace (PacingMeasurementTest’s probed run, -Dprobe=all). The instruments stay in, and are off unless a
probe is recording: vulkan.swapchain and vulkan.present log what was built at DEBUG; the presenter marks
frame.image and frame.latency (submit to fence, per frame) and dwm.start / dwm.presented; kronometer’s
baton marks gate.open and gate.woke, so a hand-off between threads has a latency that can be read.
B is the present queue filling, and the GPU is not involved. The GPU finishes every frame in 0.1 to 0.4 ms. After
the ceiling’s 16 ms park the loop produces four to six frames about 1 ms apart, all inside one 6.94 ms refresh
interval (the compositor’s vblank phase, from DwmGetCompositionTimingInfo, runs 737, 1926, 3124, 4187, 5087 us
across five consecutive frames), and then one frame waits 14 to 20 ms on its fence, which is three refreshes: the
queue draining. The swapchain has three images and the queue is deeper than that, so the image count does not bound
it (two images shrink the stall to 12 ms and do not remove it). The content cost is the visible part: the clock is
sampled at the start of each frame, so the burst advances animation time about 1 ms a frame while the display shows the
frames 6.94 ms apart, and the next frame then jumps by the whole wait. A slow stretch and a lurch, at a fixed offset
from the start, every time.
Why the loop runs ahead at all. The ceiling is a timed park and any wake ends it, and the krono clock wakes on every tick (the pacing section’s first finding). So after the first frame of an animation nothing holds the loop until the present queue does, and the queue starts empty. In steady state it is always full, which is why the middle of a pulse is clean.
Confirmed by making it stop, twice over. Holding the queue to one frame (DwmFlush after each present) removes the
burst and B with it. With the flush and the 60 Hz ceiling off, the frame gaps of pulses two and three are 6.1 to 7.9 ms
from the first frame to the last, with no stall. The flush alone removes B and leaves A’s park (gaps of 20 to 28 ms at the start, a little
longer than before because the park now ends on a vblank). The ceiling off on its own was not run.
The portable fix works: make the ceiling real, and set it to the refresh interval. The loop decides when it draws, so
it does not need the compositor to tell it how far ahead it is; it only has to not draw more often than the display
shows. That is what maxFrameRate was for, and it failed for two reasons, both now shown by experiment:
- A wake ends the park, and so does the next one.
waitEventsisMsgWaitForMultipleObjects, which returns at once while a wake message sits unread in the queue, and only the next frame’s pump removes it. So re-parking withwaitEventscannot hold anything: the first attempt (theGuiAppdraft, since rewritten) still burst, at 1.0 to 1.5 ms a frame. A plain timed sleep for the rest of the gap (LockSupport.parkNanos) does hold it. The draft also cleared its flag just before parking, discarding a wake posted during the frame; it is cleared at the start of the frame now. - The value. 60 Hz is slower than this 144 Hz display, and measured from the end of a frame it stretches to frame time plus the gap (8 ms, 125 fps). Anchored at the frame’s start and set to 6.944 ms, pulses two and three run 5.4 to 8.5 ms a frame from the first frame to the last, centred on 6.6, with no burst, no 20 ms wait and no ceiling park. Pulse one keeps a 13 ms first gap, which is A0 below.
With that, neither DwmFlush nor VK_KHR_present_wait is needed to stop the burst. DwmFlush remains a flag, off, as
the experiment that showed the burst was queue depth. present_wait is still the way to measure input to glass.
What is left, and is a decision.
- Where the ceiling’s value comes from: built.
NativeWindow.refreshIntervalNanos()(0 where a platform cannot say), implemented on Windows from the compositor’srateRefresh, andGuiApp.maxFrameRateToDisplay(fallback)makes the ceiling follow it, re-read about once a second. The framework uses it, with 60 Hz as the fallback. It is the compositor’s rate, not the window’s monitor: on displays of different rates it is whichever the compositor runs at. A per-monitor read (MonitorFromWindowand the display’s current mode) would be exact, and is not written. Linux and macOS have no implementation, so they keep the 60 Hz fallback. - Input that lands in the sleep waits for it. The re-park is a timed sleep, and a sleep is not ended by OS input as
waitEventsis. So a click during an animation can be as late as one refresh interval (6.9 ms at 144 Hz, 16.7 at 60), and only when a wake and not input ended the park; a click on an idle window is unaffected. Not measured: how often one lands there. A Win32 event waited on beside the queue would let the re-park bewaitEventsagain (TODO, the frame ceiling holds and follows the display). - Jitter.
waitEventsandparkNanosare millisecond-grained and a timed park is not locked to the vblank, so gaps scatter by about 1.5 ms around the interval. Bounded, and invisible against a 20 ms lurch, but it is not vsync. - Sample time at the display, not at the frame start. Still the exact answer, and only worth building if the jitter is ever seen.
A0, first pulse only. The first tick after the first click takes 4 to 6 ms to reach the animation, and later
pulses take 0.6 ms. The baton passes between threads cost 50 to 100 us each (gate.open to gate.woke), so it is
not thread scheduling. The time is between the kernel waking and kron.ramp’s shred starting, in the first pulse only,
which is most likely cold code on the first spork (not proven: no mark sits inside it). Not a steady-state cost, and worth a warm-up only if a first animation is ever measured to matter.
What this did not settle. Whether the lurch is visible on the glass is inferred, not measured: DWM’s own
per-window frame counters are not available to a Vulkan swapchain (DwmGetCompositionTimingInfo with a window returns
0x88980090; with none it gives global timing, whose frame counts only follow the application’s own presents). Input
to glass needs VK_KHR_present_wait or VK_GOOGLE_display_timing, as the TODO entry says.
The application’s mark
The text editor’s port gave AppInfo an icon, and the framework put it on the process and the main window. Nothing
generated could set it: @VexelApp had no member for it and the processor built INFO without one, so every
framework application’s running window wore the OS default, while four of them linked a mark into their .exe.
On Windows that default is not even the executable’s icon, because the window class is registered without one.
Ruled 2026-10-09: @VexelApp(icon = "...") names the mark, and an application that names none wears the
framework’s. Why: an icon is identity, which automation.md §7 gives the application to say. But the template’s
projects should look like something before anyone draws a mark, and a window under the OS default can’t be told
apart from any other program that forgot. The fallback is the template’s own “new app” mark
(-shell/src/main/rc/new-app.svg), so a project that never replaces its icon looks the same as one that names none.
- A resource name, not a decoded icon. Decoding reads bytes and can fail, and the framework is the party that
can report a failure and carry on. So the wiring returns the name (
Wiring.icon()), andMarkdecodes it atWINDOW, the first moment a mark has anywhere to go. The order is a decodedAppInfo.icon, then the named resource, then the framework’s, and each failure is reported once and falls to the next. - Checked at compile time, as far as a processor can see. The processor refuses a name that is not
.icoor.png, and one with no resource on the class output or the class path. Maven copiessrc/main/resourcesthere before javac runs. Whether the bytes decode is a startup question. - Registered by the processor. The generated wiring opens a resource, so the processor writes its native-image
registration as well (
META-INF/native-image/dev.vexelray.framework.generated/<wiring>/), keeping elektroq’s rule: what a processor makes the program reach, the processor also declares. - Decoded without AWT.
Icon.fromByteswas ImageIO, which isjava.desktopin a native image.vexelray-os-apigainedIcon.fromIcoandfromPng, which read the formats withjava.basealone. - One file for the window and the executable. The template’s
src/main/rc/<name>.icois both what the.rclinks into the.exeand what the pom puts on the class path for@VexelApp(icon), so they cannot drift apart.
Every window wears a mark, and the main one’s title bar does too (2026-10-10). Two gaps the suite’s icons showed once each application had one:
- A drawn caption had no mark. An application whose look draws its own frame lost the icon the native frame
showed beside the title. The shell hands the mark to the
TitleBarwith the window’s controls, atWINDOW, because its sizes are textures and a texture needs the device. - A second window wore the main one’s mark, or none.
Shell.windowMark()(fromWINDOW) is the mark for any other window, a viewer or a settings window: the suite’s window variant when the application ships one, found by name beside its mark (calculator-window.icobesidecalculator.ico, as the suite canvas exports them), and otherwise the application’s own mark. A missing variant is the normal case and is not reported; one that is there and will not decode is. A window declared withwindows.namedwears it on the taskbar and in its title bar with nothing written, since the framework claims it (the calculator’s settings window and Vexplore’s viewer); one claimed by hand names it on the window’s config and callsTitleBar.icon(app, mark). The processor registers the variant for native-image beside the mark, as a glob that may match nothing.
Under exec:exec the process is java.exe, so Windows groups the taskbar button with other Java programs and
pinning pins Java. That needs a per-process AppUserModelID, which is a shell32 binding
vexelray/docs/window-icons.md §5 records as missing.
Modules
vexelray-framework parent (pom)
├─ vexelray-framework-api the vocabulary: annotations only, JDK-only [built]
├─ vexelray-framework-core phases, launch, frame stages, pacing, disposal [built]
├─ vexelray-framework-shell the absorbed edge: input, clipboard, memory, loop,
│ and the window chrome [built]
├─ vexelray-framework-automation the driving socket, off unless asked for [built]
├─ vexelray-framework-template the project builder, and the acceptance loop's input [built]
├─ vexelray-framework-processor annotation processor: checks, then the generated wiring [built]
└─ vexelray-framework-diagnostics the Actuator analogue: frame budget, bean graph [planned]
The -template name is provisional. -diagnostics was already claimed upstream for a different
concept, which is the reason to check a name before a module is written rather than after.
The processor is deliberately last, and the reason survived a change in what comes before it. A
code generator whose output has never been written by hand is a generator whose output nobody has
checked the shape of — so the output gets written by hand first, against the real -shell, and the
processor’s job becomes reproduce these files.
Checking is the half that did not have to wait. A generator freezes what the annotations mean into
every application; a checker does not, because it only refuses what the vocabulary already says is wrong.
So the processor arrived in two halves, checks first: every rule a declaration can decide became a compile
error while nothing was generated. The sequencing argument above was about the second half, and it held:
the generator was written against the template’s hand-written Wiring, and the template now ships a
Recipes configuration instead, whose generated wiring is — field for field and call for call — the one it
replaced. -Pacceptance builds and drives it.
What generation settled that the vocabulary had left open. Four things, each derived rather than chosen.
The shell itself is an ATTACH value: a part taking the whole Shell could touch anything, and the only
phase where everything exists is the last one. A part that takes nothing is built in CONFIG: the phase
is the latest of anything it takes, and of nothing that is the first. The framework’s values are a table in
the processor, named by string — the one place in the stack a string stands for a type — because depending on
-shell would put the graphics stack on every application’s processor path; FrameworkTableTest holds it
against Shell. And a package-private class of the application’s own may be provided as it is: the
interface rule protects call sites, and nothing outside a package can hold one against a class it cannot see.
What changed is whose hand. That role was held by three applications’ wirings — CalculatorWiring,
TextEditorWiring and DesignerWiring, one window, three windows, and two windows on one device —
and it is the project builder’s now. The
applications were deleted on purpose, because a witness that must be hand-maintained taxes every
change to the thing it witnesses, and the template’s vexel-desktop tree is the same artefact without
the tax: a Wiring per phase, written out longhand, regenerated rather than maintained.
-api and -core are JDK-only, and not as an agnosticism goal — it is simply where the
dependency edges fall. A phase enum and a topological sort do not need a Vulkan device. The payoff is
that every decision the container makes is testable headlessly, on a machine with no GPU, and cannot
drift out of agreement with a driver.
Open questions
Incremental compilation.Answered: the build is always a clean one. The processor is a whole-program view and says so — it collects every round and runs its graph checks once, when the last is over — and nothing here builds incrementally, so there is no partial compilation for it to be unsound on. The same fact is what makes the@Providesinterface rule’s own type test clean: in a clean build, a class file is always somebody else’s. An IDE compiling one file at a time is not a build this framework answers for.@MainThreadon foreign types. The check is only as good as the annotations, and the types that most need it live in other repos.Provides-level@MainThreadcovers this, and the processor honours it — but it has to actually be applied, and forGuiAppandGuithe provider that would apply it is the framework’s own, which is generation’s to write.- Reachability metadata as a framework asset. The framework adds no reflection, but the stack
needs metadata already —
vexelray-gui-demoandmainframe-distboth shipreachability-metadata.json. Aggregating those into the starters, so an application inherits the whole stack’s metadata by depending on the framework, is the same value Spring Boot delivers by shipping hints for its ecosystem. Probably the highest-leverage feature not yet listed.