On the user's instruction an ADR states only what its decision changes, and mentions an existing convention only where it conflicts. The accepted ADR 007 is restored to main's text plus a one-line pointer, so merging this PR keeps it; the amendment is context/adr/007-stable-identities- amendment-1.md (280 lines, from 784), with a table of the accepted rules it replaces. The doc site lists the new file, and core.md section 20 points at it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
16 KiB
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
ADR 007 is accepted (PR #210, 2026-09-25), and 007-stable-identities.md stands. This
amendment is proposed and not yet approved (PR #326). This file states only what the
amendment changes or adds, numbered by the accepted ADR's clauses; everything else in
the accepted ADR is still true. Where the amendment conflicts with an accepted rule or an existing
behaviour, the accepted rule binds until the amendment is approved:
| clause | accepted rule or existing behaviour | the amendment |
|---|---|---|
| 1 | groups have no id | a group has an id and a display, and its id behaves like every other id |
| 1 | _index, _dim, _measure are the only ids beginning with _ |
a leading _ is reserved for virtual ids in general, including the virtual views _records and _drill |
| 1 | the virtual categories are declared and named in the file (## Category: _Measure, _Measure: column) |
they are defined by criteria with hardcoded ids and displays, never declared, and referred to by id (_measure) |
| 1 | measure_item_names merges a formula into a stored measure of the same name |
a formula never merges: it gets its own id, profit-<n> beside a stored profit |
| 2 | CellKey pairs are (CategoryId, ItemId) |
(CategoryId, ItemHandle) |
| 2, 5 | a collapsed entry and a group reference name a group by name | by its id |
| 4 | casefold; a run of U+0020 spaces becomes one _; strip characters outside a–z, 0–9, -, _; remove a leading _ |
casefold; every character outside a–z, 0–9 and - becomes _; runs of _ collapse; leading and trailing _ and - are removed; n is prepended before a leading digit |
| 5 | a hand-written id uses the alphabet a–z, 0–9, -, _ |
a hand-written id obeys every syntax rule of a generated id |
| 6 | display names typed into minibuffers are turned into ids at the input edge | only CSV import and the .improv loaders read display names in input text; command-language text names things by id |
| 12 | a typed name new to a formula edit is resolved against the document at commit | the client resolves it, ids first, and the wire carries only id-form text |
| 12 | the regression test commits the unedited text | the 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.
- A leading
_is reserved for virtual ids in general. The virtual views have ids_recordsand_drill, displayed_Recordsand_Drill: they are addressed on the wire (SwitchView), and a slugified_Drillwould otherwise bedrilland could collide with a user's view. Slugify never produces a leading_, so the grammar does not check the reservation. - Virtual ids are lower case. The code, the wire,
ViewStateand the browser client spell the virtual categories_Index,_Dimand_Measuretoday, and move to_index,_dimand_measurein the re-key step. - Virtual categories and measures are defined by criteria, not declared. Each
contains whatever in the document meets its criterion:
_measureholds the measures, and_indexand_dimhold the record positions. Their ids and displays are hardcoded in the application, so a file never declares one, and a line that refers to one spells its id:_measure: column,region=east, _measure=revenue = 1200. - Conflict with old files: they declare and name
_Measure. The loader recognises_Measure,_Indexand_Dimby their hardcoded displays, reads an old## Category: _Measuresection only for the stored measures it lists, and the first save writes_measure. - Records mode's synthetic positions are values, not ids. The row coordinate under
_index, and by the same reasoning the column coordinate under_dim, are values of those virtual categories. They take no part in id uniqueness and cannot collide with a user's item.
Formulas and measures. A measure is the normal formula target (profit = revenue - cost); a dimension target is rare, for a derived dimension.
- A formula is a kind of measure, so a
_Measureformula's id is its measure's id.Profit = Revenue - Costis one thing,profit: other formulas, each Profit cell'sCellKey, andReplaceFormula/RemoveFormulaall nameprofit. - Each measure's cells are keyed by one handle:
ItemHandle::Item(id)for a stored measure,ItemHandle::FormulaTarget(id)for a formula's measure, never both. - A formula never merges into a stored measure. With a stored
profit(displayedProfit), addingProfit = Revenue - Costcreates a second measure with a fresh id,profit-<n>, by the ordinary collision rule. The stored measure keepsprofitand its data; removing the formula removes onlyprofit-<n>.- Conflict with display uniqueness: both are displayed
Profitin_measure's scope. This is the one permitted case; the two are told apart by id (the formula panel shows ids where a display is ambiguous, and a typedProfitmatching both is refused with both ids listed, clause 12).
- Conflict with display uniqueness: both are displayed
- A target outside
_Measureis a real item with its ownItemId, and its formula has a separateFormulaId: a formulaTaxinCustomCatgets item idtax, then formula idtax-<n>. - Formulas may refer to anything by id. A function in a formula may accept any id-bearing thing (category, item, measure, formula, view, group) by its id. Whether a function takes the thing or its value (an item's display, say) is part of that function's signature.
2. Storage keys
CellKeypairs are(CategoryId, ItemHandle), so a_Measureformula cell's coordinate isFormulaTargetwithout claiming anItemIdthe document does not hold.- A collapsed entry names its group by the group's id, and
Groupgains adisplay.
4. How an id is made
Slugify's steps:
- Casefold, with Unicode full case folding (
Straße→strasse). - Replace every character outside
a–z,0–9and-with_. Ids' alphanumerics are ASCII and include-; whitespace, punctuation and every non-ASCII letter become_. - Collapse each run of
_to one_, whether the underscores came from step 2 or were in the display. - Remove every leading and trailing
_and-, repeating until neither end holds either. - If the slug starts with a digit, prepend
n, so no id is purely numeric or starts with a digit:2024→n2024. - 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.
| display | id |
|---|---|
Time Period, Time\tPeriod, Time \u{200B} Period |
time_period |
a_ b, a__b |
a_b |
Q1 - 2024 |
q1_-_2024 |
East , East_, -East- |
east |
Region(code) |
region_code |
Crème Brûlée |
cr_me_br_l_e |
2024, _2024 |
n2024 |
2024-Q1 |
n2024-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.
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 the accepted steps
(context/code/core.md §20). After approval it must replace rather than strip, collapse
_, trim a trailing _ and both ends' -, prepend n, make claim refuse what a
generated id could not be, and add the virtual views and IdKind::Group.
5. Persistence
- The loader also refuses a hand-written id that breaks the syntax above, naming
the line; the grammar's
id_tokenstays permissive. A declared id never begins with_, since a file never declares a virtual category. - Formula text quotes only keyword ids (
|sum|). No id starts with a digit, and the formula grammar's bare identifier already admits-. - Groups: a group reference (
[coastal]) and the group in acollapsed:line are ids, and a group definition declares a display and an id (> Coastal(coastal)). - A header such as
## Category: Budget (2024)in a new-version file gives the id2024, which is refused. Import pipe-quotesRegion(code)as## Category: |Region(code)|(region_code).
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]
- A measure formula has no tag:
_measureis implicit and never written as a tag. The line is the measure's only declaration:Profitis its display and(profit)the id it shares with its formula. - A dimension formula has a tag, which is always the target category's id. The
target is already declared with its display in its category, so the line names it by
item id (
tax), and(tax-1)is the formula's own id. - So the loader tells them apart by the tag. The expression is over ids; a reference to
a formula target, and a
WHERE/ISvalue, is the id text of itsItemHandle. A target display that looks likeName(x)is pipe-quoted:- |Margin(%)|(margin) = ….
6. Display names and the wire
- Only CSV import and the
.improvloaders turn display names in input text into ids. They read text people wrote. An old file's names are resolved there as a migration. - Command-language text names things by id. Anything that uses the command language
as a scripting language is a script, wherever the text comes from (a file,
improvise cmd, another program). A script naming something by display fails; it gets a display only through a dedicated function such asdisplay_for(id). - Interactive input (TUI, browser) matches either a display or an id, as long as it is unambiguous which thing the user is picking. Ids win when it is not, and the display is shown as a preview (clause 12).
- Groups on the wire:
AddItemInGroup'sgroupis a group id, a new group is created by display, a group is renamed by id and new display, andFrameGroupcarries the group's id and display. - The formula panel treats a display equal to another thing's id as ambiguous, so it never shows a bare name that ids-first would bind elsewhere.
ViewState::active_viewandWorkbook::active_vieware keyed by id (improvise-p003).- A
_Measureformula-target row is removed byRemoveFormulaand renamed by a display rename of the measure (improvise-i0n7). - CheckedBatch's created-record exemption is removed (improvise-p003). It exists for
frames like
[AddItem(East), SetCell(East)], and a frame cannot address what it creates, so nothing produces one.
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
- Step 2 adds
GroupId, a display onGroup, andItemHandle. - Step 3 moves the virtual categories' keys to
_index,_dimand_measure. - A new step 7 builds clause 12's client-side capture and resolver, ids first from the start.
12. Who resolves names in a formula edit
The client resolves; the host never sees a display.
- The client holds the display→id mapping captured when the editor opened, as it holds
OpenedCell. The TUI and the browser share one resolver inimprovise-protocol. - A name new to the edit is resolved by the client against its id-bearing projection: a typed name equal to an id names that thing, even if it is also another thing's display; otherwise a display exactly one thing has names it; a display matching several things is refused with their ids; a name matching nothing is refused. Ids are matched exactly. Ids win wherever there is ambiguity, now and once an autocomplete exists; the autocomplete prefers id matches and previews the display.
- The wire carries only id-form text.
ReplaceFormula.rawandAddFormula.raware in id form; an id that no longer exists isRejection::Gone.
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.
- The accepted regression test's step 3 becomes: edit something that is not a name
(
Revenue - Cost * 1) and commit. Committing unedited text cannot tell a correct capture from none, because a client may pass unchanged text as a no-op (improvise-hexb). - Ids-first: with A (
East,east) and B (east,east-1), typingeastin text not captured at open must bind A (and fail under displays-first),east-1must bind B, and a display that is nobody's id must still resolve by display. A fixture where no display equals an id proves nothing.
Consequences
- Scripts that name things by display break and must be rewritten over ids.
- Two measures can share a display; every place that shows displays falls back to ids.
- Old files'
_Measuredeclarations and hyphen- or digit-led names change on first save. id.rsand its tests need the follow-up in clause 4.
Open questions
None.