0008 — Config layout v2: single-file JSON5 with profiles

Context

The v1 config is a single ~/.config/dashfloat/config.json (base_url, model, api_key) with per-field DASHFLOAT_* env overrides. It describes exactly one endpoint with one credential and no names. Two problems:

  1. One provider, several subscriptions. Terence runs a work Codex subscription and a personal one. No harness models this natively; users juggle env vars or config swaps. Profiles — named per-subscription setups, each pairing an endpoint, a credential, its own settings, and its own model menu — make the same provider usable twice in one config, first-class.
  2. Unnamed models. A fleet of model ids is unreadable in a status bar. Octofriend's proven layout names every models-list entry (nickname, model, context, per-model extras) and the files are hand-edited JSON5: unquoted keys, single quotes, trailing commas, // comments.

The reference lives on this machine (~/.config/octofriend/, inspected for structure only; no values were copied). Two layout iterations on this ADR landed on the consolidated shape below: one config file, chosen over a keys/main file pair because profiles (not files) are the organizing concept — a credential is an attribute of a subscription, so it lives inside its profile. The file is settings.json5 (Terence's call over mirroring octofriend.json5's filename); the dialect is octofriend's JSON5.

Decision

File layout

One file: ~/.config/dashfloat/settings.json5 (XDG_CONFIG_HOME honored via :filename.basedir/1) — everything in it, including credentials, so the file is created 0600 and repaired to 0600 on load. No keys.json5, no oauth.json5: OAuth storage returns if an OAuth flow is ever built (footgun-free, because the loader described here never reads such a file). JSON5 throughout; hand-editing is the primary workflow (a leading UTF-8 BOM is stripped before decoding — editors add one silently).

(Reversed by the keys-split amendment: credentials live in a sibling keys.json5, settings.json5 carries no credential material, and a reserved oauth.json5 exists by name. The profiles/schema/selection machinery below stands unchanged unless the amendment says otherwise.)

JSON5 parsing (verify-first outcome)

Chosen: hex json5 0.4.1 (MIT, leex/yecc + combine parser), default Combine backend — the spec-rigorous one. Verified against octofriend-shaped input before adoption: unquoted keys, single quotes, trailing commas, // and /* */ comments, single-line maps, duplicate keys (last wins), hex/+/leading-dot numbers all behave; NaN/Infinity literals are rejected and escape sequences (\n, \u0041) are not translated — both fine: the schema's values (URLs, ids, nicknames, keys) are plain strings and octofriend files use no escapes. Decode errors return {:error, reason} whose text never contains input content — safe next to secrets. A number literal the library cannot represent (e.g. 1e10000) raises Decimal.Error whose message quotes the literal; the wrapper rescues it into the same fixed error string, so neither the crash nor the leak can reach startup. The README's "slow" warning is irrelevant here: config decodes once at startup.

Number caveat: the library returns every number as Decimal for precision. DashFloat.Config.JSON5 (thin wrapper) normalizes Decimal to integers/floats so consumers never see it, and rewrites parser errors to "invalid JSON5" (the raw reason is position-only noise).

Dependency override: json5 pins decimal "~> 2.0.0" — which resolves to exactly 2.0.0, a version carrying two MEDIUM DoS advisories (EEF-CVE-2026-32686, EEF-CVE-2026-97853; unbounded exponent/allocation in Decimal.new/round). DashFloat overrides to decimal "~> 3.2" (both advisories fixed in 3.x). Verified: json5 0.4.1 compiles clean against decimal 3.2.0 and its decode output is identical across the probe matrix. If json5 ever bumps its pin, drop the override. Exposure would in any case be theoretical — the only JSON5 input is the user's own config file — but a flagged dependency in the lock is a cost with no offsetting benefit.

Rejected: lumis_wasm_json5 (Lumis-ecosystem tree-sitter WASM, not a general-purpose parser); a vendored/hand-rolled parser or JSON-with-comments preprocessor (the issue forbids silently hand-rolling one; an upstream-tested parser is strictly better); Jason (no JSON5 support — the whole point).

