Files
improvise/context/adr/007-stable-identities.md
T

45 KiB
Raw Blame History

ADR 007 — Stable identities for categories, items, formulas and views

Ticket: improvise-sboz (implementation epic improvise-cq1q; this amendment improvise-cq1q.1) · Unblocks: improvise-fz77 · Revises: improvise-km71's interim FormulaId (PR #214) and improvise-fdk1's RenameRecordCoordinate (PR #221) · Builds on: ADR 001, ADR 002 · Review tickets filed against an early draft call this document "ADR 005".

Status

Accepted on 2026-09-25 (PR #210). An amendment is proposed and not yet approved (PR #326). Every rule tagged [Amendment] is proposed; everything without the tag is accepted and binding. The tag is the complete list of what the amendment adds.

Where the amendment replaces accepted text, the accepted rule binds until the amendment is approved:

clause accepted rule the amendment proposes
1 groups have no id a group has an id and a display, and its id behaves like every other id
1 _index, _dim, _measure are the only ids beginning with _ a leading _ is reserved for virtual ids in general, including the virtual views _records and _drill; virtual categories and measures are defined by criteria with hardcoded ids and displays, are never declared in a file, and are referred to by id (_measure)
2 CellKey pairs are (CategoryId, ItemId) (CategoryId, ItemHandle)
1 (silent; today measure_item_names merges a formula into a stored measure of the same name) a formula never merges: it gets its own id, profit-<n> beside a stored profit
2, 5 a collapsed entry and a group reference name a group by name by its id
4 casefold; a run of U+0020 spaces becomes one _; strip characters outside a–z, 0–9, -, _; remove a leading _ casefold; every character outside a–z, 0–9 and - becomes _; runs of _ collapse; leading and trailing _ and - are removed; n is prepended to a slug that starts with a digit
5 a hand-written id uses the alphabet a–z, 0–9, -, _ and may begin with _ only if reserved a hand-written id obeys every syntax rule of a generated id
6 display names typed into minibuffers are turned into ids at the input edge only CSV import and the .improv loaders turn display names in input text into ids; command-language text, from any source, names things by id
12 a typed name new to a formula edit is resolved against the document at commit the client resolves it, ids first, and the wire carries only id-form text
12 the regression test commits the unedited text after the concurrent renames the test edits a non-name token and commits, because a client may pass unedited text as a no-op without resolving any name

No questions are open.

Context

Today the name is the identity. Model::categories is an IndexMap keyed by name. CellKey and DataStore's interned symbols are names. Views, axis maps, page selections, hidden and collapsed sets, and formula text all refer by name. That costs three things:

  • An edit cannot be told apart from a delete followed by an add. No rename operation exists for a category or an item. A rename is remove plus add, which loses every cell (improvise-fz77). A formula is identified by (target, target_category), and Model::add_formula upserts on that pair.
  • An edit whose target was concurrently replaced cannot be told apart from an edit of the replacement.
  • A records-mode coordinate rename names its category and item by name (RenameRecordCoordinate { record, category, item }, String operands).

What exists already. Category.id: CategoryId and Item.id: ItemId are usize aliases from monotone counters. No production caller reads them, they have never been written to .improv, and View has no id. km71 landed an opaque interim FormulaId over (target, target_category), built only by Formula::id() (context/code/core.md §18). It is on the wire in ModelCommand::ReplaceFormula { id, was, raw }, FormulaSlot::Existing and FormulaEntry. Because it is still a name pair, Model::replace_formula refuses to rename a target outside _Measure (improvise-kb1d). Legacy JSON is gone (improvise-tt22, PR #224), so .improv is the only native format.

Decision

Categories, items, formulas and views each have an id, and so do groups [Amendment]. The id is the key. The current name field becomes the id, and the human name moves to a new display field.

  • Every reference is an id: CellKeys, axis maps, page selections, hidden and collapsed sets, formula references, data keys, and every wire operand that names an existing thing. A reference to the stable identity cannot glitch when something is renamed or replaced.
  • Display names are for people. They appear on screen, are typed into minibuffers, and are turned into ids at the input edge. [Amendment] Clause 6 narrows which readers turn display text into ids.

1. What carries an identity

kind id wire address
category yes CategoryId
item yes ItemId
formula yes FormulaId
view yes ViewId
group [Amendment] yes, with a display GroupId
record RecordHash, over ids (clause 8) unchanged shape

An id always names the same thing, so ids are unique in the whole document, across every kind. An item id names one item without its category. A CellKey still pairs each item with its category, and an operation that creates an item still names the parent category.

[Amendment] Groups. A group has an id and a display, like a category or an item, and its id behaves like every other id: it is unique in the document, generated by slugify (group is an empty slug's <kind>), declared beside the display in the file, referred to by id, created by display, and renamed by a display rename. In code, PR #336 (improvise-cq1q.12, open) already adds a private display to Group beside its key; under this amendment that key becomes the group's id.

Reserved ids. The virtual categories have ids _index, _dim and _measure, displayed _Index, _Dim and _Measure. No other id may begin with _. [Amendment] A leading _ is reserved for virtual ids in general. The virtual views have ids _records and _drill, displayed _Records and _Drill; they are addressed on the wire (SwitchView), and a slugified _Drill would otherwise be drill and could collide with a user's view. Slugify never produces a leading _ (clause 4), so the reservation needs no check in the grammar.

  • [Amendment] Virtual ids are lower case. The code, the wire, ViewState and the browser client spell the virtual categories _Index, _Dim and _Measure today, and move to the lower-case ids in the re-key step (clause 11).
  • [Amendment] Virtual categories and virtual measures are defined by criteria, not declared. Each contains whatever in the document meets its criterion: _measure holds the measures, and _index and _dim hold the record positions. Their ids and displays are hardcoded in the application, so a file never declares one. Where a line refers to one, it spells the virtual id: _measure: column, _index: none, region=east, _measure=revenue = 1200.
  • [Amendment] An old file's virtual names are recognised, not migrated as declarations. The loader recognises _Measure, _Index and _Dim by their hardcoded displays, and an old ## Category: _Measure section is read for the stored measures it lists, not as a declaration of _measure. The first save writes _measure wherever the file refers to it. The stored measures themselves (Revenue, Cost) are listed as the format lists them today, each with its id.
  • [Amendment] Records mode's synthetic positions are values, not ids. The row coordinate under _index is a value of that virtual category. So, by the same reasoning, is the column coordinate under _dim (a column index, gotcha 1). They are never persisted or typed, take no part in id uniqueness, and cannot collide with a user's item.

Formulas and measures. [Amendment] A measure is the normal formula target (profit = revenue - cost). A dimension (a category other than _Measure) is a rare one, for a derived dimension.

  • A _Measure target has no item of its own, and its handle is its formula's FormulaId. So the category panel's _Measure rows use ItemHandle::{Item(ItemId), FormulaTarget(FormulaId)} (clause 6).
  • [Amendment] A formula is a kind of measure, so there is no distinction between a _Measure formula's id and its measure's id. A measure has an id and a display, as everything else does. Profit = Revenue - Cost is one thing, profit: another formula refers to it as profit, each Profit cell's CellKey holds profit under _measure, and ReplaceFormula and RemoveFormula address profit.
  • [Amendment] Each measure's cells are keyed by one handle. A stored measure is keyed ItemHandle::Item(id). A formula's measure is keyed ItemHandle::FormulaTarget(id). The id is the measure's either way, and no column is keyed by both.
  • [Amendment] A formula never merges into a stored measure. If a stored measure profit (displayed Profit) exists and the user adds Profit = Revenue - Cost, the new formula is a second measure with a fresh id, profit-<n> from the shared counter (clause 4), like any colliding generated id. The stored measure keeps profit and its data. Removing the formula removes profit-<n> and leaves profit untouched. The formula keeps the display the user typed, so both are displayed Profit and are told apart by id: the formula panel shows the ids where the display is ambiguous (clause 6), and a typed Profit that matches both is refused with both ids listed (clause 12).
  • [Amendment] A target outside _Measure is a real item, with its own ItemId, and its formula has a separate FormulaId. A formula whose generated id would collide takes a suffix like any other generated id (clause 4): a formula Tax in CustomCat gets item id tax, then formula id tax-<n>.
  • [Amendment] Formulas may refer to anything by id. A function in a formula may accept any id-bearing thing (a category, an item, a measure, a formula, a view, a group), referred to by its id. Whether a given function takes the thing itself or its value (an item's display, say) is part of that function's signature. Global uniqueness makes every such reference unambiguous, and a function may read any part of a record, so nothing further is needed here.

2. Storage keys by id; display is the only place a human name lives

  • Categories and views: Model::categories and Workbook::views are keyed by id, and each Category, Item and View gains a display: String. [Amendment] So does Group.
  • Cells: a CellKey is a sorted list of (CategoryId, ItemId) pairs. [Amendment] The pairs are (CategoryId, ItemHandle), so that a _Measure formula cell's coordinate is FormulaTarget without claiming an ItemId the document does not hold.
    • The key stays sorted, now by category id, so the canonical form (gotcha 5) is kept with a different sort key.
    • DataStore interns ids exactly as it interns names today.
    • An id's text names one thing, because ids are unique across kinds.
  • View configuration: the axis maps, page selections, and hidden and collapsed sets are keyed by ids. [Amendment] A collapsed entry names its group by the group's id.
  • Formulas: a formula's references are ids (clause 5), and the formula panel renders them as display names (clause 6).

A rename costs one display write. It moves no CellKey, no RecordHash and no layout_version, so an edit a client composed before the rename still lands after it. The grid's labels are display strings, so a rename must still dirty the grid and the panels (Dirty::of, gotcha 35), but no basis tier moves.

The migration cost is the call sites. Every API that takes a name today takes an id instead: Model::category(name), Workbook::add_item(cat, item), CellKey::new's callers, the evaluator's Expr::Ref lookup and the View methods. Display→id resolution moves to the places a human types. The field rename itself is cheap. The change is mechanical but wide, and it is the bulk of the implementation.

Display names stay unique within their scope, as names are today. So a human-typed name resolves to exactly one id within its scope, and a rename to a display already in use there is refused. An item's scope is its category, so two categories may each have an item displayed Revenue. Formula text crosses categories, so a display may be ambiguous there. The ids differ, so the formula panel shows the ids where a display is ambiguous. [Amendment] The one exception is a formula whose measure shares a stored measure's display (clause 1): it is created anyway, and the two are told apart by id.

3. Ids are interned symbols

An id is a string, and in memory a Symbol from the existing SymbolTable: copyable, compared as an integer.

  • A Symbol never crosses the wire or reaches the file. It indexes one process's table. The wire and .improv carry the id's text, and each end interns it on arrival.
  • Rejection stops being Copy, because Rejection::Gone (clause 7) carries an id's text. SaveFailed carries no message only because Rejection was Copy. Whether it gains one is the implementer's call.

4. How an id is made: slugify, one rule everywhere

Every id the app generates comes from the display name by slugify: on import, on interactive creation, and when an id-less .improv file is loaded. So a thing's id never depends on how it was made. An id is lower case.

The accepted steps:

  1. casefold, with Unicode full case folding;
  2. replace each run of spaces with one _;
  3. strip every character that is not a–z, 0–9, - or _;
  4. remove any leading _;
  5. if nothing is left, use <kind>-<n>.

[Amendment] The steps become:

  1. Casefold, with Unicode full case folding, so Straße → strasse. Rust's str::to_lowercase is not enough (ß stays ß). The folding table or crate must pass the wasm gate, because improvise-core is in the browser bundle.
  2. Replace every character outside a–z, 0–9 and - with _. The id's alphanumerics are ASCII and include -. Every other character, including spaces, tabs and other whitespace, punctuation and every non-ASCII letter, becomes _.
  3. Collapse each run of _ to one _, whether the underscores came from step 2 or were in the display.
  4. Remove every leading and trailing _ and -, repeating until neither end holds either. A leading _ is reserved for virtual ids (clause 1), a trailing _ is dropped, and no id begins or ends with -.
  5. If the slug starts with a digit, prepend n. An id is never purely numeric and never starts with a digit, so 2024 becomes n2024.
  6. If nothing is left, use <kind>-<n>, such as category-1 or item-4. n comes from the shared counter below. A display of only -, only _ or only non-ASCII letters ends here, because - alone is not an id.

The syntax of an id is therefore: a lower-case ASCII letter, then any of a–z, 0–9, - and single underscores, never ending in _ or -: [a-z]([a-z0-9-]|_(?=[a-z0-9-]))*, not ending in -. Slugify's output already has that shape, so slugifying an id gives the same id back. The collision suffix -<n> (below) keeps the shape too, because it ends in a digit.

Examples under the amendment:

display id
East east
Time Period, Time Period, Time\tPeriod, Time \u{200B} Period time_period
Coastal_East coastal_east
a_ b, a__b a_b
Q1-2024 q1-2024
Q1 - 2024 q1_-_2024
East-1 east-1
East , East_, -East- east
_Draft draft
Region(code) region_code
Café caf
Crème Brûlée cr_me_br_l_e
Straße strasse
2024, _2024 n2024
2024-Q1 n2024-q1
Ü, -, % <kind>-<n>

Under the accepted steps, the ones that differ are East → east_, a_ b → a__b, Time\tPeriod → timeperiod, Time \u{200B} Period → time__period, -East- → -east-, Region(code) → regioncode, Crème Brûlée → crme_brle, 2024 → 2024, 2024-Q1 → 2024-q1 and - → -.

Collisions. Time Period and Time_Period slug alike, and whichever comes later takes a suffix. [Amendment] So do East, East and East_, which slug alike only under the amendment's steps. East in Region and East in Direction also slug alike, and ids are global, so the second is east-<n>.

[Amendment] A suffix and a hyphenated slug can look alike. The display East-1 slugs to east-1, which is also what the counter's suffix may make of east. They never collide, because every id a document holds is taken, whoever made it:

  • if east-1 is already held (as a slug or as a suffix), a display East-1 that slugs to it is a collision, and becomes east-1-<n>;
  • if the counter's candidate east-<n> is already held, the counter advances until a candidate is free.

So an id says nothing about how it was made: east-1 may be a suffixed east or the slug of East-1.

  • Any colliding generated id gets a suffix, whatever kind of thing holds the slug: on import, when an id-less file loads, and when the host creates something in a session. An id that does not collide stays bare.
  • The suffix is - plus the next value of one increment-only counter per document, as a plain number (east-1, not east-001). The counter works like Common Lisp's gensym: it never goes down and is never reused, so no order holds among suffixes (east-7 may follow east directly).
  • Retirement. Within a session a removed id is never handed out again. The counter is past any suffix it issued, and a removed bare slug stays reserved in memory.
  • Determinism. The counter advances in file order or creation order, never in hash order (gotcha 18), so the same input gives the same ids.
  • The counter lives in memory, starts at 1 when a document loads, and is not written to the file.

Hand-written ids are canonical. An id written in a file is never renamed, because every data key and reference in that file spells it. So:

  • a file has ids on everything or on nothing. An id-less file (an old version, or a CSV or JSON import) gets every id from slugify. A new-version file must carry an id on every declaration, and one that does not is refused (clause 5);
  • the suffix only ever goes on the id being generated. When a generated candidate slug-<n> is taken by a file's id, the counter advances until a candidate is free;
  • two identical hand-written ids in one file are refused at load, even when they declare different kinds of thing. Renaming one would corrupt the data section, and a file with a duplicate cannot be resaved safely. The error names both lines.
  • [Amendment] A hand-written id obeys every syntax rule of a generated id (the syntax above). So east-1, item-4 and q1_-_2024 load; -east, east-, 2024, a__b and _draft are refused at load (clause 5).

Reuse across a save and reload is accepted. Retired ids are not persisted. So remove East, save, reload and add East, and the new item gets east again. An in-flight command from before the reload is refused by the generation tier.

[Amendment] Two branches of one file can merge into a file that will not load. If each branch adds a different thing that generates the same slug, the merged file declares that id twice. The load is refused and names both declarations, so the merge is repaired by hand. Stable identity across copies and branches of a file is not promised (improvise-8tk5).

Built code. crates/improvise-core/src/model/id.rs (slugify, IdAllocator) implements the accepted steps, and context/code/core.md §20 describes them. Once the amendment is approved, a follow-up must:

  • replace every character outside a–z, 0–9 and -, not only spaces, with _ instead of stripping it, and collapse _ runs;
  • trim a trailing _ and both ends' -, and prepend n before a leading digit;
  • make claim refuse every hand-written id a generated one could not be;
  • add the virtual views to VIRTUAL_IDS, and IdKind::Group.

Nothing calls slugify yet.

5. Persistence: ids on declarations and references

  • Declarations (section headers, item lines, formula lines, and [Amendment] group definitions) carry the display and the id.
  • Every reference is the id: axis lines, hidden: and collapsed:, page selections, ## Aggregation, Initial View:, ## Data keys (ids are guaranteed unique, names might not be), and names inside formula text.

The id alphabet. Every id is spelled only in a–z, 0–9, _ and -. The grammar spells it with ranges, because the generator drops ASCII_ALPHANUMERIC (improvise-rxz9). So an id never needs pipe quoting in a declaration or reference.

What the loader refuses in a new-version file:

  • a declaration with no id. A header ending in a parenthesised run with a capital (Sales(Copy)), an empty tag or a tag with a trailing space does not match id_tag, so it has no id. The error names the line;
  • a hand-written id beginning with _, other than a reserved one;
  • [Amendment] a hand-written id that breaks any syntax rule of a generated id (clause 4). A file never declares a virtual category, so no declared id begins with _; a reference may still spell a virtual id such as _measure. The grammar's id_token stays permissive, and the loader refuses, so the error can name the line.

Inside formula text an id may still need quoting. The formula grammar's bare_ident must begin with a letter or _, and keywords are guarded (gotcha 34). So the writer spells an id in formula text through quote::quote_name. An id equal to a keyword (sum, if, where, is) is written |sum|, and under the accepted rules an id beginning with a digit (2024) is written |2024|. [Amendment] No id begins with a digit, so only keyword ids need quoting. A - inside an id needs none, because the formula grammar's bare identifier already admits - after its first character.

id_token = @{ ('a'..'z' | '0'..'9' | "_" | "-")+ }
// The space before the parenthesis is optional.
id_tag   = ${ " "* ~ "(" ~ id_token ~ ")" }

// Declarations: display, then id. The id is optional in the grammar so that the
// loader, not the parser, refuses a missing one and can name the line.
header_decl  = { pipe_quoted ~ id_tag ~ &NEWLINE | header_display ~ id_tag ~ &NEWLINE | line_name }
header_display = @{ (!(id_tag ~ NEWLINE) ~ !NEWLINE ~ ANY)+ }
item_decl    = { name ~ id_tag? }
grouped_item = { "- " ~ name ~ id_tag? ~ "[" ~ name ~ "]" ~ NEWLINE ~ blank_lines }

// References: an id, never a display.
id_ref       = { id_token }

model_name has no id and keeps header_name. Initial View: is a reference and takes an id_ref.

Group references. A group reference ([Coastal]) is a name. [Amendment] It is the group's id, as is the group in a collapsed: line, and the group definition (> Coastal) declares a display and an id like any other declaration (> Coastal(coastal)).

[Amendment] An axis line is tried before format:, and a format that reads as an axis line is pipe-quoted (improvise-le4p). view_entry is an ordered choice, and format_line takes the rest of the line. So a category whose id is format would lose its axis line to the view's number format, and the new-version arm puts axis_line first.

  • The collision then moves. set-format stores its argument verbatim, so :set-format none writes format: none, which would now read as the axis line of a category format.
  • So the writer pipe-quotes a number format whenever the line would also match axis_line: an axis keyword (row, column, page, filter, none) alone or followed by , <name>, or a value beginning with |. The new arm's format_line is pipe_quoted ~ &NEWLINE | rest_of_line, so format: |none| is the format none.
  • An old-version file's format: none is read as before (its arm keeps format_line first), and it is written quoted on its first save.
  • Required tests:
    • a category displayed Format, on a row axis, round-trips;
    • a view whose number format is each axis keyword round-trips in a document where that view also holds a category format on an axis.

[Amendment] A formula line declares the formula: its target, its id, and its expression over ids. Today's web/demo.improv and the format's own dimension example are written:

## Formulas
- Profit = Revenue - Cost
- Tax = Revenue * 0.1 [CustomCat]

Under the amendment the same two formulas are written:

## Formulas
- Profit(profit) = revenue - cost
- tax(tax-1) = revenue * 0.1 [customcat]
  • A measure formula (no category tag) is the normal case. The formula is the measure (clause 1), so the line is the measure's only declaration: Profit is its display, and (profit) is the id it shares with its formula. No [_measure] tag is written, because a file does not spell a virtual category where it is not needed (clause 1); a line without a tag targets _measure.
  • A dimension formula (a [<category id>] tag) targets a real item, already declared with its display in its category's section (## Category: CustomCat(customcat) lists - Tax(tax)). So the line names the target by its item id, tax, and (tax-1) is the formula's own id. The display is not repeated, because a display lives in one place.
  • The tag is always an id, the target category's ([customcat], never [CustomCat]), and _measure is implicit: it is never written as a tag.
  • So the loader can always tell the two apart: with a tag the target is an item id; without one it is a measure's display.
  • The expression is over ids, spelled through quote::quote_name. A reference to a formula target is the id text of its ItemHandle: the item id outside _Measure, the measure's (and its formula's) id for a _Measure target.
  • A WHERE/IS value follows the same rule. It is the id text of an ItemHandle in the filtered category, and the formula panel shows it as a display.
  • A display in the target position that looks like Name(x) is pipe-quoted, as in - |Margin(%)|(margin) = …. An id never contains a parenthesis.

header_display is guarded only by a lookahead, which the generator skips (gotcha 34). So the generator may emit a display ending in something that parses as an id. Round-trip property 3 still holds, because the first parse settles the split, but property 1 must be stated over the parsed result, not over the generated text.

The version line alone picks the regime (clause 10).

  • In an old-version file a header is the whole line, a reference resolves against display names, and ids are assigned by slugify. This resolution migrates a file whose names were its only identity; it is not a reference by a person.
  • In a new-version file declarations carry ids and a reference is an id.

An old file naming an item it never declares declares it at load. This happens today when a ## Data line names an undeclared item (improvise-98pd). The item gets a slug like any other, and the first save writes it, so a file that loads today keeps loading (improvise-80rm). Ids do not settle 98pd's other two sources, set-cell and a stale page selection. A page selection holds an item id, and removing the item leaves it dangling, as a removed name does today.

Old data keys change spelling on the first save. Slugify folds case, so an old file's Region=East resolves at load through the display East and is written region=east. The data is the same, but a git diff across that save touches every data line.

Round-trip properties:

  1. an id-less (old-version) file loads, gets ids, and is written in the new version on its first save;
  2. a new-version file loads with every id kept verbatim;
  3. format(parse(format(parse(x)))) == format(parse(x)).

A formula-line generator strategy is required. The grammar walker emits no =, and the walker drops such lines, so without its own strategy every formula property is vacuous (improvise-rxz9).

A duplicate, unknown or missing id in a new-version file refuses the load. The error names the id, and for a duplicate both declarations. A silent repair is ruled out, because reassigning an id would silently change what its references point at.

Import (CSV, JSON) never parses ids out of dimension names. A header Region(code) is the display Region(code), and its id is its slug. The writer pipe-quotes any display that would otherwise read as display plus id: ## Category: |Region(code)|(regioncode) ([Amendment] (region_code)).

6. The wire: address by id, create by display, learn ids from projections

  • The host assigns every id. A client never proposes one.

  • Every operand naming an existing thing is an id. The one operand naming the thing being created is a display, and the host slugifies it:

    • AddCategory(display) and CreateView(display);
    • AddItem { category: CategoryId, display };
    • AddItemInGroup { category: CategoryId, display, group }. [Amendment] group is the group's id, and a new group is created by its display under the same create-by-display rule;
    • AddFormula { target_category: CategoryId, raw }.
  • [Amendment] Only two readers turn display names in input text into ids: CSV import and the .improv loaders. They read text people wrote, where names are all there is. The wire never carries a display as a reference.

  • [Amendment] Command-language text names things by id. Anything that uses the command language as a scripting language is a script, wherever the text comes from (a script file, improvise cmd, another program). A script that names something by its display fails. A script gets a display only through a dedicated function such as display_for(id).

  • [Amendment] Interactive input matches either a display or an id, in the TUI and the browser, as long as it is unambiguous which thing the user is picking. Ids win when it is not, and the display is shown as a preview (clause 12).

  • A frame cannot address a thing it creates, because it does not know the id the host will assign. A create-then-act compound becomes one command: create-and-switch-view sends [CreateView, SwitchView] today and becomes CreateView { display, switch: true }. Any other compound of that shape gets the same treatment.

  • New operations carry ids:

    • RenameCategory { category, to: display };
    • RenameItem { category, item, to: display };
    • [Amendment] a group rename by group id and new display;
    • ReplaceFormula { id, was, raw };
    • RenameRecordCoordinate;
    • the removes (RemoveCategory, RemoveItem, RemoveFormula, DeleteView).
  • Older variants move to ids too (SetAxis, SetPageSelection, HideItem, …, including SetCell { view, … } and ClearCell { view, … }), one whole variant at a time.

  • Projections carry id and display together:

    • CategoryRow { id, display, items: Vec<(ItemHandle, String)>, axis };
    • FormulaCache rows { id, stored, display }. display is what the panel shows: the target's display with its id in parentheses (Profit (profit) = Revenue - Cost), then the expression over display names, with an id wherever a display is ambiguous. [Amendment] A display equal to another thing's id counts as ambiguous, so the panel never shows a bare name that the ids-first rule (clause 12) would bind elsewhere. stored is the id-form text the file carries, and ReplaceFormula's was is compared against it. So a rename of Revenue elsewhere changes what the panel shows, but does not refuse an edit of Profit;
    • ViewSummary { id, display, … };
    • [Amendment] the grid frame's group entries (FrameGroup) carry the group's id and display.

    AxisFrame labels stay display strings. They gain ids when a consumer needs them.

  • ViewState keys by id: the cursors, expanded_cats and the view history (view_back_stack / view_forward_stack). [Amendment] So do active_view itself and Workbook::active_view (improvise-p003). A rename then orphans nothing.

  • [Amendment] A _Measure formula-target row (ItemHandle::FormulaTarget) is removed by RemoveFormula and renamed by a display rename of the measure (improvise-i0n7).

  • [Amendment] CheckedBatch's created-record exemption is removed (improvise-p003). It exempts a record that neither resolves nor is claimed, for frames like [AddItem(East), SetCell(East)]. A frame cannot address what it creates, so nothing produces such a frame any more.

7. ADR 001: an id that no longer resolves is gone

A command naming an id that does not resolve is rejected before anything is applied, as Rejection::Gone(Named):

  • Named is Category, Item, Formula or View, and carries the id's text. [Amendment] It gains Group.
  • The rejection is structural: force never creates structure.

The check has two halves, like the record tier:

  • Before the frame: an exhaustive ids_named feeds Checked::against.
  • Within the frame: an id arm in broken_chain refuses [RemoveFormula(f), ReplaceFormula(f)] (improvise-gkyo).

Per-entity versions are not added.

8. ADR 002: RecordHash is defined over ids

RecordHash hashes the explicit serialization of the sorted (CategoryId, ItemId) text pairs: the same function over different strings. [Amendment] The pairs are (CategoryId, ItemHandle) spelled as id text. Ids are unique across kinds, so the hash stays canonical. A record is the key minus _measure, so a FormulaTarget coordinate is never part of a record.

ADR 002's decision is unchanged in substance. Its prose ("sorts coordinates by category name", records spelled in names) is out of date once this lands, and the implementation PR updates it.

In behaviour: a display rename changes no RecordHash and bumps no layout_version, because view_shape hashes ids too. So a client's surrogate survives a rename, and its in-flight edit lands. A coordinate change, as in fdk1 or a records-mode move, produces a different record and a different hash, because it is a different record.

9. Headers that change meaning

In a new-version file, header_decl reads a trailing (token), with or without a space before it, as the id. Old-version files never get this rule, so their headers never split. The writer pipe-quotes any display that would split, so the cases below arise only in a new-version file written by hand. They show why old files keep the whole-line reading.

header line whole-line reading (old version) under header_decl (new version)
## View: Default(default) Default(default) display Default, id default
## View: |Default (default)|(default) pipes literal display Default (default), id default
## View: Sales (copy) Sales (copy) display Sales, id copy
## Category: Region(code) Region(code) display Region, id code
## Category: Budget (2024) Budget (2024) display Budget, id 2024 ([Amendment] refused: an id may not start with a digit)
## Category: Time Period Time Period unchanged; id time_period generated

The app itself writes such headers: improvise import on a CSV with headers like Region(code) saves ## Category: Region(code) today (improvise-80rm).

If old files got the rule, that category would load as display Region, id code. Every reference in the file spells Region(code), which resolves to neither, so the axis lines and data keys would not resolve, and nothing would be drawn. Because old files keep the whole-line reading, none of this happens. On the first save the writer pipe-quotes such a display: ## Category: |Region(code)|(regioncode) ([Amendment] (region_code)).

10. The file version alone picks the regime

  • The version is already explicit. The first line of every .improv file is a mandatory version line, matched as an exact literal (version_line = { "v2025-04-09" ~ NEWLINE ~ blank_lines }), and format_md writes it.
  • The new format gets a second literal. The grammar's file rule gets one arm per literal, each with its own header and reference rules. The literal's spelling is the implementation's choice.
  • The regime is chosen only by the first line, never inferred from the body. The old literal means no ids anywhere. The new literal means ids on every declaration.
  • A file whose first line is neither literal is refused, as today.
  • Every save writes the new literal, so an old file changes version on its first save.

11. Migration

  1. Remove legacy JSON (improvise-tt22). Done, in PR #224.
  2. Types: the id newtypes over Symbol, the display fields, FormulaId and ViewId. [Amendment] Also GroupId, a display on Group, and ItemHandle, which the re-key needs for CellKey.
  3. Re-key storage by id (clause 2). This is the wide, mechanical step, with display→id resolution moved to every input edge. [Amendment] The virtual categories' keys move from _Index, _Dim and _Measure to _index, _dim and _measure here, in the model, on the wire, in ViewState and in the browser client.
  4. Persistence: the new literal (clause 10), clause 5's grammar, the loader and its refusals, slugify, the writer, and the round-trip properties with a formula-line strategy.
  5. Projections and ViewState (clause 6).
  6. Vocabulary: the id-addressed variants, Rejection::Gone, ids_named and broken_chain's id arm.
  7. [Amendment] Clause 12's client-side capture and resolver. No display-matching code exists yet, so the resolver is built ids first from the start.

The implementation epic (improvise-cq1q) orders these steps to keep main shippable after each one: the display field before the re-key, and projection ids before the re-key.

12. A formula edit's names are fixed when the editor opens

The problem. The formula panel shows display names, and the user edits that text. If those names were mapped to ids at commit, a concurrent rename would break the edit one of two ways:

  • the typed name no longer resolves, and the edit is refused;
  • or the name now belongs to something else (Revenue renamed away, another item renamed to Revenue), and the edit silently binds to a different thing.

The rule. The display→id mapping is captured when the formula editor opens, and the commit resolves the edited text through it. This is the cell editor's capture-at-open rule: OpenedCell (improvise-protocol/src/client.rs) records the record, its version, the document generation and the view at open, and the commit answers for those.

  • The mapping covers every display the panel showed in that formula's text, including the target's.
  • was is compared against the stored id-form text (clause 6), so a concurrent replace of the formula is still seen.
  • A captured id that no longer exists at commit is Rejection::Gone (clause 7).
  • A typed name that was not in the captured mapping is resolved against the document at commit, and refused, naming it, if it does not resolve.

[Amendment] The client does the resolving. Interactive input may match a display or an id as long as the pick is unambiguous, and ids win when it is not:

  • The client holds the mapping, as it holds OpenedCell. The TUI and the browser share one resolver in improvise-protocol.
  • A name new to the edit is resolved by the client against its id-bearing projection (clause 6), ids first, then displays. Ids are unambiguous, so they are preferred wherever there is ambiguity, now and once an autocomplete exists.
    • A typed name equal to an id names that id's thing, even where it is also another thing's display.
    • Otherwise a display that exactly one thing in scope has names that thing, and a display matching several things is refused with a list of their ids.
    • A name that resolves to nothing is refused, and the refusal names it.
    • Ids are matched exactly, with no case folding.
  • Typing prefers ids and previews displays. A view-layer autocomplete turns typed text into ids before commit, preferring a match on an id, and shows the matched thing's display as a preview. The model layer refers to ids only. This ADR does not specify the autocomplete further.
  • The wire carries only id-form text. ReplaceFormula.raw and AddFormula.raw are in the file's id form, and the host never resolves a display. The host parses ids, and one that no longer exists is Rejection::Gone.

[Amendment] What ids-first costs. Suppose item A is displayed East with id east, and item B is displayed east, so slugify gave it east-1. Typing east names A. B is reached by typing east-1. A user who typed east meaning B is bound to A without being told, unless the preview shows it. That is why the panel shows B as east-1 and never as a bare east.

Required regression test.

  1. Open a formula editor on Profit = Revenue - Cost.
  2. From another client, rename Revenue to Sales, and rename a different item to Revenue.
  3. Commit the unedited text.

The stored formula must reference the original item's id, and the commit must not be refused. The test must fail under commit-time resolution and under a refusal on rename.

[Amendment] Step 3 becomes: edit something that is not a name (Revenue - Cost * 1) and commit. Committing the unedited text cannot tell a correct capture from no capture at all, because a client may treat unchanged text as a no-op without resolving any name (improvise-hexb).

[Amendment] Required test for ids-first. With items A (East, east) and B (east, east-1), type east into an editor, in text not captured at open:

  • it must bind A, and the test must fail under displays-first;
  • east-1 must bind B;
  • a display that is nobody's id must still resolve by display.

A fixture where no display equals an id proves nothing.

What this means for the tickets

improvise-km71 (editing a formula), landed as an interim. ReplaceFormula { id: FormulaId, was, raw } writes in place. It refuses a new target that another formula owns, and any target change outside _Measure (improvise-kb1d). was makes a concurrent replace visible, which even a stable id cannot, so was stays.

This ADR changes three things:

  • FormulaId's fields become one id. The signatures do not change.
  • A target change becomes a display rename of the target. The id does not move, so kb1d's refusal and core.md §18's dangling-reference report can be lifted.
  • The panel's text is resolved through clause 12's captured mapping. The parenthesised id is not the user's to change, so an edit that changes it is refused.

Rejections:

  • Another client's deletion gives Rejection::Gone(Formula(id)).
  • A deletion earlier in the same frame gives the broken-chain refusal.

improvise-fz77 (renaming a category or item). It adds RenameCategory and RenameItem. A rename is one display write, and no key, set, formula text, RecordHash or layout_version moves. It still owes three things:

  • refuse a display already used in the scope;
  • mark the effect so labels and panels re-project (gotcha 35);
  • tests asserting that the labels changed and the surrogates did not.

It depends on migration step 3.

improvise-fdk1 (renaming a records coordinate). RenameRecordCoordinate { record, category: String, item: String } and CellId::Coordinate { record, category: String } exist on main. This ADR changes three things:

  • category becomes a CategoryId.
  • item becomes ItemRef::{Existing(ItemId), New(display)}.
  • The rename changes a coordinate, so the record's CellKeys and RecordHash change. It is a different record.

Consequences

Good.

  • Edit, rename and replace are distinct, addressed operations.
  • A rename is one display write, and in-flight edits survive it.
  • ## Data keys are unique by construction.
  • km71's FormulaId becomes a real id by changing one struct.

Bad.

  • Re-keying storage is wide. Every name-taking API moves to ids.
  • Old files change version, and their data keys change case, on the first save.
  • Rejection loses Copy.
  • Formula::raw is no longer verbatim. The file carries formula text over ids.
  • The formula panel is a second rendering of formula text, so display names typed there must be resolved back to ids (by the client, under the amendment).
  • ADR 002's prose needs a wording update.
  • [Amendment] Scripts that name things by display break and must be rewritten over ids.

Not addressed. Per-entity versions, ids in AxisFrame labels and CellId::Derived, and (under the accepted text) group ids.

Open questions

None.