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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user