Files
improvise/context/adr/007-stable-identities.md
T
Edward Langley 69e8549124 ADR 007: accepted (improvise-sboz)
The user approved revision 8 on PR #210 (owner comment 5824468104,
2026-09-25 00:17:10Z, "looks good, approved"). Status-only change on top
of fcb8f539, which the delta review passed (issuecomment-5824516651).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 17:22:33 -07:00

50 KiB
Raw Blame History

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

Status: accepted (2026-09-25). The user approved revision 7. Revision 8 then followed the adversarial review of that head: it adds clause 12, the ADR's own reading of when a formula edit's names become ids, and corrects stale facts about main. The user approved revision 8 on PR #210 (owner comment 5824468104, 2026-09-25 00:17:10Z: "looks good, approved"). · Decided by: the user; see "What the user decided" · Ticket: improvise-sboz · Unblocks: improvise-fz77 · Revises: improvise-km71's interim FormulaId (landed in PR #214) and improvise-fdk1's RenameRecordCoordinate (landed in PR #221) · Builds on: ADR 001, ADR 002 · Depended on: improvise-tt22 (remove legacy JSON), which landed in PR #224

Numbering. The ADR was first filed as 005, but improvise-l2b8 already claims ADR 005 (exact decimal arithmetic) and improvise-ilvl claims 006. The review tickets filed against the first draft still say "ADR 005" in their titles; they mean this document.

Direction from the user, 2026-09-23, in three messages:

"there would be a lot of benefit to categories/formulas having stable identities, even if they were only transient: it would help pin editing operations and distinguish them from delete/remove"

"It's probably worth a file format change (in a opt-in way, that gets rectified on save if the input file was missing them)"

"Maybe something like View: Default(default) and the standard View: |Default (default)|(default) for quoting."

What the user decided

The user answered the first draft (context/adr/005-stable-identities.md at 9d97664c) with inline comments on PR #210, and then posted one comment on the thread. Each answer is quoted verbatim below, with where it was attached and what it overturned. Five of the first draft's positions were reversed. The rest of this document is rewritten to match.

1. Storage keys by id; the human name becomes a display field. On first-draft line 92, the clause "CellKey, DataStore, views and formula text stay keyed by name":

"Shouldn't it be easy to replace this to key by ID? There's no type change so, at worst, it would be renaming field access. We could also add the name as something like display and reserve name or whatever it is currently for the id."

2. The point of ids is that keys use them. On first-draft line 97, "ids do not make a rename cheap. They make it expressible":

"This is a lot lower value if CellKeys, etc. don't refer to the new id"

3. Every reference uses the id. On first-draft line 275, the heading of clause 8, "ADR 002: RecordHash stays defined over names":

"No, anything referring to something with an id should refer to the id as the stable identity to avoid glitches."

This answers Q7 by reversing it, and reverses clause 5's "references carry names" too.

4. Q1, the id's internal type. On first-draft line 469, the recommendation to wrap "a small string":

"We probably can reuse symbols as the internal type with interning?"

Ids are strings, interned through the existing SymbolTable.

5. Q2, formula id placement. On first-draft line 473, recommendation F1 (- Profit(3) = Revenue - Cost):

"Yes, before the equal to the right of the name"

6. Q3, header syntax. On first-draft line 480, the claim that "a header with a space before its parenthesis (Sales (copy)) is unaffected":

"Space before the open paren is optional: document this edge case that might break existing docs."

7. Q4, data keys. On first-draft line 485, the recommendation "no — names only":

"In Data, we should prefer ids that are guaranteed unique to names that might not be."

8. Import generates ids; legacy JSON goes. Thread comment, 2026-09-23 06:51:

"For CSV or JSON import, the dimensions are to be treated as the human friendly name and never parsed for IDs: during import, ids should be generated by a slugify algorithm that strips non-alphanumeric characters and replaces spaces with hyphens(collapsing runs). The only supported native file format is .improv, cleanup any legacy JSON format."

Legacy JSON removal is its own implementation ticket, improvise-tt22. This ADR depended on it and did not implement it; it landed in PR #224. It also dissolves improvise-65kf: with no legacy JSON to load, there are no numeric JSON ids to accept.

The answers change the ADR's centre of gravity. The first draft put an id beside the name as an index. The user's answers make the id the key and the name a display string. So a rename becomes cheap: it changes one display field and moves no key, no surrogate and no basis tier. The first draft had called that impossible.

Second round: answers on revision 2

