diff --git a/context/adr/007-stable-identities.md b/context/adr/007-stable-identities.md index bcb0fa8c..82bc203f 100644 --- a/context/adr/007-stable-identities.md +++ b/context/adr/007-stable-identities.md @@ -1,8 +1,8 @@ # ADR 007 — Stable identities for categories, items, formulas and views -**Status:** proposed (2026-09-24). Revision 5 folds in the user's fourth round of -answers. Revision 4 folded in the third round and checked the ADR against work that -landed since revision 1. What is left is listed under "Still open". This ADR is not accepted until the user says it is. · +**Status:** proposed (2026-09-24). Revision 6 folds in the user's fifth round of +answers, and **no question is left open**. Revision 4 checked the ADR against work +that landed since revision 1. This ADR is not accepted until the user says it is. · **Decided by:** the user, in part; see "What the user decided" · **Ticket:** improvise-sboz · **Unblocks:** improvise-fz77, improvise-fdk1 · **Revises:** improvise-km71's interim `FormulaId` (landed in PR #214) · @@ -224,8 +224,8 @@ This decides three things: 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 and stays -open. +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"): @@ -261,6 +261,45 @@ mandatory, exact version literal (`version_line = { "v2025-04-09" ~ NEWLINE ~ 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. + ## Context **Today the name is the identity.** `Model::categories` is an `IndexMap` keyed by name. @@ -386,8 +425,10 @@ id-less `.improv` file at load (answer 16(a), "All id generation"). So a thing's never depends on how it was made. The steps, in order (answer 18, "casefold, replace space with `_`, strip"): -1. **Casefold** (answers 16(b) and 18). Whether this is Unicode full case folding or - Rust's `to_lowercase` is Q18(b). +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) @@ -397,14 +438,16 @@ space with `_`, strip"): answer states the constraint. Removing the underscores is this ADR's way of meeting it, so `_Draft` → `draft`. 5. **If nothing is left, use `-`** (answer 16(e)), such as `category-1` or - `item-4`, with the lowest free `n`. + `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-1`. `Straße` → `strasse` under full case folding but `strae` under -`to_lowercase` (Q18(b)). `Time Period` and `Time_Period` now slug alike, so the second -one gets `time_period-2`. `East` in `Region` and `East` in `Direction` slug alike too, -and since ids are global (answer 22), the second is `east-2`. +`Ü` → `category-`, `Straße` → `strasse` (answer 26). `Time Period` and +`Time_Period` slug alike, so whichever comes second gets `time_period-`. `East` in +`Region` and `East` in `Direction` slug alike too, and since ids are global (answer +22), the second is `east-`. Here `` is the counter's next value, for example +`east-1`. The remaining rules: @@ -412,16 +455,24 @@ The remaining rules: 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. -- **A slug collision gets a numeric suffix** (answer 21): the first thing in file or - import order keeps the slug, and later ones get `-2`, `-3` and so on, the lowest free - number. Assignment is deterministic, never in hash order (gotcha 18). The answer - wrote the suffix as `-###`. This ADR reads that as "a number", so `-2` rather than - `-002`. That reading is Q22(a). -- **Interactive creation is Q22(b).** Answer 21 applies the suffix "only when importing - a file without IDs". A category or item created in a session can still collide, for - example `East` added to `Direction` when `Region` already has `east`, and so can a - name that was removed and re-added (`east` retired in memory). The ADR does not yet - say whether that is suffixed or refused. +- **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). + - **This ADR's reading:** where a candidate `slug-` is itself already taken, for + example because a new-version file spelled it by hand, the counter advances again + until the candidate is free. 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). - **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 @@ -724,8 +775,8 @@ km71 landed in PR #214, before this ADR, and was built to be revised by it ## 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 and revision 4 added Q18–Q21. All of these are answered -except Q18(b). Revision 5 adds Q22, which comes from answer 21. +revision 3 added Q15–Q17, revision 4 added Q18–Q21 and revision 5 added Q22. **All +are answered.** | # | question | my recommendation | status | |---|---|---|---| @@ -747,39 +798,17 @@ except Q18(b). Revision 5 adds Q22, which comes from answer 21. | 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 | **open** | +| 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 | see below | **open** | +| 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) | ## Still open -- **Q18(b). What "casefold" means.** Unicode full case folding maps `ß` → `ss` and the - `fi` ligature → `fi`, so `Straße` → `strasse`. Rust's `str::to_lowercase` does not, - so `Straße` → `strae` after the strip. The two differ only on a handful of - characters, but a slug must be the same function in every client: the TUI, the - worker and any future client. Full folding needs a crate or a table in - `improvise-core`, which is a browser-bundle crate. - **Recommendation: take "casefold" literally**, as full case folding. The cost is - one small dependency, checked against the `wasm` gate. -- **Q22. Two details of answer 21's suffix rule.** - - **(a) How is the suffix spelled?** The answer wrote `-###`. The ADR reads it as - "a number": `east-2`, `east-3`. The alternative is a fixed three-digit width, - `east-002`, which sorts in order but reads worse. - **Recommendation: a plain number**, `-2`. - - **(b) What happens to a collision in a session?** The answer applies the suffix - "only when importing a file without IDs". But a slug can collide during - interactive creation too, and more often now that ids are global (answer 22). - Adding `East` to `Direction` when `Region` already has `east` collides, and so - does re-adding a name removed earlier in the session (`east` retired). - - **(i) Suffix it there too** (recommended). The host assigns `east-2`, as it - would on import. One rule covers every generated id (answer 16(a), "All id - generation"), and the user never sees the collision. - - **(ii) Refuse the creation**, and ask for a different display name. Then two - categories cannot each have an `East` item unless they came in by import, - which a user would find arbitrary. +Nothing. Every question Q1–Q22 is answered. This ADR still waits on the user's +explicit "Approved" before it is Accepted. ## Consequences