Chomen architecture
This document owns Chomen's domain and runtime decisions. Read it before changing canonical state, recalculation, persistence, or a runtime boundary. The Autarky platform is imported as @jimae/platform from the read-only ../../../platform/; its source headers own its module contracts, and SECURITY.md identifies the shared security owners.
Canonical workbook state
Inputs, not results
A Cell contains a blank, number, text, boolean, error, or formula input. It has no computed-value field. Recalculated values live in ValueProjection, so a bad dirty set can display stale output but cannot overwrite a formula in the saved workbook. Recalculation is derived work and never enters undo history.
Cells are scalar, and dynamic arrays did not change that. A formula whose top-level result is an array spills: the recalculation engine projects the array over the rectangle below and to the right of the formula, owns those cells, and answers their values, while the Cell under each stays blank. Nothing about a spill is saved — the engine rebuilds every value on open and the XLSX exporter asks Excel to recalculate — so no spill range is stored to be read back and spilling added nothing to the workbook format; version 5's blocks below are the keel's, not the arrays'. The two errors only a spill produces, #SPILL! and #CALC!, are computed-only kinds: ErrorValue carries them, LiteralValue keeps the seven, the serializer never meets them, and typing one stores text. Dates remain numbers whose display is controlled by a number format.
Merged ranges
A merge is sheet-level canonical state: a list of non-overlapping rectangles of at least two cells, ordered by anchor. The top-left anchor holds the content the grid draws over the rectangle; covered cells keep whatever they hold, undrawn, and the model does not police them. Merging through the interface clears the covered cells in the same undoable step, as Excel does. Row and column inserts and deletes move, widen, narrow, or drop merges with the cells and capture the prior list for undo. A cut that moves a rectangle moves only cells: the merges at its source and destination stay where they were. The selection treats a merge as one cell; that rule lives in the grid controller, not the model.
Formula source and trees
Formula source is canonical at rest and is what the formula bar shows. While a formula is being edited or structurally rewritten, its parsed tree is authoritative. Row and column insert/delete, sheet rename/delete, and the move-cells edit a completed cut performs walk that tree and print normalized source; they never edit reference-looking text with a regular expression. Parse-print-parse stability connects the two forms.
Sheet objects have durable ids, but formulas write sheet names and resolve them at evaluation. Renaming a sheet therefore rewrites cross-sheet formula source. Deleting one converts references to the durable #REF! literal so a later sheet reusing the old name cannot silently rebind them; undo retains the original formula source.
Defined names
A defined name is workbook-scoped and stores its definition as formula source, like a cell's. Names arrive with a document — from the file's own island or from an XLSX import — or through the name manager: "Define name…", "Manage names…" and the name box each apply one mutation through the session. A definition made or edited there is written the way Excel writes refersTo, sheet-qualified and absolute, and defined-names.ts holds the one judgement of what may be stored, so the dialogs, the name box and the automation API refuse the same things for the same reasons. Defining, editing and deleting replace the list whole under set-defined-names; a rename is its own structural edit, rename-defined-name, because formulas write a name by its spelling: the tree walk that serves a sheet rename respells every cell and every other definition that used the name, and the inverse restores what it replaced verbatim rather than renaming back, since the rewrite has already normalised a source the reader spelled their own way. Deleting a name leaves its uses reading #NAME?, as Excel does.
Because a definition is formula source standing for cells, it is rewritten with the grid. The pass that rewrites cell formulas for a row or column insert or delete, a sheet rename, a sheet deletion, or a cut that moves a rectangle rewrites every definition in the same walk, and the inverse of that edit restores the definitions it replaced alongside the cells. A name therefore keeps standing for the same cells, and a definition naming a deleted sheet becomes #REF! rather than staying free to rebind. That rewrite has to know which sheet a definition means, so every reference in a usable definition names its sheet; an unqualified one is unusable, exactly as an unparsable one is.
Resolution is substitution, performed once when the recalculation engine declares a cell and before anything evaluates it. The tree the engine keeps is therefore name-free: dependency scanning sees the cells a name stands for, and implicit intersection over a named range happens at the using cell exactly as it would over the range written out. Nothing records which cells used which name, so a change to the namespace announces itself as a workbook-wide change and recalculation rebuilds. Expansion is bounded and fails to #NAME? — for an unknown name, a name that refers to itself, a definition this build cannot use, and an expansion past its node budget.
Sheet-state blocks
Format version 5 gave a sheet seven blocks beside its cells — frozen panes, one filter, conditional formats, validations, links, comments and a page setup — and gave a style an indent and a column or row a default style. Each block is canonical: it travels in the file, undo restores it, it dirties the document. What a block produces — the rows a filter hides, what a rule paints, whether an entry is valid — is a projection its owner recomputes from the block and the value projection after each pass, never written into a cell and never saved. The engine treats every block change as a layout fact, like a column width.
A cell's style is resolved rather than read: the cell's own fields, then its row's, then its column's, then the sheet default, field by field, so a bold column under a red row under a cell naming only a number format gives a cell all three. grid-view.ts's styleAt is the one place that order is written, and the grid, the paper projection and the automation API's cellStyle all read it, because a second reading of the order is a screen and a printout that disagree about what a cell looks like. The sheet default is the application's own — a sheet carries no style — and is named so the order is complete where one is added. A default on an axis is what reaches a row that has nothing typed in it yet, which is the point of having one, so the resolution starts from the address rather than from a stored cell. The inspector's Default format group hands an axis the selection anchor's style, so it is disabled where the anchor carries none — the same call clears, and an enabled button would be an unlabelled Clear — and where the selection names no count for that axis, which is the rule the structural commands are already disabled by.
A page setup is the sheet's, and one thing the Page setup dialog sets is not. Everything stored — paper, orientation, margins as a preset or stated in inches, scale or fit, the repeated bands, gridlines, headings, page order and the print area — travels in the file, dirties the document and is undone like any other edit, which docs/UIGUIDE.md asks of anything a reader expects a save to keep; Excel does not undo a page setup, and docs/DEBT.md carries the divergence. Which rectangle this run prints — the sheet or the selection — is a choice about one print rather than about the sheet and is held for the session, the line Excel draws too. The dialog therefore edits a draft: opening copies the sheet's setup into the fields, Cancel throws the draft away, and Apply and Print each store it as one mutation. The session's choice is part of that draft rather than beside it — Apply and Print take it with the rest, Cancel puts it back — because a field the dialog writes on every keystroke is a field Cancel cannot undo. A draft equal to the defaults is stored as no setup, so a file that never had one does not gain a block for having been printed, and every number the fields carry is clamped to the sheet-state reader's bound before it becomes a mutation.
model-types.ts owns the shapes and the vocabularies, in Excel's words so the interchange lanes map them without a table. sheet-state.ts owns each block's bounds and the one reader that turns an untrusted value into the block's canonical form — fixed key order, sets sorted, nothing optional left undefined — and refuses anything malformed or past a bound. The reader serves the file and every caller alike: a mutation stores only what it has read, so the automation API and a controller cannot store a block the file could not carry. Each block has one mutation with a full inverse — the prior block put back — and one change kind; a single object or an ordered list is replaced whole, and the per-cell blocks take entries as set-cells does, an address named twice taking its last entry and restored once. A comment's time is one ISO 8601 shape: a date, a time with seconds, an optional fraction of up to nine digits, and Z or an offset.
A block follows the grid by the merge's rules, held once. An insert inside a range widens it, a delete inside narrows it, a delete across it drops it, and a rule left with no range is dropped, undo restoring it. Links and comments shift with their cells. The freeze counts and the repeated rows of a page setup are positional and stay. A filter is dropped with its header row and re-keys its columns under a column edit. A cut carries links and comments with their cells, clearing what it lands on, and carries the rectangle's part of every rule: a range wholly inside moves, one the cut takes part of is split into the moved part and what stays, and on the same sheet the ranges that stay lose what the landing covers; a split or eviction that would push a rule past its range ceiling is declined and the range stays whole. Across sheets the moved ranges become a rule of the destination's under a free id, and a cut that would leave the destination with more links, comments or rules than a sheet may hold is refused whole before anything moves, since a file the reader refuses is a worse failure than a refused edit. The freeze, the filter and the page setup describe the sheet and stay. Deleting a sheet takes its blocks and undo brings them back through the readers, as any mutation's blocks arrive. Formula source inside a block — a rule's operands, a validation's list reference, an internal link's target — is rewritten in the tree walk that serves cells and defined names, so a sheet rename respells it, a deletion severs it to #REF!, and a moved cell it named is followed; a link target is rewritten from no origin sheet, as a definition is, so it keeps naming its sheet.
Tables and objects
Format version 6 gave a sheet two more blocks: the tables laid over its cells and the objects anchored to them. Both are canonical under the rule above, and what they produce — the banding a table draws, the cells a structured reference resolves to, the geometry a chart plots — is a projection. structure-state.ts owns their bounds and their readers, the way sheet-state.ts owns version 5's, and refuses a table that overlaps another or a filter that does not cover exactly its table's header and data rows; workbook.ts adds the two refusals that need the rest of the workbook, a table over a merge and a name already taken.
A table is its header row and what follows: the first row of its range is always the header, the last is the totals row when it has one, and at least one data row lies between. Its columns are positional — one entry per column of the range, each with a name, an optional totals function and an optional calculated formula. A table's name shares the defined-name namespace and syntax, because a formula spells a name and a table the same way, and the collision is refused in both directions. An object is a chart today and reserves the word image; it carries an anchor — a cell, an offset inside it, a width and a height — and a definition whose series name their values and categories as formula source. A structure's id names it within its sheet and nowhere else, two sheets being free to carry the same one, so a table is addressed by its sheet and its id together or by its workbook-unique name, and the rename that has to set one table aside sets aside that table itself rather than an id another sheet may share. A chart's title, its axis titles and a series name are absent or they are text: a file that states one empty is refused, not read as having said nothing.
Both follow the grid. A table's range moves as a merge does, with three rules of its own: a deletion that takes the header row takes the table, as it takes a filter, because the columns the table names were that header's; one that takes the totals row leaves the table without one; and a column edit re-keys the positional columns, an inserted one arriving under a free name and the table's filter re-keyed with them. A table left without a data row is dropped, and undo restores it. An object follows its anchor cell and goes when the anchor does. The formula source inside both — a calculated column, a chart series — is rewritten by the tree walk that serves cells and defined names, so a sheet rename respells it and a moved cell it named is followed. A cut moves neither, exactly as it moves no merge.
The grammar gains structured references — Table1[Col], Table1[[#Headers],[Col]], Table1[#All], Table1[#Data], Table1[#Totals], [@Col], Table1[[A]:[C]] — scanned as one token, brackets and all, since the brackets are notation the way Sheet1! is. One resolves by substitution at declaration into the plain range it denotes, as a defined name does, so the dependency graph and the evaluator stay table-free and precedents come out right without either learning what a table is; @ is the using cell's own row, which is why resolution takes an address. A reference no table answers is left standing and reads #REF!. Substitution holds each table's sheet by id and reads that sheet's name as it builds the range, because a structured reference names no sheet for a rename to rewrite and a name held from an earlier edit would strand it. Renaming a table or one of its columns is therefore a structural edit like a defined-name rename: it rewrites the references that spelled it, an unqualified column reference only inside its own table, and its inverse is a capture rather than the opposite rename. A tables change rebuilds recalculation as names does — nothing records which formulas named a table — while an objects change is a layout fact like merges.
What the table commands are made of
table-controller.ts is planners first and a controller second: each command is a pure function from the sheet and a range to a list of mutations, and the class runs one and words the refusal. That is why typing into a cell can extend a table without the grid importing a dialog — the commit path calls planTableTyping and folds its set-tables into the same batch as the set-cells, so one Undo takes back the cell and the table together — and why a script, a keystroke and a dialog cannot disagree about what "create a table here" means.
Three of the planners are worth stating. Create inserts a header row when the reader says the range has none, so the answer is an insert rather than a row of their data taken for a heading; the tables the insert shifts are shifted here too, because the set-tables that follows replaces the list the model will hold after the insert. A calculated column stores the formula as it reads on the first data row and writes each row's own translation of it, which is the rule a fill holds to applied to a column that fills itself; a row the table gains inherits every such column. Convert to range is a rewrite and not a deletion: a structured reference resolves by substitution against a table, so dropping the table alone would leave every Table1[Amount] reading #REF!. The planner prints the references that table answered as the ranges they resolved to — leaving another table's spelled as they were — and the rewrite and the drop land as one step. That walk covers a sheet's chart series as well as its cells, because a series' source is formula source of exactly the same kind; it is resolved from the chart's own anchor, which is the address the renderer reads a series from, so an @ in one means what it meant. A series is the one reference to a table nobody can see going wrong by reading a cell: it would simply draw an empty frame naming a source that is still on the sheet.
A table's rectangle is the unit a cut moves. The keel leaves a table where it stands, as it leaves a merge; clipboard-controller.ts adds the one case the references agree on, a cut whose rectangle is exactly a table's, by batching a set-tables behind the move-cells, so the relocated table lands on what the move left. That order is the one the move already keeps for cells: it rewrites the formulas the grid shifted under, and the table derived from the rectangle as it stood before the batch replaces the result, so a paste overlapping its own source relocates the column's stored formula once rather than twice. A cut of part of a table moves cells only.
A command is decided before any of it is applied
Every planner here finds every reason to refuse before it builds its first mutation, and every caller judges the landing before it asks the model for anything. That is a rule about where a refusal may be raised, not about which refusals exist, and it exists because of what a late one costs.
WorkbookSession.edit records a transaction only once Workbook.apply has returned. A batch whose third step throws would therefore leave the first two standing with no inverse on the undo stack: the reader's next Undo pops an unrelated step, and the caller that caught the refusal reports that nothing happened while cells have already been destroyed. A table command is the shape that meets this, because its mutations come in pairs — an insert and a set-tables, a move-cells and a set-tables — and it is the second of each pair that the model judges, against the whole workbook's namespace and the destination sheet's merges, which a planner alone cannot see.
So the rule is held at three levels, and each is cheap. The planner refuses a name the namespace holds rather than letting the closing set-tables refuse it. The caller orders the batch so the step that can be refused comes first. And Workbook.#batch rolls back the steps it has applied when a later one throws, so no batch anywhere — not only a table's — can leave the workbook half-edited and unrecorded.
Charts on the drawing layer
A chart is a definition and what it plots is a projection, which is the same rule cells follow. The workbook stores the type, the labelling and each series' source as formula source; the numbers are read out of the value projection after each recalculation and thrown away again. So a chart cannot go stale in the file, a formula it plots recalculates through it, and the tree walk that moves cells under an insert moves a series' source with them because a series' source is a formula like any other. Hidden rows and hidden columns are left out of what a series reads. A series whose range was deleted, whose sheet went, or which covers more cells than a series may read draws an empty frame naming the source it lost rather than plotting nothing, because a chart that quietly emptied itself is indistinguishable from a chart of zeroes and the definition is still in the file for an undo to restore. The other two ways a chart can be less than it looks share that line: a series longer than the renderer draws, and cells that hold no number at all — which names the errors those cells read, because a source is a range and a spill the sheet blocked empties one without its reference changing at all, so saying only that nothing is a number would send the reader to the cells rather than to the blocker. Past the two losses the line has room for, the rest are counted rather than dropped. Text the definition cannot carry — a title, an axis title or a series name past 255 characters — is refused by the dialog with a message rather than stored shortened, because the file reader and the automation API refuse the same length and a reader whose typing comes back cut is being told nothing.
chart-render.ts is pure: a definition plus the numbers its series resolved to, in, one inline SVG string out — the same string for the screen and for paper, so the two cannot disagree about a chart any more than they can about a cell. There is no chart library, because the whole application is one file under a reviewed ceiling and the eight types the intersection offers are a few hundred lines of geometry. The SVG is inert: no script, no foreignObject, no href of any kind, no external reference, and every piece of a workbook's text escaped on the way in. SECURITY.md owns that promise. The palette is the family data-visualization guidance's validated categorical set, checked against Chomen's paper surface, assigned by a series' position and never by its rank; past eight series it repeats, which that guidance argues against and the three references all do, because a reader's ninth series is their data and folding it into an "other" a spreadsheet never asked for would be the worse answer.
A chart leaves the application as the two parts Excel writes for one: a drawing per sheet that holds a chart, whose frames anchor the objects, and a chart part per chart, both of which xlsx-charts.ts owns while xlsx.ts keeps the package. The anchor travels as two cells rather than a rectangle — the cell each corner sits in and an offset inside it — walked out over the sheet's own column widths and row heights, so a chart lands on the cells it covered here; the pixels are what a two-cell anchor gives up, and Excel's default column is narrower than Chomen's. No cached number travels with it, because a series is a definition over cells and the workbook already asks Excel to recalculate on load: a cached number is the one thing a chart could carry that the workbook does not say. A series' source is written as the range it denotes, its structured references resolved through the tables the sheet holds first, because a chart part has no spelling for one even though the package carries the table part beside it; a plain range read back stays a plain range, and no table is invented from a chart. A worksheet has one relationship part however many kinds of part it names, so the drawing and the sheet's table parts are numbered out of one run and the worksheet's own elements name ids from it. Reading is the same rule in reverse, with the two judgements that make it safe. A chart part that names data outside the package — c:externalData, or a reference that names another workbook by the test a cell's formula faces — refuses the file, exactly as an external relationship does. Everything else that Chomen cannot represent drops that one chart with the sentence the profile already has: a plot group this build does not draw, a series plotted from numbers written into the part, an anchor that names no cell, and anything past a bound of the block. That last is not a second list of rules — every candidate is put through structure-state.ts's reader before it is kept — so the importer cannot admit an object the file format would refuse. Version 6's blocks made the model able to hold a chart; this made the file able to carry one, and the passive drop that D5 wrote for xl/drawings and xl/charts retires for exactly what is read, a drawing or chart nothing reached still saying so.
Three things follow from a drawing being untrusted input rather than a document. Turning a marker pair into a rectangle is a walk over the cells between the two markers, because a cell's size is the sheet's own — so it is a scan, and carries a scan's ceiling: one budget for a whole drawing part, and a frame whose markers are further apart than an object could span drops like any other the reader cannot follow. A chart part is decoded, parsed and read once however many frames name it, since a thousand frames may name one part and a part may be twenty megabytes. And a chart whose series cannot all be read drops whole rather than arriving short: a chart missing one of its series is a different chart, and nothing in the file would have said which one went. Writing is the one place this profile gives something up — a chart keeps a series whose cells were deleted, and c:f has no spelling for one — so writeXlsx returns the sentences beside the package and exportXlsx stays the package alone for a caller with nowhere to put them. That is the same shape the import side has, with the sheet's name travelling beside the sentence because it is where the reader would go to look.
chart-controller.ts owns the layer. grid-view.ts places it and answers two geometry questions about it — where an anchor sits, and which anchor a dropped corner lands on — so scrolling moves the layer for nothing and a render only repositions, which is a few numbers per object. There is one layer per region, for the reason there is one cell pool per region: an element cannot be pinned on both sides of a freeze line. An object is drawn once, in the region that holds its anchor, exactly as a merge's real cell is. The scrolling region's layer is the canvas' own, in canvas coordinates and beneath the bands, so an object that scrolls under a band is covered by it and an unfrozen sheet draws exactly the element it always drew; a band's layer carries that band's offsets and is clipped at the freeze line, so an object anchored inside it stays pinned and is cut at the line rather than running over the sheet. Paint order is document order at one z-index: a region's layer sits between its own container and the next region's, so the corner covers a chart pinned in the frozen rows that has scrolled beneath it. Which layer an object belongs in is asked on every reposition, so an anchor that crosses a freeze line — dragged over it, or overtaken by a freeze — is moved between layers rather than rebuilt. Re-reading the sources happens on a model change instead, which is what the one post-mutation notification announces; a notification that changed no cell — a cursor move, an editor opening, a refused formula — repositions without reading, because re-reading costs a series' whole cell ceiling and a keystroke is not a mutation. Every gesture the layer offers — insert, move, resize, redefine, remove — is one set-objects mutation through the session, so one Undo reverses one gesture, and the anchor rather than a pixel position is what is stored, which is why a chart follows a widened column without the model being touched.
What a conditional format paints
A rule is canonical and its paint is a projection, so nothing about a highlight is stored on a cell. conditional-formatting.ts owns the whole decision — which rules reach a cell, whether each matches it, and the one style delta their matches merge to — and both painters take that delta: the grid through applyCellStyle and displayText, the paper projection through the same two. A conditional number format therefore changes what a cell shows, not only how it looks, and a conditional fill counts as a fill, so the selection's tint is not laid over one.
Priority is list order. The first rule to match a cell states a property and a later one may only fill in what it left alone, which is how Excel resolves two rules over one cell; stopIfTrue ends the walk at a match, whether or not that rule had a format to lay down. A colour scale carries its colours in its own stops rather than in a delta, and paints only a numeric cell.
A rule's operands and its custom formula are formula source read relative to the top-left of the rule's first range, as Excel reads them, so the parsed tree is translated to the painted cell and evaluated with that cell as its origin — the same translation a copied formula takes. A formula that will not parse, that evaluates to an error, or that evaluates to anything but a true boolean or a non-zero number matches nothing: a broken rule paints no cell rather than every one.
Nine kinds cannot answer for one cell without reading every cell the rule covers — duplicate, unique, the four rank kinds, the two average kinds and a colour scale. Their scan runs once per pass and is cached; the grid drops the cache in refresh, which is the path every edit takes to a repaint, so a value that has just become a duplicate is highlighted on the paint that follows the edit. A rule covering more than 262,144 cells — the ceiling every selection walk holds to — is inert: it paints nothing, the inspector's list marks it, the automation API reports it, and saving one says so on #status-message, because the save closes the dialog and a closed dialog's own status line is a line nobody reads. The count a rule is measured by is the cells it covers, which is the union of its ranges rather than the sum of their areas: a cell two of a rule's ranges name is one cell to the scan, so it is one cell to the ceiling. A whole-column or whole-row range imported from Excel lands here, clamped to the grid and inert, rather than refusing the workbook.
Deterministic serialization
Equivalent workbooks serialize identically regardless of edit order. Cells are emitted in reading order, and numeric-keyed maps are represented as arrays of pairs because JavaScript reorders integer-like object keys. Readers refuse malformed or newer formats whole; they do not partially load or silently repair a workbook. The serializer's format constant is the authority for the current version, which is 6. Version 4 carries the platform's document identity in place of the private id version 3 wrote, an optional names root for the defined names, and an optional merges list on each sheet for its merged ranges. Version 5 adds the seven sheet-state blocks to a sheet, each under the name the note gave it, a style's indent, and a column's or row's style, written as an index into the style table and interned after every cell style of the sheet, columns then rows, so the table never depends on which was styled first. Version 6 adds a sheet's tables and objects. A range inside a block is a packed-key pair as a merge is; an object's anchor cell is one packed key as a link's address is; a per-cell block is key-ascending pairs as the cells are; a block's key order is its canonical form's. Each block is omitted whole rather than emitted empty, so an older file rewritten at version 6 differs from its original only in the version number and, for a version 3 file, the retired id; a block or an axis style in a pre-5 file, and a table or an object in a pre-6 one, are refused as merges is in a version 3 file.
Document identity is the platform's — not the mutable public title, and no longer a private id of Chomen's. The design note DN-DocumentIdentity.md owns what an identity means, and the island carries it in the platform's identity field. A document with no identity of its own — the shipped seed, a new workbook, a version 1 or 2 file — is filed under the profile's seed sentinel, which is also how the seed is recognised; no filename is consulted for it. A version 3 file is filed under the private id it carried instead, until it is saved. The first Save mints the identity as part of the save transaction and carries the history filed under the shed key onto it, exactly once. What carries depends on the key: a private id names one file, so everything under it moves, while the sentinel is shared, so only the versions that session wrote there move and another unsaved session's records stay where that session can still find them. The safety copy carries in neither case — the file the save just wrote supersedes it, and a copy left under the old key would be offered straight back as newer work on the saved file's next startup.
Session and operation boundaries
Controllers receive a SessionAccessor and obtain the current session at the point of use. They never retain a workbook reference that could survive New, Open, import, or recovery restore.
Every model mutation ends at one composition-root notification. That path updates dirty state, cycle reporting, the metadata-to-title projection, and the debounced recovery schedule. Workbook metadata is canonical; controls only project it, and serialization never reads a title control.
Save, Open, import, encryption, recovery, and updates are serialized through one whole-document mutation gate. The gate is non-reentrant: a helper used by an active operation receives its OperationScope and stages any result for the owner to commit. Cancellation leaves both live state and files unchanged.
The running application is never replaced by content from an opened file. Open adopts only a validated workbook payload. Save clones the trusted captured shell and replaces only the validated document island, verified capability island, and public title.
Automation API
window.chomen is the one supported programmatic route into the open workbook, and window.chomenReady is the readiness promise that resolves to it once the application is usable; both follow the family automation convention. src/automation-api.ts owns the calls and their bounds, and the composition root only installs it.
The API is a façade over the session, not a second edit path. A read returns a detached copy; the mutable workbook is never reachable. A write is parsed and bounded, refused whole when any part is bad, then applied through the same WorkbookSession.edit() and post-mutation notification as a typed edit, so it recalculates, records history, repaints, updates dirty and cycle status, and schedules recovery exactly as typing does. A batch is one undo step. An expected refusal is returned as a result; an unexpected failure throws.
The API version moves independently of the workbook format: a script asks whether its calls still mean the same thing, which is a different question from whether a file will open. The browser tests drive the application through the API; the save path's captured shell, window.__chomenPristine, is startup machinery rather than API and is not exposed through it.
Modules Chomen keeps rather than imports
The platform and Chomen share ancestry, so a module name appearing in both platform/src/ and apps/chomen/src/ is either a copy that has fallen behind or a real difference of domain. The platform re-homed these modules on a manifest of attachments; Chomen's canonical state is a workbook of cells. That one difference explains every module Chomen still keeps, and tools/test/platform-copies.test.mjs records the list and refuses an unrecorded addition.
The amendments are these. session-types.ts and session.ts hand out a workbook and its value projection where the platform hands out a manifest and two opaque client regions; Chomen's session also owns the recalculation engine and a saved image taken from serializeWorkbook. It carries the document identity the way the platform's does, with one deviation: a new workbook does not mint one at birth, because a document filed under an identity no file records would leave its safety copy where the next startup cannot look for it. history.ts undoes a Mutation against the workbook rather than a manifest mutation against a store, and carries discardRedo for the moment an uncommitted cell edit branches, before there is a mutation to record. operation-types.ts states the gate and the operation surface in the platform's own words, and then a change vocabulary of cells, rows, columns, metrics, and sheets in place of manifest entries. recovery-controller.ts restores a workbook through deserializeWorkbook where the platform's restores a canonical island and reconciles attachment payloads. serialization-controller.ts writes Chomen's workbook island, its title projection, and its save-time metadata stamp, and deviates once on identity: it writes whatever the open session has, so “Save a copy” of a document that has never been saved produces a copy with no identity, where the platform mints a token for the copy file alone. Neither the copy nor the original adopts one, so the two still end up with different identities — the copy is filed under the sentinel, as any unsaved document is, until its own first Save. encryption-controller.ts cannot take the platform's, which requires an attachment-crypto port Chomen has nothing to satisfy and drops the password-visibility controls Chomen's shell carries. update-controller.ts treats the release links as optional, because Chomen's update card is a status line and a check button, and the platform's requires a download link that would throw on every check.
Two modules were only behind and are gone. Filenames and the save picker come from the platform's browser-file-output.ts, which reads the extension, the file noun, and the unnamed-workbook title out of profile.mjs. Recovery storage is the platform's recovery-store.ts: a record files the canonical text under island — for Chomen the output of serializeWorkbook — beside a display title the recovery controller projects out of workbook metadata, since the platform holds no title of its own. The stored title discloses nothing the serialized workbook did not already carry, and plaintext records remain suspended and purged while a workbook is protected.
Grid and recalculation
The logical address space is 16,384 columns by 1,048,576 rows. The scroll extent is smaller and dynamic: GridView.reach() covers content and stored metrics, the selection, and nearby slack. Keyboard navigation can extend it, so every logical address remains reachable without creating an unusably tall scrollbar or a million DOM rows.
The dependency graph expands exact cell precedents but retains range and whole-axis precedents as ranges. This prevents a formula such as SUM(A:A) from allocating a million graph entries. Topological ordering is iterative so long dependency chains do not consume the JavaScript call stack.
Volatile functions recalculate every pass. OFFSET and INDIRECT are volatile because the graph cannot know their precedents before evaluation. Cycle members evaluate from zero and are reported separately; downstream dependents are not themselves marked cyclic.
Spills
A spill is owned by its anchor, the cell whose formula produced the array, and it is decided in recalc.ts after each evaluation. The anchor's rectangle must be free: a cell that holds more than formatting, a cell another anchor spills over, a merge the rectangle touches, or the edge of the grid blocks it, the anchor reads #SPILL!, nothing else is painted, and the anchor watches the range it wanted so that clearing the blocker re-spills it on the next pass. Formatting alone is not a blocker, because the styled blank formatting-controller.ts stores is not content, which is why a spilled value wears the number format of the cell it lands in. Neither is a sheet-state block: the per-cell ones are held beside the cell rather than in it, so a spill runs over a cell that carries a link or a comment and nothing else, and the blocks that cover ranges — a filter, a conditional format, a validation rule — are projections over the values, not content in the way. Version 6's two blocks read the same: a table is a named rectangle and a chart is drawn from the value projection, so neither is in a cell's way, and a spill runs the length of a table's range unless the cells inside it hold content — which blocks as content anywhere does. A merge is the one block that blocks, and it always did. An empty result reads #CALC!, and an array past MAX_ARRAY_CELLS reads #SPILL! before a cell of it is allocated.
A table's commands are where that rule needs one more sentence, because they write content the reader did not type. Two do it on the reader's own gesture and are therefore ordinary: a calculated column fills itself, so a spilling formula in one is blocked by the next row's copy of itself and reads #SPILL! down the column, spilling out below the table from the last data row where nothing is in the way; and typing in the row below a table takes that row in, calculated columns and all, which is content landing in cells the reader offered. The third is not the reader's gesture: turning on a totals row writes into a row nobody pointed at, which is why lane D21 refuses it over a row that already holds cells. That test reads the cell store, and a cell a spill has run into holds nothing there — so the refusal that exists to protect the reader's values would have walked straight over the ones a spill is showing them. planTotalsRow takes the engine's spilledInto for that one question, optional as the exporter's spill lookup is: the store answers what the store knows, and a caller holding a session answers with what the reader can see.
The dependency graph knows the ownership: a formula naming a spilled cell depends on the anchor, is ordered after it, and is dirtied when the anchor recomputes; when a spill grows or shrinks, the readers of every cell that entered or left it are dirtied. A spill's shape is known only once its anchor has evaluated, so a pass runs in rounds — each round re-evaluates only the readers of cells the round before took or freed — and a cycle closed through a spill is reported as a cycle rather than chased. An anchor is not one of those readers when it merely watches the rectangle it claimed — a watch exists so that clearing a blocker re-spills it, and seeding every anchor on its own spill would cost a round on every pass — but one whose formula declares a cell of its own rectangle reads what it writes, and is seeded so that the next round sees the self-edge and reports the cycle. Without that exception the edge was visible only when a spill already stood, so the same formula entered twice answered twice differently. A1# resolves to the anchor's current range at evaluation, and @ spells implicit intersection, which is what every range inside a formula still gets at stage A: operators and one-value parameters intersect a range and take an array's top-left value, while an array handed to a range parameter travels whole so SORT(UNIQUE(…)) composes. Lifting operators over arrays is stage B, a later lane.
The spilled cells are blank in the store and blank to every command that moves cells: a sort or a duplicate removal reads them as blanks and the anchor re-spills wherever its row lands, a copy of the anchor carries the formula alone, and a cut moves the anchor. Editing a spilled cell edits the anchor: the grid moves the selection there first, and the formula bar shows the anchor's formula greyed on a spilled cell. XLSX keeps Excel's array form on the anchor and drops the cached spilled values on the way in; on the way out it writes the array form over the current spill and lets Excel recalculate.
Pivots
A pivot is canonical as a definition and a projection as its output, and that split is the whole design. The pivots block format version 7 added to a sheet carries an id, a name, a source as formula source naming a range or a table, an anchor cell, the row, column, value and filter fields, the two grand-total flags and a layout — and nothing computed. pivot.ts turns a source's values and one of those definitions into a grid of cells, knowing nothing about a workbook; recalc.ts resolves the source, reads the values it already holds, computes the grid and claims the rectangle at the anchor. structure-state.ts owns the bounds, the reader and how a pivot follows the grid, exactly as it does for a table.
There is no Refresh command, and there is nothing for one to do. The output is recomputed after every recalculation pass, so it cannot be stale. Excel offers a refresh because it keeps a cache of the source beside the definition; Sheets and Numbers recompute, and so does this. A command that did nothing would be worse than no command, because a reader who pressed it would believe something had happened.
The output is owned the way a spill is owned, and blocked the same way: a cell that holds more than formatting, a cell another anchor spills over, a merge, the edge of the grid, or a rectangle a pivot earlier in the sheet's list has claimed blocks it, and a blocked pivot claims its anchor alone and reads #SPILL! there. Two departures from the spill rule are deliberate. A pivot whose anchor cell is itself occupied claims nothing at all — what the reader typed wins over a projection, and the dialog is what says so out loud — because there is no formula cell of the pivot's own to carry the refusal, as a spill's anchor has. And the blocking is one-way: a pivot yields to a spill and a spill never yields to a pivot. A two-way rule would settle differently depending on whether the pass was an incremental one or a rebuild, since spills settle before pivots within a pass; one-way is the same answer either way.
A source that is gone reads #REF! at the anchor and paints nothing else, which is the severed reference in the definition saying so. The definition survives, so one undo brings the cells back and the summary with them. A source too large to read, or a field with more distinct values than one may have, reads #VALUE!; an output too large to place reads #SPILL!, as an array too large to spill does.
A pass settles its pivots after the formulas, then evaluates again whatever read an output cell whose value moved, up to MAX_PIVOT_ROUNDS — which is how a formula over a pivot's numbers is right in the pass that changed them rather than in the next one. The rounds end when a settlement moves nothing, not when nothing reads what moved: a pivot whose source is another pivot's output is not a formula and so is never among the readers a moved cell dirties, and it reads those cells on the next settlement rather than in the evaluator. A pass that stopped on an empty reader set would leave such a pivot one settlement behind for ever, which is the one thing "it cannot be stale" has to mean.
Of the change kinds a transaction carries, only pivots asks the sheet's spill anchors to take another look, and it does so for the reason merges does: a settled output owns a rectangle a spill must yield to, so one that moved, arrived or went changes what a spill may claim. The seven sheet-state blocks and the objects re-lay-out the grid and compute nothing, and none of them blocks a spill.
Structurally a pivot follows the grid twice over. Its anchor follows its cell, as an object's does, and goes when that cell does; its source is formula source, so the tree walk that moves a chart series moves it and a renamed table follows. Its fields are offsets into the source, so a column edit inside the source re-keys them — except a deletion that takes the whole source, which re-keys nothing, because the source is severed to #REF! and dropping the fields one by one would take the pivot with them.
What the rest of the sheet does with the region
The output is a region the engine owns drawn over cells that hold nothing, so every feature that reads or writes a cell has an answer to give about it, and each is taken once, here, rather than falling out of whichever module happened to run first.
Read through the projection, never through the store. A conditional format's walk, a filter's criteria and the paper projection all take their values from ValueProjection.value, which answers with the pivot's cell for an address the output covers. So a highlight rule whose range reaches a summary paints it from the numbers the reader sees, on screen and on paper; a filter's criteria and its value list read the same numbers. Any of them reading SheetCells.cell instead would find every cell of the output blank and quietly disagree with what is drawn, which is the failure mode this paragraph exists to name.
Typed into by nobody; blocked by everything else. Two rules meet over the region and both hold, because they answer different questions. grid-controller.ts's commit path — the one route typed text takes — refuses an entry into a region a pivot draws, in the pivot's own name, before asking the validation rule. A rule reaching over those cells would otherwise refuse the entry in the rule's words — "that is not one of the values" — where the truth is that the cells are not the reader's at all. A spill redirects such an edit to its anchor; a pivot has no formula cell to redirect to, so it refuses and says which pivot to change instead, which is what Excel and Sheets are believed to do with an edit inside an output. The one cell a blocked pivot leaves is exempt, because a reader's own cell wins over a pivot that cannot claim its anchor and an error nobody can type over is a trap.
The refusal does not repeal the ownership rule above it, and that is the half easiest to lose. Content that arrives by any other route — a fill, a paste, an import, the automation API, a formula spilling over the region, a table growing into it, or a cell already there when the pivot is made or moved — lands, and the pivot is blocked by it and reads #SPILL! at its anchor. The engine could not be given the other rule even if it were wanted: a projection claims a rectangle only while it is empty, and an import or a structural edit can put content under a settled output with no commit path to refuse at. Refusing those routes too would mean a paste that dropped the cells it covered or a file that would not open. So the refusal is a courtesy to the reader who aimed at a cell, not an invariant the projection leans on, and test/browser/pivots.test.mjs blocks an output with a spill because typing is now the one route that cannot.
Per-cell state is the cell's. A link and a note belong to the cell and neither blocks the output nor is taken away by it, which is the rule a spill growing over an annotated cell already follows. The pivot's value stands in the cell, so a link's label — which stands in only for a cell that shows nothing — does not replace it.
A filter hides rows, not cells. A row a filter hides takes whatever is drawn on it with it, a line of a summary included, on screen and on paper. A pivot's source is read whole in the same breath: the engine has no filter and reads what the file holds, so a hidden row is still summarised, and a reader narrows a pivot with its own filter fields. Neither half was checked against a reference product; docs/DEBT.md records that.
Page setup decides what prints. The printed rectangle for the whole sheet is the stored extent widened by what a projection draws, because a summary's usual place is past the last cell anyone typed in. A stated print area is not widened: it is the page the reader laid out, so one drawn over a summary prints it and one drawn beside it leaves it out.
Interchange is decision 8 of docs/DN-ChomenTableStakes.md: import reads the definition and the cache's worksheetSource, dropping the cached records with a warning, and clearing the literal grid Excel wrote where the pivot stands so the projection can claim it; export writes the output as literal cells with one warning naming the pivot. A cache Excel would accept is a project of its own, and every reference product reads a values export.
Frozen panes
Frozen counts are canonical sheet state; where the line is drawn is a projection of them. GridView derives four regions from one axis pair — the corner, the frozen row band, the frozen column band and the scrolling window — each with its own container, recycled pools, cut marquee and spill outline, and pins a band by putting the scroll offset on the container rather than on each element, which is the trick the header bands already used. The two overlays are the same shape — a rectangle round a range the reader must read whole — so one routine spans both across the regions: an element cannot be pinned on both sides of a freeze line, and a range that crosses one is therefore drawn once per region it reaches, each copy in its own container's coordinates and cut at the line by the container. The canvas copy keeps the coordinates it always had and sits beneath the bands, where an opaque band covers the part that has scrolled under it. A spill makes the crossing ordinary rather than exotic: an anchor in a frozen band spills past the line whenever its result is longer than the band is deep. The regions share the axes, the merge rules and the cell painting, so a frozen cell and a scrolling one are one cell drawn in two places, and an unfrozen sheet is the scrolling region alone: the other three containers hold nothing and are hidden, so it draws and announces exactly what it did before panes existed. The header bands stay one container each: a frozen header carries the offset itself and sits above its scrolling neighbours, so the column headers remain one row in the accessibility tree.
Reading the freeze is what makes a pointer land on what the reader sees: the hit test, the rectangle the editor and the fill handle are placed from, and scrollToShow all resolve an address in a band to where the band draws it, and scrollToShow brings a scrolling target to the band's edge rather than to the viewport's, where it would be hidden under the band. Two consequences follow from the bands being opaque containers that nothing outside them is clipped by. The editor and the fill handle float at a cell's rectangle without being inside any region, so each asks underFrozenBand before it draws: the handle is hidden and the editor is dropped below the bands' stacking level, which covers the part of it that has scrolled under one while leaving the caret and the focus where the reader put them. And a cell in a band is not in #grid-cells at all, so sizing a column to its content reads the elements of every region rather than of the scrolling one, or a frozen column would fit to nothing.
A table is not an overlay and needs none of that spanning. Its header, its banding, its filter dropdowns and its corner handle are painted onto the cells themselves, and each address is drawn by exactly one region, so a table straddling a freeze line is drawn once whatever it crosses: the header row pinned in the frozen rows by that band, the data rows below the line by the scrolling window, and the handle by whichever region holds the table's last cell. What that costs is the index the painter asks — which table covers this address — which is therefore built from every region's window rather than from the scrolling one's. A table sitting wholly inside a frozen band is outside the scrolling window by construction, since that window begins at the freeze line, so an index built from it alone would paint the pinned table's cells as ordinary ones the moment the sheet scrolled: the pinned heading a freeze exists to keep in view is exactly the table a scrolled index cannot see.
The commands read the selection, and a band is the case that needs saying: a selection covering a whole axis — one click on a header — names that axis at the edge the band begins from, because the far end of a whole axis is the edge of the grid rather than a line anybody means, and an entry that offered it would pin the sheet from one click. The counts the view draws with are the sheet's, clamped so that at least one scrolling row and column stay in the viewport; the clamp is the view's alone, so a window too short for the band shows fewer frozen rows and the stored counts never change. The counts are positional — an insert inside the band moves no line — because Workbook's structural pass leaves them alone, as it leaves a page setup's repeated rows. Two imperfections are inherent to pinned panes and recorded in DEBT.md: a logical row crossing the vertical line is two row elements under one aria-rowindex, and a merge crossing a line is drawn once per region, only the region holding its anchor drawing the real cell so that no two elements claim one address.
Controlled entry
A validation rule is canonical sheet state; whether an entry satisfies it is a projection computed at the moment the entry is made, and what a rule draws is a projection computed at paint. validation.ts is pure and owns both questions: which rule covers a cell — the last in the sheet's list that does, so a rule drawn over cells that already had one takes effect and removing it uncovers the older one — and whether a candidate satisfies it. The three reads a rule needs beyond the entry, none of which a pure module may make, arrive through a ValidationSource: the displayed text a list range yields, the number an operand stands for, and whether a custom formula holds. validation-controller.ts binds those three to one cell against the session's workbook and the evaluator, and the same binding serves the commit path and the automation API, so a keystroke and a script cannot disagree about what a rule admits.
A rule's formulas are written for the top-left of its first range and read relative to each cell they cover, which is the references' own rule and the one a conditional format will follow; $ holds a reference still, as it does in a copy, and the editor anchors a typed list source so that one list does not become a different list per row. An entry is judged by the value it produces, so a formula is evaluated against the workbook as it stands before the mutation; a blank always passes, since clearing a cell is not an entry; and a rule whose list or formula cannot be read admits, because a rule nobody can evaluate must not lock the sheet.
Two readings of "the entry" both have a claim on a list rule, so a list matches either: the value written plainly and the value written in the cell's own number format. A column formatted 0.00 shows 2 as 2.00, and a list drawn from a range of formatted cells offers 2.00; matching one spelling and not the other gives a rule that refuses every value in its own list. Every read a rule makes is bounded. A listRange source is walked to 262,144 cells — the ceiling the mark walk and every selection walk stop at — counting the cells visited and not just the values kept, because a blank costs a read without being a value and a whole-column source is almost all blanks. A write of many cells carries a memo of what each source answered, keyed by the rectangle the source resolved to rather than by its text, since an anchored source translates to the same rectangle at every cell it covers; the workbook stands still while a write is checked, which is what makes the memo safe.
The editor holds one rectangle, so it distinguishes editing a rule from laying one over part of it. Apply replaces the rule the editor opened on only when the selection is the whole of that rule; opened on one cell of a wider rule it adds a rule over the selection and leaves the older one covering the rest, which the last-rule-wins reading already makes coherent and which the status line states before Apply is pressed. Replacing in place would take the rule off every other cell it covered with nothing to say so.
Validation is asked once, in GridController's one commit path, which is what puts typing, the formula bar and the picker under it together. reject stops the commit and leaves the editor open on the cell; warn takes the entry and reports it in the status line, because that path is synchronous and Excel's confirm-or-cancel modal is not available to it. Fill, paste and import do not pass through that path and are not validated, which is decision 10 of the table-stakes note; the automation API refuses for both actions, since a script has no status line to read a warning from. The picker is the house popover and writes through the same commit path a keystroke takes, so a chosen value is an ordinary undoable edit, and a checkbox click or Space is one set-cells mutation. A mark is drawn on the whole of a merged rectangle, so a click on one resolves the pointer to the merge's anchor exactly as the selection does: the mark belongs to the cell the reader sees, not to the quadrant the chevron happens to sit over. The glyph is aria-hidden, and a checkbox draws over the cell's text, so that text becomes the cell's aria-label — otherwise a ticked box and a cleared one are one announcement.
Filters
A sheet holds one filter: a range whose first row is its header and a criterion per column. That is canonical state and the only thing saved. Which rows it hides is a projection, recomputed by filter-controller.ts after each pass from the criteria and the displayed text of each cell, because displayed text is what a reader ticked in the value list and what a number condition reads a formatted number out of. The projection caches its answer against the workbook it came from, the filter object it came from and a stamp the grid's refresh() bumps, so a recalculation, an edited filter or a new document all miss and nothing is ever handed the previous pass's rows. A row is shown when every filtered column admits it. A table in a later phase owns the same object: the projection takes a SheetFilter rather than reading the sheet's own.
A filtered-out row and a hand-hidden row are different facts and are never written to each other. RowMetrics.hidden is canonical and travels in the file; a filtered row is derived and stores nothing. The grid gives both no height — the row axis overrides a filtered row's size on top of the metrics, so a stored height comes back when the filter clears — and printing leaves both out. This is the hidden-row model the table-stakes note asks for, and it is why Clear filter leaves a hand-hidden row hidden and a filter never unhides one.
Every command says what it does with a filtered range, and each does it in the one place that command already lives. A sort orders the visible rows among the positions those rows occupy and writes nothing to the rows between them, in planSort, so the quick sorts, the dialog, the header dropdown and the automation API agree; that is Sheets' behaviour and DEBT.md records that Excel's is unconfirmed. A copy takes the visible rows and closes the gaps, rewriting each moved formula through one mapping rather than by that cell's own distance: the block is the range with its hidden rows taken out, so a reference on the sheet that closed up follows the row it names to where that row now is, a reference to a row the block does not carry is severed as #REF! and said on the status line, and an absolute reference is untouched because a copy does not move one. Translating each formula by its own row's distance instead cannot work, and was the defect this replaced — rows above a referring cell close up by less than that cell does, so a reference into the same block was translated off the grid.
That mapping reaches exactly as far as the sheet it was built from. A reference naming another sheet names rows that did not close up at all, and putting them through this sheet's map restates the reference over rows nobody chose — a live answer, and the wrong one, where a lookup into another sheet had been right. Such a reference moves instead by the distance the copied cell itself closed up, which is what an ordinary copy moves it by; composed with the translation the paste then applies, a filtered copy landing a formula on a given row names the same rows of the other sheet an unfiltered copy landing it there would. A reference naming this sheet by name is naming rows that did close up, so it follows the mapping like an unqualified one. The block travels as printable formulas, which is the one place this is not exact: a cross-sheet reference whose cell closed up past that reference's own row has no row left to be written down on, and is severed rather than clamped to row 1, with a status line of its own. A paste lands on visible rows and steps over the rest, which is decision 6. A cut is refused whenever it spans a row the filter hides, in whatever column, because a cut is one move-cells of one rectangle and scattered rows are not one; a sort is stated on rows for the same reason, a row of no height being no height across the whole sheet. Creating a filter is refused past the 262,144-cell ceiling every command that walks a selection shares, and the projection holds that ceiling again so that no stored filter — from a file, an import or a table in a later phase — can make a pass over the grid unbounded. A filter that reached that size all the same, widened by rows inserted inside it or carried in by a file, is inert; its dropdown names the bound it met and offers Clear filter alone, rather than opening a live condition editor over an empty value list that would answer every criterion with nothing.
The header cell of each filtered column carries the dropdown, drawn into the recycled cell element while that cell is in the drawn window and marked when its column has a criterion. grid-view.ts draws it and never listens; the grid controller, which owns pointer handling, reports the click; the panel is the house anchored popover, built fresh on each open because it describes the column it was opened on.
XLSX carries the criteria in autoFilter, written between sheetData and mergeCells where CT_Worksheet sequences it: value sets as filters, the text and number conditions as customFilters with the wildcards Excel writes, and between as the two comparisons joined by and. An operand's own *, ? and ~ carry Excel's tilde escape both ways, so a value holding one is a literal rather than a wildcard neither side can read, and an empty operand is the blanks condition it states. top10 and dynamicFilter are criteria computed from the column rather than stated over it and are dropped with a warning, as is a column whose criteria the profile has no shape for and one listing more values than a criterion stores. A ref past the ceiling is clipped to the rows the sheet states — Excel writes A1:C1048576 for a filter over whole columns — and dropped with a warning if it is still past it. Import re-derives the hidden rows from the criteria and ignores a hidden flag on a data row inside the range, since that flag is Excel's own projection; it warns once, because a hide the reader made there cannot be told from one the filter made. Export writes the criteria and marks the rows they hide, since that is what Excel expects to find.
Links and notes
A cell may carry one link and one note, both per-cell blocks of format version 5, both drawn from the sheet rather than cached: the grid marks a linked cell data-linked and a commented one data-commented where it paints the cell, so a link stored by any route is on screen at the next render. A link's label draws only where the cell itself says nothing, which is what the model means by it — a cell holding text shows its own text, linked — so the file never holds two texts for one cell.
Nothing follows a link but the reader. A plain click selects the cell, as it does in all three references; Ctrl-click — Cmd-click where the platform's accelerator is Cmd, because a Mac raises the context menu on Ctrl-click — follows it. An https:, http: or mailto: target goes to window.open with noopener,noreferrer and no reference kept, so the page opened has no handle on the workbook's window; an internal target names a place in this document, and the sheet is activated and the range selected instead, a severed one saying so in the status line. sheet-state.ts decides which a target is, for the click and for the exporter alike, and admits no other scheme, so no file can carry a target a click would act on.
A note is text, an author and a time. The author defaults to the workbook's Properties author, is editable, and is remembered for the session only, which is decision 9 of DN-ChomenTableStakes.md: the alternative is a per-user store docs/UIGUIDE.md declines. Threads, replies, resolution and mentions are not built — the intersection of the three references is a note — and each of them would need the identity that store would hold. The note itself is one floating element over the grid, like the marquee and the spill outline and for the same reason: it is larger than its cell and must not be clipped by it. grid-controller.ts decides which note is on show — the one under the pointer, else the active cell's — so a reader who never touches the pointer meets every note by arrowing onto its cell, and the inspector's Cell tab reads both out for a reader who uses neither.
One place decides which cell the two belong to. annotationTarget in grid-controller.ts names it — the active cell, resolved through the merge it lies in and the spill it shows — and Ctrl+K, both menu entries, the popover and the inspector's Cell tab read that one answer, so the panel cannot report one cell while the command writes another. A pointer is resolved the same way before it follows a link or shows a note: a merge is drawn as one element carrying the mark across its whole rectangle, so a gesture anywhere on it is a gesture on the cell the mark belongs to. The popover is a tooltip that the cell it is about names in aria-describedby while it is on show, which is what puts a note in the announcement path; the corner mark alone says only that there is something to read.
A link or a note is state of the cell, not content of it, and the clipboard keeps the two apart. A copied cell that holds nothing but carries one travels with an address and no content: the paste writes the annotation and leaves the destination the blank the source was, because a cell that held nothing must not arrive as a cell — one ISBLANK answers false for, COUNTA counts, the extent grows to and the file carries.
XLSX carries both. A link leaves as the external relationship SECURITY.md admits, or as a location attribute when it names a place inside the workbook, so a file of internal links names nothing outside itself; a note leaves as the legacy comments part plus the minimal VML drawing Excel needs before it will show one. The box that drawing describes is the one thing that does not survive a round trip, because Chomen stores no geometry for a note and writes the part fresh on each export. A threaded comment arrives as the text Excel writes for it in that same legacy part, with its replies and its resolution dropped by name; its tc= author is a thread identifier and is not stored as a person.
Sorting
A sort is one SortSpec — a range, up to eight keys in priority, and whether the first row is a header — and one function, planSort in grid-controller.ts, turns it into the single set-cells mutation that moves whole rows, translating each moved formula by the distance its row travelled. The quick sorts, the dialog and the automation API all build a spec and apply that one plan, so there is one definition of what a sort does and one undo step for it. The order is computed over the value projection, because a formula sorts by what it works out; a header row is outside the permutation altogether, neither read as a key nor written. A range that touches a merge in the rows that would move is refused, with a cause the grid reports in the reader's language and a reason the automation API quotes, because a merge draws one cell over several and a sort that moved cells under it would hide a row's value. The block around the selection is Excel's current region: grown from the selection while the row or column just beyond an edge holds a cell, corners joining, and declined the moment a step takes it past the sort ceiling rather than trimmed, so the walk is bounded by the ceiling and not by the sheet. Nothing about a sort is canonical state; the moved cells are.
Range commands over the projection
Sort and Remove duplicates decide which rows by reading the value projection — a formula sorts by what it works out, and a duplicate is a row whose key cells display the same text as an earlier row's, case aside — and then move whole cells, never the values they read. Each command is one set-cells mutation covering every address in the range, with each moved formula translated by the distance its row travelled, so one Undo restores the range and no computed value is ever written into a Cell. Remove duplicates keys on displayed text because that is the rule Microsoft documents for the command; DEBT.md holds it as a rule still to confirm. The planner both the grid command and the automation API call lives in grid-controller.ts, so a script and the dialog remove the same rows.
Fill
A fill is planned once, by planFill in the grid controller, from a source rectangle, a target and a mode: the handle drag, its double-click, Fill down, Fill right and the automation API's fill all call it, so a scripted series and a dragged one cannot disagree. Tiling repeats the source with each formula translated by its distance; a series continues each line of the source as the pure fill-series.ts says — numbers along their least-squares line, numbered text, weekday and month names — with styles tiled as a copy tiles them and any line that forms no series tiled whole. A cell that holds formatting and no value — the placeholder formatting-controller.ts stores when a blank is styled — is blank to a fill: its isBlankPlaceholder is the one predicate, so the double-click's walk down the neighbouring column stops at one and a series keeps one's place, tiling its style. The plan covers only the lines inside the target, which the fill ceiling has already clipped, never the source's whole width. The mode is the controller's decision: one source cell copies, two or more extend, and Ctrl at release flips it. Either way the result is one set-cells mutation and one undo step.
Worksheet functions
All worksheet functions have the shape (context, args) => FunctionResult behind one dispatcher: a scalar, or an array built only through context.array, which holds every one to MAX_ARRAY_CELLS and turns an empty rectangle into #CALC!. The dispatcher owns arity, error propagation, and declared coercion modes. Direct values are coerced, while values reached through a range are filtered according to spreadsheet rules; an array result handed to a range parameter arrives as a range-shaped argument, and one handed to a one-value parameter is reduced to its top-left cell. Implementations use EvaluationContext for both paths; they do not recreate coercion locally.
An exception escaping a function implementation is a defect. The dispatcher converts it to #VALUE! so one function cannot abort the recalculation pass. Unverified spreadsheet-compatibility choices are listed in DEBT.md.
Interface language
The interface follows the browser's ordered language preferences, with no control of its own: the preference is the reader's, not the workbook's. src/localization-controller.ts captures the shell's English once, before any controller edits a node, and repaints those nodes from a language record; a string the record lacks keeps its English.
Most of what a reader sees is composed at run time and in no capture of the shell. It becomes interface text only by passing through src/interface-text.ts, whose header owns the two calls and the one exclusion, the message of a thrown Error. scripts/i18n-surface.mjs reads the same calls, so a string that skips the port is invisible to the catalog checks and ships in English unnoticed.
A language record is capability material like any other: scripts/language-core.mjs validates the family's catalogs and packages one record per language, the kit budgets, compresses, digests and stamps it through the record's buildPayloads seam, and src/material-activators.ts says what the platform must find inside one. What a translation has to be is the family's (@jimae/family/i18n/). Unlike Wakigaki's, a record carries its own fingerprint index, because the vendored kit compiles no esbuild define for one; docs/DEBT.md owns the divergence.
Built artifact
The build produces one HTML file containing the logo, document island, capability island, shell, and runtime. The runtime and document use compressed, digested envelopes whose build and browser implementations must agree byte-for-byte. The capability island carries the ten interface languages and nothing else; a record hydrates from the file itself, on demand, and nothing hydrates from the network.
Build tooling may use Node and the filesystem. Runtime modules under src/ may not import that tooling. The build is the vendored platform dev kit's, told what Chomen is by app-record.mjs; the seed workbook is the one step scripts/build.mjs adds after it. The complete artifact and its component envelopes are bounded by the hard ceilings that record declares, enforced by the kit's size audit through scripts/audit-size.mjs.
Chomen security delta
The shared cryptographic design, compressed envelopes, bounded ZIP/XML readers, mutation gate, and signed-update rules are owned by ../../../platform/docs/SECURITY.md. Chomen keeps those boundaries intact. This document records only its workbook- specific threat surface, adaptations, and known non-defenses.
Report vulnerabilities privately to the maintainer rather than in a public issue.
Workbook threat surface
A workbook is a file received from another person. Chomen treats its document island, CSV text, and XLSX package as untrusted. Open and import build a detached workbook and adopt it only after complete validation and the user's unsaved-work decision. No opened file supplies application code, shell markup, or interface material.
Workbook deserialization validates every encoded shape rather than casting parsed JSON: fields have exact types and bounds, a cell has one content discriminant, unknown properties and duplicate indexes are refused, and model errors are translated to WorkbookFormatError at the boundary. Unknown newer format versions fail closed. File reads and compressed expansion have explicit ceilings. The version 5 sheet-state blocks are read by the same readers the model applies to every caller (src/sheet-state.ts), so a file and a script meet one bound; a link's target is admitted only as an https:, http: or mailto: address or an internal sheet-qualified reference, so no other scheme can reach the click that opens one.
A chart is markup drawn from a workbook: its title, its axis titles, its series names and its category labels are text a received file supplied. src/chart-render.ts writes the SVG rather than assembling one from a template, and the string it produces carries no script, no foreignObject, no href of any kind, no image or use, and no absolute reference but the SVG namespace itself; every piece of text is escaped on the way in, including inside an attribute. A chart therefore cannot become script, a fetch, or an escape from its own element, and the layer inserts nothing else into the page. test/chart-render.test.ts holds the module to that from the inside and test/browser/charts.test.mjs holds the built artifact to it from the outside, under the same watcher every other browser suite runs beneath.
Formula, number-format, CSV, and workbook readers have fuzz targets because they directly accept untrusted bytes or text. An unhandled exception or hang is a defect; workbook and CSV parsers may reject only through their documented format errors. A discovered input becomes a normal regression test.
XLSX profile
Chomen uses the platform's ZIP and XML safety readers; only SpreadsheetML interpretation is local. Import applies package, part, sheet, and materialized- cell budgets before adoption. It rejects macro-enabled and legacy formats, encrypted or malformed archives, path escapes, external relationships and formulas, connections, queries, OLE, and other embedded active content. It does not evaluate formulas or resolve resources during import.
The supported passive profile is sheets, scalar cells, compatible formulas, workbook-scoped defined names, basic styles, dimensions, merged ranges, tables, charts, a sheet view's frozen pane, a sheet's page setup, and a sheet's autoFilter. An array formula is admitted on its anchor alone: the cached values Excel wrote under its ref are dropped, keeping only their formatting, and the dynamic-array metadata part is ignored, since a spill is recomputed rather than read. A ref that is malformed or does not start at the anchor is ignored, not a rejection. A merged range that is malformed, covers one cell, or overlaps another rejects the file. A frozen pane's row and column counts are read only from a pane whose state is frozen or frozenSplit, and each must be a whole number inside the grid or the file is rejected; a split pane, whose identically named attributes measure a divider rather than count rows, is dropped with a warning. The counts are taken from the first sheetView of the sheet's own sheetViews and nowhere else: a pane under customSheetViews is one reader's saved window, not the sheet's state. topLeftCell is not read, so nothing in the element can move the freeze line except the counts.
A sheet's autoFilter is read from the worksheet's own children, never from a table's, and its ref must be a well-formed range inside the grid or the file is rejected, as a merge's is. It is bounded by area as well as by shape: Excel writes A1:C1048576 for a filter over whole columns, three million cells the projection would walk on every refresh, so a range past the 262,144 every command that walks a selection shares is clipped to the rows sheetData states and dropped with a warning if it is still past it. The projection holds that ceiling too, so a filter reaching it by any other route — a .chomen file whose filter was widened by rows inserted inside it, or one written by hand — is inert rather than unbounded, and its dropdown says which bound it met rather than leaving the reader to discover that nothing answers. A filterColumn naming a column outside that range rejects the file; one carrying top10 or dynamicFilter — a criterion computed from the column rather than stated over it — is dropped with a warning, and one whose customFilters cannot be expressed in the profile's vocabulary leaves its column unfiltered rather than being approximated, with a warning of its own. An operand's ~ escape is read before its wildcards, so an escaped * is a literal and not a pattern. A value set is bounded by the format's own per-column ceiling — a longer list drops its column with a warning rather than refusing the package, since Excel's own dropdown reaches ten thousand — and each value is truncated to the length it stores. Which rows the criteria hide is never read: a hidden flag on a data row inside the filtered range is Excel's own projection of those criteria, so it is dropped and the rows re-derived, with one warning saying so, while a hide outside the range or on its header row is the reader's and is kept. Export writes the criteria back and marks the rows they hide, because that is what Excel expects to find.
A sheet's page setup is read from four elements — sheetPr/pageSetUpPr, printOptions, pageMargins and pageSetup — against Excel's defaults rather than Chomen's, since a sheet that states one has said what every part of it is. Every number is clamped to what the sheet-state reader accepts: a margin to twenty inches, a scale to 10–400, a repeat to one short of the grid. An enumerated value outside the profile falls back to the default, and a paper size outside the five Chomen lays out on is replaced by Letter with a warning. Presentation is clamped rather than refused, because a refused workbook is the worse answer; the round-trip through the own format before adoption is what proves the clamps reach the file's own bounds.
The print area and the repeated bands come from the sheet-scoped built-in names _xlnm.Print_Area and _xlnm.Print_Titles, which are page setup spelled as names rather than names a formula could use: they are read here and are neither imported as defined names nor reported as dropped ones. A definition longer than 1,024 characters, one naming another workbook, or a print area naming more than one rectangle is dropped whole with a warning, since half a print area is a printout missing pages. A print area naming whole columns or whole rows — what Excel writes when the area is picked from the headers — has no last corner to read and is dropped with a warning of its own, which names that case because reselecting the cells is the answer to it. A repeated band that does not begin at the top left of what the sheet prints has no count to become and is dropped with its own warning. A name dropped for any of these reasons leaves the sheet's page setup exactly as the worksheet stated it, including stating none. A <col> or <row> default format is read only when customFormat says the attribute is meant and the index names a non-empty style, so a row Excel has merely touched gains no formatting. An alignment indent past Excel's classic fifteen steps is reduced to fifteen with a warning.
A sheet's conditionalFormatting blocks and the dxfs table are read: a cfRule of a kind the model has, with its ranges from sqref and its format from the dxf its dxfId names, in the priority order the file states — read from the attribute before it is converted, since an absent priority would otherwise convert to the first place rather than the last. A rule is a decoration, so nothing about one refuses a workbook's cells: what this reader cannot represent is dropped, always with a warning, and the warning names which of the two reasons it was. A kind the model has not — a data bar, an icon set, a time period, an above-average band — is dropped as one it has no rule for, as is a cfRule whose formula will not parse and a block whose sqref this reader cannot turn into cells. A rule the model does have, dropped because one of its numbers is past a bound — more ranges than a rule holds, a longer condition text, a larger rank — is dropped as one past Chomen's limits, and past the ceiling on a sheet's rules the list is cut after it is ordered, so the rules the file ranked highest are the ones kept. A whole-column or whole-row sqref is a form ST_Ref admits and an ordinary authoring action, so it is clamped to the grid rather than dropped; a rule that large is simply past the scan ceiling and paints nothing. What survives still goes through the model's own bounded reader before the sheet holds it, which refuses the package if this reader ever hands it something the model will not hold. A rule with no dxfId states no format rather than taking the table's first entry. No dxf is evaluated: it is read as the same style delta the file format already carries. A sheet's dataValidations are read into validation rules: a rule's sqref must name well-formed ranges on the grid or the file is rejected, as a merged range must — an anchored $A$1:$A$3 and a whole-column or whole-row part (C:C, 5:6) are the ranges they name, taken to the edge of the grid, since those are spellings and not shapes — while a rule whose type has no counterpart here — time, the none a prompt-only rule carries, the x14 extension form inside an extLst the importer never reads, and a quoted list holding no value at all — is dropped with one warning, the cells kept. A sheet declaring more rules than a sheet may hold rejects the file. The list a rule offers is one quoted, comma-separated formula1, in which a doubled quote is one character, or a reference; a rule's formulas are stored as source and are never evaluated at import. Export writes every kind but the checkbox, which has no portable form and leaves its cells' booleans behind with a warning, and writes a rule's error and prompt as escaped attribute values, since a message holding a double quote would otherwise end the attribute and leave a worksheet part that is not XML. Unsupported passive features may be dropped only with a visible warning. Beside the table part below, two kinds of part are read rather than dropped: a worksheet's drawing (xl/drawings/*.xml) and the chart a frame in it names (xl/charts/*.xml), each recognised by its location and the content type the package declares for it — both tested where a relationship is followed and not only where the parts are counted, so a chart is read from the two places the profile names and nowhere else. A part at one of those locations declaring another kind's type drops with the drawing sentence, as does a drawing or a chart no sheet reached; a chart's style and its colour set, which Excel writes beside every chart, get a sentence of their own so the drawing sentence keeps meaning that something was lost. The walk between an anchor's two markers is a scan over the grid and carries a scan's ceiling, one budget for the whole drawing part, and each chart part is decoded and parsed once however many frames name it. Nothing active is read from either: a chart part carrying c:externalData, or a reference in one that names another workbook by the test a cell's formula faces, rejects the file as an external relationship does, and no cached number in a chart part is read at all — a series is a definition over the workbook's own cells. A chart this build cannot draw, a series plotted from numbers written into the part, an anchor that names no cell or whose markers are further apart than the walk allows, and a chart past a bound of the objects block each drop that one chart with a warning rather than rejecting the file; every candidate passes the model's own reader before adoption, so the importer cannot admit an object the format would refuse. Export writes the same two parts and no cached values, and says what it could not write: a series whose source names no range has no spelling in a chart part, so it is left out and the sheet it went from is named. A series whose source is a structured reference is written as the range that reference denotes, because a chart part cannot spell one even though this profile writes the table part beside it; a plain range read back stays a plain range, and no table is invented from a chart. Five kinds of part Excel writes are admitted because the importer never reads them — print settings, pictures, the preview thumbnail, a note's VML box and a comment thread (xl/printerSettings/, xl/media/, docProps/thumbnail.*, xl/drawings/vmlDrawing*.vml, xl/threadedComments/). Each is recognised by kind, its location and the content type the package declares for it, so a part at one of those locations that declares another kind's type, or none, is refused, and each drops with a warning naming its kind. The legacy comments part is read rather than dropped, under the same two tests, and a thread's words reach the reader through it. A table part (xl/tables/, declaring table XML) is read rather than dropped, under the same two tests: it is parsed by the standard XML reader, its fields are bounded by the model's own reader, and a table the model has no shape for — one with no header row — is dropped with a warning while its cells stay. How many table parts are read is bounded twice: a sheet declares at most 1,024 of them, and each distinct part is parsed once for the whole package rather than once per worksheet naming it, so the parsing is bounded by the parts the archive holds and not by that count times the sheet count. Without either bound the package's 20 MB ceiling would hold hundreds of thousands of tablePart elements in a few kilobytes of compressed XML, each costing a fresh parse. A table that cannot stand beside the others its sheet declares — one overlapping another, covering a merge, or claiming a name the workbook has already given out — is dropped alone rather than taking them with it. What is not bounded below the per-sheet ceiling is that judgement itself: every declared table is validated where it is declared, so a package naming many tables from many sheets still costs work proportional to sheets times declarations — seconds, not minutes, at the ceilings above. A table's filter criteria are dropped with a warning of their own: the criteria this profile reads are a sheet's autoFilter, read from the worksheet's own children, and a table holds a filter block of its own that no control here writes.
A pivot's definition and its cache definition (xl/pivotTables/, xl/pivotCache/, each declaring the content type ECMA-376 gives its kind) are read under the same two tests as a table part, and the relationships beside them are admitted as a worksheet's are — every .rels in the package was read and checked for a target that leaves it before any part was judged. The cache's records — Excel's stored copy of the source — are dropped with a warning of their own, because a pivot's output here is recomputed from the cells after every pass and a stored copy could only go stale. How many pivot parts are read is bounded the way the table parts are: a sheet names at most 64 of them and each distinct part is parsed once. A pivot the model has no shape for — a cache that is not a worksheet source, a definition with no data field, a location that does not read as a rectangle — is dropped with a warning while the cells Excel wrote for it stay, and the grid Excel cached where a read pivot stands is cleared, because that grid is Excel's output rather than the reader's cells and leaving it would block the projection. The clearing waits until every sheet, table and defined name is in place and happens only for a pivot whose source then resolves: a cache naming a table, a defined range or a sheet the package does not carry would otherwise take the reader's numbers away and leave #REF! where the summary had been, and Excel's cached grid is the only record of it left. Nothing in a pivot part is executed, resolved or fetched: every field is a number, a word from a closed list, or a reference the model's own reader bounds. Export writes a pivot's output as literal cells with one warning naming it and no pivot part at all, so nothing Chomen writes can carry a cache.
One external relationship is admitted: a hyperlink, whose target is a string the importer never resolves and nothing in the application ever follows on its own. A target is stored only when it is one the model admits — https:, http:, mailto: or a sheet-qualified reference — so a javascript:, file: or workbook-relative target is dropped with a warning and never enters the saved file, and a stored one opens only on the reader's own Ctrl- or Cmd-click, through window.open with noopener,noreferrer. A hyperlink element's ref may name a rectangle rather than a cell, and one rectangle may name every link a sheet can hold, so the reader counts the cells the elements ask for as it reads them and refuses the sheet the moment the running total passes that cap — before the rectangle that passed it is expanded, since a two-kilobyte package must not be able to ask the importer for millions of addresses. The export writes links back the same way: an external target as that one relationship, an internal one as a location attribute naming nothing outside the package. Since format version 6 the tokenizer reads a structured reference as one token, so a formula naming a table parses; a cell whose formula names a table the importer has not read — one defined on a sheet it has not reached — keeps the value Excel cached, with the unsupported-formula warning, rather than a reference that would resolve to #REF!. A cell formula that names another workbook — a bracketed workbook index, name or path before a sheet and !, inside a quoted sheet name or not, with the apostrophe Excel doubles there — still refuses the file; a defined name whose definition names one is dropped with the defined-names warning, since a dropped name is loud, every use of it reading #NAME?, where a cell's cached value would pass for the link's current one; and text inside a formula's quotes is data, not a reference. That test reads the formula once, left to right, and reads a string literal and a quoted sheet name exactly as the formula tokenizer does, a doubled quote or apostrophe standing for one, so it sees each bracket where the tokenizer would; a bracket after a table's name opens a structured reference, which it reads to that reference's closing bracket, counting the nested ones and honouring the apostrophe that escapes one, before reading on from there. Every cell formula is put to that test before the parser is called at all, and one that fails it refuses the file, so a formula that opens live never names another workbook, whatever order its literals and sheet names come in and whatever its sheets are called, a name holding an apostrophe or a double quote included. A formula the test cannot finish reading, one that leaves a literal, a quoted name or a structured reference open, is one the parser refuses, so its cell keeps the cached value. The scan is linear in the formula's length whatever its shape, because a formula is tested before the parser bounds it. Every other external relationship, active part and unknown part still refuses the file, and this list grows only by a recorded decision (the table-stakes note's decision 2). Imported values are bounded to what the workbook format can store: oversized dimensions and font sizes are clamped with a warning, a number-format code the format cannot hold rejects the file, and the imported workbook must reopen from its own saved form before adoption. Export emits the same non-macro profile, writing a spilling formula in its array form over the range it currently covers and a table as its own part with the worksheet relationship and content-type override that name it.
Protection and recovery
Chomen uses the platform-approved password-protection algorithms and parameters. Derivation is separate from encryption: a salt is minted once and the derived key is retained for the open workbook so ordinary saves do not run the password KDF again.
Unlocking stages key material; workbook adoption commits it. A rejected or cancelled candidate cannot change the current workbook's key state, and opening plaintext drops an old key.
HTML outside the encrypted island is public. Protected saves therefore use the profile's masked title and basename rather than exposing workbook metadata in <title>, controls, or suggested filenames. Enabling protection purges the workbook's plaintext recovery copies and automatic versions and suspends future recovery writes until protection is removed.
Release discovery
The build compiles Chomen's profile and version into the platform update client. The profile owns the app id, manifest URL, masked identity, and public trust anchor. Artifact tests reject the platform's reference identity, and release tests recompute the anchor id from its public coordinates.
The user-initiated update check is Chomen's only sanctioned fetch. It retrieves only the manifest, without credentials or referrer and without cache, then uses the platform's fail-closed verifier. Chomen additionally rejects an unparseable content-length and validates the notes URL as HTTPS during verification. The family release-key custody rule is owned by the root README.md; no private key belongs in this repository or CI.
Launch and the served copy
Family Launch policy owns recognition, signed inventory verification, inherited browser policy, handoff, and offline installation. Every member follows that contract without exceptions.
Verification and known limits
The browser suite runs the built artifact and observes requests at browser- context level, covering pages, popups, workers, redirects, fetch/XHR, subresources, beacons, CSP reports, and websockets. Its known blind spots are DNS-prefetch and preconnect hints, WebRTC/STUN/TURN, OS-handled schemes, activity after the observation window, and contexts Playwright cannot attach to. The XML reader is checked against real engines: namespace resolution, malformed input, and the node and depth budgets are asserted in Chromium and WebKit. Firefox is not exercised on this machine. The reader's tolerance of a byte order mark ahead of the declaration is unreachable on WebKit, which refuses such a string; package parts reach the reader through TextDecoder, which consumes the mark.
Chomen does not defend against a compromised browser or operating system, physical access to an unlocked workbook, disclosure by someone who possesses the file, or traffic analysis of a deliberate update check. The derived key is held in memory while a protected workbook is open; that is the explicit trade for responsive saves.