The user answered revision 2 (context/adr/007-stable-identities.md at 5e46ce03) with thirteen inline comments on PR #210, posted 2026-09-23 15:03–15:11Z. Each answer is mapped to the question on the line it is attached to, and quoted verbatim.

9. Q5, group ids. On line 468, inside Q5 ("Recommendation: not now … the one exception to 'every reference is an id'"):

"This seems right"

Groups get no id in this ADR.

10. Q6, per-entity versions. On line 469, Q6 ("Recommendation: not in this ADR"):

"Agreed"

11. Q8, a duplicate or unknown id. On line 473, the recommendation to refuse the load:

"Yeah, don't load the file because you can't safely resave it"

12. Q9, the reading of "opt-in". On line 476, the recommendation that an id-less file is read forever and every save writes ids:

"Yes"

13. Q10, id reuse after a reload. On line 485, inside option (b), "Accept reuse across a reload (recommended)":

"Yes, b"

This is option (b), and (b) was the recommendation. Within a session, retirement still prevents reuse. Across a save and reload, a removed name that comes back gets its old slug back.

14. Q11, who assigns ids. On line 492, inside option (a):

"The host assigns"

15. Q12, old-version files and the new header rule. On line 502, inside option (a), "No (recommended)":

"Yes, a as long as version is unambiguously marked"

Old files are read as they always were, on the condition that the version is marked unambiguously. Clause 9 says how the loader meets that condition.

16. Q13, slugify. Five answers, one per item:

  • (a) where it applies. On line 510:

    "All id generation"

  • (b) case. On line 512, against the recommendation to keep case:

    "lower case only"

  • (c) _ and -. On line 515:

    "Yeah, keep both"

  • (d) non-ASCII letters. On line 518:

    "Strip non lower alphanumeric or -_"

  • (e) the empty slug. On line 520, the recommendation <kind>-<n>:

    "Yes"

So an id is lower case, and its alphabet is a–z, 0–9, - and _. Clause 4 states the rule. Q15 asked the user to confirm the order of its steps (answer 18).

17. Q14, formula text. On line 529, the recommendation "ids in the file":

"Display in the formula panel but not as column/row/page headers."

This does not contradict ids in the file, which answer 3 already requires. It answers where formula text is displayed, and it could be read two ways. That was Q16, which answer 19 settles.

Third round: answers on revision 3

The user answered revision 3 (context/adr/007-stable-identities.md at 18c82e1c) with three inline comments on PR #210, posted 2026-09-24 00:34–00:36Z. Each is mapped to the question on the line it is attached to, and quoted verbatim.

18. Q15, the order of slugify's steps. On line 571, Q15's recommendation "fold, then strip":

"casefold, replace space with _, strip"

The fold comes first, as recommended. The answer also changes answer 8's middle step: a space becomes _, not -. Two details of that sentence are Q18.

19. Q16, what the formula panel shows. On line 584, the paragraph that declined to recommend a reading:

"It should be at least Profit (profit) = Revenue - Cost but if Revenue is ambiguous, you'll have to display the slugs. Working with slugs internally and making the friendly names view-layer is almost always the right move here."

So the panel shows formula text over display names, with the target's id beside it in parentheses. Where a display name in the text is ambiguous, the panel shows the id instead. Ids are what the file and the model hold, and display names belong to the view layer. This also settles answer 17: the thing displayed "in the formula panel but not as column/row/page headers" is the target's id. Whether an id is enough to resolve an ambiguous name is Q19.

20. Q17, capitals in a hand-written id. On line 589, the question "May a hand-written id contain capitals?":

"no."

This is option (a). id_token is lower case only, and a header ending in a parenthesised run that contains a capital loads as a whole-line display with a load warning. The virtual categories are spelled with capitals today. That is Q20. (Answer 21 later made a new-version file all-or-nothing, so that header is now refused rather than warned about. See clause 5.)

Fourth round: answers on revision 4

The user answered revision 4 (context/adr/007-stable-identities.md at 024aa2d3) with five inline comments on PR #210, posted 2026-09-24 06:05–06:08Z. Each is mapped to the question on the line it is attached to, and quoted verbatim.

21. Q18(a), runs of spaces, and the collision suffix. On line 658, Q18(a) ("Does a run of spaces collapse to one _?"):

"runs of spaces are collapsed to one _ and then -### numeric suffix is added if duplicate (only when importing a file without IDs: a file must either have no IDs at all or always have IDs)"

This decides three things:

  • a run of spaces collapses to one _, as recommended;
  • a duplicate slug gets a numeric suffix after a -;
  • a file has ids on everything or on nothing. A new-version file with a declaration missing its id is refused, like any file that cannot be resaved safely (answer 11). So the earlier rule that a hand-written id wins over a generated one in the same file is gone.

