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
This commit is contained in:
committed by
Edward Langley
parent
98db007e71
commit
38058ec73f
@@ -0,0 +1,280 @@
|
||||
# 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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1714,7 +1714,8 @@ underscore-collapsing step exists. A trailing run survives, so `East ` slugs
|
||||
to `east_`. Only *leading* underscores are removed (step 4).
|
||||
|
||||
**ADR 007's proposed amendment changes these rules, and this code predates it.**
|
||||
The amendment (PR #326) is not yet approved. Its clause 4:
|
||||
The amendment (`context/adr/007-stable-identities-amendment-1.md`, PR #326) is not
|
||||
yet approved. Its clause 4:
|
||||
- every character outside `a`–`z`, `0`–`9` and `-` becomes `_` instead of being
|
||||
stripped (not only U+0020), so `Region(code)` slugs to `region_code`, while
|
||||
`Q1-2024` keeps its `-`;
|
||||
@@ -1727,7 +1728,7 @@ The amendment (PR #326) is not yet approved. Its clause 4:
|
||||
This section, `slugify`, and the tests
|
||||
`slugify_collapses_a_run_of_spaces_to_one_underscore` (its `east_` case) and
|
||||
`slugify_keeps_an_underscore_the_display_already_had` describe the earlier rule
|
||||
above. They change in a follow-up once the amendment is approved. The ADR's
|
||||
above. They change in a follow-up once the amendment is approved. The amendment's
|
||||
clause 4 lists every difference.
|
||||
|
||||
**The allocator records every id it admits and releases none.** That is the
|
||||
|
||||
@@ -47,6 +47,7 @@ const SECTIONS = [
|
||||
["context/adr/004-faces-and-themes.md", "adr/004-faces-and-themes", "ADR 004 — Faces"],
|
||||
["context/adr/005-exact-decimal-arithmetic.md", "adr/005-exact-decimal-arithmetic", "ADR 005 — Exact decimal arithmetic (proposed)"],
|
||||
["context/adr/007-stable-identities.md", "adr/007-stable-identities", "ADR 007 — Stable identities"],
|
||||
["context/adr/007-stable-identities-amendment-1.md", "adr/007-stable-identities-amendment-1", "ADR 007, amendment 1 (proposed)"],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user