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

On this page

ADR 007, amendment 1 — Stable identities

Ticket: improvise-sboz (this amendment improvise-cq1q.1; implementation epic improvise-cq1q) · Builds on: ADR 001, ADR 002

#Status

Accepted (2026-10-09, PR #326). It amends ADR 007 (accepted 2026-09-25, PR #210, 007-stable-identities.md). This file states only what the amendment changes or adds, numbered by the accepted ADR's clauses; everything else in ADR 007 is still true. Where the amendment replaces an ADR 007 rule or an existing behaviour:

clauseaccepted rule or existing behaviourthe amendment
1groups have no ida group has an id and a display, and its id behaves like every other id
1_index, _dim, _measure are the only ids beginning with _a leading _ is reserved for virtual ids in general, including the virtual views _records and _drill
1the virtual categories are declared and named in the file (## Category: _Measure, _Measure: column)they are defined by criteria with hardcoded ids and displays and referred to by id (_measure); the ## Category: _Measure header lists the stored measures but declares nothing
1measure_item_names merges a formula into a stored measure of the same namea formula never merges: it gets its own id, profit-<n> beside a stored profit
2a display already in use in a scope is refusedequal displays in a scope are shown with a gapless #2, #3, … suffix in id order
2CellKey pairs are (CategoryId, ItemId)(CategoryId, ItemHandle)
4ids sort by plain string order (CellKey::new sorts with String::cmp, and the writer orders data lines by their text)ids sort in natural order. CellKey's canonical coordinate order changes with it, so RecordHash values change (they are on the wire, not in files), and a saved file's data lines and their coordinates are written in the new order; the loader canonicalises keys, so the data read back is the same
4records mode string-sorts the store on entry (DataStore::sort_by_key, run by SortData)records-mode rows come out in natural order, and so does the order matching_positions folds in (gotcha 18), so a SUM/AVG can change in the last bits
4the writer string-sorts hidden:, expanded(…), collapsed: and ## Aggregation linesthose lines are written in natural order too
4a GP fit string-sorts its records (improvise-gp dataset.rs)the feature matrix's row order, and so a seeded fit's folds, follow natural order: a seeded fit can produce a different tree
4app.rs view-shape fingerprints and Model's Debug string-sort idsthey sort naturally too; nothing visible changes
2, 5a collapsed entry and a group reference name a group by nameby its id
4casefold; a run of U+0020 spaces becomes one _; strip characters outside a–z, 0–9, -, _; remove a leading _casefold; every character outside a–z, 0–9 and - becomes _; runs of _ collapse; leading and trailing _ and - are removed; n is prepended before a leading digit
5a hand-written id uses the alphabet a–z, 0–9, -, _a hand-written id obeys every syntax rule of a generated id
6display names typed into minibuffers are turned into ids at the input edgeonly CSV import and the .improv loaders read display names in files and command-language text, which names things by id; interactive input matches a display or an id
12a typed name new to a formula edit is resolved against the document at committhe client resolves it, ids first, and the wire carries only id-form text
12the regression test commits the unedited textthe test edits a non-name token and commits

#Context

Implementing the accepted ADR (epic improvise-cq1q) and using it raised points the accepted text does not settle: whether groups and measures carry ids, what a virtual category is in a file, exactly what slugify produces, which inputs may read display names, and which side resolves names in a formula edit. This amendment settles them.

#Decision

#1. What carries an identity

Groups have ids. A group has an id and a display, like a category or an item, and its id is an ordinary id: unique in the document, generated by slugify (group is an empty slug's <kind>), declared beside its display, referred to by id, created by display, and renamed by a display rename. Its wire address is GroupId. In code, PR #336 (improvise-cq1q.12) already adds a display to Group beside its key; that key becomes the group's id.

Virtual ids.

Formulas and measures. A measure is the normal formula target (profit = revenue - cost); a dimension target is rare, for a derived dimension.

#2. Storage keys

#4. How an id is made

Slugify's steps:

  1. Casefold, with Unicode full case folding (Straße → strasse).
  2. Replace every character outside a–z, 0–9 and - with _. Ids' alphanumerics are ASCII and include -; whitespace, punctuation and every non-ASCII letter become _.
  3. Collapse each run of _ to one _, whether the underscores came from step 2 or were in the display.
  4. Remove every leading and trailing _ and -, repeating until neither end holds either.
  5. If the slug starts with a digit, prepend n, so no id is purely numeric or starts with a digit: 2024 → n2024.
  6. If nothing is left, use <kind>-<n> from the shared counter. A display of only -, only _ or only non-ASCII letters ends here.

The syntax of an id: a lower-case ASCII letter, then a–z, 0–9, - and single underscores, never ending in _ or -. Slugify's output has this shape, so slugifying an id returns it unchanged, and a collision suffix -<n> keeps the shape.

displayid
Time Period, Time\tPeriod, Time \u{200B} Periodtime_period
a_ b, a__ba_b
Q1 - 2024q1_-_2024
East , East_, -East-east
Region(code)region_code
Crème Brûléecr_me_br_l_e
2024, _2024n2024
2024-Q1n2024-q1
Ü, -, %<kind>-<n>

A suffix and a hyphenated slug can look alike. East-1 slugs to east-1, which is also what the counter may make of east. They never collide, because every id a document holds is taken however it was made: a display slugging to a held east-1 becomes east-1-<n>, and a held counter candidate is skipped.

Ids are always sorted in natural order, wherever ids are ordered. Split each id into alternating runs of digits and non-digits; compare the runs pairwise, a non-digit run by byte order and a digit run by value: strip its leading zeros, then the shorter run is smaller, then compare bytes (so runs of any length work, with no fixed-width parse); when two digit runs have equal value, the one with fewer leading zeros sorts first; when one id runs out of runs first, it sorts first. So profit, profit-1, profit-2, profit-10, and a-7 before a-007.

Hand-written ids obey every syntax rule of a generated id. east-1, item-4 and q1_-_2024 load; -east, east-, 2024, a__b and _draft are refused at load.

Two branches of one file can merge into a file that will not load, if each adds a different thing that generates the same slug: the merged file declares that id twice, and the load is refused naming both. Stable identity across copies of a file is not promised (improvise-8tk5).

Built code. crates/improvise-core/src/model/id.rs implements this clause (context/code/core.md §20): slugify follows the six steps, is_id_syntax is the syntax, claim refuses what a generated id could not be, VIRTUAL_IDS holds the five virtual ids, IdKind::Group exists, and natural_cmp and display_suffixes implement natural order and clause 2's suffixes. No other code calls them yet.

#5. Persistence

An axis line is tried before format:, and a format that reads as an axis line is pipe-quoted (improvise-le4p). view_entry is an ordered choice and format_line takes the rest of the line, so a category with id format would lose its axis line to the number format; the new-version arm puts axis_line first. Then :set-format none would write format: none, read as an axis line, so the writer pipe-quotes a format that would also match axis_line (an axis keyword alone or followed by , <name>, or a value starting with |), and the new arm's format_line is pipe_quoted ~ &NEWLINE | rest_of_line. An old file's format: none is read as before and written quoted on first save. Required tests: a category displayed Format on a row axis round-trips; a view whose format is each axis keyword round-trips beside a category format on an axis.

A formula line. Today:

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

Under the amendment:

## Formulas
- Profit(profit) = revenue - cost
- tax(tax-1) = revenue * 0.1 [customcat]

#6. Display names and the wire

#7. Rejections

Rejection::Gone(Named) gains Named::Group.

#8. RecordHash

RecordHash hashes (CategoryId, ItemHandle) pairs spelled as id text. Ids are unique across kinds, so it stays canonical; a record excludes _measure, so a FormulaTarget is never part of one.

#11. Migration

#12. Who resolves names in a formula edit

The client resolves; the host never sees a display.

What ids-first costs. Item A is displayed East (id east) and item B is displayed east, so it got east-1. Typing east names A; B is reached as east-1. The panel therefore shows B as east-1, never as a bare east.

Tests.

#Consequences

#Open questions

None.