The parenthesis also limits the suffix to generating ids for a file that has none. Two details of that are Q22. Q18(b), what "casefold" means, was not answered in this round (answer 26 settles it).

22. Q19, whether an id disambiguates formula text. On line 678, option (a) ("Make item ids unique in the document"):

"Ids have to be globally unique."

This is stronger than option (a). No two ids in a document are equal, whatever their kind, so a category, an item, a formula and a view never share one. An id in formula text or on the wire therefore always names exactly one thing.

23. Q20, the virtual categories. On line 691, option (a) ("Lower-case ids, capitalised displays"):

"Virtual categories have IDs: we need to add a "slug" version such as _index, but no non-virtual id can start with _"

The ids are _index, _dim and _measure, and the displays stay _Index, _Dim and _Measure. A leading _ is reserved for them. Clause 4 says how slugify keeps a generated id out of that space, and clause 5 says what happens to a hand-written one.

24. Q21, an old file naming an undeclared item. On line 708, option (a) ("Declare the item at load"):

"yes, this is right."

25. The file version. On line 731, the Consequences line "A new file version.":

"File version should be added explciitly if it's not already part of the format."

It is already part of the format. The first line of every .improv file is a mandatory, exact version literal (version_line = { "v2025-04-09" ~ NEWLINE ~ … } in improv.pest), and the writer emits it. So the new format is marked by a new literal, and nothing is inferred. Clause 10, new in this revision, states the rule in one place.

Fifth round: answers on revision 5

The user answered revision 5 (context/adr/007-stable-identities.md at e044c968) with three inline comments on PR #210, posted 2026-09-24 19:50–19:51Z. Each is mapped to the question on the line it is attached to, and quoted verbatim.

26. Q18(b), what "casefold" means. On line 766, the recommendation to take "casefold" literally, as full case folding:

"full unicode"

Slugify's first step is Unicode full case folding, so Straße → strasse.

27. Q22(a), how the suffix is spelled. On line 771, the recommendation "a plain number":

"Just a plane number."

The suffix is - followed by a plain decimal number, with no padding.

28. Q22(b), collisions outside import. On line 782, option (i), "Suffix it there too":

"Any ambiguous id gets a suffix, if if the id is between different types of things identified. Don't bother implementing a strict order here: east east-1 east-2, etc. just do what gensym does in CL: global increment-only counter appended when dedupe needed."

This decides three things:

  • every colliding id gets a suffix: on import, in a session, and across kinds (a category and an item that slug alike);
  • the number comes from one counter for the document that only ever increments, as Common Lisp's gensym does, so it is not "the lowest free number";
  • the suffix is appended only when a dedupe is needed, so an id that does not collide stays bare.

Clause 4 is rewritten to match. The rules this replaces (lowest free number, and suffixing only on import) were revision 5's.

Sixth round: answer on revision 6

The user replied at 2026-09-24 22:09Z to their own answer 28, on line 782 of e044c968:

29. Hand-written ids are canonical.

"You have to treat the file's hand-written ids as canonical, doing otherwise will corrupt the data section."

