# 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](#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](#the-frame-is-the-main-threads-and-a-rate-is-everyone-elses).

**A hook that throws is stopped, not fatal** (ruled 2026-10-08). The first ruling let a throwing hook take
the loop down. The reasoning was that a loop that keeps presenting while part of the application has stopped
is subtly wrong, while a program that has stopped is at least honest, and that hooks are the application's
own code, documented not to throw. The calculator disproved that assumption. Closing its settings window left
`WindowMemory.poll`, which is *framework* code, reading the bounds of a destroyed window. `GetWindowRect`
failed, and the application went down with the user's tape in it. The loop is the one place where one part
failing takes every other part with it, so `FrameHooks` now catches a `RuntimeException` per hook, replaces
that hook with a no-op for the rest of the run, and hands it to the shell. The shell logs it once at `ERROR`
with the stack and records it through `Diagnostics`. An `Error` still propagates. A bare `FrameHooks` with no
listener still rethrows, because swallowing an exception with nobody to tell is silence. The `try` is free on
the path that does not throw, so the no-allocation rule holds.

That one crash had three causes, and each is now closed where it lives. That matters more than the ruling
itself:

- **The lifetime was the application's to pair.** `WindowMemory.watch` held the window, and `forget` let go
  of it, but nothing anywhere called `forget`. Every earlier watch was on a main window, which only closes
  when the application does. `WindowMemory.remember(key, spec, w, h)` now wires both ends from the spec's
  `onCreated` and `onClosed`. It is the same move `TitleBar.commands` made for a bar's controls ("forgetting
  either is silent") and `GuiApp.release` made for textures: a lifetime only the framework can see is the
  framework's to end.
- **A dead window threw.** `Win32Window` already knew it had been destroyed, but its geometry did not ask.
  Every query now answers with what the window last was, every command does nothing, a second `close` is a
  no-op, and only `createVulkanSurface` refuses. `NativeWindow`'s class comment states this as the contract.
- **Nothing exercised it.** The component witness (W2) now opens a remembered tool window, closes it with
  the control its title bar's X uses, and does that twice. It then checks that the run's log holds no
  contained hook and no uncaught exception.

### 4. Deadlines and wakes are contributed, not enumerated

Render-on-demand means the loop parks, and parking correctly requires knowing every deadline the
application holds. Today that is one expression:

```java
app.pacing(() -> Math.min(krono.kron().sleepTimeout().nanos(), memory.nanosUntilSettle()));
gui.onWork(app::postWake);
krono.kron().onWork(app::postWake);
```

Correct, and the wrong shape: it must be edited by whoever adds the next deadline holder, and the
failure when they forget is that **the loop parks past a deadline**. Nothing throws — the window
placement is not written, or an animation resumes late, on a machine that was idle at the time.
`DeadlineSource` and `WakeSource` make a component bring its own; `Pacing` reduces over whatever the
graph produced.

The GUI's own record of the wake half: five missing wakes shipped past a green suite, because a test
that draws its own frames cannot notice a wake that never came.

### 5. Chrome placement is the framework's; chrome appearance is the application's

This direction is easy to get backwards, and `vexelray-gui/docs/automation.md` §7 already argues it:

> A title bar is window chrome, and chrome belongs to whoever owns the window. An application
> contributes **identity** — its title, an icon as identity — and never controls... The moment one app
> puts its own button in the caption, the strip is app-addressable, and no framework instrument can
> rely on the space existing or on its meaning being the same from one window to the next.
> "Screenshot this window" is only free if the framework owns the place it lives.

So the framework builds the `TitleBar`, supplies identity from `@VexelApp`, and hands it real
`WindowControls` at `ATTACH` — the seam that exists because *"a native window cannot photograph
itself... only `GuiApp` owns a window's render bundle, so only `GuiApp` can make working controls."*

**The look stays entirely the application's.** `Appearance.theme()` is whatever the application says,
including a `Theme` of its own construction with its own nine-anchor `Palette`, `Shading` and
`Relief`; the chrome reads that same theme rather than one of its own. An application that wants to
draw things differently is not fighting a default — it is supplying the only value there is.

Instruments follow §7's rule: the framework supplies a default set and a window may take fewer or
none. Free to *enable*, not present unconditionally. The default is `WindowInstrument.standard()` with
one substitution: the screenshot is `vexelray-gui-nfd`'s `SaveScreenshot`, which opens a native save
dialog parented to the clicked window, rather than core's, which files a timestamped PNG in the working
directory. A desktop application's working directory is wherever it was launched from, and nothing on
screen names it, so a person clicking the camera could not find the picture. Core's stays for a script.

Two knobs an application will want are **not yet expressible**, and neither gap is the framework's to
close:

- **Corner radius** is a per-node prop (`Node.corner(Length)`) with no theme-level default. Making it
  convenient means `Theme` gaining a radius anchor beside `Relief`, in `vexelray-gui`. Backwards
  compatible if added as a `default` member.
- **Light direction** is *hardcoded in the generated shader* — `CanvasShader.java`, `unit top-left
  light dir`, baked into the SPIR-V. Making it app-controllable means a uniform threaded through the
  SDF uber-shader in `vexelray`. That is the one change here that touches the path every pixel in the
  stack goes through.

### 6. Scopes are application / window / frame

Not singleton / request / session. Window scope is already real and already hand-maintained — the
text editor binds the clipboard to *every* window in a loop, and remembers each window under its own
`WindowMemory` key.

## The concurrency model

