Files
improvise/context/adr/007-stable-identities-amendment-1.md
T
Claude (adr007-steward) 38058ec73f docs(adr): move ADR 007's amendment to its own file, stating only what it changes (improvise-cq1q.1)
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
2026-10-09 09:43:16 -07:00

16 KiB
Raw Blame History

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 _records and _drill, displayed _Records and _Drill: they are addressed on the wire (SwitchView), and a slugified _Drill would otherwise be drill and 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, ViewState and the browser client spell the virtual categories _Index, _Dim and _Measure today, and move to _index, _dim and _measure in the re-key step.
  • Virtual categories and measures are defined by criteria, not declared. Each contains whatever in the document meets its criterion: _measure holds the measures, and _index and _dim hold 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, _Index and _Dim by their hardcoded displays, reads an old ## Category: _Measure section 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 _Measure formula's id is its measure's id. Profit = Revenue - Cost is one thing, profit: other formulas, each Profit cell's CellKey, and ReplaceFormula/RemoveFormula all name profit.
  • 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 (displayed Profit), adding Profit = Revenue - Cost creates a second measure with a fresh id, profit-<n>, by the ordinary collision rule. The stored measure keeps profit and its data; removing the formula removes only profit-<n>.
    • Conflict with display uniqueness: both are displayed Profit in _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 typed Profit matching both is refused with both ids listed, clause 12).
  • A target outside _Measure is a real item with its own ItemId, and its formula has a separate FormulaId: a formula Tax in CustomCat gets item id tax, then formula id tax-<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

  • CellKey pairs are (CategoryId, ItemHandle), so a _Measure formula cell's coordinate is FormulaTarget without claiming an ItemId the document does not hold.
  • A collapsed entry names its group by the group's id, and Group gains a display.

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.

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_token stays 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 a collapsed: 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 id 2024, which is refused. Import pipe-quotes Region(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: _measure is implicit and never written as a tag. The line is the measure's only declaration: Profit is 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/IS value, is the id text of its ItemHandle. A target display that looks like Name(x) is pipe-quoted: - |Margin(%)|(margin) = ….

6. Display names and the wire

  • Only CSV import and the .improv loaders 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 as display_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's group is a group id, a new group is created by display, a group is renamed by id and new display, and FrameGroup carries 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_view and Workbook::active_view are keyed by id (improvise-p003).
  • A _Measure formula-target row is removed by RemoveFormula and 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 on Group, and ItemHandle.
  • Step 3 moves the virtual categories' keys to _index, _dim and _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 in improvise-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.raw and AddFormula.raw are in id form; an id that no longer exists is Rejection::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), typing east in text not captured at open must bind A (and fail under displays-first), east-1 must 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' _Measure declarations and hyphen- or digit-led names change on first save.
  • id.rs and its tests need the follow-up in clause 4.

Open questions

None.