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) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-21 13:50:34 -04:00
parent 643d1e1fcc
commit 3bde76126a
1 changed files with 904 additions and 0 deletions

View File

@ -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 F1F11.
- **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<String, T>` by name plus a `Vec<String>` 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<RefCell<…>>` 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<BufferId, Vec<…>>`
(`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<String, ConfigDefinition>,
order: Vec<String>,
global: HashMap<String, ConfigValue>, // overrides only
locals: HashMap<BufferId, HashMap<String, ConfigValue>>,
listeners: Vec<ConfigListener>, // 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<RefCell<ConfigRegistry>>` 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<BufferId, HashMap<String, ConfigValue>>` 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<RefCell<_>>`, 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.