*The reasoning is here; the rules it produces are in [threading.md](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](#2-thread-affinity-is-a-type-level-concern), which is `@MainThread` and the two-colour rule.

That is precisely the failure `@MainThread` was created to fix. `vexelray-gui/CLAUDE.md` files the
main-thread rule under constraints *not visible in the code*, and the annotation's own Javadoc says
why that was worth fixing: it was *"true, load-bearing, and enforced by nothing but the reader's
memory."* The concurrency model has been in the same condition, one level up.

**Sequence matters.** The processor generates the wiring, so whatever `@Component` means when the
processor is written is what gets baked into every generated application. Its contract today is a DI
one — *"a container-managed singleton, constructed once, by its one constructor, with its parameters
supplied"* — and constructor injection hands component A a direct reference to component B, which is
the thing a mailbox exists to prevent. Writing the processor first would freeze a vocabulary the
concurrency model then has to fight. So this is settled first, and the mismatches it names are part of
the processor's brief.

### The mapping is static, and that is the load-bearing simplification

A component lives on a thread that is not the main thread. Whether each component gets its own thread
or several share one is **decided in the wiring and never at runtime**: there is no work stealing, no
placement decision, and nothing to tune while running. This is not a pool and not a scheduler.

That one restriction is what makes everything below fall out.

**The processor can emit the whole thing.** Static assignment means thread construction, the
component-to-thread mapping and barrier participation are all generated code, with no scheduler in the
binary. It is the same trade the container already makes everywhere else — a compile-time replacement
for a runtime mechanism — so it fits the thesis rather than straining it.

**Platform threads, not virtual ones**, and the argument is measured rather than stylistic. Virtual
threads exist to multiplex many blocking tasks onto few carriers dynamically, which is exactly the
thing being declined. More sharply: Kronometer's kernel already owns the virtual-thread scheduler in a
desktop application that follows its advice. `kronometer/docs/architecture.md` §3.1 recommends
`-Djdk.virtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=1` — worth 3× on
the baton handoff — and then states the sharp edge of that decision:

> **The precompute pool and `offload()` must run on their own executors, never the kernel's carrier.**
> One carrier means one runnable virtual thread. Pure evaluation scheduled onto that carrier would not
> merely be slow, it would deadlock against the serialization that makes the baton fast.

Component work is in the same category as that precompute pool. Placing components on virtual threads
would put every component in the application onto the one carrier the baton needs in order to be
answered — so the choice is not "platform threads are a little simpler" but "virtual threads are the
documented deadlock". A platform thread is outside that scheduler entirely. Pinning inside a Vulkan
call would be a hazard for no benefit on top of that.

**Placement is semantically neutral, and Kronometer is the reason.** Order over effectful work is *"a
total order, one baton, strictly at `now`"* with *"no two shreds can race"*, and a `Rate` is *"an
independent sampling grid over the timeline"* carrying its own `priority` — the tie-break *"for shreds
of different domains waking at the same moment"*. So a component keeps its schedule and its ordering
wherever it is placed. **Regrouping costs capacity, not meaning.** And capacity is a reading rather
than a guess: slip is a debt, `wall(m) = m + slip`, and `Overrun` says which kind of trouble it is —
*"a slip that drains is a hiccup, a slip that plateaus is a capacity problem, and a slip that climbs is
a system heading for a `RESYNC`."*

### The component thread does not touch the timeline

This is the part the phrase "in sync with Kronometer" hides, and getting it wrong would make the model
unbuildable. `KronBridge` — the module that exists to join the bus to the timeline — states the
constraint plainly:

> Two systems with incompatible threading models, which is the whole problem. The bus publishes on
> whatever thread published — that is what makes it fast — while the timeline is single-threaded by
> construction, because that is what makes it ordered.

and rules out the shortcut: delivering on the publisher's thread *"would mean mutating the graph from
off the timeline, which is the one thing the design does not permit."*

So a component thread never holds the baton, and never reads or writes a `Signal`, `Cell` or `Effect`.
What "in sync with Kronometer" means is a round trip through the seam the stack has already built:

| Step | Where it runs |
| --- | --- |
| The component declares a `Rate` — its grid, its `maxCatchUp`, its `priority` | The wiring, at compile time |
| The rate steps, and the step publishes to the component's mailbox | The timeline, on the baton |
| The mailbox drains and the component does its work | The component's own platform thread |
| The result is published on a `Topic` | The component's thread |
| The bridge folds that topic into a `Cell`, and the graph sees it | The timeline, on the baton |

That last step is `KronBridge`'s stated purpose — a topic driving a cell is *"the natural way live
input enters the graph (with `horizon == now`, as it should be)"* — so a component's output enters the
predictable world by the same door tactroller's input does.

The cost is one drain period of latency, and it is already priced rather than waiting to be
discovered: *"input latency is bounded by the draining domain's period... The frame of latency is the
price of the ordering guarantee, and it is the same price every retained-mode GUI pays."* Naming it
here is what stops it being re-litigated later as a defect of the component model.

### What the framework owes the model, and what it already has

Two of the three obligations are seams that already exist, written for a single-threaded edge without
anyone noticing they were the multi-threaded answer as well:

- **The deadline is already covered.** `Kron.sleepTimeout()` is *"how long a host may block before it
  should tick again — the whole render-on-demand condition, as one number"*, composed over the
  timeline with its rate domains in it. The framework already registers that as a `DeadlineSource`, so
  a component whose rate is due in 20 ms is a loop that parks for 20 ms, with no new API. That matters
  more than it sounds: the timeline is driven from the main thread's frame, so a parked window would
  otherwise stop a 50 Hz component dead. `DeadlineSource`'s own Javadoc already lists *"a component
  waiting out a cue"* among its implementors.
- **The wake was a real gap, and it is now closed by construction.** A component that finishes early and
  publishes a result has produced work the loop cannot predict — which is `WakeSource`'s definition — and
  no wake existed for it. `gui::onWork` covers a node mutated off the frame thread and
  `krono.kron()::onWork` covers the timeline; a component's own publish is a third path, and the symptom
  of omitting it is the exact one the GUI already paid for — a window that is responsive except for the
  interactions that happened to arrive that way. **Each component mailbox owes a `WakeSource`**, so a
  `Placement` *is* one: the container connects it when it starts the component, and a component publishes
  through `published()`. The obligation is no longer something the processor has to remember to emit —
  what it will emit is the call site, which is a smaller job than the registration and a much smaller one
  to get wrong. The designer's hand-written component is the argument for doing it this way round: it met
  the obligation *by accident*, because announcing a phase wrote to a node and a node mutation wakes the
  loop, so a real wake was hanging on an unrelated line of reporting.
- **`Overrun` wants surfacing per thread.** Slip is a property of the one timeline and cannot be made
  per-domain, but *which grouping is late* is a question a static mapping makes answerable — so an
  overloaded grouping should name itself rather than show up as a global plateau.

Measurement is still worth doing, and its purpose is narrower than it looks: a synthetic barrier
against Kronometer at the N a real application reaches says where the tail starts to hurt, since frame
jitter is `max()` over participants rather than the mean. That chooses a sensible **default grouping**.
It does not decide the architecture, because the reasoning above already did.

### What is actually wired today

The model above is the design. This is an inventory of the code as it stands, taken so that the gap
between the two is a readable distance rather than an impression. Everything here is a grep over the
four modules' main sources.

> **Both of it have since been built**, and the paragraphs below are struck through where they have
> stopped being true. The container owns the application's bus — `Shell.bus()`, handed to the framework'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
`Gui`s 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:

```java
public Gui(Atchung bus)                                            // one shared fabric
public Gui(Atchung bus, java.util.concurrent.Executor handlers)    // and who runs the handlers
```

**The first is now taken**, and it reaches less far than it first appeared to. `Shell` owns an
`Atchung` and `VexelApplication` builds its `Gui` on it, asserted headlessly by
`OneBusPerApplicationTest`. The dialogs do **not** join it, and neither does a second window's tree —
see the constraint below, which was found by putting them there and watching an application stop.

#### One `Gui` per bus, and everything else on it

`Gui`'s topics are `static`: `vexelray.gui.mutations` is one name for every instance. So two trees on
one bus each receive the other's mutations, into a mailbox bounded at 65,536 with
`Backpressure.BLOCK` — and a tree that is not being presented never drains it. The second `Gui` fills
up and then blocks the first one's node setters permanently. An application that freezes after some
tens of thousands of edits, with no exception and nothing in a log.

It was found the hard way: putting the designer's viewport window on the application's bus stopped the
viewport marching at all, and moving that one line back brought it straight back. The framework had the
same defect for three commits, because `Modals` builds a tree of its own and the dialogs' mailbox is
drained only while a dialog is on screen.

So the rule the bus is actually offering today:

| May share a bus | May not |
| --- | --- |
| Input publishers, workers, **components and their mailboxes**, anything that is not a tree | A second `Gui` |

`Gui(Atchung)`'s Javadoc now carries the constraint, where it previously carried an invitation. **And
this is the ceiling on the wider ambition** — one inspectable fabric carrying every application
semantic — because it is exactly what a second window is barred from joining. Lifting it means `Gui`
naming its topics per instance rather than per class, which is a real change in `vexelray-gui` and not
a line in this repo.

**The second is now taken too, and what it cost is worth recording.** `Gui`'s worker pool was a field
initializer, so a `Gui` built a `newCachedThreadPool` whatever it was handed and `Gui.async` submitted to
*that* pool rather than to the executor. Passing a handler executor therefore redirected handlers and did
not remove the per-`Gui` pool — so *one bus was not one thread*, and this was correctly filed as an
upstream change rather than another argument at this call site.

It was one change upstream and it closed three things at once. `Gui` now takes both lanes, `null` means
*build that one and close it*, and `close()` shuts down only what that `Gui` built. The lanes are named
apart, because "worker thread" meant both of them; and the offload default is bounded, because the
unbounded one answered a wedged filesystem mount by spawning a thread per blocked call. Two of those
three were filed here as `upstream` rules that this repo could not fix, and they turned out to be one
problem rather than two.

So the distance between this section and the model above is no longer the substrate *or* the seam.
`Pump`, `State`, `Fold`, `Backpressure`, `Rate` and `KronBridge` were all built and none of them was
reached from here; a placed component reaches the first four now. The container owns **the bus a component
publishes on** and **the threads it runs on**, which is where the model starts. What remains is the
colour rule — which is the processor's, and is the one thing on this list that a runtime object cannot
be made to hold.

### The first component, written by hand

`vexelray-designer`'s shader composition now runs as one: `Mailbox<T>` — one platform thread, one slot,
latest wins — with `Composed` as the immutable result it publishes. Nothing was extracted here, on
purpose. What two of these have in common is what the framework should own, and one of them is not a
census.

**It found a defect, which is the answer to whether the exercise was worth doing.** `Viewport.show`
hand-rolled the same mechanism — an `AtomicInteger` revision, a submit to `Gui`'s pool, and a check
that this task was still the newest — and made the check *before* composing rather than around it. So
an edit that passed it and then lost its place kept composing beside the edit that replaced it, and
both wrote five independently `volatile` fields:

```
reports, in the order they finished:
  6,188 B in 17 ms       the sphere — asked for last, finished first
  2,662,152 B in 171 ms  the heavy design — superseded after 40 ms, composed anyway, and landed last
```

The viewport settled on a design the user had already replaced, and the frame pump could build a
pipeline from one compose's modules and another's push size. A slider emits an edit every few
milliseconds and a compose takes tens, so that is the ordinary case of dragging one.

Four things the hand-written version turned up that the model above does not say:

- **The mailbox is the bus's, after atchung grew the one thing it was missing.** `COALESCE_LATEST` was
  always the right policy and its capacity *is* one; what `Pump` had no answer for was *waiting*, since
  a pump is drained by an owner that already has a wake — a frame loop. `Pump.drain(long)` is that
  answer: it parks on a pump-level monitor, holds no mailbox lock while parked, and costs a publisher
  one field read when nobody is waiting. `Pump.wake()` ends a park for shutdown. There is deliberately
  no untimed form, because a drain that waits forever on a thread that also publishes is a hung
  application rather than a slow one. The designer's hand-written `Mailbox` was **deleted** rather than
  moved: an edit is now an ordinary message on `designer.viewport.edit`, which a debug port can watch
  and a script can send — which a private queue could never have been.
- **The wake obligation is real, and was being met by accident.** A compose landing woke the loop
  because announcing the phase writes to a `Node` and a node mutation wakes the loop. A real wake
  hanging on an unrelated line of reporting: stop announcing and the window parks. It is now
  `shell.wake(viewport::onWork)`, which is `WakeSource` used as written.
- **Drain-then-stop is an edge rule, not a universal one.** It exists so nothing downstream loses what
  it cannot reconstruct. A coalescing mailbox holds a *sample* by construction, and the sample it holds
  at shutdown is a picture nobody will see — so this one stops.
- **Coalescing cannot cancel work already started, and the framework does not say whether a message is
  obsolete.** Nothing here interrupts a compose in flight. This note used to describe a `superseded()` a
  component could ask before publishing; it was deleted because it answered "is anything queued on this lane?"
  to the question "is my message obsolete?", and the two agree only for a lane with one mailbox of full
  replacements. What one message means to the next is the component's to settle while it drains: drain more
  often, and raise the mailbox's `capacity`.

### 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](#6-scopes-are-application--window--frame) 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](#there-is-no-scan-at-runtime-or-at-build-time). 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](#what-may-cross-a-lane-and-what-crossing-does-to-it). 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](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:

```java
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](#2-thread-affinity-is-a-type-level-concern)'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](#the-component-thread-does-not-touch-the-timeline).

| | `FrameStage` | `Rate` |
| --- | --- | --- |
| Scope | The main thread, which is special because Vulkan makes it special | Every component |
| What the order means | Causal — each stage reads what the last produced | A tie-break between grids due at the same moment |
| Who declares one | The framework, four times; the application, in `APP` | The component, in the wiring |
| Open to more positions | No. It is a recipe, not a scale | Yes. That is what a grid is for |

**`FrameStage`'s argument against integer priorities wants a scope, not an answer.** *"An integer
priority makes every hook's position a negotiation with every other hook's"* holds for an open hook
list, where unrelated parties pick numbers with no shared meaning. It does not hold for
`Rate.priority`, where one author declares a few grids whose relationship is real — physics before
render, because render displays what physics computed. Recording that distinction is what stops the
next reader collapsing one into the other, and there are two collapses to refuse:

- **The four stages as four `Rate`s at priorities 0–3** loses the causality and makes "equal priority"
  both expressible and meaningless.
- **Everything as a `FrameStage`** cannot express independent rates at all, which is `Rate`'s whole
  reason to exist — *"an animation framework with one frame rate is a toy."*

#### The meeting point is `APP`, and it is narrower than it was

The two scopes meet where `FrameStage.APP` narrows from "the application's own per-frame work" to
**the main thread draining what a worker left for it**. Its own Javadoc was already uneasy about the
wider reading — *"the right place for reading a queue drained by a worker, and the wrong place for the
work the worker was doing"* — and `vexelray-gui` names the same hook from the other side of the seam,
describing what an input handler does when its effect is neither a tree mutation nor a clock
operation:

> it drops a request on one of the application's own queues — a history to restore, a file to open, a
> preview to render — each drained once per frame from the host's beforeFrame hook.

The designer is the worked example on this stack today. Its viewport marches on the GPU, and *"a
`VkQueue` is not thread-safe, so a drag handler running on a worker must only move the camera and
raise a flag — `pump()` is called from the frame loop and is the only place the GPU is touched"*, with
that `pump()` given exactly one home, one hook in `APP`.

**And the framework registers nothing there, which is a correction to how this was first planned.**
The obvious move — have the framework own a `Pump` on the Gui's bus and drain it in `APP` — is wrong
for a reason the GUI already documents: `Gui` owns a pump on that bus and drains it *inside*
`Gui.frame`, folded by cell and lossless, with navigation arriving on the same mailbox so that
everything enters the tree *"at the same point in the frame the tree's own edits do — before the
drain, never in the middle of one."* A framework hook draining that bus would be a second drain point
landing at a different moment than the edits it has to agree with. What gets drained in `APP` is the
*application's* queues, and only the application knows what they are — so `APP` stays an empty slot,
and what changed is the sentence saying what the slot is for.

Notably, the designer's witness is a flag and a GPU submission rather than an Atchung mailbox. The
shape is the same and the plumbing is not, which is the evidence that the mailbox half belongs with
the component model rather than ahead of it.

### A wedged component cannot freeze the window

*Status: layers 2 to 4 are built for component lanes (the group, the supervisor, `LivenessPolicy`, the watchdog and
the shutdown bound); the main thread is not watched, and the processor check is not written. See the TODO entry. [v1.md](v1.md#the-liveness-guarantee) 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](#what-the-port-left-as-the-applications) 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-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](#the-two-reserved-keys)
below. `terminal` is also hand-typed for now, and `AppInfo.settingKeys` says so — the claim that the
list "cannot fall out of date, because it is not written by anybody" is the argument for the
processor, not a description of today.

### What the port left as the application's

Three things looked like framework candidates and are not, recorded here so they are not re-proposed:

- **The zoom chords** — but *not* the zoom range, and the split is the point. `CalculatorWiring` has
  ruled on the chords: *"which chord zooms, or whether zooming exists at all, is not something a
  framework should be choosing."* The drift there is real and visible (the calculator binds the numpad
  chords, the editor does not) but it is drift between two applications' decisions rather than between
  two copies of one. How far the zoom *goes* is a different question, and it was answered identically
  in all five places — `gui.zoomRange(0.5f, 3f, 1.25f)`, with no reason given at any of them, which is
  what a default looks like before anybody has taken it. That half is now
  `Appearance.ZoomRange`, applied where `minSize` already is, defaulting to the numbers everybody
  chose; the framework had already owned the other half, that the zoom is *remembered*. Owning both is
  what makes them agree, because the remembered factor is restored through `Gui.zoom`, which clamps to
  the range.

  Taking it turned up the clipboard's problem in a second guise. The framework dresses the one `Gui`
  it built, and the editor calls `zoomShortcuts` on three — so a range set only on the main window
  would leave the others on different bounds. Hence `Appearance.applyTo(Gui)`: the theme and the zoom
  range, in one call, for a window the framework never saw. Not the minimum size, because
  `Gui.minSize` is *"not an OS window minimum"* but the smallest canvas one tree can be laid out on,
  and the main window's floor is the wrong answer for a tool window beside it. The same call is what
  `VexelApplication` uses on its own `Gui`, so there is one definition of what applying an appearance
  means rather than two to keep in agreement — and it is the seam that stops the `Modals` defect
  below from being every multi-window application's as well.

  **And then the editor corrected it.** Applying that method to the editor's other two windows is
  wrong: its file drawer is deliberately a different hue — *"they are different machines... hue is
  the cheapest thing a glance resolves"* — and the MainFrame console it opens brings its own palette,
  because *"a window that had to be themed by whoever embedded it would look different in every
  application that used it."* So the two facts are not the same kind after all, and the split is
  worth stating exactly:

  | Fact | Whose | Why |
  | --- | --- | --- |
  | How far the zoom goes | Every window on the desk | A second window that disagreed is not making a point; it is inconsistent. `ZoomRange.applyTo` |
  | The theme | The application, *unless a window has its own* | A departure can be a decision, and overwriting one with a default is the defect, not the fix. `Appearance.applyTo` |
  | The minimum size | One tree | It is a floor for a layout, not for an application |

  `ZoomRange.applyTo` is also what a **library** window reaches for. `EditorWindow` and
  `FolderWindow` both run under MainFrame as well as under this framework, and under that host there
  is no `Shell` to ask — so they state their own look and take the range off `ZoomRange.DEFAULT`,
  which keeps the three numbers in one place on the stack even where the container is not running.
- **`--capture` and its two siblings.** The editor has three headless entry points and two of them
  photograph a window that is not the main one. `Launch` already says an application with its own
  capture tooling intercepts its own flag first; what was missing was only a tree to point it at.
- **`FpsProbe`.** Generic enough to move and specific enough not to: it deliberately pokes the loop —
  a timeline post, a node mutated from a worker, a handler that changes nothing — to prove each wake
  path is still alive. That belongs in `-diagnostics` when there is one, and until then
  `Launch.FRAMEWORK_KEYS` advertising `profile` is a reserved name rather than a promise: the flag
  parses, and the application still owns the probe it is supposed to turn on.

### The two reserved keys

`Launch.FRAMEWORK_KEYS` accepts `--profile` and `--automation` without the application declaring
them, and **neither is consumed by `-core` or `-shell`.** The socket is bound by `-automation`'s
`Driver`, from the application's own `attach`; the probe is the application's until `-diagnostics`
exists. So an application depending on neither can be given `--automation=7654`, have it parse, and
have nothing happen — which is the failure `Launch` exists to prevent, sitting inside `Launch`.

It is kept, because both alternatives are worse:

| Alternative | Why not |
| --- | --- |
| Refuse the key unless something consumes it | Requires asking at runtime whether `Driver` is linked — a `Class.forName` on the startup path, and **nothing here reflects** |
| Make each application declare the key | Returns the stack to what it had: the scaffold read `System.getProperty("automation", "off")` inside a factory method, and every application spelled the switch for itself |

Reserving the name is what buys the one property worth having — the same instrument is asked for the
same way in every application on the desk — and the honest fix is a compile-time one that does not
exist yet. `@ConditionalOnType` makes the dependency decide, and a key with no consumer becomes a
build question rather than a quiet launch. Until then the gap is documented on `FRAMEWORK_KEYS`
itself, and `Launch.usage` prints the reserved keys on their own line so the two categories are not
presented as one list.

## What porting the designer found

The third port, and the first with **two windows on one device and one frame loop** — a tree window
and a ray-marched viewport, each with its own `Gui`. It was chosen for that: the calculator was one
window, the editor was three windows that were three separate applications' worth of `Gui` with one
device between them, and neither exercised a second window whose *content is rendered by the
application into a target the host mints*.

It went through the seams. `DesignerApp` was 558 lines; **about 130 of them were edge** — `run`, two
input backends, the flag parsing behind two automation sockets, the shutdown ordering, and a `main`
that decided what `--capture` meant — and that is now roughly 40 lines of `DesignerWiring`, most of
it the one thing the framework could not take. The file total went *up*, to 611 across two files,
because the rest of `DesignerApp` was always the toolbar, the properties panel and the tree source,
and the phase methods now carry as Javadoc the reasoning that used to be comments inside `run()`.
Counting lines is the wrong measure here; what changed is that none of the remaining lines are about
starting an application.

Three things it confirmed rather than found:

- **`InputBackend.perWindow()` was already exactly right.** The designer's `attachWindowInput` — open
  a backend, attach it to the new window's handle, settle `CLIENT`, bridge it to that window's bus,
  close it with the window — is the framework's method line for line, including the comment about the
  two unrelated `NativeWindow` types. A second window's input needed nothing.
- **The `CLIENT` census.** `Appearance.decorations` defaults to `CLIENT` on the grounds that *"three
  of the four applications on this stack draw their own frame"*. This is the fourth. It says
  `Decorations.SYSTEM`, gets no framework title bar and therefore no instrument strip — which is
  right, because the screenshot that matters in this application is the viewport's and it comes off
  the driver.
- **`Appearance.applyTo`**, found in the editor's three `Gui`s, has its second witness here: the
  viewport's window is the application's, so its `Gui` is too, and without dressing it that window
  disagrees with the one beside it about the theme.

And two that were new. Neither is a defect in something the framework does; both are the framework
declining to do something, which is the second kind of gap the phase audit above had to name:

| What | Why it stayed the application's |
| --- | --- |
| **A second automation socket** | `-automation`'s `Driver` binds one, for the `Gui` the framework built. Two window trees need two — `tree` on the first does not list the viewport and `shot` on it photographs the wrong window. The application binds the second at `Driver.port() + 1`, so it still never parses the flag; what it cannot do is ask the framework for it. One application needs this, which is not yet enough to grow the API — see `docs/TODO.md` |
| **A named window's controls** | `WindowSpec.onControls` hands them down when the window opens, and the driver starts before the frame loop creates it, so they have to be resolved per command. That is a fact about a window that can be closed and reopened, not about starting an application |

### A phase is decided by a component's listeners, not only by its data

The one genuinely new thing the port taught about `Phase`, and it is a trap worth naming. The
designer seeds a starting design so the first frame is not an empty sky, and a seed is model data, so
it reads as `MODEL`. It is not: `Design.silently` coalesces the four edits into a single `onChange`,
and that one announcement is what refreshes the tree view and compiles the first `Surface`. Both
listeners have to exist before it fires, so the seed belongs in `TREE` — **after the things it wakes
up, not beside the model it edits.**

`Phase`'s own rule already covers this correctly and says so in a way that is easy to read past: a
component's phase is *"the latest phase of anything the component depends on"*, and an announcement
depends on its listeners. The processor will infer that from constructor parameters and get it right
without anybody thinking about it. A hand-written wiring has to think about it, and putting the seed
one phase too early is a first frame that draws nothing with no error anywhere.

## The witness is the project builder, not an application

Three applications were ported onto this framework and all three are gone. That was the plan rather
than an accident, and the reasoning decides how everything after it gets verified.

They were built to find out what the framework needed, and they did — [what porting the text editor
found](#what-porting-the-text-editor-found) and [what porting the designer
found](#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 <default> — 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.**

- **`settle` answers before the first frame.** The socket opens at `ATTACH`, so a client that connects at once can
  ask before anything is drawn. `Gui.onWork` re-arms the *frame owed* flag when the wake is wired, so the edits the
  tree was built with are owed nothing, and `settle` said `ok v0` over an empty tree. W1 waits on a landmark instead.
  The flag is right that no wake is outstanding and wrong that the tree has caught up, which is the question
  `settle` asks it. Fixed upstream the same day (`vexelray-gui` `1a40f59`: a new tree owes its first frame); what
  remains is the moment before the main window is bound, when every verb answers that no window is open.
- **A `Window` cannot be injected by name.** A recipe that wants the window's `show` for a button takes the part that
  owns the button (`Ui`) and hands it over, as W2 and the calculator already did. That is the `@Window("settings")`
  question from FN-1, which is a processor feature; nothing in three applications has needed more than the handover.

**Not yet exercised.** A window declared with a theme of its own (unit-tested only).

**Then the main window.** `Windows.main()` is the main window as a `Window`, and W1's Quit button is
`windows.main()::close` in a recipe that takes no `GuiApp`. Both launches now end that way, with the other windows
still open, and wait for the application to stop by its own loop; the second launch then finds the placement the
first wrote on its way out. With it, `Driver` reads the windows from `Windows.views()` rather than the `GuiApp`, and
`memory()` is gone. `app()` stays, as the device: every remaining use on the stack was textures, viewports, compute or
a dialog (see the `Shell` surface ruling in TODO.md), and the dialogs went next.

**Then the dialogs.** `Dialogs` is a framework root from `CONFIG`, not main-thread: a `Modal`, and the native open,
folder and save pickers. What a part is handed is the same value in every phase, and it holds a question until
something can answer it — the framework's own from `ATTACH` (`Modals` and NFD, parented to the main window), or the
application's, handed back through a `@Provides Dialogs`, which is how a test answers in a headless run. It replaced
three copies of one class, the text editor's `NativeDialogs`, Vexplore's `Chooser` and Pix's `Opener`, each of which took
the `GuiApp` for a window handle it only passed along, and the `ask` fields the text editor and the template had grown
so a test could answer (Vex FN-6). W1's fourth launch asks from `TREE`, before there is a window: the question comes
up in a window of its own once there is one, Enter takes its default button and the count moves, and a second one,
asked from a button and cancelled with Escape, leaves it where it was.

**Then the close gates.** A gate belongs to a window, so it is `Window.onClose(CloseGate)`, on any window including
the main one, once per window, and read when the close arrives rather than when it is set. `CloseGate` is two
callbacks, `proceed` and `cancel`, rather than gui-core's `CloseRequest`, which nothing outside gui-core can construct:
the template and the text editor had each kept a `guardClose(Runnable, Runnable)` beneath the real gate so a test could
call it (Vex FN-6's other half), and now that is the gate. `Shell.onClose` is deleted. W1's palette has a gate that
asks through `Dialogs` before it closes: its own Close button brings the question up as a fourth window, Escape keeps
the palette, and Enter lets it go. One window-management use of the `GuiApp` was left on the stack — Pix's viewer
closed the main window through `app.controls()` — and it is `windows.main().close()` now, so a close gate would be
asked there too. Since then, the only uses of `GuiApp` left on the stack are the device.

**Then undo.** After 0.1.0 it was plain that undo worked only where an application had wired a `History` by hand
(`vexelray-gui/docs/plans/undo.md`). Every `Window` now owns one, built and bound in its constructor so there is no
window without it: Ctrl+Z, Ctrl+Shift+Z and Ctrl+Y anywhere in the tree, and every drop and paste recorded into the
same stack. `Window.history()` is where an application records its own commands and marks a save; an Undo button
calls `gui().undo(history())`, which takes the user to what will change before changing it. A narrower history — a
settings page's, a document's — still outranks the window's by claim scope, so MainFrame's workspaces page, which
binds its own at `VISIBLE`, is unchanged. Each window also gets `UndoFeedback` on `Gui.onUndo`: a refused control is
ringed where it stands, and anything else that fails is an error prompt through `Dialogs`, the same seam a test
answers. The ring is timed by the main window's clock for every window, because only the main window has one; a
named window's cue relies on that clock's loop being the one that draws it, which is so today (one `GuiApp`, one
loop) and is not yet witnessed.

## What the long-running witness found

W2 and the counter are event-driven: they do something and go quiet. The parking and wake machinery — the loop
that sleeps when nothing is owed, and the `onWork` calls whose omission is *a parked loop rather than an
exception* — is only exercised for real by something that keeps producing work, so W3 is a generated application
with a metronome: a component on its own lane that ticks to itself a hundred times a second and rewrites a readout
each time. The frame count, thread count and heap are copied into a line by a button, never drawn continuously,
because a readout that changed every frame would owe the next frame itself and a loop that wakes itself cannot be
measured for whether it parks. (`vexelray-framework-acceptance`, `witnesses/longrun`.)

| Measured | Result | Reading |
| --- | --- | --- |
| Frames in 3 s, nothing running | 22 | parked: about 7 a second, the idle refresh |
| Frames in 5 s, metronome running | 521 | a component's thread does wake the loop, with nothing else asking |
| Frames in 3 s, after Stop | 18 | it goes back to parking, so nothing is still waking it |
| Threads, over ~20 s of steady load | 17 → 17 | no thread per tick |
| Heap after a collection, same run | 5 MB → 5 MB | nothing accumulates |
| Input while flat out | a click is answered | the loop is not starved by its own wakes |

**Two findings, both real.**

**The frame ceiling does not cap wake-driven frames.** The loop ran about **104 frames a second** against a
60 Hz `maxFrameRate`, which is the tick rate and not a coincidence. The ceiling is implemented as a timed park
(`waitEvents(budget)`), and a wake a component posts is a message on the same queue, which ends the park early —
the ceiling's own javadoc says as much for OS input (*"the wait ends early on OS input regardless"*), and a
component's wake is indistinguishable from it. **This first reading was incomplete and one claim in it was
wrong:** the pacing measurements below show the ceiling holds nothing that wakes the loop, animations included,
and that the presenter, not the ceiling, is what stops a wake storm. It is why the entry is now a question about
intent and not a bug report.

**`settle` cannot report a run with nothing to settle.** It answered `ok` while the metronome was ticking. That is
what its javadoc says — *"exact about the frame loop and the clock, and blind to application work still in
flight"* — and a component ticking to itself is exactly that work, so in the gap between two ticks nothing is owed
and `settle` is right about the frame loop and wrong about the application. The socket has no way to say *this
application never goes quiet*, and a script that calls `settle` before a photograph of an animating window gets one
taken mid-change. The remedy the javadoc already names is to wait on a landmark the application publishes, which is
what every assertion in W3 does.

**What it did not exercise.** A run of minutes or hours: twenty seconds rules out a thread or a heap that grows
per tick and nothing that grows per minute. The pulse and the clock's own repeating cues, which are the path
`settle`'s *"a repeating cue is reported by name"* covers and a component's ticks are not.

## What the pacing measurements found

The GUI's design is a hybrid: retained, drawn on demand, parked when nothing is owed, and running at the display's
rate the moment something animates. W3 suggested the wake half worked and the ceiling did not; three measurements
on a generated application (`witnesses/pacing`, `PacingMeasurementTest`) test the design directly, and a fourth
(the *Idle* probe) tests the parked half. The numbers are from one machine, whose display appears to run at 144 Hz;
they were re-taken on 2026-10-17, after the ceiling began holding against wakes, and the first measurements are
kept in brackets where they differ.

| Question | Result |
| --- | --- |
| Frames across one 600 ms pulse (three runs) | 87, 88, 88 in ~610 ms: **~144 a second** |
| Wake to the first frame from a parked loop (15 trials) | median **0.6 to 1.3 ms** across two runs, p95 1.0 to 2.0 ms (was 1.03 ms) |
| Frames in 3 s under 35 to 41 million writes, one wake behind each | **402 to 430, 134 to 143 a second**, 0 failures (was 434) |
| Frames in 4 s with nothing owed, unfocused | **0** |
| Frames in 4 s with nothing owed, focused | **20, 5.0 a second**: the idle floor, exactly |
| Frames earned by one write from a parked loop, counted over the next 100 ms (10 trials, two runs) | **1, every time** |
| Process CPU over those 4 s | 31 to 125 ms, **0.8 to 3 % of one core**; see below |

**What it says.**

- **Animation runs at the display's rate.** ~143 against ~145 under a storm is the same number, so the presenter
  is what blocks and *animate at vsync speed* is already true. The 60 Hz `maxFrameRate` is not what holds it: an
  ordinary pulse, with no component involved, ran at 143. The krono clock's wake every tick ends the ceiling's timed
  park exactly as a component's does.
- **A wake from a parked loop is answered in about a millisecond**, not in an idle refresh's 200. That is the
  half of the design that was most feared, and on this path it is not where the latency is. It measures to the
  start of the next frame, not to the glass: with FIFO present the picture is further behind by the swapchain's
  queue, which is not measured here.
- **A storm cannot spin the loop.** Eleven million writes a second plateau at the refresh rate. This is also why
  the message-queue overflow worry did not materialise: `Gui` coalesces its wakes ("the wake is a cell, not an
  event"), so the queue sees about one message a frame however many writes there are. A component that called
  `postWake` itself, per delivery, is not covered by that and was not measured.
- **Parked means parked.** Unfocused, the loop ran no frame in four seconds; focused, it ran the floor's five a
  second and nothing more. A write from a parked loop earned exactly one frame in all twenty trials, so neither a
  text change nor the paint fades keeps the loop awake after the frame that drew it. Which of the two a run gets
  depends on whether the launched window happened to come up in front; the probe reports either.
- **The idle CPU is small and not zero, and the instrument is coarse.** Windows accounts thread CPU in 15.6 ms
  ticks, so four seconds resolve to a few ticks: the readings are 2 to 8 of them. Of the Java threads, `main`
  spent two ticks across the 20 floor frames (about 1.5 ms a frame) and the watchdog, the prober and a pool
  worker a tick each; the process total in the second run was about 80 ms above the Java threads' sum, which is
  the JVM's own threads or the drivers' and is not broken down further here. A minute-long run would be needed to
  put a number on the unfocused residue that the ticks cannot hide.

**Which changes an earlier claim.** The ceiling was written up as a bug whose fix was a wake object, on the
reading that a wake storm would run the loop flat out. It would not. What the numbers leave open is the intent: a
ceiling that worked would hold a 144 Hz display to 60, which is the opposite of the goal the design states.

**The click path, and a theory that was wrong.** The first suspect for felt input latency was Tactroller's 125 Hz
polling thread, which samples the pointer and keys with `GetCursorPos` and `GetAsyncKeyState` and would add up to
8 ms between an OS event and its publish. **It is not in the framework's path.** Nothing calls `Tactroller.start()`:
`TactrollerInputBridge` takes a `snapshot()` once per frame, at `FrameStage.INPUT`, and a real OS input message
ends the parked wait. Measured through that path (twenty clicks, each after the loop had parked): the time from the
frame that took the click to the frame that drew its result, through the handler lane and back, is a median
**6.5 ms** (p95 7.4 ms, two runs), of which the hop to the handler is 0.1 to 0.2 ms. **That is one refresh, and it
is the ceiling.** The first measurement here was 1.3 ms, taken while any wake could end the ceiling's park; since
the loop holds the ceiling against wakes (`render-on-demand.md`, *the ceiling is a real ceiling*), the handler's
write lands about 0.2 ms into a frame and the frame that draws it waits out the rest of the 6.94 ms gap from that
frame's start. `render-on-demand.md` names this as the price of a ceiling that holds: at most one gap. Whether it
costs anything on the glass is not measured: with FIFO present the earlier frame would have queued behind the click's
own frame, so the difference may be less than a refresh. (Tactroller's own 125 Hz loop is still what a consumer that
calls `start()` gets; the framework is not one.)

**Smoothness, which is the goal, and one hiccup.** Across three pulses (about 260 frames) the frame gap has a
median of **6.94 ms**, which is 144 Hz and is steady. But **every pulse has one or two late frames, gaps of 20 to
30 ms** (three or four frames dropped at that rate), and they are not scattered: they fall at about **30 ms and 60 ms
after the animation starts**, and the remaining ~550 ms is clean. So the hiccup belongs to the transition from a
parked loop into an animation, not to animating. It repeats on the third pulse, so it is not first-run class
loading or a cold JIT. A run with `atchung-probe` on (`-Dprobe=all -Dprobe.format=csv`, and
`PacingMeasurementTest`'s probed runs) shows it has **two separate stalls at fixed offsets, and one is explained**:

- **A, about 29 ms, 1 to 3 ms after the click's handler: explained, in two parts.**
  1. **The 60 Hz ceiling, taken at the wrong moment.** The handler's `krono.ramp` posts to the timeline during
     the frame that took the click, after that frame's CLOCK stage has passed, so nothing ticks it until the
     *next* frame. That frame is then held back: `sleepTimeout()` says work is pending, which is a budget of zero,
     and `maxFrameRate` replaces a zero budget with 16.6 ms, parked after the present, with no wake coming (the
     post's wake was consumed by the frame already running). The kronometer tick log shows it: `time tick 1` on
     the main thread happens at the start of the frame *after* the park, never before. The clock is not late; the
     loop has not asked it yet. This is the design's documented 60 fps ceiling (`render-on-demand.md`) doing what
     it says, at the one moment it costs an animation its start.
  2. **Windows throttling the timer.** That 16 ms wait returned after **28 ms**. Windows 11 ignores a timer
     resolution request from a process with no visible, foreground window unless it opts out, and a test window
     launched by a build is such a process. Across 266 timed parks that ran to their timeout, 264 were on time and
     these were the only overshoots, both by about 12 ms. **Confirmed by experiment:** with
     `SetProcessInformation(ProcessPowerThrottling, IGNORE_TIMER_RESOLUTION)` plus `timeBeginPeriod(1)` at startup
     the stall drops from ~29 ms to **~18 ms** in every pulse (18.1, 18.0, 17.7). The same run with
     `timeBeginPeriod(1)` alone is unchanged, which is why it looked ruled out. **This is now built**
     (`vexelray-os-windows`, `TimerResolution`, held per window) and asserted by the pacing test, and the plain
     default run reproduces the ~18 ms with no experiment switch.
- **B, about 21 ms, 27 to 60 ms in: not explained.** The wait is instant; the frame then spends 14 to 19 ms in
  `gpu wait fence`, waiting on the previous frame's GPU work. The opt-out leaves it exactly where it was (21.5,
  20.3, 21.2 ms).

**Ruled out for both:** *a JVM pause.* With `-Xlog:gc,safepoint`, the only pause of 3 ms or more in a whole run
is one 3 ms young collection at startup. *The uncommitted `GuiApp` re-park draft*, which was installed for the first
measurements: the committed one reproduces it exactly. *The wake path*, which is about a millisecond.

**Which leaves.** A's remaining ~18 ms is the ceiling, so it goes when the ceiling does (TODO, *`maxFrameRate`'s
60 Hz is not in force*): the frame after a click would then start at once. B wants a run with the GPU held busy
beforehand, to see whether it is an idle power state, and the present queue's depth looked at, since a burst after
a stall can fill three FIFO images and then wait on vblanks. One caution on scope: the timer half is from a window
that was not in front, and a focused window may not be throttled at all; the opt-out is cheap and harmless either
way, but the size of A for a real user in front of the window is not measured.

## What the stall probes found

The pacing section left stall B unexplained (a 14 to 19 ms `gpu wait fence` 27 to 60 ms into every animation).
Once the stack had one logger, the stall was instrumented rather than guessed at, and everything below reads out of
one probe trace (`PacingMeasurementTest`'s probed run, `-Dprobe=all`). The instruments stay in, and are off unless a
probe is recording: `vulkan.swapchain` and `vulkan.present` log what was built at `DEBUG`; the presenter marks
`frame.image` and `frame.latency` (submit to fence, per frame) and `dwm.start` / `dwm.presented`; kronometer's
baton marks `gate.open` and `gate.woke`, so a hand-off between threads has a latency that can be read.

**B is the present queue filling, and the GPU is not involved.** The GPU finishes every frame in 0.1 to 0.4 ms. After
the ceiling's 16 ms park the loop produces four to six frames about 1 ms apart, all inside *one* 6.94 ms refresh
interval (the compositor's vblank phase, from `DwmGetCompositionTimingInfo`, runs 737, 1926, 3124, 4187, 5087 us
across five consecutive frames), and then one frame waits 14 to 20 ms on its fence, which is three refreshes: the
queue draining. The swapchain has three images and the queue is deeper than that, so the image count does not bound
it (two images shrink the stall to 12 ms and do not remove it). The content cost is the visible part: the clock is
sampled at the start of each frame, so the burst advances animation time about 1 ms a frame while the display shows the
frames 6.94 ms apart, and the next frame then jumps by the whole wait. A slow stretch and a lurch, at a fixed offset
from the start, every time.

**Why the loop runs ahead at all.** The ceiling is a timed park and any wake ends it, and the krono clock wakes on every
tick (the pacing section's first finding). So after the first frame of an animation nothing holds the loop until the
present queue does, and the queue starts empty. In steady state it is always full, which is why the middle of a pulse
is clean.

**Confirmed by making it stop, twice over.** Holding the queue to one frame (`DwmFlush` after each present) removes the
burst and B with it. With the flush *and* the 60 Hz ceiling off, the frame gaps of pulses two and three are 6.1 to 7.9 ms
from the first frame to the last, with no stall. The flush alone removes B and leaves A's park (gaps of 20 to 28 ms at the start, a little
longer than before because the park now ends on a vblank). The ceiling off on its own was not run.

**The portable fix works: make the ceiling real, and set it to the refresh interval.** The loop decides when it draws, so
it does not need the compositor to tell it how far ahead it is; it only has to not draw more often than the display
shows. That is what `maxFrameRate` was for, and it failed for two reasons, both now shown by experiment:
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.**
- *Where the ceiling's value comes from: built.* `NativeWindow.refreshIntervalNanos()` (0 where a platform cannot say),
  implemented on Windows from the compositor's `rateRefresh`, and `GuiApp.maxFrameRateToDisplay(fallback)` makes the
  ceiling follow it, re-read about once a second. The framework uses it, with 60 Hz as the fallback. It is the
  *compositor's* rate, not the window's monitor: on displays of different rates it is whichever the compositor runs at.
  A per-monitor read (`MonitorFromWindow` and the display's current mode) would be exact, and is not written. Linux and
  macOS have no implementation, so they keep the 60 Hz fallback.
- *Input that lands in the sleep waits for it.* The re-park is a timed sleep, and a sleep is not ended by OS input as
  `waitEvents` is. So a click during an animation can be as late as one refresh interval (6.9 ms at 144 Hz, 16.7 at 60),
  and only when a wake and not input ended the park; a click on an idle window is unaffected. Not measured: how often one
  lands there. A Win32 event waited on beside the queue would let the re-park be `waitEvents` again (TODO, *the frame
  ceiling holds and follows the display*).
- *Jitter.* `waitEvents` and `parkNanos` are millisecond-grained and a timed park is not locked to the vblank, so gaps
  scatter by about 1.5 ms around the interval. Bounded, and invisible against a 20 ms lurch, but it is not vsync.
- *Sample time at the display, not at the frame start.* Still the exact answer, and only worth building if the jitter
  is ever seen.

**A0, first pulse only.** The first tick after the first click takes 4 to 6 ms to reach the animation, and later
pulses take 0.6 ms. The baton passes between threads cost 50 to 100 us each (`gate.open` to `gate.woke`), so it is
not thread scheduling. The time is between the kernel waking and `kron.ramp`'s shred starting, in the first pulse only,
which is most likely cold code on the first spork (not proven: no mark sits inside it). Not a steady-state cost, and worth a warm-up only if a first animation is ever measured to matter.

**What this did not settle.** Whether the lurch is *visible* on the glass is inferred, not measured: DWM's own
per-window frame counters are not available to a Vulkan swapchain (`DwmGetCompositionTimingInfo` with a window returns
`0x88980090`; with none it gives global timing, whose frame counts only follow the application's own presents). Input
to glass needs `VK_KHR_present_wait` or `VK_GOOGLE_display_timing`, as the TODO entry says.

## The application's mark

The text editor's port gave `AppInfo` an icon, and the framework put it on the process and the main window. Nothing
generated could set it: `@VexelApp` had no member for it and the processor built `INFO` without one, so every
framework application's running window wore the OS default, while four of them linked a mark into their `.exe`.
On Windows that default is not even the executable's icon, because the window class is registered without one.

**Ruled 2026-10-09: `@VexelApp(icon = "...")` names the mark, and an application that names none wears the
framework's.** *Why:* an icon is identity, which `automation.md` §7 gives the application to say. But the template's
projects should look like something before anyone draws a mark, and a window under the OS default can't be told
apart from any other program that forgot. The fallback is the template's own "new app" mark
(`-shell/src/main/rc/new-app.svg`), so a project that never replaces its icon looks the same as one that names none.

- **A resource name, not a decoded icon.** Decoding reads bytes and can fail, and the framework is the party that
  can report a failure and carry on. So the wiring returns the name (`Wiring.icon()`), and `Mark` decodes it at
  `WINDOW`, the first moment a mark has anywhere to go. The order is a decoded `AppInfo.icon`, then the named
  resource, then the framework's, and each failure is reported once and falls to the next.
- **Checked at compile time, as far as a processor can see.** The processor refuses a name that is not `.ico` or
  `.png`, and one with no resource on the class output or the class path. Maven copies `src/main/resources` there
  before javac runs. Whether the bytes decode is a startup question.
- **Registered by the processor.** The generated wiring opens a resource, so the processor writes its native-image
  registration as well (`META-INF/native-image/dev.vexelray.framework.generated/<wiring>/`), keeping elektroq's rule:
  what a processor makes the program reach, the processor also declares.
- **Decoded without AWT.** `Icon.fromBytes` was ImageIO, which is `java.desktop` in a native image.
  `vexelray-os-api` gained `Icon.fromIco` and `fromPng`, which read the formats with `java.base` alone.
- **One file for the window and the executable.** The template's `src/main/rc/<name>.ico` is both what the `.rc`
  links into the `.exe` and what the pom puts on the class path for `@VexelApp(icon)`, so they cannot drift apart.

**Every window wears a mark, and the main one's title bar does too** (2026-10-10). Two gaps the suite's icons
showed once each application had one:

- **A drawn caption had no mark.** An application whose look draws its own frame lost the icon the native frame
  showed beside the title. The shell hands the mark to the `TitleBar` with the window's controls, at `WINDOW`,
  because its sizes are textures and a texture needs the device.
- **A second window wore the main one's mark, or none.** `Shell.windowMark()` (from `WINDOW`) is the mark for any
  other window, a viewer or a settings window: the suite's *window* variant when the application ships one, found
  by name beside its mark (`calculator-window.ico` beside `calculator.ico`, as the suite canvas exports them), and
  otherwise the application's own mark. A missing variant is the normal case and is not reported; one that is
  there and will not decode is. A window declared with `windows.named` wears it on the taskbar and in its title bar
  with nothing written, since the framework claims it (the calculator's settings window and Vexplore's viewer); one
  claimed by hand names it on the window's config and calls `TitleBar.icon(app, mark)`. The processor
  registers the variant for native-image beside the mark, as a glob that may match nothing.

Under `exec:exec` the process is `java.exe`, so Windows groups the taskbar button with other Java programs and
pinning pins Java. That needs a per-process AppUserModelID, which is a `shell32` binding
`vexelray/docs/window-icons.md` §5 records as missing.

## Modules

```
vexelray-framework                    parent (pom)
├─ vexelray-framework-api        the vocabulary: annotations only, JDK-only          [built]
├─ vexelray-framework-core       phases, launch, frame stages, pacing, disposal      [built]
├─ vexelray-framework-shell      the absorbed edge: input, clipboard, memory, loop,
│                                and the window chrome                              [built]
├─ vexelray-framework-automation the driving socket, off unless asked for            [built]
├─ vexelray-framework-template   the project builder, and the acceptance loop's input [built]
├─ vexelray-framework-processor  annotation processor: checks, then the generated wiring  [built]
└─ vexelray-framework-diagnostics  the Actuator analogue: frame budget, bean graph   [planned]
```

*The `-template` name is provisional. `-diagnostics` was already claimed upstream for a different
concept, which is the reason to check a name before a module is written rather than after.*

The processor is deliberately **last**, and the reason survived a change in what comes before it. A
code generator whose output has never been written by hand is a generator whose output nobody has
checked the shape of — so the output gets written by hand first, against the real `-shell`, and the
processor's job becomes *reproduce these files*.

**Checking is the half that did not have to wait.** A generator freezes what the annotations mean into
every application; a checker does not, because it only refuses what the vocabulary already says is wrong.
So the processor arrived in two halves, checks first: every rule a declaration can decide became a compile
error while nothing was generated. The sequencing argument above was about the second half, and it held:
the generator was written against the template's hand-written `Wiring`, and the template now ships a
`Recipes` configuration instead, whose generated wiring is — field for field and call for call — the one it
replaced. `-Pacceptance` builds and drives it.

**What generation settled that the vocabulary had left open.** Four things, each derived rather than chosen.
*The shell itself is an `ATTACH` value*: a part taking the whole `Shell` could touch anything, and the only
phase where everything exists is the last one. *A part that takes nothing is built in `CONFIG`*: the phase
is the latest of anything it takes, and of nothing that is the first. *The framework's values are a table in
the processor, named by string* — the one place in the stack a string stands for a type — because depending on
`-shell` would put the graphics stack on every application's processor path; `FrameworkTableTest` holds it
against `Shell`. And *a package-private class of the application's own may be provided as it is*: the
interface rule protects call sites, and nothing outside a package can hold one against a class it cannot see.

What changed is whose hand. That role was held by three applications' wirings — `CalculatorWiring`,
`TextEditorWiring` and `DesignerWiring`, one window, three windows, and two windows on one device —
and [it is the project builder's now](#the-witness-is-the-project-builder-not-an-application). The
applications were deleted on purpose, because a witness that must be hand-maintained taxes every
change to the thing it witnesses, and the template's `vexel-desktop` tree is the same artefact without
the tax: a `Wiring` per phase, written out longhand, regenerated rather than maintained.

`-api` and `-core` are **JDK-only**, and not as an agnosticism goal — it is simply where the
dependency edges fall. A phase enum and a topological sort do not need a Vulkan device. The payoff is
that every decision the container makes is testable headlessly, on a machine with no GPU, and cannot
drift out of agreement with a driver.

## Open questions

- ~~**Incremental compilation.**~~ **Answered: the build is always a clean one.** The processor is a
  whole-program view and says so — it collects every round and runs its graph checks once, when the last
  is over — and nothing here builds incrementally, so there is no partial compilation for it to be unsound
  on. The same fact is what makes the `@Provides` interface rule's *own type* test clean: in a clean
  build, a class file is always somebody else's. An IDE compiling one file at a time is not a build this
  framework answers for.
- **`@MainThread` on foreign types.** The check is only as good as the annotations, and the types
  that most need it live in other repos. `Provides`-level `@MainThread` covers this, and the processor
  honours it — but it has to actually be applied, and for `GuiApp` and `Gui` the provider that would
  apply it is the framework's own, which is generation's to write.
- **Reachability metadata as a framework asset.** The framework adds no reflection, but the *stack*
  needs metadata already — `vexelray-gui-demo` and `mainframe-dist` both ship
  `reachability-metadata.json`. Aggregating those into the starters, so an application inherits the
  whole stack's metadata by depending on the framework, is the same value Spring Boot delivers by
  shipping hints for its ecosystem. Probably the highest-leverage feature not yet listed.