Schema

settings.json5 (placeholder values shown):

{
  configVersion: 1,
  yourName: 'Terence',           // optional, reserved (no consumer yet)
  profiles: [
    {
      name: 'work',
      baseUrl: 'https://provider.example/v1',
      apiKey: 'PLACEHOLDER',     // the credential lives in its profile
      settings: { defaultModel: 'Flash' },   // optional, open map
      models: [                  // this profile's menu, in order
        { nickname: 'Sol', model: 'glm-5.3', context: 524288 },
        { nickname: 'Flash', model: 'glm-5.3-flash', context: 196608 },
      ],
    },
    {                            // same provider twice: the differentiator
      name: 'personal',
      baseUrl: 'https://provider.example/v1',
      apiKey: 'PLACEHOLDER',
      models: [{ nickname: 'Sol', model: 'glm-5.3', context: 262144 }],
    },
  ],
}

Model addressing and selection (startup-time only)

A model is addressed by nickname + profile — Sol (work) — matching the status-bar vocabulary. Selection at startup:

  1. Active profile = --profile <name> CLI flag, else DASHFLOAT_PROFILE env (non-blank), else the first profiles entry. A flag/env profile that matches no defined profile — including when no profiles exist at all — warns and is used anyway: the effective fields can still be completed by other env vars. Duplicate profile names warn and the first definition wins.
  2. Model menu = the active profile's models, in file order.
  3. Selected model = the entry named by --model <nickname>, else the profile's settings.defaultModel, else the first menu entry. Named lookup is two-pass — nickname first, then model id — so when entry A's id collides with entry B's nickname, B wins (nicknames are the user-facing address). A miss at either named step warns and falls back to the profile default; a present-but-unusable defaultModel (a number, a blank) warns too; a --model against a model-less profile warns that the flag was discarded.

DashFloat.CLI.parse/1 turns argv into [profile: name, model: name] — --profile work, --profile=work, --model Sol, --model=Sol; a switch without a value or an unrecognized argument warns and is ignored, and the warning carries the argument's name only — --api-key=<secret> is exactly the typo this parser sits next to, and values never reach logs. The Application parses argv once at startup and hands the result to DashFloat.Config.load/1; the UI and agent remain untouched by argv.

Out of scope, as named future slices: runtime in-TUI profile/model switching (the schema and selection semantics leave room for it), any behavioral settings, and an OAuth flow (whose token storage would arrive as a profile attribute in this same file).

Effective provider fields and precedence

The DashFloat.Config.t() struct keeps its consumer-facing shape (base_url, model, api_key, context_tokens) and gains nickname and profile. Resolution per field, file < env exactly as v1:

Field File source (overridden by env) Env var
base_url active profile's baseUrl DASHFLOAT_BASE_URL
model selected entry's model DASHFLOAT_MODEL
api_key active profile's apiKey DASHFLOAT_API_KEY
context_tokens selected entry's context DASHFLOAT_CONTEXT_TOKENS

