What the state model takes and returns, how it meets each of the seven hard cases, and what building on it caught. Every figure on this page comes from running the code, and names the file it came from.
It takes the selected items, the field, a method, the value to apply, and any items the operator has taken out. It returns one row per item, with the value before and after, and each row in one of three states:
| State | What it means |
|---|---|
| changing | The operation changes this item, and it will be changed. |
| excluded | The operation would change it, but the operator took it out. |
| unchanged | The operation does nothing here: the item already matches. |
Excluded and unchanged are never one number. One is a decision the operator made; the other is work the system found nothing to do. Collapsing them hides a decision.
It commits nothing. A commit is asynchronous, has side effects, and belongs to whatever system owns the data, so the model stays out of it. What it does own is the report afterwards, because that is the last place a bulk edit gets to lie. The rest of the model answers the questions around plan():
| Function | What it answers |
|---|---|
| summarise() | What does this field look like across the selection? Every value present, with its count, instead of the word Mixed. |
| methodsFor() | Which verbs are legal for this kind of field? |
| groupByTransition() | What classes of change does this plan contain? |
| describe() | What does the plan say in one line, without rounding or flattering? |
| reconcile() | What actually happened? It never reports attempted as succeeded, and retryScope() gives a retry the failures and nothing else. |
| planBatch() | What do several operations do together? It reduces the batch to the shortest list with the same outcome, instead of calling anything a conflict. |
The available verbs change with the kind of field. That is why bulk edit cannot be a form with a Save button.
| Field | What it holds | What you can legally do |
|---|---|---|
| Environment | One value per host | Set — nothing else is meaningful |
| Tags | A list per host | Add, Remove, Replace, Clear |
| Monitoring | On or off | Enable or Disable — never toggle |
| Log retention | A number, 1–365 days | Set, Increase by, Decrease by |
Toggle is the interesting one. A switch on mixed state has no defined meaning. Half are on, half are off — toggle them to what? Every one I have used ships a toggle here, which means it has already decided the answer, and none of them says which.
Relative numbers are the other one. “Decrease retention by 14 days” moves each host from its own current value, not from a shared one, and clamps at the field’s floor. A host already at the minimum does not change at all. The operation succeeds and does nothing, and the interface has to say so.
| Hard case | What it demands | Where it is met |
|---|---|---|
| Mixed values | The field must never show a value the selected items do not share. | Groups by transition. There is no single-value field to lie with. |
| Method per field type | The legal verbs change with the kind of field. | methodsFor(field) — a boolean is never offered a toggle. |
| No-op honesty | An operation that changes nothing has to say so. | describe() reports what already matches on its own, never inside the count of what changes. |
| Per-item preview | You can see what happens to each item, not only the total. | Expand any group: before → after, per host. |
| Partial failure | Some succeed, some fail, and the retry must know which. | reconcile(). The retry touches only what failed. |
| Scope honesty | “All on this page” and “all matching the filter” cannot be the same click. | The selection bar states both. Deselect one and it says so. |
| Keyboard | A tool used many times a day cannot be mouse-only. | Esc backs out of every state except a commit in flight. |
All seven are met, and each is checkable in the component on the main page rather than on my word.
The main page shows plan() on three made-up hosts. This is the same call on the repo’s demo data:
Generated by npm run figures, from demo/hosts.json, 24 hosts.
And the grouping that section 03 of the main page illustrates, on the 200 hosts its component loads, with Environment set to prod:
Generated by npm run figures, from demo/fixture.js, 200 hosts.
Three groups, because the field has three values. The component draws the same three: choose All 200 matching this filter, then Bulk edit 200 hosts, and it opens on Environment set to prod.
Counted by npm run figures, which runs the suite.
They encode what products usually get wrong: a mixed field that refuses to pick a winner, a boolean that is never offered a toggle, a numeric change that clamps and says so, excluded and already-matching never counted together, a partial failure never reported as a success, and a retry that touches only what failed. If one of them breaks, the interface on top is lying to someone.
A second file, spec/published-figures.test.js, recomputes every number on this page and fails if demo/figures.json no longer matches, naming this page in the failure. npm test runs both, on every push.
The interface is built, as a web component on this model, and it runs on the main page.
Four treatments of several operations at once went into an evaluation, and it declined to name a winner; the one I had ranked first came last. So the tie was mine to break, and it broke on a single line of the reasoning: a dead operation you must click a tab to discover is one you will not discover. That is why it is one lane per operation, all of them on screen, and no tabs.
All seven hard cases behave, including the two that took longest. Scope honesty: the moment a selection becomes “all matching this filter”, the header checkbox still means exactly one thing, and deselecting one host from an all-matching selection says so rather than letting the number quietly slide. And several operations at once, where the honest answer turned out to be this is the same as… and never these conflict: nothing is blocked, no ordering is chosen for anyone, and a superseded operation reads as “changes nothing — Set Log retention to 1d undoes it”.
Plus a pass on what gets expensive to retrofit once there is a visual layer. Focus used to be destroyed on every render, which made the whole thing mouse-only in practice; group headers were divs, so the central interaction could not be reached by keyboard at all; focusable elements had no accessible names. All fixed, and the counts that move when you exclude a host are now announced rather than changing in silence.
And building it caught three things that three evaluations did not. The batch has to become the truth the moment one operation is banked, not at two — gated at two, a single banked operation was still being previewed by the emptied picker, and the button offered to change every host instead of only the ones that would. The button, the summary line and the lane list all have to read from the same place; two of the three were still reading the single-operation plan. And a new piece of state has to be added in two places, because the reset path rebuilds the whole object — added in one, it threw on the first click.
None of those are model bugs. The model needed no change and no test moved. They are all the same fault: two parts of an interface disagreeing about what is true — which is precisely what this component exists to stop happening between a field and its value.