This constrains answer 28. The gensym suffix applies only to an id the program is generating, on import, when an id-less file loads, or when the host creates something in a session. An id written in a file is never renamed, because every data key and reference in that file spells it. Clause 4 says what follows:

  • a generated id that would collide with a file's id takes the suffix, and the file's id keeps its spelling;
  • two identical hand-written ids in one file are refused at load, not repaired.

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. The user's first message names what that costs:

  • 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 effectively identified by (target, target_category), and Model::add_formula upserts on that pair. improvise-km71 (PR #214) made formula editing its own operation, with an interim id built from that pair (below).
  • 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. improvise-fdk1 (PR #221) landed RenameRecordCoordinate { record, category, item } with String operands (below).

What already exists. Category.id: CategoryId and Item.id: ItemId are usize aliases, assigned from monotone counters. No production caller uses either value. Model::add_category and Category::add_item return them on the idempotent path, and nothing reads the returned value; the only reader is one test. Neither has ever been written to .improv. View has no id.

FormulaId exists, as an interim. km71 landed an opaque FormulaId whose private fields are (target, target_category), built only by Formula::id() (context/code/core.md §18). It is on the wire in ModelCommand::ReplaceFormula { id, was, raw }, in FormulaSlot::Existing and in FormulaEntry. It was made opaque so that this ADR changes one struct rather than every signature. It is still a name pair, so a formula whose target is renamed gets a different FormulaId. Model::replace_formula therefore refuses to rename a target outside _Measure until this ADR lands (improvise-kb1d).

Legacy JSON is gone. improvise-tt22 landed in PR #224, so .improv is the only native format.

Decision

Categories, items, formulas and views each have an id, and the id is their key. A category or item's current name field becomes the id (the user's suggestion), and the human name moves to a new display field.

  • Every reference is an id. That covers CellKeys, axis maps, page selections, hidden and collapsed sets, formula references, data keys, and wire operands that name an existing thing.
  • Display names are for people. They appear on screen, are typed into minibuffers, and are resolved to ids at the input edge.

Twelve clauses.

1. What carries an identity

kind id unique within wire address
category yes the document, across every kind (answer 22) CategoryId
item yes the document, across every kind (answer 22) ItemId
formula yes the document, across every kind (answer 22) FormulaId
view yes (the user's example) the document, across every kind (answer 22) ViewId
record RecordHash, now over ids (clause 8) the document unchanged shape
group no, not in this ADR (answer 9)

Ids are unique in the whole document (answer 22). One namespace covers categories, items, formulas and views, so an item id names one item without its category. A CellKey still pairs each item with its category, because the key needs the categories. An operation that creates an item still names the parent category.

Virtual categories have reserved ids (answer 23): _index, _dim and _measure, with the displays _Index, _Dim and _Measure. No other id may begin with _. A _Measure formula target has no item of its own. Its handle is its formula's FormulaId, which is why the category panel's _Measure rows need ItemHandle::{Item(ItemId), FormulaTarget(FormulaId)} (clause 6).

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

Following answers 1 to 3:

  • Categories and views: Model::categories and Workbook::views are keyed by id, and each Category, Item and View gains a display: String.
  • Cells and derived surrogates: a CellKey is a sorted list of (CategoryId, ItemId) pairs. It stays sorted, now by category id, so gotcha 5's canonical form is kept with a different sort key. DataStore interns ids exactly as it interns names today.
  • View configuration: the axis maps, page selections, and hidden and collapsed sets are keyed by ids.
  • Formulas: a formula's references are ids (clause 5). The formula panel renders them as display names (answer 19; clause 6).

What a rename now 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. That is the "avoid glitches" the user's rule is for. The grid's labels are display strings, so the rename's effect must still dirty the grid and the panels (Dirty::of gotcha 35), but no basis tier moves.

What the migration costs, stated honestly. Every API that takes a name today (Model::category(name), Workbook::add_item(cat, item), CellKey::new callers, the formula evaluator's Expr::Ref lookup, View methods) takes an id instead. The display→id resolution moves to the places a human types, such as the minibuffer commits, the formula parser's reference resolution, and import. The change is mechanical but wide, and it is the bulk of the implementation. The user judged it cheap ("at worst, it would be renaming field access"), and for the field that is right. The call sites are the cost.

Display names stay unique within their scope, as names are today. So a human-typed name resolves to exactly one id, and a rename to a display name already in use is refused. An item's scope is its category, so two categories may each have an item displayed Revenue. Formula text crosses categories, which is where answer 19's "ambiguous" arises. Their ids differ (answer 22), so the panel resolves the ambiguity by showing the ids.

3. Ids are interned symbols

Following answer 4, an id is a string, and in memory it is a Symbol from the existing SymbolTable: copyable, with integer comparison. Two consequences.

  • A Symbol never crosses the wire or reaches the file. It is an index into 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. basis.rs's SaveFailed carries no message because Rejection was Copy, so that reason lapses. Whether SaveFailed now gains its message is the implementer's call.

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

Following answers 8, 16 and 18, every id the app generates comes from the display name by slugify. That covers import, interactive creation, and assigning ids to an id-less .improv file at load (answer 16(a), "All id generation"). So a thing's id never depends on how it was made. The steps, in order (answer 18, "casefold, replace space with _, strip"):

  1. Casefold, with Unicode full case folding (answers 16(b), 18 and 26). Rust's str::to_lowercase is not enough (ß stays ß and is then stripped), so this needs a case-folding table or a small crate in improvise-core. It must pass the wasm gate, because that crate is in the browser bundle.
  2. Replace each run of spaces with one _ (answers 18 and 21; answer 18 changed answer 8's -).
  3. Strip every character that is not a–z, 0–9, - or _ (answers 16(c) and 16(d)). This strips non-ASCII letters, so an id never needs quoting in an .improv reference (clause 5 notes where formula text differs).
  4. Remove any leading _ (answer 23: no non-virtual id may begin with _). The answer states the constraint. Removing the underscores is this ADR's way of meeting it, so _Draft → draft.
  5. If nothing is left, use <kind>-<n> (answer 16(e)), such as category-1 or item-4. n comes from the same counter as every other suffix (answer 28), not from the lowest free number.

Examples: East → east, Time Period → time_period, Coastal_East → coastal_east, Q1-2024 → q1-2024, Region(code) → regioncode, Café → caf, Ü → category-<n>, Straße → strasse (answer 26). Time Period and Time_Period slug alike, so whichever comes second gets time_period-<n>. East in Region and East in Direction slug alike too, and since ids are global (answer 22), the second is east-<n>. Here <n> is the counter's next value, for example east-1.

The remaining rules:

  • A file has ids on everything or on nothing (answer 21). An id-less file (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). So no file mixes hand-written and generated ids.
  • Any colliding id gets a suffix, gensym-style (answers 21, 27 and 28).
    • When: whenever a generated slug is already taken, whatever kind of thing holds it. That applies 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.
    • What: - and the next value of one increment-only counter per document, as a plain number (east-1, not east-001). The counter never goes down and is never reused, so no order is enforced among suffixes: east-7 may follow east directly.
    • Retirement: within a session a removed id is never handed out again. The counter has already moved past any suffix it issued, and a bare slug that was removed is still reserved in memory.
    • Determinism: the counter advances in file order or creation order, so the same input gives the same ids, never hash order (gotcha 18).
    • Hand-written ids are canonical (answer 29). The suffix only ever goes on the id being generated. An id read from a new-version file keeps its spelling, even when it looks like a slug or a suffixed one (east-3), because the file's data keys and references spell it. So when a generated candidate slug-<n> is already taken by a file's id, the counter advances again until the candidate is free.
    • Two identical hand-written ids in one file are refused at load (answers 11, 22 and 29). By answer 22 they are a duplicate even when they declare different kinds of thing, such as a category and an item. Renaming either one is exactly what answer 29 forbids, and answer 11 already refuses a duplicate because such a file "can't safely resave". The error names both lines (clause 5). This follows from the answers, so it is not asked as a new question.
    • The counter lives in memory and starts at 1 when a document loads. It is not written to the file, since retired ids are not persisted (answer 13). That is this ADR's reading.
  • Across a save and reload, reuse is accepted (answer 13, Q10(b)). 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. The one remaining risk is an outside reference that outlives the file, and none exists today.

5. Persistence: ids on declarations and references

Following answers 3 and 7, the file now carries ids everywhere:

  • Declarations (section headers, item lines, formula lines) carry the display name and the id.
  • Every reference carries the id: axis lines, hidden: and collapsed:, page selections, ## Aggregation, Initial View:, ## Data keys, and names inside formula text (answer 3). The formula panel shows display names instead (answer 19).

Id alphabet. Every id, generated or hand-written, is spelled only in a–z, 0–9, _ and - (clause 4; answer 20). The grammar spells them with ranges, because the generator drops ASCII_ALPHANUMERIC (improvise-rxz9). So an id never needs pipe quoting in an .improv declaration or reference. A header ending in a parenthesised run that contains a capital, such as Sales(Copy), does not match id_tag, and neither does an empty or trailing-space tag. In a new-version file that declaration has no id, so the load is refused (answer 21), and the error names the line. Revision 4 generated an id and warned. A hand-written id beginning with _, other than the three virtual ids, is refused too (answer 23).

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, as it spells a name today. An id beginning with a digit (2024) or equal to a keyword (sum, if, where, is) is written |2024| or |sum|. No new rule is needed, but "never needs quoting" does not extend to formula text.

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

// Declarations: display name, then its id. The id is optional in the grammar so the
// loader, not the parser, refuses a missing one and can name the line (answer 21).
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 name.
id_ref       = { id_token }

model_name has no id, so it keeps header_name. Initial View: becomes a reference and takes an id_ref. A group reference ([Coastal]) stays a name while groups have no id (answer 9).

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 later 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.

Which regime a file is read under is decided by its version line alone (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. 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 (answer 24). This happens today when a ## Data line names an undeclared item (improvise-98pd, second source). The item gets a slug like any other, and the first save writes it, so a file that loads today keeps loading (the lesson of improvise-80rm). The NonItem work (improvise-gk51, PR #265) projects such a cell as CellId::NonItem. From a loaded file that case no longer arises. 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 exactly as a removed name does today.

Slugify folds case (answer 16(b)), so old data keys change spelling on first save. An old file's Region=East resolves at load through the display East. The first save writes the id: Region=East becomes region=east. The data is the same, but a git diff across that first 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 (answers 11, 21, 22 and 29; a duplicate across two kinds of thing counts, and a hand-written id is never renamed to resolve one; answer 11: "don't load the file because you can't safely resave it"). The error names the id, and for a duplicate it names both declarations. Repairing silently is ruled out, because references carry ids (answer 3): reassigning a duplicate would silently change what its references point at.

Legacy JSON is removed (improvise-tt22), so it needs no migration here.

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

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

  • The host assigns every id (answer 14). 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 name, and the host slugifies it:

    • AddCategory(display) and CreateView(display);
    • AddItem { category: CategoryId, display }, AddItemInGroup { category: CategoryId, display, group } and AddFormula { target_category: CategoryId, raw }.
  • A frame cannot address a thing it creates, because it does not know the id the host will assign. So a create-then-act compound becomes one command carrying both halves. create-and-switch-view sends [CreateView, SwitchView] today and becomes CreateView { display, switch: true }. Any other compound of this shape gets the same treatment.

  • New operations are born with ids:

    • RenameCategory { category, to: display };
    • RenameItem { category, item, to: display };
    • ReplaceFormula { id, was, raw }, which km71 already landed with an interim id (below);
    • fdk1's RenameRecordCoordinate, which landed with name operands (below);
    • the removes (RemoveCategory, RemoveItem, RemoveFormula, DeleteView).
  • Older variants (SetAxis, SetPageSelection, HideItem, …) must also move to ids. Their operands are references, and a reference is now an id. They may move one whole variant at a time. That includes SetCell { view, … } and ClearCell { view, … }, which improvise-wpxr (PR #281, merged) gave a view named by its name, like every other view-scoped variant (improvise-lltt).

  • 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 (answer 19): the target's display name with its id in parentheses, then the expression over display names, with an id wherever a display name is ambiguous (ids are unique, answer 22). stored is the id-form text the file carries, and it is what ReplaceFormula's was is compared against. So a rename of Revenue elsewhere changes what the panel shows but does not refuse an edit of Profit, which is the "avoid glitches" of answer 3. What makes that hold for an edit already open is clause 12;
    • ViewSummary { id, 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). Answer 1 makes this natural rather than a migration of its own. A view or category rename then orphans nothing.

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 one of Category, Item, Formula or View, and carries the id's text.
  • It 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 (answer 10).

8. ADR 002: RecordHash is now defined over ids

Following answer 3, RecordHash hashes the explicit serialization of the sorted (CategoryId, ItemId) text pairs, the same function over different strings.

  • ADR 002's decision is unchanged in substance. It still hashes an explicit serialization of a normalized record, with a fixed key and a width argued for.
  • Its prose will be out of date once this lands. ADR 002 says CellKey::new "sorts coordinates by category name", and a record is spelled in names. Both become ids. ADR 002 is not edited here. Updating that wording belongs to the implementation PR.

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

9. Headers that change meaning: the edge case answer 6 asks to document

Under answer 6, header_decl reads a trailing (token), with or without a space before it, as the id. It applies only to new-version files (answer 15, Q12(a)). An old-version file is read as it always was, so its headers never split. In a new-version file the writer never produces an ambiguous header, because it pipe-quotes any display that would split. So the edge cases below arise only when a new-version file is written by hand. The table shows what the header rule would do to lines that exist in old files today. That is why old files do not get the rule.

header line whole-line reading (today) under header_decl
## 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
## Category: Time Period Time Period unchanged; id time_period generated

The documents that change meaning are those with a view or category header ending in a parenthesised run of letters, digits, _ or -. The app writes such headers itself: improvise import on a CSV with headers like Region(code) or Size(cm) saves ## Category: Region(code) today (improvise-80rm). Any view someone named Sales (copy) does the same.

What it would cost if old files got the new rule (the case answer 15 rules out):

  • The category is renamed on load: display Region, id code.
  • Every reference in that old file spells Region(code), which resolves to neither the display nor the id.
  • So the axis lines and every data key for that category do not resolve.
  • Today's loader would silently drop the axis lines and orphan the data, so nothing is drawn.

Decided (answer 15): old-version files keep the whole-line reading, so none of this happens to a file that exists today. On the first save the writer pipe-quotes such a display, for example ## Category: |Region(code)|(regioncode), so the new file cannot split it either.

10. The file version is explicit, and it alone picks the regime

Following answers 15 and 25:

  • 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 } in improv.pest), and format_md writes it. Nothing is added to make the version explicit. What is added is a second literal.
  • The new format gets a new literal. The grammar's file rule gets one arm per literal, and each arm uses its own header and reference rules. The new literal's exact spelling is left to the implementation.
  • The regime is chosen only by the first line, never inferred from what the body looks like. So the old literal means no ids anywhere, and the new literal means ids on every declaration (answer 21).
  • A file whose first line is neither literal is refused, as it is today.
  • Every save writes the new literal (answer 12). 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, and FormulaId and ViewId.
  3. Re-key storage by id (clause 2). This is the wide, mechanical step, with display→id resolution moved to every input edge.
  4. Persistence: the new version literal (clause 10), the grammar in clause 5, 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.

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

This clause is the ADR's own reading, not a user answer. It closes a contradiction the adversarial review of 4830fa30 found between clause 6 and the km71 section, which is also ChatGPT Codex's P1 on 29ad14f4.

The problem. The formula panel shows display names (answer 19), and the user edits that text. If the host maps those names to ids at commit, a concurrent rename breaks the edit one of two ways:

  • the name the user typed no longer resolves, so the edit is refused, which contradicts clause 6's "does not refuse an edit of Profit";
  • or the name now belongs to something else (Revenue renamed away, another item renamed to Revenue), so the edit silently binds to a different thing. That is the glitch answer 3 exists to prevent.

The rule. The display-name-to-id mapping is captured when the formula editor opens, and the commit resolves the edited text through that captured mapping. This is the capture-at-open rule the cell editor already follows: OpenedCell in improvise-protocol/src/client.rs records the record, its version, the document generation and the view at open, and the commit answers for those rather than for whatever the document holds by then.

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

Required regression test. Open a formula editor on Profit = Revenue - Cost. From another client, rename the item Revenue to Sales and rename a different item to Revenue. Commit the unedited text. The formula must still reference the original item's id, and the commit must not be refused. The test must fail under commit-time resolution, which binds to the new Revenue, and it must fail under a refusal on rename. Both are the rivals this clause rules out.

What this means for you

improvise-km71 — editing a formula (landed as an interim)

km71 landed in PR #214, before this ADR, and was built to be revised by it (context/code/core.md §18):

  • What landed. ModelCommand::ReplaceFormula { id: FormulaId, was, raw }. FormulaId is opaque, with private fields (target, target_category). Model::replace_formula writes in place, keeping the position. It refuses a new target that another formula owns, and it refuses any target change outside _Measure (improvise-kb1d). was is the text the editor opened on. It makes a concurrent replace visible, which even a stable id cannot do, so was stays.
  • What this ADR changes.
    • FormulaId's fields become one id. The signatures that carry FormulaId do not change, which is what making it opaque was for.
    • A target change becomes a display rename of the target. The id does not move, and nothing that references the target is rewritten. So kb1d's refusal outside _Measure can be lifted, and so can the dangling-reference report core.md §18 describes.
    • The panel shows Profit (profit) = Revenue - Cost (answer 19). The user edits that text. Its display names are resolved to ids through the mapping captured when the editor opened (clause 12), not through the names the document holds at commit, and was is compared against the stored id-form text (clause 6). The parenthesised id is not the user's to change, since the host assigns ids (answer 14). An edit that changes it is refused. This is the ADR's reading, not a user answer.
  • 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

  • The operations. RenameCategory { category, to } and RenameItem { category, item, to }. The panel rows carry the ids.
  • It is now cheap (answers 1 and 2): one display write. No CellKey, axis map, page selection, hidden or collapsed set, or formula text is rewritten, because all of them hold ids. No RecordHash or layout_version moves, so in-flight edits survive.
  • What it still owes:
    • Uniqueness. Refuse a display name already used in the same scope.
    • Dirtiness. Mark the effect so the grid labels and panels are re-projected (gotcha 35). The labels are display strings.
    • Tests. Assert that the labels changed and the surrogates did not. That is the property this design exists for.
  • It depends on step 3 of the migration, the re-keying. Until then there is no cheap rename to build on.

improvise-fdk1 — renaming a records coordinate

fdk1 landed in PR #221, before this ADR. What exists on main:

  • ModelCommand::RenameRecordCoordinate { record: RecordHash, category: String, item: String }, which moves a whole record to a new coordinate and creates the item if need be;
  • CellId::Coordinate { record: RecordHash, category: String }, the surrogate a records-mode coordinate cell carries.

What this ADR changes, as it does for km71's FormulaId:

  • category becomes a CategoryId, in the command and in CellId::Coordinate.
  • item becomes an ItemRef: Existing(ItemId) or New(display). That is the create-by-display half of clause 6, since the host assigns the new item's id.
  • Unlike a display rename, this changes a coordinate, so the record's CellKeys and RecordHash change. It is a different record, and that does not change.

Questions as asked, and where each now stands

The first draft asked Q1–Q9. Revision 1 added Q10 and Q11, revision 2 added Q12–Q14, revision 3 added Q15–Q17, revision 4 added Q18–Q21 and revision 5 added Q22. All are answered.

# question my recommendation status
1 Integer or word ids? first draft: words; revision 1: integers only answered: interned symbols (answer 4), generated by slugify (answers 8, 16). Words.
2 Formula id placement F1, - Profit(3) = … answered: F1 (answer 5)
3 Headers in old files first draft: accept the rare break; revision 1: gate on version answered: the space is optional (answer 6), and old files keep the whole-line reading (answer 15, Q12)
4 Do ## Data keys carry ids? no answered: yes, ids (answer 7), reversing the recommendation
5 Group ids? not now answered: not now (answer 9)
6 Per-entity versions? not in this ADR answered: not in this ADR (answer 10)
7 RecordHash over ids? no, keep names answered: over ids (answer 3), reversing the recommendation
8 Duplicate id: repair or refuse? revision 2: refuse answered: refuse the load (answer 11)
9 Reading of "opt-in" read optional, write always answered: yes (answer 12)
10 Id reuse after a reload revision 2: (b) accept reuse answered: (b) (answer 13)
11 Who assigns ids? the host answered: the host (answer 14)
12 Old files and the new header rule (a) no answered: (a), with the version marked unambiguously (answer 15)
13 Slugify details all generation; keep case; keep _ -; strip non-ASCII; <kind>-<n> answered (answer 16): all generation, lower case only (reversing the recommendation), keep both, strip, <kind>-<n>
14 Formula text: ids or display names? ids in the file answered: ids in the file (answer 3); the panel is Q16
15 Slugify order fold, then strip answered: casefold, space → _, strip (answer 18); the space character changed from -
16 What the formula panel shows none answered: display names, target shown as Profit (profit), ids where a name is ambiguous (answer 19)
17 Capitals in a hand-written id (a) no answered: no (answer 20)
18(a) Does a run of spaces collapse? collapse answered: collapse, plus a numeric suffix on a duplicate, and ids on everything or nothing (answer 21)
18(b) What "casefold" means full case folding answered: full Unicode case folding (answer 26)
19 Does an id disambiguate formula text? (a) item ids unique in the document answered: ids are globally unique (answer 22), stronger than (a)
20 Virtual category ids (a) lower-case ids, capitalised displays answered: (a), and no other id may begin with _ (answer 23)
21 An old file naming an undeclared item (a) declare it at load answered: (a) (answer 24)
— "A new file version" (a Consequences line) — answered: the version must be explicit (answer 25); it already is, and clause 10 adds a second literal
22 The suffix's spelling, and interactive collisions (a) plain number; (b)(i) suffix there too answered: a plain number (answer 27); any collision is suffixed, across kinds, from a gensym-style increment-only counter (answer 28), and only a generated id is ever suffixed, since a hand-written one is canonical (answer 29)

Still open

Nothing is open among Q1–Q22, and answer 29 settles the one case it raises: two identical hand-written ids are refused at load. The user approved revision 7. Revision 8 adds clause 12, which is the ADR's own reading, and the user approved it ("looks good, approved", 2026-09-25 00:17:10Z). Three follow-ups from the same review stay as tickets, not ADR text: improvise-le4p, improvise-m56i and improvise-p003.

Consequences

Good.

  • Edit, rename and replace are distinct, addressed operations.
  • A rename is one display write: it moves no key, surrogate or basis tier, and in-flight edits survive it.
  • ## Data keys are unique by construction.
  • km71's FormulaId becomes a real id by changing one struct, and kb1d's refusal can be lifted.

Bad.

  • Re-keying storage is wide: every name-taking API moves to ids, and display→id resolution moves to the input edges.
  • A second version literal (clause 10), and every old file changes version on its 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, over display names, so the host must resolve display names typed there back to ids (answer 19).
  • Ids are lower case, so an old file's data keys change spelling on its first save.
  • ADR 002's prose needs a wording update when this lands.

Not addressed. Per-entity versions (Q6), group ids (Q5), and ids in AxisFrame and CellId::Derived.