The recorder left out every call that opened a dialog, so a script with an
edit before a menu-style step lost that edit from the recording. A call is
left out only when it opened a dialog and changed nothing (the dialog's
confirming call is the step); one that changed the workbook is kept.
Run from inside another command, automate.stopAction rolled back that
command's own group, pulling the workbook from under the rest of its run; and
the recording buffer kept it, so a recording could replay the stop inside the
run it was part of. Nested, the command is refused and the group stays; the
recorder leaves it out (record::NOT_RECORDED).
The guard refused any name of the stash's shape, so a visible name a person
had defined, such as _gc_rate_0, could not be edited or deleted from Name
Manager, which cannot pass force. It now refuses a name only when the
workbook holds a hidden workbook-scope item under it; a visible name of the
shape is the person's own, as the stash itself treats it. The test covers
such a name. The query comment says what the copy skip relies on: a query
runs nothing that changes the history.
`Trust` defaulted to `Local`, and `DocState::new` set it, so a
document was trusted unless its maker remembered to mark it. Every
current path that reads a file goes through `file.open`, which marks
it `FromFile`, but a later way of building a document from bytes
(recovery, templates, a new import) would have come out trusted by
omission.
`DocState::new` now takes the `Trust`, and `Trust` has no default.
New workbooks and samples pass `Local`, `file.open` passes
`FromFile`, a new window passes the trust of its workbook, and the
tests that build documents from bytes pass `FromFile`.
`execute` copied the document's undo and redo history on every call,
so a panic could put it back: up to 100 entries per stack, each with
a label and a selection. Queries paid for it too, although no query
runs a command or changes the history, and front ends and agents run
queries all the time.
The copy is now skipped for queries (commands that neither journal
nor make undo steps). A test runs every query, with empty and with
typical parameters, and checks the history is unchanged, so a query
that ever changes it fails there. Commands that can change the
history, nested ones included, still take the copy: a nested command
that panics is restored at its own level, and the outer command can
carry on, so relying on the outermost copy would weaken that restore.
A menu-style call with no parameters, such as `file.open`,
`data.fromTextCsv` or `home.formatCells` with `{}`, only queues its
dialog and succeeds. The recorder kept it as a step with empty
parameters, and then kept the dialog's confirming call as well, so
playing the recording back ran the command twice, or failed on the
empty step when played back headless. Only `execute_headless` took
the step back, and only for the open and save-as dialogs.
`execute` now compares the queued UI requests before and after a
top-level command and does not record one that queued a dialog. The
special case in `execute_headless` goes (it keeps turning a missing
file into an error), and with it `Recorder::truncate`, which had no
other caller.
A command that opened an undo group and then returned an error (a `?`
between `group_begin` and `group_end`) left the group open for good:
Undo and Redo refused with "finish or stop the running action first",
later edits made no undo steps, and nothing could stop the group.
`execute` now rolls back every group a failed command opened, as
`group_rollback` does, restoring the workbook, selection, undo and
redo history to where each group began. Only a command that succeeds
keeps a group it opened, which is what a debugger's start needs.
`Session::abort_groups(doc)` and the menu-less command
`automate.stopAction` roll back the groups left open on a document,
for a debugger the person abandons or a caller that dropped its
token. The command is not undoable and works while a group is open,
and the Undo refusal now names it.
Every nested command a dry run executes is journaled, so a long macro pushes
the journal past its limit and the journal drops its oldest entries. The
restore cut the journal back to its old length, which then kept entries from
the dry run and pointed Record Actions at the wrong place. The session now
counts the entries its journal has dropped (journal_dropped) and truncates
with its size bookkeeping (truncate_journal); the restore keeps the old
length less what was dropped meanwhile and moves the recording's start back
by the same count. Regression test with 12,000 nested steps.
Session::grouped ran its action without a panic guard, so a caller outside
a command that caught the panic found the group still open: Undo and Redo
disabled, the partial edits kept, and the next grouped action nested in the
abandoned group. The group is now rolled back and the panic resumed.
Regression test.
- Workbook::macros is a shared list (Macros, a copy-on-write newtype): a
workbook copy made for a cell edit or an undo snapshot holds the same list,
and the undo budget charges a list once a snapshot owns its own copy.
Before, every mutating command copied every step of every macro, and a
hundred undo entries kept a hundred copies the budget never saw.
- Saving to xlsx drops the last save's items and writes the macros in one
pass each (stash::set_many), instead of a scan of every name per macro.
- A hidden _gc_macro_<x>_<k> name with a key this build did not write is
kept on open and on save, as a visible one is, instead of being dropped
without a warning.
- macros::check is the one check for every path that adds a macro (JSON, a
file, built in code): the model's limits and the xlsx size limit.
Regression tests for each.
A group open before a command and ended or rolled back inside it lost its
undo step when the command then panicked: the restored history held no entry
for the group's changes, so Undo skipped the state before the group. The
panic path now pushes the outermost such group's step from its start to the
restored workbook. The group stays ended, because its token went with the
command. Regression test for a group ended and one rolled back.
Truncating the undo history to its length at the command's start cannot
bring back an entry that a nested edit.undo popped. The guard now keeps the
undo and redo history as they were and puts both back. The regression test
gains the undo-then-panic case.
file.open with no file succeeds by queuing a dialog, which execute_headless
turns into an error for callers with no UI; the recording kept the step.
Recorder::truncate forgets it and keeps the byte count right. Regression test.
The panic guard restored the workbook and selection but not the history, so
a nested edit made before the panic left a no-op undo step and the redo
history it had cleared stayed lost. The guard now truncates the steps made
during the command and puts the redo history back. Regression test, and the
architecture note says what the guard restores.
Session::grouped ended its group after the action, and when the action had closed the group's document (file.close, or file.open taking the place of an untouched new workbook) the group had gone with it, so the action was reported as failed although it had done what it was asked. A group that is gone because its document is gone now counts as ended; a group ended by someone else while the document is open is still an error.
The history limit is applied once the outermost command has finished, so the lengths its bookkeeping compares stay true (round 3). That left the steps nested commands make unbounded until then: a script of up to 100,000 steps, or a subtotal with a step per group, kept every one of them alive, each with the cells it retains, and before this branch every push trimmed the history at once.
Each nested command now bounds the steps made since the latest mark on its document to the undo limit and byte budget, dropping the oldest of them. The marks are the starts of the running commands, the starts of the open groups and the end of the last group: the positions a command or group collapses its steps by when it finishes, so nothing before them moves. What the bound drops is only what the final trim would drop.
Two boundaries of the group rules were wrong. A command that ended a group open before it was treated as grouped whatever it did next: with an unchanged group (no step) its later edit had no undo entry at all, and with nested edits after the end the entry was the nested command's. The group's step now stays the group's, and the command makes one step of its own, under its name, for what changed after the end, from the workbook the group ended at.
A command that edited and then opened a group it leaves open (a debugger's start) left its nested entries in the history, so one Undo after the group ended brought back a state from inside the command. Such a group now takes the command's start as its own, and its step covers the command's earlier edits.
The record of where the last group ended now holds the workbook and selection it ended at, the history length then and whether it made a step; it lives until the outermost command finishes. A rollback records where it ended too, unless the document is back exactly where the last recorded step left it.
The record of the last group step held the document, the history length and a group epoch, and a command reused the group's label when the lengths matched and the epoch was newer. A direct edit after the group (no nested command, so the length still matches) then left the step named after the group alone.
The record now holds the workbook the group's step led to (a weak reference, so it keeps nothing alive). A command keeps the group's name only when the one entry it added starts from the command's own starting workbook and ends at its result, which is provenance by identity rather than by position: an entry a nested command made, a step of an earlier command undone since, and a group step the command edited past all name the step after the command. A later group that rolled back changes nothing, and the rollback no longer needs to clear the record.
Approvals were stored as (document, FNV-1a hash of the macro's JSON).
FNV-1a is not collision resistant, so a crafted workbook could hold a
harmless macro and a different one with the same hash, and approving
the first would let the second run without asking.
The key is now the macro's canonical JSON text itself (object keys
sorted), so two different macros never share an approval.
macros::hash is replaced by macros::fingerprint.
macro.run runs a macro as one undo step ("Run Macro: <name>"); an error
at any step rolls everything back and names the step and its path (3,
2.1 inside a Repeat, 4.then.1 inside an If). It works in four parts:
- Policy (macro_policy). File, app, window, automate and macro commands
and Undo and Redo never run in a macro. Reading or writing files
(data.getData, data.fromTextCsv, insert.picture, Page Layout
Background, or an export with a path) needs the command in the
macro's permissions and the person's approval. A macro in a workbook
opened from a file asks before it runs. Approval holds for the
session until the macro's content changes; it is asked for with the
new EngineError::Approve and given with approve: true. A step that
would open a dialog or a web page fails instead.
- Inputs. Each declared input is bound (given, its default, or the
selection for the first one), whole columns shrink to the used range,
and the shape and column types are checked with messages that name
the cells: "Input column 2 (Amount) must hold numbers; C4 holds text".
- Footprint. Unless the macro may overwrite, it first runs in dry-run
mode on the real workbook inside an undo group that is rolled back,
with the session's clipboard, journal, windows and the document's
trust put back. CellStore::diff on every sheet, plus the cells
commands wrote (DocState::touched), sheets and objects, gives what it
would write outside its inputs; cells that only recalculated do not
count. Writing over data, deleting a sheet or deleting an object is a
conflict: ask (the new EngineError::Confirm, so programmatic runs fail
unless they pass onConflict or overwrite), fail, overwrite, or shift
the data down or right with Insert Cells and check again. A step that
would fail, for example past the edge of the sheet, fails the run
before anything changes. File steps and Solver stop the dry run early
and are reported.
- Steps run on a machine with an explicit frame stack: Repeat over the
rows or columns of a range with Row or Column bound, If on one cell
with Excel's comparisons, and Stop, which keeps the changes or rolls
them back. A run executes at most 100,000 steps within a time limit
(60 s by default, timeLimit to change; the web build has no clock, so
only the step limit applies there).
macro.check reports the bound inputs, the output anchor, the approval
needed, the footprint and the conflicts without changing anything;
nothing from an unapproved macro runs, not even as a dry run.
A macro is plain data in the model (model/src/macros.rs): a name, a
description, an optional shortcut, declared inputs (shape, header,
column types, non-empty, default reference), steps, an output rule
(anchor and what to do on a conflict) and the commands it may run that
read or write files. A step runs a command (id and params) or is one of
three bounded control steps: Repeat over the rows or columns of a range,
If on one cell's value, and Stop with a message. Every step carries
enabled, breakpoint and note flags. These are GridCraft macros, not VBA;
a VBA project in a file stays preserved and is never run.
Workbook::macros holds them. The JSON format keeps the field. In xlsx,
io::save_bytes writes each macro as one chunked hidden-name item,
_gc_macro_<n>, and io::open_bytes takes the items back out of the names,
so the names never linger in the open document.
Limits are checked whenever a macro is read from a file or a command:
at most 1,000 macros, 10,000 steps each, Repeat and If nested 4 deep,
names and notes of bounded length, and at most 4,194,304 characters of
JSON, so that saving a workbook never fails because of a macro; nesting
is checked before the steps are deserialized. What is over or damaged
is dropped with one warning, and clashing names get a suffix.
Automate scripts keep their own storage (_sc_scripts) and are neither
read nor changed by this.
A command that changed the workbook cleared the redo history even when an open group owned
its step. When the group was then rolled back inside the same command (the debugger's Stop
with Undo Changes), the rollback put the redo history back and the command's bookkeeping cleared
it again. Now only a command that makes a step of its own clears the redo history; a group
clears it when it ends as a step, and a rollback restores it.
The record of the last group step (document, history length, epoch) outlived the Undo of that
step. A later command that opened and closed its own groups at the same history length, after
rolling one back, passed the epoch check (the record's epoch merely differed from the one the
command started with) and took the name of the nested edit it had made instead of its own.
The step must now come from a group ended during the command (an epoch past the command's
starting one), and a rollback drops a record of a step it truncated away.
crates/engine/src/tests.rs: kept both sides' new tests (journal tests from #1230, HTML export tests from the PR).
docs/file-format-parity.md: header Last updated 2026-10-21 with both change clauses; both revision-history rows, newest first.
crates/engine/src/lib.rs: kept both the journal caps (#1230) and the undo byte budget; separate constants and call sites.
crates/engine/src/cmd/edit.rs: undo pushes through push_history; the redo entry keeps the undone edit's selection (#1855 behaviour).
crates/engine/src/tests.rs: kept both tests (redo_selects_the_redone_cell and the two history-bound tests).
A workbook opened with GridCraft scripts (`_sc_scripts`) starts untrusted:
automate.runScript refuses them until the user trusts the workbook for the
session (Macros dialog > Trust Scripts, i.e. automate.trustScripts) or the
caller passes `trusted: true` for that one run. Workbooks without scripts,
and scripts written in the session, are unaffected.
Whatever the trust, a script whose steps reach outside the workbook
(file.*, link.*, or any `path` param: Get Data, From Text/CSV, Insert
Picture) is refused before any step runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the history held 100 steps, group_end pushed its step and dropped the oldest one at once,
so execute's collapse compared against a length that no longer matched. A command that was one
group kept the group's step and added its own, losing two old steps. After test.editThenGroup,
the second Undo brought back the intermediate state after the outside edit.
The history limit now applies after the outermost command has collapsed its steps, and when a
group ends outside any command. A document with an open group is trimmed when the group ends.
execute treated any change in the group epoch as 'the group owns the step'. A command that edited outside its own group, or whose group rolled back before it edited, left that edit with no undo step or only part of it. Now only a group that was open before the command (or is still open after) owns the step; a command that opened and closed its own groups replaces their steps with one entry for the command.
Active content such as macros must not run only because a workbook was
opened. DocState gets a trust state: Local for workbooks made in the
session (new workbooks and samples) and FromFile for anything opened
with file.open. Active content in a FromFile workbook asks before it
runs. A second window on a workbook shares its trust.
The clipboard carries the trust of its source document. Pasting
formulas, formats (conditional formats hold formulas) or validation
from a FromFile workbook into another workbook makes that workbook
FromFile too; pasting values, column widths or notes carries nothing
active and changes nothing. Sheet moves and copies stay inside one
workbook, so its trust is unchanged. document.inspect reports the trust.
A macro recorder needs more than a journal index: the journal is
trimmed past 10,000 entries, it also holds the commands that other
commands run (so playback would run them twice), and it keeps no
selection, which relative references need.
Session now tracks how deeply execute calls are nested. While recording,
a buffer (record.rs) keeps each successful, journaled top-level call with
the document uid, the active sheet (index and name) and the selection it
ran in, then its id and parameters without secrets, as the journal
stores them. Queries, failed commands and the command that starts
recording are not kept. The buffer holds up to 100,000 steps and counts
any it drops. Session::start_recording, stop_recording, is_recording and
depth are the interface.
Automate › Record Actions keeps recording through the journal as before.
Session::grouped(label, f) runs f as one undo step on the active
document. On success the group leaves exactly one undo entry holding the
workbook from before the group; on error it restores the workbook,
selection, undo and redo stacks as they were and returns the error.
group_begin, group_end and group_rollback do the same across separate
calls, for an action that runs one step per call.
While a group is open on a document, undoable commands on it add no
undo steps of their own, so a long group neither grows the history nor
pushes older entries past the undo limit, and Undo and Redo are
disabled there. Groups nest: the outermost one makes the step, and a
failing inner group rolls back only its own changes. Closing a document
drops its groups, and a command that panics drops the groups it opened.
history.list reports whether a group is open.
Insert Copied Cells folds its nested steps into one undo step by
counting revisions; inside a group the nested steps push no entries, so
it now leaves the history alone there instead of removing entries from
before the group.
Save as Web Page wrote only the cell table, so pictures and charts on a sheet
were dropped. A sheet that has either now keeps its own geometry and draws them:
- The table gets the sheet's column widths and row heights (`table-layout:fixed`,
a `<colgroup>`, a height per row); hidden columns and rows take no room and
merged-cell spans count only visible lines.
- A picture is an `<img>` with a `data:` URI, a chart is inline SVG built from
the chart crate's drawing primitives (titles, axes, legends and data labels
included), each absolutely positioned at its anchor cell plus offset.
- Sheets without pictures or charts write byte-identical HTML.
Adds contributors/people.toml entry for bilalmughal.