VexelRay
Framework docs

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.frame reconciles 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:

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:

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:

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’s Gui — 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 @Component on 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 Guis it happens to hold. 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 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 modules — Shell 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:

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

  1. Isolation. Components without a lane share 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).
  2. Supervision. The default lane is watched always on, as Stalls watches 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 to Shell like 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.
  3. 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, and ThreadGroup.interrupt() sweeps all of them at once, so there is no tree of children to walk and no ThreadLocal to keep in step. Tested on the JDK this repo builds against (25.0.3, HotSpot): a new Thread made inside the lane inherited the group, an executor created inside it ran its workers in the group, and one interrupt() reached the lane thread, the child and the pool task, while an executor created outside the lane was untouched. Not tested: native-image.
  4. 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 is halt rather than System.exit because exit runs 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:

  1. Absorb what no library depends on — CLI parsing, capture dispatch, app icon, the wiring recipe. (Done.)
  2. Port one demo. Prove the claim against real code. (Done twice: calculator-vexel-demo and text-editor-vexel-demo — see What porting the text editor found, below.)
  3. Move WindowMemory, Settings, AppHome in one atomic sweep, in dependency order with an mvn install between 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.
  4. mainframe-vexel-gui becomes vexelray-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 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:

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 — it has been inside one delivery…"*): it reads correctly and is filed under the wrong idea, which matters the day something filters that channel.

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.

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.

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:

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:

  1. A wake ends the park, and so does the next one. waitEvents is MsgWaitForMultipleObjects, 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 with waitEvents cannot hold anything: the first attempt (the GuiApp draft, 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.
  2. 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.

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.

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:

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