69e8549124
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
917 lines
50 KiB
Markdown
917 lines
50 KiB
Markdown
# 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 `CellKey`s, 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.
|
||
|
||
```pest
|
||
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 `CellKey`s 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`.
|