From 3bde76126afc4cc01d369472d1f0909d24a86b76 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 13:50:34 -0400 Subject: [PATCH] docs(config): framing for the config registry (revision 2) The cross-cutting substrate ranked first on the side-quest north star. A third registry beside CommandRegistry and HookRegistry, with two scopes (global and buffer-local), a closed scalar value vocabulary, post-commit listeners, and no wire surface. Ground truth established by scout at 7bc0c61, including three findings that shaped the design: * Tab width is five constants across two crates with two different values -- four TAB_WIDTH = 8 in the daemon, 4 in the GPU minimap, and no tab expansion at all on the GPU main text path. It is a rendering-parity bug, not a config gap, so it is deferred to stage 2 rather than made the proving adopter. * The mode system is unwired: every editor KeymapStack::resolve passes an empty active-modes slice. A mode scope would ship a knob that silently never fires. * load_user_config and set_init_complete both run inside EditorState::new under one cfg(not(test)) block, so the StartupOnly freeze point covers the daemon and local entry points uniformly -- and never runs in --lib test builds. Revision 2 records review round 1 (F1-F11). The load-bearing fix is F1: an override is now always stored, with only value_epoch and listener dispatch keyed on effective-value change. The prior "equal-value set is a true no-op" reading meant an equal-valued buffer-local override stored nothing, so a later global set would flip the very buffer the user had pinned -- silently voiding the per-buffer toggle this arc exists to deliver. Also carried by revision 2: listener dispatch semantics pinned across scopes, GC-collected listeners dropped (no MetaMethod::Gc precedent exists), migration wrappers keep their legacy coercion, StartupOnly x set_local rejected at define time, string-list dropped, describe's `local` field renamed buffer_local (Lua keyword), and the direct-remove leak corrected to permanent-but-non-aliasing since BufferIds are never reused. No implementation. Framing only. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/config-registry-framing.md | 904 ++++++++++++++++++++++++++++++++ 1 file changed, 904 insertions(+) create mode 100644 docs/config-registry-framing.md diff --git a/docs/config-registry-framing.md b/docs/config-registry-framing.md new file mode 100644 index 0000000..476d25a --- /dev/null +++ b/docs/config-registry-framing.md @@ -0,0 +1,904 @@ +# Config registry — framing (cross-cutting substrate) + +pmacs has no unified configuration surface. Every setting that exists +today invented its own shape: a getter-when-nil function here, a raw +mutable Lua table there, a window field, a Rust preference struct with +its own epoch counter and wire channel. Nothing is discoverable, nothing +is validated centrally, and there is no way at all to say "this setting, +but only in this buffer." + +That gap is the named blocker on five separate backlog items — the +per-buffer auto-pair toggle, language-aware indent, per-language comment +padding, per-project compile commands, and the tab-width duplication — +and `docs/side-quest-backlog.md` ranks it first on the north star for +exactly that reason. + +This framing proposes a third registry alongside `CommandRegistry` and +`HookRegistry`, built to the rules those two already enforce, with two +scopes (global and buffer-local) and no wire surface. + +Backlog: `docs/side-quest-backlog.md` — "Cross-cutting substrate", +north-star item 1. Not a numbered roadmap arc; it is the substrate the +roadmap keeps tripping over. + +**Revision 1 — 2026-07-21.** Supersedes a withdrawn same-day draft. +Ideas carried forward from it, credited where they land: the +post-commit listener API with disposable handles (Q#CR6), `Live` vs +`StartupOnly` mutability (Q#CR10), owned-value/deep-copy storage +(Q#CR3), the two-epoch counters (Q#CR2), strict raw-table spec +validation (Q#CR3), and the LuaJIT-vs-lua54 integer-exactness +requirement (acceptance 6). Not carried forward, with reasons inline: +global-only scoping (Q#CR4), `editor.tab_width` as the proving adopter +(Q#CR13), snake_case names (Q#CR9), and Lua bindings inside +`lua_bindings/mod.rs` (Q#CR2). + +**Revision 2 — 2026-07-21, review round 1.** Findings F1–F11. +- **F1 (the one that mattered):** "equal-value set is a true no-op" + contradicted `is_set`, and under the naive reading an equal-valued + buffer-local override stored nothing — so a later global set would + flip the very buffer the user had pinned, silently breaking the + flagship feature. Overrides are now **always stored**; only + `value_epoch` and listener dispatch key on effective-value change + (Q#CR2, Q#CR4, acceptance 11). +- **F2:** listener dispatch semantics pinned across scopes (Q#CR6). +- **F3:** GC-collected listeners dropped — no `MetaMethod::Gc` exists + anywhere in `mod.rs` (verified: zero matches), the compile-mode + precedent is explicit-dispose only, and GC timing differs across the + two Lua backends (Q#CR6). +- **F4:** the migration wrappers keep their legacy coercion (Q#CR8). +- **F5:** `StartupOnly` × `set_local` resolved — the combination is + rejected at define time (Q#CR10). +- **F6:** `string-list` dropped from the stage-1 vocabulary (Q#CR3). +- **F7:** `describe`'s `local` field renamed `buffer_local` — `local` + is a Lua keyword (Q#CR11). +- **F8:** the direct-remove leak is permanent-but-bounded with no + aliasing hazard, and now has an acceptance item (Q#CR5). +- **F9:** `get(name)` with no buffer resolves the global chain only + (Q#CR4). +- **F10:** builtin defines moved to their owning modules (Q#CR14). +- **F11:** all stale line anchors corrected against `7bc0c61`. +- **Bet 6 confirmed and retired** into ground truth, with a correction + to this doc's own "Init ordering" claim. + +--- + +## Ground truth (as of `7bc0c61`) + +### `src/config.rs` is not a config registry + +Despite the name, `src/config.rs` (274 lines) is the `init.lua` *loader* +and nothing else: XDG config-dir resolution (`user_config_dir`, +`resolve_config_dir`), a `package.path` prepend so a user's config can +span files (`install_package_path`), and a non-fatal eval +(`load_user_config_at` — missing file, unreadable file, parse error and +runtime error are all survivable by contract). It stores no settings and +knows no setting names. The name is taken; that is a naming problem for +the new module, not a design one. + +### The settings zoo — nine shapes, none of them shared + +Every one of these is a real user-facing configuration surface at +`7bc0c61`. They agree on nothing: + +| Surface | Shape | Storage | Validated? | +| --- | --- | --- | --- | +| `pmacs.async_config.frame_target_ms(ms)` (`async.lua:458`) | getter-when-nil fn | module-local upvalue | ad-hoc | +| `pmacs.async_config.default_max_batch(n)` (`async.lua:468`) | getter-when-nil fn | module-local upvalue | ad-hoc | +| `pmacs.autosave.interval_ms(ms)` (`autosave.lua:42`) | getter-when-nil fn | module-local upvalue | hand-rolled floor + NaN check | +| `pmacs.killring.max(n)` (`killring.lua:65`) | getter-when-nil fn | module-local upvalue | ad-hoc | +| `pmacs.editops.trim_on_save(on)` (`editops.lua:844`) | getter-when-nil fn | module-local upvalue | none | +| `pmacs.autosave.enable` (`autosave.lua:34`), `pmacs.recentf.enable` (`recentf.lua:21`), `pmacs.saveplace.enable` (`saveplace.lua:21`), `pmacs.session.desktop_mode` (`desktop.lua:19`) | boolean fn | module-local upvalue | `on ~= false` | +| `pmacs.lsp.config`, `pmacs.lsp.filetypes`, `pmacs.comment.strings`, `pmacs.pair.sets` (`pair.lua:40`) | raw mutable table | plain Lua | at read time, per-consumer | +| `pmacs.parse.shebangs` / `.filenames` / `.injection_aliases` (`syntax.lua:55`) | write-through proxy | Rust registry behind a proxy | at write | +| `pmacs.theme.set/merge`, `pmacs.gpu.set_font` (`font_pref.rs`), `pmacs.window.set_line_numbers` (`mod.rs:11254`), `pmacs.statusline.register` (`statusline.rs`) | Rust binding | Rust struct + epoch + wire channel | at the binding | + +Consequences worth stating plainly: no enumeration (you cannot ask what +is configurable), no `describe`, no type discipline, no change +notification, and **no scoping** — every entry above is global except +`line_numbers`, which is window-local because a window happened to be +the convenient place to hang it. + +### Two registries already do this correctly + +`src/command.rs` (320 lines) and `src/hook.rs` (627 lines) are the +precedent, and they agree with each other: + +- `HashMap` by name plus a `Vec` insertion order for + stable listing. +- **R42** — a non-empty `description` is mandatory at define time + (`CommandError::MissingDescription`, `HookError::MissingDescription`). +- **R50** — spec-table keys are checked against a closed set, so a typo + is an error, not a silent no-op (`UnknownField`, carrying the + supported-key list in the message). +- Duplicate names are rejected, never silently overwritten — "silent + overwrite makes refactoring bugs invisible" (`command.rs:12`). +- `SourceLocation { file, line }` captured from Lua debug info at + registration, surfaced verbatim by `pmacs.describe.*`. +- Both live behind `Rc>` as Lua app data + (`SharedCommandRegistry`, `SharedHookRegistry`, `mod.rs:2265-2271`). +- `HookRegistry::snapshot` exists specifically so the caller can drop + the registry borrow before running user code that may re-enter. + +A settings registry that does not look like these two would be the odd +one out for no reason. + +### Scoping: what exists, what does not + +- **Window-local** exists exactly once, ad-hoc: + `core.active_window_mut().line_numbers` (`mod.rs:11254`). +- **Buffer-keyed side tables** are established: + `BufferRemoveCallbacks` is a `HashMap>` + (`mod.rs:133-176`); `KeymapStack` keys per-buffer maps the same way. +- **`Buffer` has no property bag.** The struct (`buffer.rs:158`) is + rope, name, revision, views, marks, undo/redo, path, file meta, the + in-flight-edit flag, and optional CRDT state. There is nowhere to put + a setting, and it is the type carrying undo and CRDT invariants. +- **Buffer death has one choke point**, `after_buffer_removed` + (`mod.rs:1458`), which already drops that buffer's keymaps before + firing remove callbacks. Three call sites route through it + (`mod.rs:3033`, `3123`, `5014`). +- **`BufferId`s are never reused** — allocated from a global counter and + documented unique (`buffer_registry.rs:82-84`). Any leaked per-buffer + state is permanent-but-bounded and can never alias onto a future + buffer. +- **The mode system is unwired.** Of 33 `.resolve(` sites in `src/`, + every `KeymapStack::resolve` in the editor passes `&[]` for + `active_modes`; the only non-empty callers are two unit tests inside + `keymap_stack.rs`. Mode-scoped anything is unavailable today. +- **Project detection exists**: `pmacs.project.detect(path)` → + `{root, kind}` (`mod.rs:10360`). +- **Language resolution exists**: `buffer_language` (`lsp.lua:471`), + exported as `pmacs.lsp.buffer_language` (`lsp.lua:494`), added by + auto-pairing (#110). + +### Init ordering, and the freeze point (corrected in revision 2) + +Revision 1 said `init.lua` runs "from `load_user_config` on the real +entry points only." **That was wrong** — it described `install_state_dirs` +(`editor.rs:551`), the Arc 3 pattern, not user config. The truth: + +Builtin runtime chunks are evaluated inside `EditorState::new()` +(`editor.rs:194-408`, the linear bootstrap). At the tail of the *same* +function, under a single `#[cfg(not(test))]` block (`editor.rs:465-476`): + +```rust +#[cfg(not(test))] +{ + crate::config::load_user_config(&mut lua_host); + lua_host.set_init_complete(); +} +``` + +Three consequences the design depends on: + +1. **Builtins define, the user sets, the flag freezes** — one ordered + sequence, no new machinery needed. +2. Because it lives in `new()` itself, the freeze covers the daemon + entry (`daemon.rs:468`) and the local one uniformly. This retires + revision 1's bet 6: the `StartupOnly` freeze point exists and is + `InitCompleteFlag` (`mod.rs:2270`), consumed by `require_init_phase` + (`mod.rs:663`). +3. **In `--lib` test builds neither line runs.** User config is never + loaded and the flag never flips. Any acceptance test asserting + post-freeze behavior must flip it explicitly, the way + `mod.rs:14655-14690` already does; otherwise it passes vacuously. + +### Both Lua backends ship + +`Cargo.toml:75-77` — `default = ["luajit"]`, with `lua54` a supported, +mutually exclusive fallback (audit F-002 pins the +`--no-default-features --features lua54` build). LuaJIT is Lua 5.1 +semantics and has no native integer subtype; lua54 does. Any numeric +validation must therefore behave identically under both feature +selections and cannot rely on `math.type`. The same caution kills two +other tempting designs: GC-timed listener lifetime (Q#CR6) and `#` on a +holey array (Q#CR3). + +### No GC-cleanup precedent exists + +`mod.rs` contains **zero** `MetaMethod::Gc` implementations. The only +`dispose` binding is the explicit one at `mod.rs:1867` (the compile-mode +overlay handle). Resource lifetime in this codebase is +explicit-dispose, never finalizer-driven. + +### Tab width — the motivating example is worse than advertised + +The backlog calls this "the five hardcoded tab-width sites." The scout +found five sites across **two crates with two different values**: + +- `const TAB_WIDTH: u32 = 8` in `src/text_view.rs:35`, + `src/highlight.rs:321`, `src/diag.rs:393`, `src/completion.rs:594` — + four independent copies of the same constant and the same + `col += TAB_WIDTH - (col % TAB_WIDTH)` expansion. +- `pmacs-gpu/src/main.rs:6728-6733` — `advance_minimap_col` expands a + tab to **4**, not 8. + +And the GPU's main text path expands nothing at all: buffer text reaches +the frontend as raw bytes via `BufferSnapshot`/`CrdtOp`, so a literal +`\t` is handed to glyphon and shaped by the font. (The only two `'\t'` +sites in `pmacs-gpu/src/main.rs` are both in the minimap.) Tab rendering +is already inconsistent between the two frontends *before* any setting +exists. This is a rendering-parity bug wearing a config-shaped hat, and +it drives Q#CR13. + +--- + +## Non-goals + +No settings GUI. No second config-file format. No persistence outside +`init.lua`. No per-project trust or loading policy. No automatic +migration of every existing setter. No protocol messages and no +frontend-local pixel/font settings. No filesystem watching or hot reload +of `init.lua`. + +--- + +## Decisions + +### Q#CR1 — Scope: substrate, two scopes, three consumers, no wire + +Stage 1 delivers the registry, the Lua surface, buffer-local scoping, +discovery, and exactly three first consumers (Q#CR8). It does **not** +deliver persistence, a `customize` UI, a settings panel, tab width, +per-language or per-project scopes, or any protocol change. One feature, +one branch, one PR. + +The deliverable that justifies the PR on its own: the named backlog item +"per-buffer auto-pair toggle (config-registry-blocked)" stops being +blocked. + +### Q#CR2 — `src/config_registry.rs`: a third registry, Rust-owned + +New module `src/config_registry.rs` — not `config.rs`, which the +`init.lua` loader owns. Shape mirrors `hook.rs`: + +```rust +pub struct ConfigDefinition { + pub name: String, + pub description: String, // R42, mandatory + pub kind: ConfigKind, + pub default: ConfigValue, + pub mutability: ConfigMutability, // Live | StartupOnly + pub source: SourceLocation, +} + +pub struct ConfigListener { + id: u64, // generation-safe, never reused + name: String, + body: Function, + source: SourceLocation, +} + +pub struct ConfigRegistry { + by_name: HashMap, + order: Vec, + global: HashMap, // overrides only + locals: HashMap>, + listeners: Vec, // registration order + next_listener_id: u64, + frozen: bool, // Q#CR10 + definition_epoch: u64, + value_epoch: u64, +} +``` + +**Override storage versus epoch advancement are two different questions +(F1).** Revision 1 conflated them and broke the flagship feature. The +rule: + +- **An override is always stored**, even when it equals the value it + shadows. `set` and `set_local` unconditionally record an entry, which + is what makes `is_set` meaningful (Q#CR4) and what makes a + buffer-local *pin* actually pin. +- **`value_epoch` advances, and listeners fire, only when an effective + value changes.** Storing an override equal to the current effective + value is observationally silent but not structurally absent. + +The failure this prevents: with `editing.auto-pair` globally `true`, a +user calls `set_local(buf, "editing.auto-pair", true)` to pin that +buffer, then later `set("editing.auto-pair", false)` globally. Under +"true no-op" the local was never stored and the pinned buffer flips — +the pin silently never existed. Acceptance 11 pins this, bite-verified. + +`definition_epoch` advances when a key is defined; `value_epoch` gives +future render-path consumers a single `u64` to gate on, mirroring the +split syntax/face epochs from #120 (Q#TH6). + +`ConfigError` carries `EmptyName`, `MissingDescription` (R42), +`DuplicateName`, `NotFound`, `UnknownField` (R50, listing supported +keys), `TypeMismatch { name, expected, got }`, `OutOfRange`, +`NotAChoice { name, got, choices }`, `StartupOnlyLocal` (Q#CR10), and +`StartupOnlyAfterFreeze`. Behind +`SharedConfigRegistry = Rc>` as Lua app data, +set beside the other five at `mod.rs:2265-2271`. + +**Rust-owned, not a Lua table**, for two reasons. Rust consumers must +read a setting without borrowing Lua (the tab-width sites in Q#CR13 are +the eventual proof). And duplicate/typo/type rejection must be enforced +somewhere a user's `init.lua` cannot bypass — the argument +`command.rs:12` already makes. + +**Bindings go in a new `src/lua_bindings/config.rs`, not in `mod.rs`.** +The withdrawn draft proposed an "owned section of +`src/lua_bindings/mod.rs`"; that file is 15,594 lines, the paused F-016 +split lives there, and the concurrent vterm lane names it as its own +overlap file. A submodule reduces the shared-file footprint to one `mod` +line plus one `install_config(...)` call and removes almost all of the +inter-lane conflict surface. + +### Q#CR3 — Closed value vocabulary, owned data, strict specs + +``` +boolean | integer | number | string | enum +``` + +`string-list` is **dropped** from stage 1 (F6). No stage-1 adopter wants +it, and "copied densely from a 1-based array" does not say what happens +to a table with holes — `#` on a holey table returns an arbitrary +border, with no guarantee LuaJIT and lua54 choose the same one. +Admitting it means either explicit hole-rejection logic or a +cross-backend nondeterminism vector that acceptance 6 would have to +chase. It returns with its first real adopter and an explicit hole rule. + +`ConfigValue` is owned Rust data. Lua tables, functions and userdata are +never stored. Metadata returned to Lua (`describe`, `list`) is a fresh +table each call, never a handle onto registry state. Definition specs +are strict raw tables: unknown fields, missing fields, +metatable-provided values, wrong types, non-finite bounds, inverted +ranges, duplicate enum choices, and a default that violates its own +contract all reject **before** any mutation. Defaults pass the same +validator as user values. + +Numbers must be finite; integers must be exact — checked by value, not +by `math.type`, per the two-backend ground truth. `integer` and `number` +carry optional `min`/`max`. + +Tables as *values* stay out. Table-valued configuration already has a +working home — `pmacs.lsp.config`, `pmacs.pair.sets`, +`pmacs.comment.strings`, the write-through proxies — and admitting them +means answering deep-equality, merge-vs-replace, per-key validation and +per-key notification. That is a second arc. + +### Q#CR4 — Two scopes: global and buffer-local. Language and project are patterns, not scopes + +This is the framing's load-bearing decision and the main departure from +the withdrawn draft, which was global-only. Global-only would not +unblock a single one of the five backlog items the registry exists to +unblock — four of the five are inherently per-buffer or per-language. + +```lua +pmacs.config.define { name = …, description = …, type = …, default = … } +pmacs.config.set(name, value) -- global override +pmacs.config.set_local(buf, name, value) -- buffer-local override +pmacs.config.get(name [, buf]) -- see resolution below +pmacs.config.reset(name [, buf]) -- drop exactly one layer +pmacs.config.is_set(name [, buf]) -- override present, not value ≠ default +``` + +**Resolution, pinned (F9).** `get(name, buf)` resolves +buffer-local → global → default. `get(name)` with **no buffer argument +resolves the global chain only** (global override → default) and never +consults an implicitly-active buffer. The signature is the contract; +there is no hidden ambient buffer. A consumer that wants buffer-aware +behavior must pass the buffer, and the acceptance list pins that +forgetting it yields the global value rather than a surprise. + +`reset(name)` drops the global override; `reset(name, buf)` drops only +that buffer's local layer and re-exposes the global. `is_set` reports +override *presence* at the queried layer, which is well-defined +precisely because Q#CR2 always stores overrides. + +Per-language and per-project behavior is achieved the way Emacs achieves +it: a callback on `buffer.after-load` reads +`pmacs.lsp.buffer_language(buf)` or `pmacs.project.detect(buf:path())` +and calls `set_local`. The registry never learns what a language or a +project is. + +The claim, stated so it can be falsified: **all five backlog-blocked +features are expressible as a hook that sets buffer-locals.** +Language-aware indent, per-language comment padding and the per-buffer +auto-pair toggle are buffer-local by nature. Per-project compile +commands are a `set_local` keyed on the detected root. Tab width is +buffer-local (Q#CR13 defers it for an unrelated reason). + +Mode-scoped settings are not offered because they cannot work: every +editor `KeymapStack::resolve` passes `&[]`. Offering a mode scope on an +unwired mode system would ship a knob that silently never fires. + +### Q#CR5 — Buffer-local storage: registry-owned, purged at the existing choke point + +`locals: HashMap>` inside the +registry, following `BufferRemoveCallbacks` (`mod.rs:133`) rather than +adding a field to `Buffer` — the buffer struct carries undo and CRDT +invariants and has no property bag, and config does not belong in it. + +Purge rides `after_buffer_removed` (`mod.rs:1458`), one line beside the +keymap purge already there. No new hook is defined for this, and the +purge does **not** fire listeners (Q#CR6): the buffer is gone, so there +is no effective value for anyone to observe. + +Honest limitation, corrected in revision 2 (F8): `BufferRegistry::remove` +can be called directly without going through `remove_buffer_and_fire` +(`editor.rs:5527` does so in a test). Such a path leaks that buffer's +locals **permanently** — not "until the `BufferId` is reused", because +`BufferId`s come from a global counter and are never reused +(`buffer_registry.rs:82-84`). The leak is therefore bounded and can +never alias onto a future buffer, which makes it a memory footnote +rather than a correctness hazard. Stage 1 pins the contract with +acceptance 13 rather than restructuring `BufferRegistry`. + +### Q#CR6 — Change delivery: post-commit listeners, borrow released + +Carried forward from the withdrawn draft, whose design here is better +than a plain hook and matches the compile-mode `handle:dispose()` +precedent (`mod.rs:1867`). The registry never runs arbitrary Lua while +mutably borrowed. + +```lua +local handle = pmacs.config.on_change('editing.auto-pair', function(new, old, buf) + -- receives already-validated owned values +end) +handle:dispose() -- idempotent, generation-safe +``` + +Set/reset flow: resolve and validate the candidate with no mutation → +**store the override unconditionally** (Q#CR2) → advance `value_epoch` +iff the effective value changed → **release the registry borrow** → +if the effective value changed, invoke listeners in registration order +with copied values → log a failing listener to the normal Lua error sink +and continue the rest. + +**Dispatch semantics, pinned (F2):** + +- **(a)** A global `set` that changes the global effective value fires + once with `buf = nil`. +- **(b)** It does **not** additionally fire per-buffer. A buffer holding + its own override is shadowed — its effective value did not change — so + a listener that cares about a specific buffer must re-resolve with + `get(name, buf)`. The notification says "the global changed", not + "every buffer changed". +- **(c)** The `after_buffer_removed` purge (Q#CR5) fires nothing. +- **(d)** `on_change` on an undefined name raises `NotFound`, matching + the define-before-set posture of Q#CR10. + +None of the three stage-1 adopters consumes `on_change` — `pair.lua` +reads at insert time, `editops.lua` at save, `autosave.lua` per tick — +so the acceptance tests are the only exercise these semantics get. That +is precisely why the contract is written out here rather than left to +the first consumer to discover. + +**Listeners persist until explicitly disposed (F3).** Revision 1 said a +garbage-collected handle stops firing; that is dropped. There is no +`MetaMethod::Gc` anywhere in `mod.rs`, the compile-mode precedent is +explicit-dispose only, and GC timing differs between LuaJIT and lua54 — +importing finalizer nondeterminism would cut directly against the +cross-backend exactness this framing demands elsewhere. A user who +registers `on_change` and drops the handle keeps the listener; that is +the same bargain `pmacs.hook.add` already makes. + +A listener error does not roll the value back: earlier listeners may +already have applied side effects, and rollback would create two sources +of truth. Re-entrant `set`/`reset` is permitted after borrow release and +produces a later notification epoch; a per-dispatch recursion bound +stops an accidental listener cycle from hanging the editor. Listener ids +are never reused, so a stale handle can never dispose a newer listener. + +The borrow-release step is the one to bite-verify — +`HookRegistry::snapshot` exists for exactly this reason, and the +statusline arc's three-phase borrow-released transaction is the recent +precedent for getting it wrong being expensive. + +### Q#CR7 — No wire surface; protocol stays at v18 + +Every stage-1 setting is read daemon-side, in Lua. Nothing new goes on +the wire and `SUPPORTED` is untouched. + +Settings whose consumer lives in a frontend already have their own +authoritative facts channel — `ThemeFacts` (v16), `FontFacts` (v17), +`StatuslineSegments` (v18), `LineNumbers` (v13/v14). That +one-channel-per-concern shape is deliberate, with an +authoritative-per-attachment contract and a snapshot/baseline reset +contract behind it (#120 rounds 2-5); a generic "config facts" channel +would have to re-derive all of that and would fit worse than what those +four already do. The registry is not a transport. + +### Q#CR8 — Three first consumers, chosen to prove three shapes + +| Setting | Type | Proves | Status | +| --- | --- | --- | --- | +| `editing.auto-pair` | boolean | buffer-local resolution | **new** — unblocks the named backlog item | +| `editing.trim-on-save` | boolean | migration behind a stable API | migrates `editops.lua:844` | +| `autosave.interval-ms` | integer, min 1000 | validation + live re-read each tick | migrates `autosave.lua:42` | + +All three are consumed entirely in Lua, on the daemon, which is what +makes Q#CR7 true. The withdrawn draft deferred adopter selection to +implementation time; naming them now is what lets the acceptance list +below be written before any code exists. + +**No existing surface is removed**, and the wrappers keep their legacy +coercion (F4). This is the subtle part of the migration. Today +`pmacs.autosave.interval_ms(1500.7)` succeeds and floors to `1500` +(`autosave.lua:48`), and `pmacs.editops.trim_on_save("yes")` sets true +(`on ~= false`, `editops.lua:846`). A *thin* wrapper over a strict +`config.set` would reject both, because Q#CR3's `integer` demands +exactness and `boolean` demands a real boolean. So the wrappers coerce +first — `math.floor` / `~= false` — and then call `set` with an +already-conforming value. The registry stays strict; the legacy API +stays lenient; acceptance 27-28 pin both directions on inputs no current +test covers. + +Richer feature-specific APIs are explicitly not deleted: +`pmacs.gpu.set_font` remains the font preference API until a separately +framed migration decides how a daemon-global preference and +frontend-local resolution interact. + +`pair.lua` reads `editing.auto-pair` in its insertion predicate. It +loads before `lsp.lua` by an existing ordering contract (Q#AP7, +`editor.rs:319` and `:325`), so the config surface must be installed +before `pair.lua` (Q#CR14). + +### Q#CR9 — Names are dotted and kebab-cased + +`editing.auto-pair`, `autosave.interval-ms`, and — when it arrives — +`editor.tab-width`. Lowercase ASCII segments, total length bounded at +128 bytes. + +Segment grammar, tightened in revision 2: `[a-z][a-z0-9]*(-[a-z0-9]+)*`. +Revision 1's `[a-z][a-z0-9_-]*` admitted `auto-` and `a--b`; this form +forbids a trailing hyphen and a doubled hyphen while accepting every +name we actually want. + +The withdrawn draft used snake_case (`editor.tab_width`). Kebab matches +the two registries that already exist — `buffer.before-save`, +`buffer.after-switch`, `buffer.self-insert`, `buffer.kill-this` — and +setting *names* are strings in the registry vocabulary, not Lua +identifiers. Lua field names stay snake_case as they are today. + +### Q#CR10 — Define before set; `Live` vs `StartupOnly` + +`pmacs.config.set` on an undefined name raises `NotFound`, exactly as +`pmacs.hook.add` does. Silent acceptance of an unknown name is how typos +become permanent mysteries. + +Definitions are immutable after first registration, except that a +byte-for-byte identical redefinition succeeds (idempotent reload); a +conflicting redefinition fails and leaves the original exactly as it +was. + +`mutability` is `Live` or `StartupOnly`. `StartupOnly` keys accept +writes while user config is loading and freeze when `set_init_complete` +runs at the tail of `EditorState::new()` (`editor.rs:465-476`); a later +write returns an error naming the key and the policy. This generalizes +the posture `require_init_phase` (`mod.rs:663`) already hard-codes for +`pmacs.attach`, and gives it a declarative home. + +**`StartupOnly` and `set_local` are mutually exclusive (F5).** +Buffer-locals are set at runtime, from `buffer.after-load` hooks that +fire long after the freeze — so a `StartupOnly` key could never carry a +buffer-local override, and a `set_local` against one would be dead code +that looks live. `define` rejects the combination outright with +`StartupOnlyLocal` rather than shipping a knob whose two halves +contradict each other. + +The ordering that makes define-before-set safe is the corrected sequence +in ground truth: builtins define during `EditorState::new()`, user +config runs, then the freeze — all inside the same function. Packages +loading after init cannot be pre-configured from `init.lua` — the +existing v0.1 posture, not a new limitation. Staging pending sets for +later-defined names is deferred. + +### Q#CR11 — Discovery: `describe` + `list` + `M-x describe-setting` + +`pmacs.config.describe(name [, buf])` returns a fresh table with `name`, +`description`, `type`, `default`, `choices`, bounds, `mutability`, +`value`, `global`, `buffer_local`, and `source`. + +**`buffer_local`, not `local` (F7)** — `local` is a Lua keyword, so +`info.local` is a syntax error and every consumer would be forced to +write `info["local"]`. The field is present only when a buffer argument +is given and that buffer holds an override. + +`pmacs.config.list()` returns fresh metadata tables, deterministic by +key. Neither ever exposes an internal table or a listener function. + +`M-x describe-setting` renders through `src/help.rs`, following +`render_hook` / `format_hook_text` (`help.rs:160`, `:170`) so the +`*help*` buffer, its link spans and its view-rebuild path work +unchanged. The withdrawn draft had `describe`/`list` as Lua-only; wiring +the `*help*` surface is what makes the registry discoverable to a user +rather than to a script. + +A `list-settings` listview panel is deferred — the machinery exists from +Arc 1b, but it is UI scope on a substrate PR. + +### Q#CR12 — No persistence in stage 1 + +Emacs's `custom-file` problem — the init file says one thing, the +persisted state file says another, and the user cannot tell which won — +is a genuine design hazard deserving its own framing round. The +`$XDG_STATE_HOME/pmacs` machinery from Arc 3 exists +(`install_state_dirs`, `editor.rs:551`), so this is a question about +what we want, not about plumbing. + +### Q#CR13 — Tab width is stage 2, and the reason is not config + +Tab width is the backlog's headline example, and the withdrawn draft +used `editor.tab_width` as its running example and candidate adopter. It +is deliberately out of stage 1 here. + +Per the ground truth: the daemon has four `TAB_WIDTH = 8` constants, the +GPU minimap expands tabs to 4, and the GPU's main text path does not +expand tabs at all. So `editor.tab-width` cannot be honored on the GPU +by defining a setting. It needs frontend tab expansion, a decision about +whether the value crosses the wire or the frontend reads its own, and a +rendering-parity fix that stands on its own merits. Note that the +withdrawn draft's own non-goals excluded protocol messages and +frontend-local settings — which its headline example required. Stage 1 +resolves that contradiction by deferring the example, not the non-goal. + +Stage 2: unify the four daemon constants into a resolved value threaded +through the display-column functions as a parameter — they are pure +functions today and should stay pure — then answer the GPU question. + +### Q#CR14 — Surface in `config.lua`; each module defines its own keys + +`builtin/runtime/config.lua` holds the friendly Lua surface only, loaded +in `EditorState::new()` immediately after `fs.lua` (`editor.rs:206`) and +before every module that defines or reads a setting — in particular +before `pair.lua` (`editor.rs:319`), whose own load-before-`lsp.lua` +contract (`editor.rs:325`) is unaffected. + +**Builtin `define` calls live with their owning modules (F10)**, not +centralized in `config.lua`. Revision 1 centralized them, which would +have pointed every builtin setting's `SourceLocation` at `config.lua` — +weakening exactly what acceptance 9 celebrates — and coupled +`config.lua` to autosave's floor, pair's default and editops' semantics. +The hook precedent is owner-defines: `builtin/hooks/default.lua` defines +hooks, but each module owns its own behavior. So `pair.lua` defines +`editing.auto-pair`, `editops.lua` defines `editing.trim-on-save`, and +`autosave.lua` defines `autosave.interval-ms`. Ordering still works — +every builtin chunk precedes `init.lua` regardless. + +The Rust `install_config` call sits with the other installs in +`attach_editor`. Total footprint in the two files the vterm lane also +touches: one install call plus one chunk load in `editor.rs`, one `mod` +line plus one call in `lua_bindings/mod.rs`. + +### Q#CR15 — Threading and hot paths + +Main-thread `Rc>`, matching the syntax, statusline, command +and hook registries. Reads return borrowed or copied data internally and +allocate only when crossing into Lua metadata. Render hot paths cache a +typed value or gate on `value_epoch`; they must never build a Lua table +or do a string lookup per cell or per frame. + +No worker-thread mutation. Workers receive copied settings in job specs, +so a live change affects future jobs only, unless the owning feature +explicitly cancels and restarts work. + +--- + +## Bets + +1. **Global + buffer-local is sufficient.** Falsified if any of the five + backlog-blocked features cannot be expressed as a hook that calls + `set_local`. The most likely falsifier is per-project compile + commands, where the natural key is a project root — if a + project-scoped value must survive with no buffer open, this fails and + a third scope is needed. +2. **No protocol change is needed.** Falsified if any stage-1 consumer + turns out to have a frontend-side reader. Checked against all three: + `pair.lua`, `editops.lua`, `autosave.lua` all run daemon-side. +3. **Scalars are sufficient for stage 1.** Falsified if a first consumer + wants a table- or list-valued setting. The three chosen adopters are + two booleans and an integer, so this is near-certain for stage 1 and + says nothing about stage 2. +4. **`after_buffer_removed` catches every buffer death that matters.** + Falsified by a production path that removes a buffer from the + registry without it. The known exception is test-only today + (`editor.rs:5527`), and per F8 its blast radius is a bounded, + non-aliasing leak. +5. **Migrating two settings behind unchanged public functions is + invisible to users.** Falsified if any observable behavior of + `trim_on_save` / `interval_ms` changes — including the coercion + behavior on non-conforming inputs, which is why F4's pins exist. + +Revision 1's bet 6 (a real `StartupOnly` freeze point) is **confirmed** +and retired into ground truth: `set_init_complete` at +`editor.rs:465-476`, inside `EditorState::new()`, covering both entry +points. + +--- + +## Deferred (named) + +- **Persistence** and the `custom-file` split-brain question (Q#CR12). +- **Tab width and the GPU tab-expansion parity fix** — stage 2 + (Q#CR13), including the GPU minimap's divergent width of 4. +- **`string-list`** (F6) — returns with its first adopter and an + explicit hole-rejection rule. +- **Per-language and per-project first-class scopes**, if bet 1 falls. +- **Mode scope** — blocked on wiring the mode system (every editor + `resolve` passes `&[]`); a cross-cutting backlog item in its own right. +- **Window-local scope** — `line_numbers` is the existing precedent and + the first migration if a second window-local setting appears. +- **Table-valued settings** (Q#CR3), and with them any migration of + `pmacs.lsp.config`, `pmacs.pair.sets`, `pmacs.comment.strings` or the + `pmacs.parse.*` write-through proxies. +- **Migrating the remaining scalar surfaces** — `async_config` (×2), + `killring.max`, `autosave.enable`, `recentf.enable`, + `saveplace.enable`, `session.desktop_mode`. +- **Migrating `pmacs.gpu.set_font`** — needs its own framing for the + daemon-preference / frontend-resolution split (Q#CR8). +- **Deprecating the getter-when-nil functions** once migration + completes; stage 1 keeps every one of them, coercion included. +- **Pending-set staging** for names defined after `init.lua` runs + (Q#CR10), which would also give packages a configuration story. +- **`M-x list-settings`** as a listview panel (Q#CR11). +- **A `customize`-style editing UI**, and a `:set`-style minibuffer + command. + +--- + +## Acceptance + +Registry semantics (unit, `src/config_registry.rs`): + +1. Valid definitions round-trip every value kind and produce a fresh + metadata table. +2. `define` rejects an empty name, a missing or whitespace-only + description (R42), a duplicate name, an unknown spec key (R50), a + metatable-provided field, non-finite bounds, an inverted range, + duplicate enum choices, and a default violating its own contract — + each **without** adding a definition or advancing either epoch, and + each with a message naming the offending field. +3. `define` rejects the name grammar's edge cases: trailing hyphen + (`auto-`), doubled hyphen (`a--b`), empty segment, leading digit, and + over-length (Q#CR9). +4. An identical redefinition is idempotent; a conflicting redefinition + leaves the original exact. +5. Values and definitions are deep-copied from Lua — mutating the + caller's table afterwards cannot alter registry state. +6. Integer and number finite/boundary cases are exact under **both** + `--features luajit` (default) and + `--no-default-features --features lua54`. +7. `list` is deterministic by key; `describe`/`list` never expose an + internal mutable table or a listener function. +8. `names()` / `list()` order is stable across ≥3 defines. +9. `SourceLocation` is captured from the *defining module's* chunk and + renders `file:line` — `editing.auto-pair` reports `pair.lua`, not + `config.lua` (Q#CR14). + +Scoping, storage and buffer-local lifecycle: + +10. `get(name, buf)` resolves buffer-local → global → default; + `reset(name, buf)` drops only the local layer and re-exposes the + global; `reset(name)` drops only the global. +11. **An equal-valued override is still stored (F1).** With the global + value `true`, `set_local(buf, name, true)` then + `set(name, false)` leaves `get(name, buf)` `true` and + `get(name)` `false` — **bite-verified**: the test fails against a + "true no-op" implementation that declines to store it. +12. A buffer-local set on buffer A does not change the value seen in + buffer B; `is_set` reports override presence per layer, including + for an equal-valued override. +13. A buffer-local value is dropped when the buffer is removed through + `remove_buffer_and_fire`; removing a buffer directly via + `BufferRegistry::remove` leaves the entry, and the test asserts that + documented limitation rather than a fix (Q#CR5, F8). +14. `get(name)` with no buffer argument returns the global chain result + even when the active buffer holds a different local override (F9). +15. `value_epoch` advances only on effective-value change; storing an + equal-valued override advances neither epoch. + +Listeners (Q#CR6): + +16. Callbacks run after borrow release, in registration order, with + copied `(new, old, buf)` values — **bite-verified**: the test fails + with the borrow held. +17. A global `set` fires once with `buf = nil` (a), and does **not** + fire for a buffer whose own override shadows the change (b). +18. The `after_buffer_removed` locals purge fires no listener (c). +19. `on_change` on an undefined name raises `NotFound` (d). +20. One raising listener is logged once and does not block later + listeners; the committed value stays authoritative. +21. A re-entrant `set` from inside a listener creates a later ordered + epoch without a `RefCell` panic; a recursive cycle hits the bounded + error rather than hanging. +22. Dispose is idempotent and id-generation-safe; a stale handle never + disposes a newer listener. A dropped-but-undisposed handle keeps + firing (F3) — the inverse of revision 1's claim, pinned so the + behavior cannot silently regress to GC-dependence. + +Startup and mutability: + +23. `StartupOnly` keys accept writes before the freeze and reject after, + with a message naming the key and the policy. **The test flips + `InitCompleteFlag` explicitly** (`mod.rs:14655-14690` is the + pattern) — in `--lib` builds `set_init_complete` never runs, so a + test that omits this passes vacuously. +24. `define` rejects `mutability = 'startup'` combined with any + `set_local` attempt, via `StartupOnlyLocal` (F5). +25. A failing `init.lua` preserves prior successful sets and still + starts the editor; missing `init.lua`, broken `init.lua` and + `require` package-path behavior are unchanged. +26. `config.lua` is installed before `pair.lua`, and `pair.lua` still + loads before `lsp.lua` (the Q#AP7 contract is untouched). + +Adopters: + +27. `pmacs.editops.trim_on_save(true)` and + `pmacs.config.set("editing.trim-on-save", true)` are interchangeable + in both directions — each observes the other's writes — and + `trim_on_save("yes")` still sets true (F4). +28. `pmacs.autosave.interval_ms(1500.7)` still returns `1500`, and + `interval_ms(500)` still raises on the floor — now enforced by the + registry validator behind the wrapper's coercion. **Bite-verified** + against the removed hand-rolled check (F4). +29. `editing.auto-pair` false globally suppresses pairing; false + buffer-locally suppresses it in that buffer only, with pairing still + active in a second buffer of the same language. +30. The autosave tick observes a mid-session interval change without a + restart (the existing live-re-read contract, re-pinned). +31. Neither adopter performs a per-frame or per-cell Lua lookup, and + both preserve their previous default behavior exactly. + +Discovery: + +32. `pmacs.config.describe` returns every documented field, with + `buffer_local` absent when no buffer-local override exists and the + field name usable without bracket syntax (F7). +33. `M-x describe-setting` renders into `*help*` with the source + location present. + +Gates: the full standing suite per `CLAUDE.md`, plus the new acceptance +suite, plus `--no-default-features --features lua54` for item 6. + +--- + +## Resolved review questions (round 1) + +1. **Scope set** — accept global + buffer-local. Bet 1's named falsifier + (a project value surviving with no buffer open) is real and correctly + deferred. +2. **`string-list`** — dropped (F6). Returns with an adopter. +3. **`Live`/`StartupOnly`** — accepted; freeze point confirmed at + `editor.rs:465-476`, covering both entry points. Test-build caveat + (acceptance 23) and the `set_local` exclusion (F5) carried into the + doc. +4. **No-rollback listener errors** — accepted; matches the hook + log-and-continue posture. +5. **Three adopters, `set_font` untouched** — accepted, with F4's + coercion pins added. +6. **Defer tab width** — accepted; the parity mess is verified real and + the withdrawn draft's contradiction correctly diagnosed. +7. **Kebab-case** — accepted, with the grammar tightened to + `[a-z][a-z0-9]*(-[a-z0-9]+)*` to forbid `auto-` and `a--b`. + +Open for round 2: nothing blocking. F10 (owner-defines) was adopted as +recommended; if the lead prefers centralized defines for reviewability, +acceptance 9 is the item that changes. + +--- + +## Lane coordination (config registry ↔ vterm) + +Both lanes are active concurrently in separate worktrees. Assignment: + +| File | Owner | Other lane | +| --- | --- | --- | +| `src/editor.rs` runtime-load block | **config** — its chunk must load before consumers | vterm appends its chunk at the tail | +| `src/lua_bindings/mod.rs` | **neither** — config adds `lua_bindings/config.rs`, vterm adds its own submodule; each takes one `mod` line + one install call | keep both footprints to one line | +| `src/lib.rs` module exports | one line each | trivial rebase conflict, resolve in favor of both | +| `src/ansi.rs` | **vterm** | config never touches it | +| `docs/agent-handoff.md`, `docs/active-work.md` | whichever merges second rewrites its §1 entry post-rebase | do not co-edit | + +Neither feature imports the other's types. Recommended merge order: +config registry first (smaller, substrate, heaviest footprint in the +shared files), then vterm rebases. Per the standing constraint, vterm +keeps terminal settings hard-wired in its early stages and adopts +`pmacs.config` only after both contracts land, so neither arc is a +prerequisite for the other. + +Implementation branch, after framing approval: `config-registry`, cut +from then-current canonical `main`, never from a vterm branch.