51 KiB
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; onlyvalue_epochand 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::Gcexists anywhere inmod.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_localresolved — the combination is rejected at define time (Q#CR10). - F6:
string-listdropped from the stage-1 vocabulary (Q#CR3). - F7:
describe'slocalfield renamedbuffer_local—localis 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.
Revision 3 — 2026-07-21, implementation round 1. Two corrections found while building the adopters, both against this document rather than against the code.
- Acceptance 30 vs 31 contradicted each other — 31 forbade a
"per-frame lookup" that 30 required. Q#CR15's prohibition is about
render hot paths (per cell, per line, table construction), not one
O(1) scalar
getper tick. Item 31 reworded. builtin/runtime/config.luadoes not exist (Q#CR14).pmacs.configinstalls from Rust before any runtime chunk, exactly aspmacs.commandandpmacs.hookdo, so the chunk had nothing to hold — and the one helper it might have held would have broken acceptance 9 by capturing every builtin define'sSourceLocationatconfig.lua. Acceptance 26 rewritten from a now-vacuous claim. Net effect: this arc touchessrc/editor.rszero times.- F5's rejection point was unimplementable as written (Q#CR10).
Revision 2 had
definerejectingStartupOnly+set_local, butdefinecannot know about a future call andStartupOnlyis legal alone. Moved toset_local, where the combination actually manifests. Acceptance 24 rewritten. - The registry's
frozenflag is driven from the existingInitCompleteFlagat write time rather than by a neweditor.rscall, which is what keeps the zero-touch claim above true.
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 aVec<String>insertion order for stable listing.- R42 — a non-empty
descriptionis 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 bypmacs.describe.*.- Both live behind
Rc<RefCell<…>>as Lua app data (SharedCommandRegistry,SharedHookRegistry,mod.rs:2265-2271). HookRegistry::snapshotexists 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:
BufferRemoveCallbacksis aHashMap<BufferId, Vec<…>>(mod.rs:133-176);KeymapStackkeys per-buffer maps the same way. Bufferhas 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). BufferIds 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 insrc/, everyKeymapStack::resolvein the editor passes&[]foractive_modes; the only non-empty callers are two unit tests insidekeymap_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 aspmacs.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):
#[cfg(not(test))]
{
crate::config::load_user_config(&mut lua_host);
lua_host.set_init_complete();
}
Three consequences the design depends on:
- Builtins define, the user sets, the flag freezes — one ordered sequence, no new machinery needed.
- 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: theStartupOnlyfreeze point exists and isInitCompleteFlag(mod.rs:2270), consumed byrequire_init_phase(mod.rs:663). - In
--libtest 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 waymod.rs:14655-14690already 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 = 8insrc/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 samecol += TAB_WIDTH - (col % TAB_WIDTH)expansion.pmacs-gpu/src/main.rs:6728-6733—advance_minimap_colexpands 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:
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.
setandset_localunconditionally record an entry, which is what makesis_setmeaningful (Q#CR4) and what makes a buffer-local pin actually pin. value_epochadvances, 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.
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
BufferIds 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.
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
setthat changes the global effective value fires once withbuf = 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_removedpurge (Q#CR5) fires nothing. - (d)
on_changeon an undefined name raisesNotFound, 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.
One honest divergence from "the wrapper's shape stays exactly as it
was", found in review round 1: pmacs.autosave.interval_ms(1e30)
previously stored the float, and now raises NonIntegral because the
floored value exceeds i64 range. The old behavior stored a nonsense
interval; the new one refuses it. An improvement, but a behavior change
at the extreme rather than a pure no-op migration, so it is recorded
here rather than claimed away.
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.
Corrected in revision 3. Revision 2 said "define rejects the
combination outright", which is not implementable: define cannot know
about a future set_local, and StartupOnly is perfectly legal on its
own. The rejection lives where the combination actually manifests —
set_local returns StartupOnlyLocal when the named definition is
StartupOnly, unconditionally and independent of freeze state, so the
error is the same before and after startup rather than changing shape
mid-session.
reset(name, None) after the freeze is also refused for a StartupOnly
key: dropping a frozen override would let the default silently reassert
itself post-freeze, which is the same hazard set is blocked for.
Buffer-local reset needs no such check, since such a key can never
hold a local override in the first place.
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 in definition
order, matching names() and the command/hook registries' "stable
listing" rule. Neither describe nor list ever exposes an internal
table or a listener function.
Revision 3 clarification: revision 2 said "deterministic by key", which
implementation read as possibly meaning sorted-by-name. The requirement
is determinism — never HashMap iteration order — and definition
order satisfies it while staying consistent with the two sibling
registries. A UI that wants alphabetical sorts at the presentation
layer; the substrate does not decide that. Bounds are exposed as flat
min / max fields, not a nested bounds table.
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 — No runtime chunk; each module defines its own keys
Revised in revision 3. Revision 2 put a "friendly Lua surface" in
builtin/runtime/config.lua, loaded after fs.lua. Implementation
established there is nothing for that file to hold, so it does not
exist.
Two facts kill it. First, pmacs.config is installed entirely from
Rust by attach_editor, which runs before any builtin/runtime/*.lua
chunk is evaluated in EditorState::new() — so the bindings are already
the friendly surface, with no raw underscore layer needing a Lua wrapper
the way pmacs._async needs async.lua. pmacs.command and
pmacs.hook are the precedent: both are pure-Rust surfaces with no
runtime chunk. Second, the one helper such a file might plausibly hold —
a shared define-wrapping convenience — would actively break acceptance
9, because SourceLocation is captured from Lua debug info at the
define call site, so routing every builtin define through a helper in
config.lua would make every builtin setting report config.lua as its
source instead of its owning module.
The module documentation that revision 2 assigned to that chunk lives in
the src/lua_bindings/config.rs module header instead.
Builtin define calls live with their owning modules (F10), not
centralized. pair.lua defines editing.auto-pair, editops.lua
defines editing.trim-on-save, autosave.lua defines
autosave.interval-ms. pair.lua's load-before-lsp.lua contract
(editor.rs:319, :325) is untouched, and because the bindings install
ahead of every chunk, no load-order constraint is added by this arc at
all.
Consequence worth noting for the concurrent vterm lane: this arc now
touches src/editor.rs zero times.
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
- 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. - 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.luaall run daemon-side. - 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.
after_buffer_removedcatches 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.- Migrating two settings behind unchanged public functions is
invisible to users. Falsified if any observable behavior of
trim_on_save/interval_mschanges — 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-filesplit-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
resolvepasses&[]); a cross-cutting backlog item in its own right. - Window-local scope —
line_numbersis 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.stringsor thepmacs.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.luaruns (Q#CR10), which would also give packages a configuration story. M-x list-settingsas a listview panel (Q#CR11).- A
customize-style editing UI, and a:set-style minibuffer command. - A
scope = "global"define flag (review round 1, finding 2).set_localcurrently succeeds for anyLivesetting, including ones whose consumer only ever reads the global chain.editing.trim-on-savewas fixed by making its consumer buffer-aware — the save hook now resolves against the buffer being saved — but a per-bufferautosave.interval-msis semantically meaningless (there is one sweep timer, not one per buffer) and the API still accepts it, stores it, and reports it fromdescribe. That is a stored value nothing reads, the same shape F1 exists to prevent. The fix is a define-time scope declaration letting the registry refuseset_localoutright, exactly asStartupOnlyLocalalready refuses another meaningless combination. Deferred rather than taken in review round 1 because it adds public API surface after review. - Field-naming in bound-parse errors — a bad
minreports the config name and the expected type but not which ofmin/maxoffended. Cheap, but wants its own error variant rather than a synthesized pseudo-name. reset(name, buf)symmetry forStartupOnly—set_localis refused withStartupOnlyLocalbut the buffer-localresetis allowed. Unreachable-harmless today (no such local can exist), so a rejection would be symmetry rather than a fix.
Acceptance
Registry semantics (unit, src/config_registry.rs):
- Valid definitions round-trip every value kind and produce a fresh metadata table.
definerejects 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.definerejects the name grammar's edge cases: trailing hyphen (auto-), doubled hyphen (a--b), empty segment, leading digit, and over-length (Q#CR9).- An identical redefinition is idempotent; a conflicting redefinition leaves the original exact.
- Values and definitions are deep-copied from Lua — mutating the caller's table afterwards cannot alter registry state.
- Integer and number finite/boundary cases are exact under both
--features luajit(default) and--no-default-features --features lua54. listreturns definition order (neverHashMaporder);describe/listnever expose an internal mutable table or a listener function, and each call returns a fresh table.names()/list()order is stable across ≥3 defines.SourceLocationis captured from the defining module's chunk and rendersfile:line—editing.auto-pairreportspair.lua, notconfig.lua(Q#CR14).
Scoping, storage and buffer-local lifecycle:
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.- An equal-valued override is still stored (F1). With the global
value
true,set_local(buf, name, true)thenset(name, false)leavesget(name, buf)trueandget(name)false— bite-verified: the test fails against a "true no-op" implementation that declines to store it. - A buffer-local set on buffer A does not change the value seen in
buffer B;
is_setreports override presence per layer, including for an equal-valued override. - A buffer-local value is dropped when the buffer is removed through
remove_buffer_and_fire; removing a buffer directly viaBufferRegistry::removeleaves the entry, and the test asserts that documented limitation rather than a fix (Q#CR5, F8). get(name)with no buffer argument returns the global chain result even when the active buffer holds a different local override (F9).value_epochadvances only on effective-value change; storing an equal-valued override advances neither epoch.
Listeners (Q#CR6):
- Callbacks run after borrow release, in registration order, with
copied
(new, old, buf)values — bite-verified: the test fails with the borrow held. - A global
setfires once withbuf = nil(a), and does not fire for a buffer whose own override shadows the change (b). - The
after_buffer_removedlocals purge fires no listener (c). on_changeon an undefined name raisesNotFound(d).- One raising listener is logged once and does not block later listeners; the committed value stays authoritative.
- A re-entrant
setfrom inside a listener creates a later ordered epoch without aRefCellpanic; a recursive cycle hits the bounded error rather than hanging. - 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:
StartupOnlykeys accept writes before the freeze and reject after, with a message naming the key and the policy. The test flipsInitCompleteFlagexplicitly (mod.rs:14655-14690is the pattern) — in--libbuildsset_init_completenever runs, so a test that omits this passes vacuously.set_localagainst aStartupOnlydefinition returnsStartupOnlyLocalboth before and after the freeze (F5, corrected in revision 3 —definecannot police a future call). AStartupOnlyglobalresetafter the freeze is refused for the same reasonsetis.- A failing
init.luapreserves prior successful sets and still starts the editor; missinginit.lua, brokeninit.luaandrequirepackage-path behavior are unchanged. - The
pmacs.configtable is populated before the firstbuiltin/runtime/*.luachunk evaluates — a builtin module's top-leveldefinecall succeeds — andpair.luastill loads beforelsp.lua(the Q#AP7 contract is untouched). Revision 3: this replaces "config.luais installed beforepair.lua", which became vacuous when that chunk was removed (Q#CR14).
Adopters:
pmacs.editops.trim_on_save(true)andpmacs.config.set("editing.trim-on-save", true)are interchangeable in both directions — each observes the other's writes — andtrim_on_save("yes")still sets true (F4).pmacs.autosave.interval_ms(1500.7)still returns1500, andinterval_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).editing.auto-pairfalse globally suppresses pairing; false buffer-locally suppresses it in that buffer only, with pairing still active in a second buffer of the same language.- The autosave tick observes a mid-session interval change without a
restart (the existing live-re-read contract, re-pinned), whether the
change arrived through the wrapper or through a direct
pmacs.config.set. - No adopter builds a Lua table or performs a string-keyed lookup
per cell or per rendered line, and all three preserve their
previous default behavior exactly. Revision 3 note: this item and
item 30 read as contradictory in revision 2 — 30 requires the
autosave tick to re-read every frame while 31 forbade a "per-frame
lookup". Q#CR15's prohibition targets render hot paths, not a single
O(1) scalar
getonce per tick, which is exactly what Q#CR8 asks autosave to prove. One scalargetper tick is explicitly conforming.
Discovery:
pmacs.config.describereturns every documented field, withbuffer_localabsent when no buffer-local override exists and the field name usable without bracket syntax (F7).M-x describe-settingrenders 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)
- Scope set — accept global + buffer-local. Bet 1's named falsifier (a project value surviving with no buffer open) is real and correctly deferred.
string-list— dropped (F6). Returns with an adopter.Live/StartupOnly— accepted; freeze point confirmed ateditor.rs:465-476, covering both entry points. Test-build caveat (acceptance 23) and theset_localexclusion (F5) carried into the doc.- No-rollback listener errors — accepted; matches the hook log-and-continue posture.
- Three adopters,
set_fontuntouched — accepted, with F4's coercion pins added. - Defer tab width — accepted; the parity mess is verified real and the withdrawn draft's contradiction correctly diagnosed.
- Kebab-case — accepted, with the grammar tightened to
[a-z][a-z0-9]*(-[a-z0-9]+)*to forbidauto-anda--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.