Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR-0063: Data flows down, events flow up

  • Status: Accepted
  • Date: 2026-08-16
  • Relates to: docs/ARCHITECTURE.md §9, §11; narrows ADR-0062; amends §9’s “one/two-way binding”

Context

§9 says bind “is one/two-way binding against an observable model”. ADR-0062 shipped the reading half and described the writing half as waiting for a control that writes — a checkbox resolving a typed property and calling set from its own click handler.

That plan is at odds with the rest of the toolkit. A widget is an immutable description (ADR-0004); state lives on the element and changes only through setState (ADR-0052); a build must be pure. A control that writes to the application’s model breaks that in the one place it is hardest to see: the write does not come from application code at all, it comes from a bind= attribute in a data file, so the answer to “what changed this value?” is a string somebody typed into markup — possibly while the window was open, since markup hot-reloads (ADR-0051).

Two-way binding is also the feature that makes the update graph a graph. Once a control writes to a model that other controls read, the order of updates is a property of the binding topology rather than of the code, and every framework that shipped it — WPF, Angular 1, Knockout — grew a vocabulary for controlling it: modes, triggers, delays, UpdateSourceTrigger, $digest cycles. Goldberry’s entire update story today is “mark dirty, flush once per frame”, which is comprehensible because it is one direction.

Decision

Binding is one-way, and the type system says so. Observable<T> is the half of a Property<T> that can be read and watched; Property<T> adds set and is what the application keeps. Bindings.resolve hands out an Observable, and Widget.binding() returns one — so a widget built from markup cannot write to the model, because there is no method to call.

A control reports, and the application decides. What the user did travels back up the way it already does: as an action, through the Actions registry (ADR-0059). A checkbox will be checkbox bind="prefs.frost" change="toggleFrost" — the value flows down through bind, the intent flows up through change, and the one line that mutates anything is Java the application wrote.

The registry is not a way back to a writable handle. Bindings.bound() returns observables, and there is no writable(path). A path is how a value is published to markup; something that could also fetch it back for writing would make the registry a service locator, and any code holding the Bindings object could then mutate any model it names.

§9’s “one/two-way” is amended to say one-way. That is a change to the architecture document, made deliberately and recorded here rather than by editing history.

Alternatives considered

Two-way, as §9 originally said. The reason it is in the document is real: checkbox bind="prefs.frost" with no handler is less to write than a bind plus a change, and for a settings dialog of thirty toggles that difference is thirty handlers. Rejected because the saving is at the wrong end — it saves typing in the easy case and costs comprehensibility in the hard one, and the hard one is a control writing a value another control’s bind reads, mid-frame, from markup.

Two-way as an opt-in, bind versus a bind-two-way. Rejected: an opt-in is still the feature, with all of its semantics to specify, plus a second spelling. An escape hatch that is used once is a feature that has to work forever.

Convention, not types — hand widgets the Property and write down that they must not call set. That was the position ADR-0062 shipped with, and it is the weaker one: the rule holds until somebody in a hurry reaches for the method that is right there. The split costs one interface.

A read-only wrapper rather than a supertype, so Observable cannot be cast back to Property. Rejected as disproportionate: it allocates per resolve and breaks identity comparisons, to stop a downcast that a widget author could only write on purpose. This is a design boundary, not a security boundary — see below.

Consequences

Controls are “controlled” in the React sense, and this is the consequence worth understanding before writing the next widget. A checkbox draws the value it is bound to. Clicking it does not move the tick; it raises a change, and the tick moves when the application sets the property. A handler that forgets to set produces a control that visibly does nothing — which is the same class of bug React’s controlled inputs have, and the same defence applies: the UI is a function of the state, so a control that will not move means the state did not change, and that is exactly where the bug is.

Local, ephemeral state stays local. Nothing here says a control may not have state of its own — a text field’s caret and selection, a spinner’s mid-edit text, an IME’s preedit — that is what State on the element is for. The rule is about the model: what the application owns, only the application writes. text-input will be where this line has to be drawn precisely, and M5’s IME work is where it will hurt if it was drawn wrong.

The type split is defeatable by a cast. Property implements Observable, so a determined widget can (Property<?>) binding() and write. Nothing prevents that, and nothing tries to: the point is that the honest path is one-way and the dishonest one has to be typed out deliberately. A test pins the signatures, so widening resolve or binding() back to Property fails the build rather than being noticed later.

§9 is now narrower than it was, and any reader who took “one/two-way” as a promise will find one direction. That is why this is a record and not a silent edit: the document is amended, ADR-0062’s plan for the writing half is withdrawn, and both say so.

Property.set remains fully public. It is the application’s API, used from a button handler, from a completion on the UI thread, from a hot-reload callback. One-way is about which layer may write, not about ceremony around writing.