Desktop settings contract
Status: accepted authority contract, version 0.4.0 (0.2.0 added the summary topic and read view, decision; 0.3.0 added snapshot schema 2, decision; 0.4.0 adds key bindings, decision). Full desktop consumer integration remains in development. No frozen ABP wire bytes change.
settingsd serves one explicitly initialised profile in the initial slice.
settings provides shared headless types, resolver, consumer and snapshot reducer;
design::DesignReadProjection is render data that cannot construct an accepted
design or fork compiler apply lineage. Consumers are the desktop applications
and compd's Quoin shell.
Served verbs
All trusted mesh operators can invoke these verbs. Requests carry JSON bodies; responses preserve ordinary ABP rc conventions: 0 for handled results and 10 for refusal, invalid input or unknown verbs. Domain status is structured in the body.
| Verb | Input and result |
|---|---|
settings.describe | No mutation; version, fields, ranges, defaults, limits and implemented/deferred capabilities |
settings.get | {binding:{instance,profile}, view?, schema?}; view full (default): complete accepted snapshot under snapshot, in schema 1 (default) or 2 (each distinct design projection once under designs, contexts naming it by digest; other schemas refused with unsupported_schema); summary: the profile's Summary (schema, binding, incarnation, revision, design revision, source digest, appearance names, custom_source, keymaps when any are set, terminal when any value is set, and email when any value is set; never projections or a source) under summary; both with publication/recovery status |
settings.appearance.get | {binding:{instance,profile}, schema?}; read-only appearance projection (mode, contrast, sRGB accent) tagged with incarnation and revisions; the reply also includes the atomic snapshot (in schema 1 or 2, as for settings.get) for shared Consumer validation; wrong_target on a foreign binding |
settings.validate | Fenced apply-shaped body; resolve/compile candidate without a write or receipt; valid with current base identity or diagnostics/conflict |
settings.apply | Fenced batch described below; durable changed/unchanged receipt |
settings.reset | Apply-shaped body with empty changes and explicit reset paths; same transaction semantics |
settings.status | Binding plus optional operation_id; accepted/published revision and retained receipt or unknown_operation |
The profile identifier and instance use 1–64 ASCII letters, digits, dash or underscore. Operation IDs use the same character set, up to 128 bytes. Revisions are canonical decimal u64 strings, including on strict-data boundaries; numeric, overflowing, signed and leading-zero representations are refused. Panel thickness accepts a whole numeric value such as 48 or 48.0, within its declared range; Mix data numbers use floating representation. Fractional values, numeric strings and overflow are refused before acceptance.
{
"binding": {"instance": "example", "profile": "default"},
"expected_incarnation": "authority-readback-uuid",
"expected_revision": "1",
"operation_id": "example-batch-1",
"changes": {
"appearance.mode": "light",
"ui.text_scale": 1.1,
"shell.panels.bottom.thickness": 48
},
"reset": []
}
An optional request_digest is checked against BLAKE3 of the canonical serde JSON representation of the typed request, with request_digest set to null. Object maps are sorted; reset-list order remains part of the request. Use the shared request digest API rather than hashing arbitrary JSON text. Reusing an operation ID with different input is refused. Receipt lookup/deduplication precedes expected revision conflict, after target/incarnation checks. An old retained operation can retrieve its original receipt after subsequent edits. Retry the identical request to recover its receipt. Updating a fence changes the digest and requires a new operation ID. Validation checks the present fence; another writer may still win before a later apply.
Retain at most 128 receipts, inside the accepted document. A no-op writes its receipt durably without increasing desktop revision or publishing a new snapshot. Missing/evicted IDs return unknown_operation: opaque IDs have no expiry order, and the initial contract cannot prove expired_operation. Resolve ambiguity by readback and a newly fenced operation; exactly-once is not promised beyond retention. Restores establish a new incarnation and clear foreign-history receipts. Status includes publication_pending and recovering separately from durable acceptance; a changed result can remain unpublished after broker failure.
Initial data and resolution
The schema covers appearance scheme/style/mode/contrast, window decorations (csd or ssd) and caption side (right or left) plus embedded or complete strict-data design source; UI density/text scale/reduced motion; named panels with edge/mode/thickness; ordered shell pages; and whole app override records with scheme/mode/contrast/text scale. Describe supplies exact ranges. Unknown fields/scopes are refused. Field reset restores a package default; removing an app override restores profile inheritance. Per-field authored provenance beyond these initial concrete profile values is later work. The package source is pinned in accepted storage at profile creation. Null appearance.source selects that pinned source, including after binary upgrades; it does not silently adopt the upgraded binary's embedded defaults. Adopting a new package source requires an explicit mutation with a new revision. The accepted effective digest also fences compiler interpretation drift. A changed interpretation requires explicit migration rather than changing the same accepted identity on reload.
The accepted record carries a format number (absent in format 1; now 2). On open the record's seal is verified as stored, in its own format, so tampering is still refused. An older record is then upgraded by one rule: everything authored is kept, the effective interpretation is resolved again from the authored desktop and the pinned source (derived data is never trusted across formats), both digests are sealed at the current format, and the backup and then the primary are replaced atomically. A second open changes nothing. An authored field added later is omitted at its default, so older records still verify; a change to the effective interpretation bumps the format.
Validate the whole candidate and all advertised app contexts before acceptance. Compilation checks the source's claimed contexts. App overlays affect their context alone; profile high contrast takes precedence. A custom source is at most 256 KiB. Encoded requests are at most 384 KiB. Inline snapshots are at most 960 KiB, reserving 64 KiB for ABP/broker envelope overhead under the current 1 MiB retained limit. Larger settings are refused until native immutable artifact delivery is implemented. Required fonts/assets and runtime live capability are not yet advertised.
Snapshot schema 1 includes binding, incarnation, desktop revision, resolved-design generation, source digest, complete desktop and effective per-app/desktop design projections. The resolved-design generation is owned by the authority and survives restart; it is not a broker sequence or a reconstituted compiler apply handle. Clients must confirm an incarnation change through fresh bound readback before installing it. Work tickets fence superseded asynchronous completion. Conflicting payloads at the same incarnation/revision trigger a fresh read; if the bound authority confirms the contradiction, report it and preserve the installed values rather than enter an endless readback loop. Fresh decoded topic deliveries use Reducer.observe. Captured tickets are for asynchronous completions; a new delivery can advance the state while an older bound read is pending, and that read cannot roll the revision back.
The settings library API is now 0.5.0; authority verbs are 0.3.0. Snapshot
schema 1 is the default; schema 2 is its compact wire form, decoded into the
same in-memory snapshot. The shared native consumer follows the compact topic
and reads schema 2; consumers on their own library copy stay on schema 1 until
ported. The shared consumer performs subscribe-before-get over the host's
existing supervised Bus connection through its optional native executor. It
owns no transport/task/incoming receiver. Hosts feed connection generations,
deliveries and explicit queue loss, execute at most one current action, and
cancel superseded work. Bootstrap buffers one latest candidate; loss coalesces
one later full read. A bounded history suppresses repeated rejected confirmation
candidates. Transport recovery has one pending backoff from 250 ms to 30 seconds;
success/disconnect cancels it. Protocol/target/schema refusal is visible and
quiescent until a new event or explicit refresh. Every native call is bounded
to one second; the host must also cap the combined initial bootstrap to one
second and show a labelled fallback while recovery continues.
Use the same initial deadline with native::execute_until for subscribe and
read. Recovery exposes an absolute deadline, preserved during malformed-event
storms; arrivals cannot restart or bypass it. Canonical app/shell constructors
choose app:<id> or desktop contexts. Captured read tickets and baselines
distinguish real same-incarnation rollback from a valid read racing a newer
publication. Cancel old executor futures when their current work ticket changes.
One context-specific change plan separates paint, text, layout, resources, motion
and optional shell geometry. Source/provenance/revision-only changes advance
evidence without rendering work. Received accepted data is distinct from staged
and applied data. Prepare all resources, then on the renderer event loop check
the stage ticket immediately before synchronous activation and acknowledgement,
without an intervening await. Old connection, consumer and stage completions
cannot claim application. Hosts must fence the actual swap as well as its report.
Identical render inputs need no swap; application never proves presentation.
Consumer::evidence() reports binding, context, connection generation,
confirmation, presentation kind, accepted (current) and UI-acknowledged
(applied) identities, plus normal and fallback faults. Each identity carries
incarnation, canonical string revision, design revision and source digest.
Absent identities are null. Losing confirmation demotes installed current data
to LastGood; an installed fallback is never an authority fence. The shell
exposes this same evidence at shell.props.get path settings, including root
and dotted reads such as settings.applied.revision, behind its existing caller
and connection checks. Evidence does not assert a broker participant record or
frame presentation. settings_cache supplies the shared host's persistence
identity, cache fault and fallback diagnostics. The persistence identity is
historical and must match current applied evidence to describe that capture.
Quoin uses the shared worker's service-owned persistent cache while its existing
Bus supervisor starts; terminal refusal preserves offline resources, and only
explicit initial rejection permits a configured scene service override.
compd.truth.slots.<id>.decided independently reads the
geometry owner's decided size, or null when absent, without falling back to
committed client geometry. The native desktop geometry gate compares these
readbacks with real client configures and captured pixels.
DTO field additions require explicit classification in the shared comparison;
source/provenance exclusions are deliberate rather than wildcard omissions.
Shared fallback preparation tries validated retained bootstrap data, then local
cache, then current embedded defaults. The host supplies readiness checks for
every reference used by its context and shell capability on its worker. A fenced
completion produces the same immutable UI stage; it never seeds the authority
reducer or mutation fences. Installed data has explicit Current, Retained,
Cached, LastGood and Embedded labels. Identical fresh authority data can promote
cache/embedded evidence without a redundant swap.
Optional settings/cache stores applied authority/retained generations in an
existing isolated absolute cache directory. Its versioned JSON envelope binds
instance/profile, context, shell capability, interpretation and content digest.
Bounded directory-relative regular-file reads refuse symlinks in every path
component and cannot block on FIFOs. Before reuse, complete projections must
match current compilation of their authored inputs; unavailable old pinned
package sources and compiler drift are refused visibly. Exact JSON float
round trips preserve these checks. Digests detect corruption, not file origin;
valid cache data still supplies no authority evidence. A stable locked serial
writer fences superseded applied-generation saves, permits retry of the latest
failed attempt and reports ambiguous post-rename failure. All I/O belongs off
the UI loop. Cache failure preserves usable applied data. Real font/asset loading,
artifact preparation and GUI fallback/first-map timing remain pending.
Shell panel preferences
Quoin prepares appearance and the global panel policy together, then installs
both on the compositor loop before acknowledging the settings revision. Each
record's explicit edge selects one of four panels on every current and future
output; the record ID is provenance, independent of a connector or page name.
dock, overlay and hidden map to runtime docked, pinned and hidden modes.
The default profile owns the bottom panel in dock mode, requesting 40 logical
pixels. An edge without pages draws nothing and reserves no work area.
Thickness is requested logical pixels. Runtime edge ranges, the active page's
minimum and the output/opposite-edge budget fit the request. A bottom page with
a 52-pixel minimum therefore presents 52 pixels for the default 40-pixel
request. Panel rows include a nullable settings object with the record ID,
requested_px, fitted_px, presented_px, reserved_px and constraint names.
Output shrinkage fits the panel; regrowth recovers the request. Page selection
and registration use the existing carousel and panel model.
Settings owns the mode and thickness of each declared edge. Conflicting legacy
panel writes refuse SETTINGS_MANAGED before changing interaction state; exact
current mode or settled-size requests are no-ops. Transient show/hide and page
selection remain available. Persistent toggles cannot undock managed panels.
For managed edges Toggle changes transient visibility only while hidden; docked
or pinned modes treat it as a successful no-op. Corner-menu mode entries are
disabled while managed; activating a disabled selection is an inert action.
Removing a profile record
restores that edge's captured local mode and settled thickness. Saving a page
selection keeps those local values, so profile preferences do not leak into the
local state file. Thickness or appearance updates retain interaction holders;
an actual transition to hidden retires invalid keyboard/menu holders.
Duplicate records targeting one edge and non-empty shell.page_order lack a
runtime identity mapping and fail whole shell preparation. The authority's
schema can accept them, but Quoin reports the preparation fault and retains
the last good appearance and panel policy. Correct them through a new fenced
settings mutation. This is consumer capability validation, not a rollback of
the accepted authority revision. Empty page ordering preserves local order.
Requested maximised windows keep their owning output and follow only changes to their desired content rectangle; removal chooses an available output while retaining the original unmaximise geometry. Scene reservations are reconciled after settings activation and before frame work, including without the comp Bus port. An unchanged target does not emit another maximise configure while the client is slow to acknowledge or replace its buffer. The compositor's decided slot remains authoritative during these transitions; older legally acknowledged buffers do not undo a newer target. Existing aspect-preserving fit and inverse input mapping remain in use.
Fullscreen continues to use the whole output. Its compositor record, pending protocol intent and committed state fence work-area reconfiguration through delayed entry/exit commits. After fullscreen releases ownership, the next normal dispatch restores the latest maximised content rectangle, even if the work-area map has not changed again. Window geometry changes schedule redraw and retarget the human Wayland pointer, respecting grabs. This does not by itself prove stationary hover or scene bounds before/after rendering.
The protocol fixtures exercise actual xdg configure/ack/buffer commits using the production geometry executor. Their headless output has no renderer; native Bus delivery fixtures and protocol fixtures do not establish their composed rendered settings-to-frame behaviour. Persistent tiling, new maximise intent with no outputs, native window/input/frame evidence and VT latency acceptance remain outstanding. Current text scale changes the title font, while decoration extents continue to come from the selected style's fixed height and border.
Key bindings
The profile's keymaps holds the person's key bindings: per application
id, per action id, the chords chosen. An empty list unbinds the action; an
action not listed keeps the application's own default, which ships with the
application. The field is omitted while empty, so profiles written before it
keep their seals, and it does not affect the resolved design.
- Apply:
keymaps.<app>takes the whole map (null drops every change for that app);keymaps.<app>.<action>takes a list of chords (null returns the action to its default). Action ids are dotted: everything after the app id is the action id. - Chords:
Ctrl,AltandShift, each at most once and in any order, then one key: a name of ASCII letters and digits (ArrowLeft,F6,PageDown,B,1) or=,-,/. settingsd checks this grammar; the application checks that it knows the key. - Limits: 16 applications, 256 actions each, 4 distinct chords per action, 32 bytes per chord, 64 bytes per action id.
- Followers: the summary carries
keymaps, and each application applies its own entry when the revision changes (toolkit::Registry::rebind).
Terminal
The profile's terminal holds the term's preferences. Each default is the
term's behaviour before the group existed, except padding (8 px instead of
1 px) and the focus ring: accent at strength 0.25 gives a quiet tint from
the current theme accent. neutral remains available; accent at strength
0.5 matches the earlier half-accent ring. Each value is omitted at its
default, so older profiles keep their seals. It does not affect the resolved
design. Terminal colours are not a setting: they follow the session theme.
| Field | Type and range | Default |
|---|---|---|
terminal.font.family | string or null, 1–128 bytes, no control characters | null: appearance.mono_family |
terminal.font.size | number 6–48 | the design's UI type size |
terminal.font.weight | integer 100–900, step 100 | 400 |
terminal.padding | integer 1, 2, 4, 8 or 16 (px) | 8 |
terminal.scrollback.lines | integer 0–1000000 | 1000 |
terminal.scroll.speed | integer 10–200 (rows per wheel line, scaled by speed/40; touchpad travel in cell heights at that scale) | 40 |
terminal.border.width | integer 1–4 (px) | 1 |
terminal.cursor.shape | block, underline or bar | underline |
terminal.cursor.blink | boolean | false |
terminal.focus.ring | neutral or accent | accent |
terminal.focus.strength | number 0–1, used with accent | 0.25 |
terminal.mouse.copy_on_select | boolean | false |
terminal.env.term | [a-z0-9-]{1,64}; new tabs and panes | xterm-256color |
Apply and reset take each path, or terminal for the whole group. The
summary carries terminal when any value is set.
The profile's email holds the email app's JMAP accounts and mail options.
It is not secret and cannot hold a secret. The snapshot and summary are
retained on the Bus for any client to read, so an account has no password
field and every record in the group refuses unknown keys: an account carrying
password is refused with validation_failed, and nothing is written.
Passwords live only in the email app's private secrets file, set through its
own write-only verb. The group is omitted at its defaults, so older profiles
keep their seals and the store format stays 3. It does not affect the
resolved design.
| Field | Type and range | Default |
|---|---|---|
email.accounts | list of accounts, at most 64, ids unique; replaced whole, in tree order | [] |
email.default_account | an id in email.accounts, or null | null: the first enabled account |
email.mail.page_size | integer 10–500 | 100 |
email.mail.mark_read_on_open | boolean | true |
email.mail.confirm_delete | boolean | true |
email.mail.quote_on_reply | boolean: whether a plain Reply quotes the original | true |
email.mail.reply_position | "above" or "below": where the reply text goes in a quoted reply | "above" |
Each account is a record of exactly these keys:
| Key | Type and range | Default |
|---|---|---|
id | [a-z0-9-]{1,64}, stable; the writer mints acct- and 12 hex digits | required |
name | the tree label: 1–128 bytes, no control characters, no surrounding space | required |
url | the JMAP base: https://host[:port][/path], the host a DNS name, IPv4 or bracketed IPv6 address; no userinfo (user:secret@, so no password rides in a URL), query, fragment, backslash, trailing slash or space; at most 2048 bytes | required |
email | the login and default From: one @ with text on both sides, no space; at most 254 bytes | required |
ca_path | an absolute path to a PEM bundle, or null for the system roots | null |
enabled | boolean | true |
signature | plain text appended to new drafts: at most 4096 characters, no control characters but newline and tab | "" |
A refused account is named by position: email.accounts[2].url. The default
account must name an account, so removing it from the list needs
email.default_account changed or reset in the same batch. Apply and reset
take each path, or email for the whole group (null resets it too);
email.mail alone is not a path. The summary carries email when any value
is set; settings::email::from_snapshot and from_summary read it.
Topics and storage
Publish retained settingsd.desktop.changed.<profile> (the snapshot in schema
1), then retained settingsd.desktop.compact.<profile> (schema 2), then
retained settingsd.desktop.summary.<profile> (its Summary), all owned by
the registered settingsd service. The unscoped base names are reserved too.
A revision is published once all three topics are; a failure retries all three. All clients may
subscribe; ordinary publishers/clearers cannot replace its canonical state.
Noded strips inner routing headers and stamps broker_service independently of
caller input. The publisher repopulates retained state at startup and reconnect,
even without a settings mutation. Generic props-prefix ownership is deliberately
not used: embedding a profile in that prefix changes its matched service owner.
Failed publication starts a single pending retry job, with exponential backoff
from 250 ms capped at 30 seconds. Success removes the job and every timer.
Reconnection or a new revision restarts the backoff. There is no idle heartbeat;
publication_pending remains visible throughout a failed pending job.
Following from an app. An app on a plain Bus connection (no owner
stamps) subscribes to the profile's summary topic, treats a delivery only as
a hint, and reads the look with settings.get's summary view, never the
complete snapshot: settings::follow::Follower keeps one read in
flight (hints during it fold into one more), ignores an answer older than one
already seen (same incarnation, lower or equal revision), adopts the instance
from wrong_target once (never the profile), and reports settingsd missing so
the app keeps its theme file. toolkit::session_look turns the profile's
appearance into the app's session theme; an appearance naming its own design
package (source) or a name the app does not know keeps the theme file's.
Default root: resolved MixOS Etc directory / settings/<profile>/. Explicit
test roots are supported. One retained writer.lock inode provides a process
flock; it is never removed on normal exit. The root must be an owned directory
not writable by other users. desktop.conf.mix holds schema, binding,
incarnation, revisions, authored desktop, pinned package source, receipts and
a verified BLAKE3 content digest and effective interpretation digest. These
digests detect corruption and interpretation drift; they are not an
authentication signature. A synced
desktop.previous.conf.mix is written before replacing the accepted generation.
Files are private, never evaluated as Mix source.
Reads, lock acquisition, replacements and syncs are relative to the same held
directory descriptor. Replacing/detaching the profile directory or writer-lock
inode fences the existing writer. Newly created directory entries are synced in
their parents before initial acceptance.
Replacement uses exclusive temporary create, write, file sync, directory-relative rename and directory sync. Pre-rename errors leave accepted state unchanged. Post-rename sync errors report outcome_unknown and fence further mutations until recovery; they cannot be reported as a known uncommitted failure. Corrupt input is preserved before a valid backup is restored with a new incarnation. Newer schemas or mismatched target bindings are not overwritten by a fallback. Corruption evidence is hard-linked and synced before the atomic replacement; recovery never removes the primary before its replacement is ready. A missing established primary fails visibly even with a backup; automatic recovery applies only to a present corrupt primary. I/O errors, strict shape changes and intact documents requiring a changed compiler interpretation fail without an automatic backup rollback.
settingsd init --instance example explicitly creates a profile and refuses
existing accepted/backup data. settingsd serve --instance example never
materialises defaults for a missing established store. Plain settingsd seed --instance example validates an existing profile without rewriting valid state;
it refuses a wholly missing directory. First installation explicitly uses
settingsd seed --allow-create --instance example as the service account.
A retained writer.lock is establishment evidence: even --allow-create refuses
a missing established primary without a backup. Existing-store
corruption uses the same validated, evidence-preserving recovery as serve.
The unit validates with plain seed before serving, binds the instance to the machine hostname, and
the session target wants/upholds it. Settings readiness never blocks application
or compositor startup. The image provisions the authority account's writable
MixOS settings parent and seeds once explicitly. Automatic unit startup never
uses --allow-create; missing mounts/directories fail visibly rather than resetting
preferences. Isolated native image adoption still needs runtime evidence.
Validation and remaining scope
Machine fixtures: authority.spec.mix. Rust tests cover
precision, patches, projection round-trip, writer locking, changed/no-op receipts,
lost replies, conflicts, digest reuse, eviction, backup restore and future schemas.
tests/settings/authority_test.mix runs real ABP publication, unrelated operator
mutation, forged publish/clear refusal, authority restart and cold broker restart
against isolated noded instances. Broker loss keeps the authority alive and its
writer exclusive; the shared consumer reconnects through the same client and
the authority repopulates the empty retained broker without a semantic edit.
The broker-restart fixture also starts a headless cold consumer from persisted
cache while offline, then promotes it to current with no redundant stage after
fresh ABP readback. Resource readiness is a fixture inventory, not GUI proof.
Another native fixture keeps an authority registered but deliberately withholds
read replies, exercising the combined bootstrap deadline and expired work.
OS process lifecycle here belongs to the test fixture; application calls
and observations remain native ABP.
This slice does not claim GUI propagation, renderer acknowledgements, presentation, GUI offline-fallback/first-map timing, candidate import/watch, immutable artifacts, full field provenance, named-profile management, compatibility, scheduled policy, preview, routed observation or replication. Every deferred feature must extend the same authority and shared contracts, with appropriate contract versions and migration. Native VT/image presentation and latency gates remain outstanding.