# The headless CLI `improvise cmd` and `improvise script` are one function — `run_headless_commands` in `src/main.rs`; `run_headless_script` reads the file and delegates. Every claim below therefore holds for both, and a fix to one is a fix to both by construction rather than by remembering. ## 1 — the status line is the only channel, so it must be printed `App` has no return channel for "what happened". A command's outcome — a refusal, a warning, a `fit-measure`'s reproducing seed — is written to `ViewState::status_msg` and rendered by the TUI's bottom bar. Headless, nothing read it, so **success and refusal were byte-identical: silence and exit 0** (improvise-q914, improvise-j6x8). Two things followed that are worth keeping in mind, because both were mistaken for correct behaviour by agents working above this layer: - A P1's carefully-worded refusal can be written, tested at the effect, and still reach nobody, because the test asserts the *effect's* status and the surface that would show it drops it. That is this repo's characteristic defect one layer out, and the only cure is a test at the process boundary — `tests/headless_cli.rs`, which spawns the real binary. - `improvise cmd 'fit-measure …'` looked like it did nothing whether it worked or not, which made the whole GP feature read as broken from a script. The loop takes the message with `std::mem::take` and clears it per command, so one command's status cannot be reported twice or attributed to its successor. ## 2 — stderr, all of it, and why no split was possible The tempting design is stdout for a report and stderr for a refusal. **It cannot be built honestly**, because `status_msg` is a bare `String` with no severity, and both candidate classifiers are disproved by code in the tree: - *"A refusal is a batch that aborted."* Only three effects set `abort_effects` (gotcha 21). `AddItem` writes `workbook.add_item`'s `Err` straight to the status and does not abort — and that is q914's own worked example of a refusal. So this classifier puts a refusal on stdout labelled a success. - *"A refusal is anything that spoke."* `cmd/fit.rs` sets a status on **success**, carrying the seed that reproduces the fit. So this one labels a deliverable a failure. Guessing between them would be precisely the adjacent-check defect `context/design-principles.md` §7 names. So everything goes to stderr: it is the diagnostics stream, it leaves stdout free for a future data output, and it matches every other diagnostic `main.rs` already emits (`Saved to …`, `warning: …`, `Parse error: …`). **No `--quiet` flag.** Silence is never the default and `2>/dev/null` is the standard suppression; a flag would be a second knob with nothing asking for it. ## 3 — the exit code (superseded by §10) This section used to say: exit 1 on a parse error or on `App::last_batch_aborted()`, and a refusal that only writes a status exits 0, because closing that needed a severity beside `status_msg` — a `ViewState` change, and so a wire change (gotcha 25). Both halves were wrong in a way worth keeping. The abort half made a *success* exit 1 (improvise-c5ip), and the severity half named the wrong home: the question an exit code asks is about one batch, not about a message, so it belongs beside `batch_aborted` on `App` and never reaches the wire. §10 is what replaced it. ## 4 — a run that changed nothing does not write `persistence::save` used to run unconditionally, so `improvise cmd` normalised the document on every invocation — `1e20` expanded to twenty digits. The cost was not cosmetic: an agent editing a fixture by string-replacing a value after one run got a silent no-op, and two misleading test results out of it. The gate is `changed`, accumulated from `model_state.dirty` **after each command**, not read once at the end. Reading it at the end is wrong because `effect::Save` clears the flag, so `[add-item …, w other.improv]` would look unchanged and drop the edit from the `-f` document. `!path.exists()` is or-ed in so that naming a new file still creates it. Over-approximation is the safe direction here, the same one `Dirty::of` takes (gotcha 35): a `Model(_)` effect that happens to write the value already there still costs one redundant save. ## 5 — the refusal names the word the user typed `CmdRegistry::resolve` maps `formula` → `add-formula` *before* the arity check runs, so every validator that spelled its own name reported a command nobody typed (improvise-z05h). The fix moves the name to the one place that knows which of the two the user wrote: `require_args` no longer takes a `word`, and `CmdRegistry::parse` prefixes `"{typed}: "` onto any `Err`. That removed 41 name literals from `cmd/`, which is the same argument gotcha 15 makes about `registry.rs` having no name literals — a name spelled twice can be spelled wrong once. `interactive` deliberately does **not** prefix: `dispatch` in `command/keymap.rs` already formats `"{name}: {e}"` with the *bound* name (improvise-55l5, PR #126), and prefixing in both would double it. The message states the **shape** rather than the arity, because "requires 1-2 argument(s), got 6" does not tell anyone to quote anything. ## 6 — the help's ex-commands were the unguarded surface `help.rs` derives `HelpLine::Keys` rows from the keymap and drops any row whose command does not resolve, so a key row cannot go stale. `HelpLine::Literal` (an ex-command only, since improvise-wk18: built by `ex!`) and `HelpLine::Accent` rows are hand-written prose and had nothing checking them — which is how all three documented spellings of `:formula` came to be ones the parser refuses. `every_ex_command_the_help_pages_show_is_one_the_parser_accepts` closes that surface rather than the one instance: every help line whose trimmed text opens with `:` is split on its colons and each piece handed to `parse_line`. The split exists because one row shows two examples side by side. What it still cannot see, and should be closed if the welcome text moves again: `draw.rs::draw_welcome` builds its lines as a local array inside a ratatui render function, so nothing can walk them. Its `:formula` line was corrected by hand. ## 7 — a script's command is built against the cursor it will run against `improvise cmd` and `improvise script` used to resolve every command through `CmdRegistry::parse` — the **pure** arm, which builds `CursorState::default()`: row 0, col 0, and a grid of zero rows and zero columns. The interactive arm, which builds `CursorState::from_ctx(ctx)`, was never reached from a script. So every `Move` (`move-selection`, `scroll-rows`, `page-scroll`, the four `jump-*`) and `enter-advance` snapshotted a zeroed cursor at **parse** time, clamped to `(0, 0)`, and emitted `SetSelected(0, 0)`. That is worse than a no-op: it *resets* the cursor. Nothing could position it, so every cursor-reading command — `clear-cell`, `yank`, `paste`, `cycle-axis-at-cursor`, `hide-selected-row-item`, `edit-or-drill`, `drill-into-cell`, `toggle-group-under-cursor`, the three `delete-*-at-cursor`, `commit-and-advance` — was pinned to the origin (improvise-6uva). It was introduced with `CursorState` and the two-arm registry in April and predates the browser work by five months. **A registry with two construction arms silently demotes every caller of the weaker arm, and nothing type-checks which arm a caller deserves.** That is the shape to remember, and it has a second instance in the same fix: the `register_pure` entries had an interactive arm that refused **empty** args while their pure arm accepted them, so `:w` (save to the current path) parsed one way and refused the other. Both arms are now `parse`, which is what "needs no interactive context" was supposed to mean; `a_pure_commands_two_arms_agree_on_no_arguments` is the guard. The repair is `parse::segments` plus `parse::parse_segment_interactive`, and the loop re-parses **per segment**, because command *n* must see command *n-1*'s cursor — which means construction cannot be hoisted out of the loop. Line atomicity is preserved by keeping the existing whole-line `parse_line` as a **syntax gate** before anything in the line runs: a line that parsed before still parses, and a line that did not still does not, so the only behavioural change is which cursor the commands see. **That atomicity claim was false as written** (improvise-2bac), and §8 is what makes it true. The gate runs the pure arm; the loop runs the interactive arm. A gate is only a gate if *every argument list the pure arm accepts, the interactive arm accepts too* — and `commit-cell-edit` broke it: `add-category Foo . commit-cell-edit editing 5 Foo/x` on an empty document passed the gate, applied the first segment, and then refused the second with `no cell at cursor`. Half the line landed. The TUI's `:` line had the same defect and is fixed the same way, with one limitation that is not new: `ExecuteCommand` returns one effect batch, so the segments of `:a . b` all see the pre-batch cursor, exactly as a `Binding::Sequence` does (gotchas 21 and 23). ## 8 — the interactive arm is `parse`, then the cursor; it is never a second parser #151 fixed three instances of *a registry with two construction arms demotes whichever caller reaches the weaker one*, one patch each. A post-merge review found the fourth (improvise-2bac): `commit-cell-edit `, whose interactive arm read only ``, took the value from the `edit` buffer and the key from the cursor. So a script that wrote 5 to `Region/East` **deleted the cell under the cursor** — the empty buffer, committed — with exit 0 and nothing on stderr. The fifth was already in the tree: `toggle-panel-and-focus false false` ignored both flags and toggled. The shape rather than the instances is what changed. `CmdRegistry::register` used to take two free functions, `parse(args)` and `interactive(args, ctx)`, and nothing related them; each command re-derived its arguments twice and every disagreement was silent. The interactive arm is now **built from `parse`**, not beside it: - `parse(args) -> C` — the only function that reads arguments a caller supplied. - `fallback: Option<(args, ctx) -> C>` — reached **only** when `parse` refused. This is where "no coordinates means the cursor" lives: `clear-cell` bare, and `commit-cell-edit ` bare, which is what the keymap binds. - For `register_at_cursor` only, the registry itself then sets the command's **cursor** — its `CursorState`, reached through `AtCursor::cursor_mut` — to the live one. That is the whole of what the interactive arm adds to a parsed command, and no registration supplies it. Three consequences hold by construction rather than by test. **Whatever `parse` accepts, the interactive arm accepts** — nothing after `parse` can fail — so the §7 gate cannot pass a segment the loop then refuses. **An accepted argument list cannot be overridden by the cursor**, because the only function that sees both args and context never runs on an accepted list. And **no field `parse` set can be overwritten from context**, because nothing a registration writes can name one: the cursor slot is typed `&mut CursorState`, and no command parses a `CursorState` out of its arguments. `register_pure` and `register_nullary` already had one arm; they are `register` with no fallback. That last one is improvise-yf7p, and it is why the registry lost a parameter. #171 took a third function, `contextualize(C, &CmdContext) -> C`, handed the built command and not the args, and stated honestly that it could still **overwrite** a parsed field — naming the field was "a visible line in review". Review then injected five such overwrites on commands other than the one with a test (yank, paste and drill-into-cell taking their key from the cursor over the parsed one; toggle-panel-visibility ignoring its parsed panel; move-panel-cursor clamping its parsed delta to ±1) and the whole workspace stayed green. A hole guarded by review discipline plus one command's test is not guarded. Every contextualizer in the tree fell into one of two shapes, and each had an answer that removes the function rather than checking it: - **The cursor** (`Move`, `EnterAdvance`, `CommitAndAdvance`). Always one `CursorState` field, always `CursorState::from_ctx`. So the registry does it, through a slot whose *type* is the guard: an `AtCursor` impl pointing at a `CellKey` or a delta is a compile error, which was tried. - **A read of context that belonged in `execute`** (`toggle-panel-visibility`'s `currently_open`, `cycle-panel-focus`'s three flags, `move-panel-cursor`'s cursor and bound). These copied context into the command only to read it back; `execute` now reads `ctx` directly, the same answer `toggle-panel-and-focus` got below. Those three are `register`/`register_nullary` now, so their interactive arm is exactly `parse`. What `execute` does with a parsed field is still the command's own logic and is tested as such — `toggle_panel_visibility_reads_the_panel_it_names` and `move_panel_cursor_moves_from_the_named_panels_own_cursor` fail if the panel or the delta is ignored there. The difference is that the registry is no longer a second place such a bug can be written. What is still representable, stated rather than hidden: - A `fallback` sees the args `parse` refused and could ignore some of them. `commit-cell-edit`'s refuses anything but a lone mode, so the malformed `commit-cell-edit editing 5` is an error on the `:` line — which has no pure gate — rather than a commit of the edit buffer to the cursor cell. - The gate is **stricter** than the loop wherever a fallback exists: a bare `clear-cell` is refused in a script. That is the safe direction — the whole line is refused — and it is unchanged from before #151. - A new command could declare a *second* `CursorState` field and parse it from args; `cursor_mut` could then point at the wrong one. No command has one, and a parsed cursor would be a new idea deserving its own argument. `toggle-panel-and-focus` did not fit #171's `contextualize` either, because its pure arm defaulted the omitted flags to `true` and a contextualizer cannot tell an omitted flag from an explicit one. So the omission is now in the type — `target: Option<(bool, bool)>`, `None` meaning *toggle* — and the toggle is resolved in `execute` against the same context. That is the honest home: the decision depends on context, and `execute` is where context is. Guards: `every_argument_list_the_gate_accepts_the_run_accepts_too` enumerates **every registration** against every candidate argument list, in a context whose cursor sits on no cell — the context in which the old `commit-cell-edit` arm refused. It lists no commands, so a new registration is covered the day it lands. It is backed by the process-level tests in `tests/headless_cli.rs`. ## 9 — the extent is fixed, because it reaches the document A headless run used to read `crossterm::terminal::size()`, and the page size derived from it decides where `page-scroll` lands and therefore which record the next cursor-reading command writes (improvise-up8g). So one script wrote a different document in a different window, and with no tty — CI, cron — the read failed and left `App`'s default, so CI and a shell disagreed too. It was invisible until §7 let a script see its cursor. `HEADLESS_EXTENT` in `main.rs` pins it to 80x24 — the size CI already had, so nothing that passed there moves. There is **no flag** to change it, for §2's reason: a second knob with nothing asking for it. A script that wants a particular row says so with `move-selection` or `jump-*`, which do not depend on any window. What this does not close: the **TUI** still takes its extent from the terminal, and that is correct there, because the window really is an input to an interactive session. The ticket's wider claim — that a replayable log needs the extent in it (improvise-ilvl) — is about that log, which does not exist yet; a headless run has nothing to replay *except* its script, so pinning is the whole fix for this surface. ## 10 — the exit code asks whether the change happened, never whether a chain stopped `App::last_batch_aborted()` answered the wrong question for an exit code, in both directions at once. - **A success that exited 1** (improvise-c5ip). `commit-cell-edit` ends in `EnterAdvance`, which emits `AbortChain` when the cursor is already bottom-right or the grid is empty: *the advance's premise no longer holds*. The write had already landed and was saved, and the run exited 1 with nothing on stderr. Before §7 that was every scripted commit. - **A refusal that exited 0** (improvise-udt2). `AddItem` into a missing category writes the error and does not abort, because nothing after it depends on the item. So the flag never saw it. An abort is a statement about the **chain**; a refusal is a statement about the **request**. `AbortChain` stops a chain without refusing anything, and `AddItem` refuses without stopping anything. The two coincide only for `AddCategory` and the file writes, which is why reading one for the other looked right for so long. So `App` carries a second per-batch outcome, `batch_refused`, reset where `batch_aborted` is and written by exactly one method, `App::refuse(msg)`, which sets the status and the flag together — a refusal cannot write its message and forget the flag. Commands reach it through `effect::refuse`, the `Refuse` effect, beside `effect::set_status` for reports. `main.rs` exits 1 on `last_batch_refused()` and no longer reads `last_batch_aborted()` at all. **Why not a severity on `status_msg`**, which udt2 proposed. A severity on the message would live in `ViewState`, so it would be portable state sent to every client in a `Snapshot`, and it would go **stale**: every one of the ~60 writers of `status_msg` would have to reset it or leave the previous message's severity in place. A per-batch flag cannot go stale, because `apply_effects` clears it before every batch, and it is not in `ViewState`, so nothing on the wire carries it or has to agree with it. The TUI colouring the ticket hoped for is still possible later, and would want the severity *on the message*; that is a separate change with a separate staleness problem. **The classification is per writer.** Every refusal in `ui/effect.rs`, in `cmd/{cell,commit,fit,grid,mode,panel,search,text_buffer}.rs` and in `command/keymap.rs::dispatch` goes through `refuse`. The last of them were converted in two steps: - **improvise-i3o5:** `EditOrDrill`'s collapsed-group arm, and the three spelled-paste declines in `cell.rs` (aggregate, coordinate column, inside a drill). - **improvise-cwfj:** the three `cmd/panel.rs` declines, and `dispatch`'s arg-error arm. The sweeps that pin refusals revert `refuse(` calls, so they **cannot see a refusal written as `set_status`**. Finding one takes a read of every `set_status` on a decline path, and that is how both batches were found. **A bound key whose command will not build is a refusal** (the orchestrator's ruling, improvise-cwfj). The key asked for something that did not happen, which is this section's definition. So `dispatch` answers a constructor's `Err` with `refuse(e)` rather than `set_status(e)`. That arm is where every key-path decline lands, including `p`, `x` and `i` onto an aggregate, a fold, a coordinate column or inside a drill. Before the conversion, the key path was checked for any reader of the flag: - `last_batch_refused` is read in production only by `Host::mutating_frame`, which handles frames and not keys, and by `main.rs`'s headless loop, which handles `:` lines and not keys. - `Refuse` is `ViewOnly`, like `SetStatus`, so the `Dirty` a key returns and the projections built from it are unchanged. - The browser lockstep suite compares `ViewState`, including `status_msg`, never the flag, and `Refuse` writes the same message. The whole workspace stayed green with the change. `q` on a dirty document is deliberately left a report: headless, its text is false — the CLI saves anyway (improvise-ep5o). The two rivals udt2 named both stay green: a successful `:w` and a successful `fit-measure` report through `set_status` and exit 0. **`remove-formula` of a formula that is not there now exits 1** (improvise-tej1, improvise-9trl). It used to exit 0: the effect was keyed by names and removing nothing was a silent no-op. Since tej1, `RemoveFormula` names its formula by `FormulaId`, and `remove-formula ` resolves the two names to an id in `execute`. When nothing matches it `refuse`s with `No formula for in `, so a script that removes a formula it never added now fails where it used to pass. That is the §10 rule applied: nothing changed, so the request was not met. `remove_formula_of_a_missing_formula_is_a_refusal` pins it, with a present formula as the control. ## 11 — a headless import refuses colliding records unless told otherwise (improvise-cgqt) `import --no-wizard` takes `--on-duplicate refuse|sum|separate`, and the default is `refuse`. The argument is in `context/code/formula-and-io.md`, "Colliding records are a question, not a default". In short, a default of sum is the silent outcome the user called critical, and a default of separate reshapes a scripted document. The refusal prints every colliding tuple through `WizardCollision::describe`, the wizard's own wording, then the flag. It exits non-zero and writes nothing. It is checked before the build rather than read off the build's error, so the listing is structured data and not a parsed sentence. `separate` uses `ImportPipeline::suggested_duplicate_category`, and there is deliberately no flag to name the category yet. ## 12 — `import` takes folders, and the CLI only says which arguments are folders (improvise-lpzu) `improvise import` takes any mix of files and folders. A folder stands for its `*.csv` files, in name order. A folder always merges, even when it holds a single CSV, so that file keeps its `File` item and view. A single file named directly still imports alone (the user's ruling on the PR). The rules and their reasons are in `context/code/formula-and-io.md`, "A folder of CSVs is one sparse model, with a view per file". The CLI's part is the one question `Storage` cannot answer: whether a path is a folder. `get_import_data` asks `Path::is_dir` for each argument, hands the resulting `ImportPath`s to `expand_import_paths` over `PhysicalFs`, and from there takes the path a list of files always took. A folder with no CSV in it is refused before anything is read. The message names the folder, and the run exits non-zero and writes nothing. A merge now reaches the build as an `ImportPipeline` carrying the merged files' `sources`, rather than as a bare JSON value. Both `--no-wizard` and the TUI wizard (`run_tui` → `App::start_import`) therefore build the per-file views. The headless path matters most, because the code-maat pipeline (improvise-1xfb.5.1) runs there. `a_headless_import_of_a_folder_merges_its_csv_files_and_repeats_byte_for_byte` pins the views, the sparse Data lines and the byte-identical second run, with the first run's output sitting in the folder it reads. ## 13 — a headless import refuses rather than saving less than was asked (improvise-8jk6.4, 8jk6.5, 8jk6.6) The user treats the headless command and model layer as a product of its own, and the scripted `import --no-wizard` is where it meets a pipeline. The repo-stats pipeline (improvise-1xfb.5.1) found three ways that import saved something other than what the flags asked for, and still exited 0. The reasoning for each is in `context/code/formula-and-io.md`, "A CSV field is text, and the field's role reads it" and "An import refuses what it cannot keep". The CLI's contract: - **`--category` keeps the CSV text.** A forced category's items are the file's text, verbatim: `00565796` stays `00565796`, and `220944e2` stays `220944e2`. Measures still read as numbers. A JSON number has no source text, so it is named as JSON writes it (`1e3` is `1000.0`). - **An unparseable `--formula` is a refusal.** It exits non-zero and writes nothing. Every bad formula is named on stderr, each with its parse error. This is §10's rule: the request was not met, so the exit code says so. The wizard refuses the same text at the formula step, with the same wording. - **An import that kept no record is a refusal.** It exits non-zero, writes nothing, and says why: every record was skipped as not a JSON object, or the source had none. - **An import that skipped *some* records exits 0 by default.** It writes the model and warns with a count. With `--strict` (the user's ruling on PR #321) any skipped record is a refusal instead: exit 1, the same count named, and nothing written. The argument is in `formula-and-io.md`. Two refusals bail in `run_headless_import` before the build: the `--strict` skip check (`src/main.rs:305`) and the `--on-duplicate refuse` collision check. The rest come from `build_model_with_warnings` as an `Err`. All of them reach `main` as exit 1 before `persistence::save`. So "writes nothing" holds by statement order, not by where the refusal is raised. The headless tests check it by asserting that the output path does not exist. ## 14 — the command language reads `|pipe quoting|`, and a surplus argument is refused (improvise-31oe.3) `create-view |By area|` used to make a view named `|By` and drop `area|` without a word. Two separate defects, fixed separately. **The quoting.** `parse.rs` now has one lexer, `lex`, and everything else is built on it: `parse_line_with`, `segments` and `parse_segment_interactive`. The rules are: - A `|…|` piece is read by `improvise_formula::quote::unescape_pipe_body`, the one home of pipe quoting (gotcha 34). So `\|`, `\\` and `\n` mean exactly what they mean in `.improv` and in formulas. The lexer only finds the closing pipe: a backslash takes the next character with it, so `\|` does not close. - A piece may sit **anywhere in a word**, and the pieces and bare text are joined. So a coordinate quotes each half, `|Sales Region|/|East Coast|`, the way a `.improv` data line quotes each name. The alternative, a pipe only at the start of a word, would have left no way to spell a coordinate with a space in it. - `"…"` is **kept**, verbatim, with no escapes, and a `|` inside it stays a `|`. It is not a workaround to retire. Formula text carries its own pipes: `add-formula "Net = |Gross Rev| - Cost"` must reach `parse_formula` with the pipes on. If the line had pipes only, every pipe in a formula would need escaping. Its old leniency is kept too: a `"` that is never closed runs to the end of the line. A `|` that is never closed is refused, naming the text from the open pipe on, because a name is never meant to run to the end. - The separator is a **bare** `.` word. A quoted `|.|` or `"."` is an argument, and a `.` inside `|a . b|` does not split the line. The old `split_on_dot` was a second scanner that knew double quotes and not pipes. With one lexer there is no second scanner to disagree. - A comment line (`#`, `//`) is not lexed, so an unmatched pipe in a comment is not an error. `segments` now returns `Result`, because an unterminated pipe is a fact about the whole line. `main.rs` refuses the line before running any of it, as §7 does for a parse error, and `ExecuteCommand` refuses it on the status line. What is still not spellable: an item whose name contains `/`. The coordinate is split on the first `/` **after** unquoting, so `|a/b|/x` is still read as category `a` and item `b/x`. Closing that needs the lexer to hand structured coordinates to the commands, which is a larger change than this ticket. **The arity.** Every registration now declares `MaxArgs::AtMost(n)` or `MaxArgs::Any` beside its prototype. `CmdRegistry::parse` and `CmdRegistry::interactive` apply it after construction succeeds. So the rule is written once and every command gets it, and a new registration cannot compile without a declaration. The refusal names the command, the limit and every surplus argument (`create-view: takes at most 1 argument(s), got 2; unexpected "area"`). A headless run exits non-zero through the §7 parse gate, and the `:` line refuses on the status line. It runs **after** the command's own parse, deliberately. A command whose own refusal states its shape keeps it: `add-formula`'s "quote the formula" (improvise-z05h) is more use than an arity, and it is what `formula Product Total = Widget + Gadget` still says. `MaxArgs::Any` is a claim, and the list of commands making it is short: `add-items`, `set-cell`, `set-format` (it joins its words), `clear-cell`, `yank`, `paste`, `drill-into-cell` and `commit-cell-edit`. Each takes a list of items or coordinates. `no_command_silently_drops_an_argument_it_was_given` names that list, so widening another command to `Any` has to add it there. ## 15 — a command that names something the document lacks is refused (improvise-31oe.2) `set-axis Regoin row`, `set-page Region Nowhere` and `set-axis Region sideways` all exited 0 and said nothing. §10's rule applies: nothing changed, so the request was not met. - **An unreadable axis is an argument error.** `set-axis` now checks its axis with `parse_axis` when it is parsed, so a script is refused before any of it runs. `execute` still refuses an axis it cannot read rather than returning nothing. That arm cannot be reached through the registry. - **An unknown category, item, group or view is a refusal**, from `RequireKnown` (below), because only the document knows what it holds. `effect::unknown_name` asks the active view (`try_axis_of`) for a category. That is exactly the condition under which `View::set_axis` was a silent no-op. It asks `effective_item_names` for an item, so a formula target is a known `_Measure` item. It asks both an item's own group and the category's `groups` for a group. `add_item_in_group` registers no `Group`, and a parent group has no items, so either one alone misses a real group. A view is asked of `workbook.views`. The siblings fixed with it: `hide-item` and `show-item` (hiding an unknown item wrote it into `hidden_items`), `toggle-group`, and `switch-view` (the effect ignores a view it lacks, and says nothing). `set-page` with an unknown item was worse than a no-op: it wrote the name into `page_selections`. The check runs at **apply** time, as an effect of its own. `execute` runs against one context built before the line ran, and a `:` line builds every segment up front, so a check in `execute` refused `create-view Second . switch-view Second` and `add-item Type Tools . set-page Type Tools` after their first halves had landed, while `improvise cmd` (which rebuilds the context per segment) accepted them. Each of these commands therefore returns `RequireKnown(needs)` followed by the real effect. `RequireKnown` refuses and aborts the batch (gotcha 21), so the real effect never applies. It is a `ViewOnly` effect that writes nothing and is off the wire on purpose: the wire's own effects (`model_effects`, gotcha 38) stay non-refusing, since a refusing effect there becomes a frame rejection (`MutationRefused`). That is arguably right, but it changes the protocol, and a browser composes these only from names it was sent. It is left for a ticket of its own. A refused name stops the rest of a `:` line, because the abort ends the one batch the whole line runs as. Headless runs each segment as its own batch, so an abort never crosses a `.` there: `:set-page Type Nowhere . add-item Type X` adds nothing, while `improvise cmd` on the same line exits 1 and still adds `X`. `load`/`Save` already behaved this way. The two front ends' chain semantics are left for a ticket of their own. The interactive cursor fallbacks of `clear-cell`, `yank`, `paste` and `drill-into-cell` answer only for coordinates that were OMITTED (`only_when_omitted`). Coordinates that were given and are malformed are refused, never replaced by the cursor's cell.