ADR 007 revision 6: fold in the fifth round; no question left open (improvise-sboz)

Answers 26-28: casefold is full Unicode case folding; the suffix is a
plain number; any colliding id, on import, in a session or across kinds,
gets a suffix from a gensym-style increment-only document counter.
Status stays proposed until the user's Approved.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Edward Langley
2026-09-24 15:06:23 -07:00
parent e044c9683a
commit b89bb49b78
+79 -50
View File
@@ -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 `<kind>-<n>`** (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-<n>`, `Straße` → `strasse` (answer 26). `Time Period` and
`Time_Period` slug alike, so whichever comes second gets `time_period-<n>`. `East` in
`Region` and `East` in `Direction` slug alike too, and since ids are global (answer
22), the second is `east-<n>`. Here `<n>` 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-<n>` 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