(The api_key row's file source is keys[profile.name] since the keys-split amendment; env precedence is unchanged.)

A blank env var counts as absent (v1 rule). nickname has no env override — it is file vocabulary. Env-only configs (no config file at all) keep working, as in v1. Missing required fields after the merge yield {:error, message} naming only field names — never values (v1 rule, unchanged); a profile without an apiKey therefore surfaces as api_key in that message.

Security posture

Migration (one-time, automatic, idempotent)

Migration.maybe_migrate/1 runs as the first step of every load:

Status-bar label contract

DashFloat.TUI.State.provider_label/1 replaces provider_model/1 as the status-bar field (ADR 0006's model slot): "nickname (profile)" — e.g. Sol (work) — falling back to the bare nickname when no profile resolved, then the model id, then — (view fallback unchanged). The context-usage denominator still reads context_tokens off the provider.

Alternatives considered

Consequences


Amendment (2026-10-25, issue #29): credentials split back out

Status: Accepted. Reverses the file-layout and security-posture sections above (the "one file holds the secrets" decision, and the keys.json5 alternative rejection). Everything else — profiles, the schema's non-credential fields, model selection, precedence (file < env), the never-echo-values rule, the JSON5 dialect choice — stands.

Why the reversal

The single-file layout made every copy of settings.json5 a secrets-bearing artifact: the 2026-10-25 backup-perms incident (issue #28) was exactly this failure mode — a legacy file that had held a credential was copied world-readable. Hardening every copy is whack-a-mole; a settings file that cannot hold a credential makes every backup, example, dotfile sync, and export structurally incapable of leaking one. Nothing to remember, nothing to redact. Owner decision same day, after the PA's recommendation: "continue to have a keys.json5 and an oauth.json5 so we can be secure."

Decision

Layout in ~/.config/dashfloat/ (XDG_CONFIG_HOME honored):

File Contents Mode
settings.json5 configVersion, yourName, profiles (name, baseUrl, settings, models) — NO credential material 0600 anyway (free)
keys.json5 API keys keyed by PROFILE NAME — the only home for keys 0600 always: create + repair
oauth.json5 reserved for a future OAuth flow; absence-tolerant, never read by this loader 0600 when present (posture-only)

configVersion 2 is the split marker. The effective API key is DASHFLOAT_API_KEY > keys[active profile name] — precedence otherwise unchanged; a profile whose key is missing yields the existing field-names-only error.

Stray inline credentials: tolerate-and-warn, value IGNORED — never a fallback. A stray apiKey in settings.json5 warns: settings.json5: profile "<name>": apiKey is ignored — API keys live in keys.json5, keyed by profile name. Falling back to it would make the structural guarantee cosmetic.

Backups inherit the source's sensitivity — the principle: any backup of a file that ever held credentials is 0600. config.json.bak, a pre-split settings.json5.bak, the legacy file before its rename: all 0600 wherever they live (issue #28's rider, generalized).

Migration v2 (idempotent, atomic tmp+rename, both paths)

  1. Legacy (no non-empty settings.json5, config.json present): renders the de-secreted settings + keys.json5 %{'default': <key>}, writes keys BEFORE settings (the credential reaches its new home before the marker file exists), then renames the legacy file to config.json.bak — all #28 rider behavior verbatim (never clobber an existing .bak, tighten it and the legacy file, symlink-safe chmod, non-regular files left alone).
  2. In-place split (a settings.json5 older than v2 has inline apiKey fields): copy the file byte-exact to settings.json5.bak (0600 — a COPY, not a rename: a rename-first crash window would leave no settings.json5 and nothing to re-trigger; a copy leaves the original to converge on re-run), write keys.json5 from the collected keys, then rewrite settings de-secreted at configVersion 2. A v1 file with no inline credential field is byte-untouched — rewriting it churns user formatting for nothing.

Split ordering is crash-convergent: renders (pure) run before any write; backup → keys → de-secreted rewrite; the credential always has its new home before the old one is emptied. A failed backup copy is a warning, not an error (the keys still land first); a failed keys write aborts before the settings rewrite.

Merge rules: a pre-existing keys.json5 WINS over collected keys (it is the user's explicit new home; the old values survive in the backup) — never rewritten. A malformed existing keys.json5 is a migration error: never destroy unreadable data. Symlinked or non-regular settings.json5/keys.json5 skip the split with a warning — tmp+rename would sever the link; split by hand.

General JSON5 renderer

DashFloat.Config.JSON5.render/1 renders a whole decoded map (sorted keys, bare identifier keys, 2-space indent, trailing commas, quote_value semantics), so tolerated content — yourName, unknown top-level keys, models extras — survives a rewrite losslessly; only formatting is lost. An unrepresentable string (a backslash, or both quote kinds — the Combine backend processes no escapes) fails the render naming the value's PATH (e.g. profiles[0].apiKey), never its content. Bare keys also dodge the parser's ECMA reserved words (default: as a bare key is unreadable — verified against the dep, and "default" is a keys.json5 key). The legacy path keeps its up-front per-field representability checks so its field-name error survives.

Consequences of the amendment