Source: context/adr/007-stable-identities.md

On this page

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

Amended: by 007-stable-identities-amendment-1.md (accepted 2026-10-09, PR #326), which states every rule it replaces.

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:

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:

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:

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:

#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:

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.

Twelve clauses.

#1. What carries an identity

kindidunique withinwire address
categoryyesthe document, across every kind (answer 22)CategoryId
itemyesthe document, across every kind (answer 22)ItemId
formulayesthe document, across every kind (answer 22)FormulaId
viewyes (the user's example)the document, across every kind (answer 22)ViewId
recordRecordHash, now over ids (clause 8)the documentunchanged shape
groupno, 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:

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.

#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:

#5. Persistence: ids on declarations and references

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

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

#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):

The check has two halves, like the record tier:

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.

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 linewhole-line reading (today)under header_decl
## View: Default(default)Default(default)display Default, id default
## View: \|Default (default)\|(default)pipes literaldisplay 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 PeriodTime Periodunchanged; 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):

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:

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

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):

#improvise-fz77 — renaming a category or item

#improvise-fdk1 — renaming a records coordinate

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

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

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

#questionmy recommendationstatus
1Integer or word ids?first draft: words; revision 1: integers onlyanswered: interned symbols (answer 4), generated by slugify (answers 8, 16). Words.
2Formula id placementF1, - Profit(3) = …answered: F1 (answer 5)
3Headers in old filesfirst draft: accept the rare break; revision 1: gate on versionanswered: the space is optional (answer 6), and old files keep the whole-line reading (answer 15, Q12)
4Do ## Data keys carry ids?noanswered: yes, ids (answer 7), reversing the recommendation
5Group ids?not nowanswered: not now (answer 9)
6Per-entity versions?not in this ADRanswered: not in this ADR (answer 10)
7RecordHash over ids?no, keep namesanswered: over ids (answer 3), reversing the recommendation
8Duplicate id: repair or refuse?revision 2: refuseanswered: refuse the load (answer 11)
9Reading of "opt-in"read optional, write alwaysanswered: yes (answer 12)
10Id reuse after a reloadrevision 2: (b) accept reuseanswered: (b) (answer 13)
11Who assigns ids?the hostanswered: the host (answer 14)
12Old files and the new header rule(a) noanswered: (a), with the version marked unambiguously (answer 15)
13Slugify detailsall 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>
14Formula text: ids or display names?ids in the fileanswered: ids in the file (answer 3); the panel is Q16
15Slugify orderfold, then stripanswered: casefold, space → _, strip (answer 18); the space character changed from -
16What the formula panel showsnoneanswered: display names, target shown as Profit (profit), ids where a name is ambiguous (answer 19)
17Capitals in a hand-written id(a) noanswered: no (answer 20)
18(a)Does a run of spaces collapse?collapseanswered: collapse, plus a numeric suffix on a duplicate, and ids on everything or nothing (answer 21)
18(b)What "casefold" meansfull case foldinganswered: full Unicode case folding (answer 26)
19Does an id disambiguate formula text?(a) item ids unique in the documentanswered: ids are globally unique (answer 22), stronger than (a)
20Virtual category ids(a) lower-case ids, capitalised displaysanswered: (a), and no other id may begin with _ (answer 23)
21An old file naming an undeclared item(a) declare it at loadanswered: (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
22The suffix's spelling, and interactive collisions(a) plain number; (b)(i) suffix there tooanswered: 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.

Bad.

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