On this page

Configuration architecture

Configuration reference and wiring audit

The exact shoal-config schema, layer and environment precedence, validation behavior, host consumers, and settings that are currently inert.

Status
Source-derived reference with wiring gaps
For
Host, configuration, prompt, and integration contributors
On this page
  1. Configuration dataflow
  2. Discovery and precedence
    1. Merge semantics
  3. Complete typed schema
    1. Default object
  4. Schema checking and diagnostics
    1. Semantic validation
  5. Environment override table
  6. Host wiring matrix
    1. Interactive assembly
    2. Noninteractive assembly
    3. In-language snapshot
  7. Rich prompt configuration is a separate system
  8. Reef configuration is a separate system
  9. Failure and observability model
  10. Safe extension procedure
  11. Current risks and ranked work
  12. Behavioral invariants worth preserving
  13. Test map
  14. Source navigation

Configuration is not one thing in Shoal. The repository currently has a typed core configuration, a richer prompt-specific configuration loader, Reef’s own manifest parser, adapter manifests, and several environment-only controls. This chapter describes the typed shoal-config path exactly and then shows where another subsystem deliberately—or accidentally—takes a different path.

The most important rule is therefore:

A field being accepted by shoal-config does not by itself prove that a running host consumes it.

The source of truth for schema and merging is shoal-config. The source of truth for local-shell wiring is shoal/src/main.rs and shoal/src/repl.rs.

Configuration dataflow🔗

flowchart LR
accTitle: Configuration dataflow
accDescr: Shows the components and relationships described in Configuration dataflow.
  Defaults["Config::default()"] --> Merge["deep TOML merge"]
  System["/etc/shoal/shoal.toml"] --> Merge
  User["$XDG_CONFIG_HOME/shoal/shoal.toml"] --> Merge
  Project["nearest ancestor .shoal.toml"] --> Merge
  Merge --> Env["explicit SHOAL_* overrides"]
  Env --> NoColor["NO_COLOR, applied last"]
  NoColor --> Deserialize["deserialize Config"]
  Deserialize --> Validate["semantic validation"]
  Validate --> Loaded["Loaded { config, warnings, sources }"]
  Loaded --> Local["shoal host consumers"]
  Loaded --> Snapshot["language config snapshot"]
  User -. "separately reparsed" .-> Prompt["shoal-prompt loader"]
  User -. "separately reparsed" .-> Reef["shoal-reef user scope"]

There are three different kinds of output:

  • Loaded.config is the complete typed value after defaults, files, environment, and validation;
  • Loaded.warnings contains unknown-key diagnostics collected per file layer;
  • Loaded.sources lists only file paths that existed and were successfully merged, from lowest to highest precedence. Environment is not represented as a source path.

Malformed TOML, filesystem errors other than not-found, type mismatches, malformed environment values, unsupported versions, and invalid semantic values are hard errors. Unknown keys are warnings; they are not silently treated as valid future fields.

Discovery and precedence🔗

LoadOptions::discover(cwd) proposes these layers, in ascending precedence:

RankLayerLocation or ruleMissing behavior
0built-in defaultsConfig::default()always present
1system/etc/shoal/shoal.tomlskipped
2user$XDG_CONFIG_HOME/shoal/shoal.toml; otherwise $HOME/.config/shoal/shoal.tomlskipped
3projectfirst .shoal.toml found from cwd upwardomitted when none exists
4environmentexplicit static SHOAL_* tableonly recognized names participate
5color vetopresence of NO_COLORforces render.color = false

Project discovery stops at the first matching ancestor. It does not combine every ancestor .shoal.toml, and it does not stop at a Git root or home-directory boundary. For a working directory /a/b/c, the search is /a/b/c/.shoal.toml, /a/b/.shoal.toml, /a/.shoal.toml, then /.shoal.toml. This differs from Reef’s multi-scope tool chain and from the prompt loader’s current-directory-only project prompt layer.

Merge semantics🔗

Tables merge recursively, key by key. A later scalar or array replaces the earlier value in full. There is no deletion marker and no array concatenation.

# system
[history]
enabled = false
max_entries = 5000

# user
[history]
max_entries = 20000

The result keeps history.enabled = false and sets history.max_entries = 20000. In contrast, a later history.ignore = ["secret *"] replaces the whole earlier ignore array.

Each file is schema-checked before merging. A wrong type in a lower-precedence file is therefore an error even when a later layer would have replaced it. This produces local, source-attributed errors instead of permitting a malformed layer to be hidden.

Complete typed schema🔗

The following is the exact public Config tree. “Default” means the value produced by Config::default() before any layer is read.

PathTypeDefaultIntended meaning
versionunsigned integer1schema version; only version 1 is accepted
prompt.templatestring"{cwd}"legacy/simple prompt template, migrated by the rich loader
history.enabledbooleantrueenable interactive persistent line history
history.max_entriesunsigned integer10000file-backed history capacity
history.pathoptional path stringabsenthost-selected state path when absent
history.dedupbooleantrueexclude an entry identical to its immediate predecessor
history.ignorestring arrayemptyhost-matched exclusion patterns
history.ignore_spacebooleantrueexclude lines beginning with a space
render.widthoptional unsigned integerabsentrequested output width
render.colorbooleantrueANSI color permission
render.pagingstring enum"never"never or interactive auto paging
render.pageroptional stringabsentexplicit pager command
render.echooptional string enumabsentquiet, commands, or all
editor.modestring enum"emacs"emacs or vi Reedline edit mode
editor.bracketed_pastebooleantrueenable bracketed-paste handling
editor.keybindingsstring mapemptychord to action overrides
kernel.enabledbooleantrueintended resident-kernel gate
kernel.sessionstring"default"intended named kernel session
adapters.dirspath-string arrayemptyextra adapter directories, in order
journal.enabledbooleantrueintended journal gate
journal.state_diroptional path stringabsentintended journal state root
leash.policyoptional path stringabsentintended Leash policy file
init.filespath-string arrayemptyinteractive startup scripts, in order
completion.fuzzybooleantrueenable fuzzy ranking
completion.case_insensitivebooleantruefold case while matching
completion.max_resultsunsigned integer100candidate cap
completion.menubooleantruemenu selection versus quick cycling
reef.toolsopaque TOML tableemptyuser-scope tool declarations
reef.runnersopaque TOML tableemptyuser-scope extension runners
reef.options.hermeticbooleanfalserequest a synthesized-only child PATH
aliasesstring mapemptystartup alias name to expansion
envstring mapemptystartup environment name to value

“Opaque” is an intentional ownership boundary: shoal-config verifies that the value is a table but does not inspect its user-chosen keys or entry shapes. shoal-reef owns that grammar and separately parses the raw user file. This lets Reef evolve without duplicating all of its schema in the core configuration crate, at the cost of two parsing paths that must remain coordinated.

Default object🔗

An equivalent abbreviated default file is:

version = 1

[prompt]
template = "{cwd}"

[history]
enabled = true
max_entries = 10000
dedup = true
ignore = []
ignore_space = true

[render]
color = true
paging = "never"

[editor]
mode = "emacs"
bracketed_paste = true

[editor.keybindings]

[kernel]
enabled = true
session = "default"

[adapters]
dirs = []

[journal]
enabled = true

[leash]

[init]
files = []

[completion]
fuzzy = true
case_insensitive = true
max_results = 100
menu = true

[reef.tools]
[reef.runners]
[reef.options]
hermetic = false

[aliases]
[env]

Optional values such as history path, pager, echo, journal state directory, and Leash policy are absent rather than serialized as TOML null, which does not exist.

Schema checking and diagnostics🔗

The declarative schema::ROOT tree describes fixed tables, opaque tables, booleans, unsigned integers, strings, string arrays, and string maps. One recursive walk does both unknown-key detection and type checking.

Input defectResultDetail
unknown fixed-table keywarningfull dotted path and optional near-name suggestion
user-defined key inside aliases, env, or editor.keybindingsacceptedvalues must be strings
user-defined key inside reef.tools or reef.runnersacceptedonly containing table is checked
scalar where table expectedhard errorpath, expected type, found TOML type
negative integer for unsigned fieldhard errorreported as expected non-negative integer
non-string array memberhard errorindexed path such as history.ignore[2]
malformed TOMLhard errorsource path plus parser message
unreadable existing filehard errorsource path plus I/O message

Suggestions use Levenshtein distance with a length-sensitive threshold: distance 1 for keys up to three characters, 2 for four through six, and 3 for longer keys. A wildly unrelated spelling gets no speculative recommendation.

/work/.shoal.toml: unknown config key `hsitory` (did you mean `history`?)

Unknown keys remain in the merged TOML value, but Serde ignores them when materializing Config. The warning is therefore the only evidence that a setting had no typed effect. Hosts must display the warnings; suppressing them turns a diagnosed typo back into a silent one.

Semantic validation🔗

After deserialization, validate applies constraints that types alone cannot express:

  • version must equal 1;
  • history.max_entries and completion.max_results must be greater than zero;
  • editor.mode must be emacs or vi;
  • render.paging must be never or auto;
  • a present render.echo must be quiet, commands, or all;
  • alias names may not be empty or contain whitespace;
  • environment names may not be empty;
  • history ignore patterns may not be empty strings.

Environment names are not validated against POSIX identifier syntax. Alias validation likewise does not require a language identifier. The host later synthesizes source statements from these maps, so a name can pass configuration validation but fail or be skipped during binding injection. This should be treated as a schema/consumer mismatch, not as a supported exotic-name feature.

Environment override table🔗

Environment overrides are an explicit list, not an automatic conversion from field names. This avoids ambiguity around underscores in names such as max_entries and bracketed_paste.

Environment variableTyped pathCoercion
SHOAL_PROMPT_TEMPLATEprompt.templatestring
SHOAL_PROMPTprompt.templatestring; legacy alias
SHOAL_HISTORY_ENABLEDhistory.enabledboolean
SHOAL_HISTORYhistory.enabledboolean; legacy alias
SHOAL_HISTORY_MAX_ENTRIEShistory.max_entriesunsigned integer
SHOAL_HISTORY_FILEhistory.pathstring/path
SHOAL_HISTORY_DEDUPhistory.dedupboolean
SHOAL_RENDER_COLORrender.colorboolean
SHOAL_RENDER_WIDTHrender.widthunsigned integer
SHOAL_RENDER_PAGINGrender.pagingstring
SHOAL_RENDER_PAGERrender.pagerstring
SHOAL_RENDER_ECHOrender.echostring
SHOAL_EDITOR_MODEeditor.modestring
SHOAL_EDITOR_BRACKETED_PASTEeditor.bracketed_pasteboolean
SHOAL_KERNEL_ENABLEDkernel.enabledboolean
SHOAL_KERNELkernel.enabledboolean; legacy alias
SHOAL_KERNEL_SESSIONkernel.sessionstring
SHOAL_JOURNAL_ENABLEDjournal.enabledboolean
SHOAL_LEASH_POLICYleash.policystring/path
SHOAL_COMPLETION_FUZZYcompletion.fuzzyboolean
SHOAL_COMPLETION_CASE_INSENSITIVEcompletion.case_insensitiveboolean
SHOAL_COMPLETION_MAX_RESULTScompletion.max_resultsunsigned integer
SHOAL_COMPLETION_MENUcompletion.menuboolean

Boolean values accept 1, three case variants of true, yes, or on; false accepts 0, three case variants of false, no, or off. Other capitalization such as YeS is rejected. Integer values must parse as non-negative signed-64-bit TOML integers.

NO_COLOR is special. Its presence, even with an empty or false-looking value, forces render.color = false. It runs after every named override, so SHOAL_RENDER_COLOR=true cannot undo it.

There are no environment overrides for history.ignore, history.ignore_space, keybindings, adapter directories, journal state directory, init files, Reef declarations/options, aliases, or the general environment map.

Host wiring matrix🔗

This table distinguishes accepted schema from observed local-host behavior. “Active” means an ordinary shoal path reads the typed field and changes behavior. “Parallel path” means the feature exists but the authoritative consumer reparses another representation. “Inert” means the local host currently accepts and snapshots the field without applying its intended behavior.

Setting areaInteractive shell-c / script / stdinStatus and evidence
render.coloractiveactiveapplied before diagnostics and rendering
render.paging, pageractive for final REPL resultnot usedpager is explicitly interactive-only
render.echoactiveactivehost-specific fallback: all interactive, quiet noninteractive
render.widthnot consumednot consumedinert typed field; renderers use terminal/context width
all history.*activeno historywraps Reedline history with exclusions/dedup
editor.mode, bracketed_paste, keybindingsactivenot applicableedit-mode and binding builder consume them
adapters.dirsactiveactivebundled adapters load first, configured directories later
init.filesactive, in orderdeliberately skippedstartup scripts are REPL-only
all completion.*activenot applicablecontrols matcher, cap, and Reedline menu behavior
aliases, envactiveactivesynthesized into evaluator bindings/environment
resolved config snapshotactiveactiveexposed to language config methods
prompt.templateparallel pathnot applicablerich prompt loader independently reads and migrates it
reef.*parallel pathparallel pathReef reparses raw user config with its own schema
kernel.enabled, kernel.sessionnot consumednot consumedinert in local shoal; local execution embeds an evaluator
journal.enabled, journal.state_dirnot honoredno journalinert; REPL opens default journal unconditionally
leash.policynot consumednot consumedinert security field; local host does not load/set policy
flowchart TD
accTitle: Host wiring matrix
accDescr: Shows the components and relationships described in Host wiring matrix.
  Config["typed Config"] --> Render["color / pager / echo"]
  Config --> Editor["history / editing / completion"]
  Config --> Bindings["aliases / env / ConfigSnapshot"]
  Config --> Adapters["adapter search dirs"]
  Config --> Init["interactive init files"]
  Config -. "not wired" .-> Kernel["resident kernel settings"]
  Config -. "not wired" .-> Journal["journal gate / state dir"]
  Config -. "not wired" .-> Leash["policy load"]
  Config -. "not wired" .-> Width["render width"]
  Files["raw config files"] --> Prompt["independent rich prompt loader"]
  Files --> Reef["independent Reef parser"]

Interactive assembly🔗

The REPL loads configuration at startup, applies color, prints warnings, sets echo, installs the config snapshot, seeds aliases and environment, adds user Reef config, opens journal/frecency, loads adapters, evaluates init files, then builds completion, keybindings, history, and prompt. A change on disk is not automatically reloaded into an existing session.

The journal behavior is especially important: interactive startup currently opens the default state directory regardless of journal.enabled and ignores journal.state_dir. Noninteractive evaluation does not install a journal. The schema therefore promises configurability that the host does not yet implement.

Noninteractive assembly🔗

Noninteractive execution still loads the layered configuration and consumes color, echo, aliases, environment, config snapshot, user Reef path, and adapter directories. It deliberately omits editor, completion, history, prompt, init files, and journal. The default echo policy is quiet, not the REPL’s all.

In-language snapshot🔗

The host serializes the complete typed Config to JSON and converts that JSON into Shoal Value data for config.get and config.all. This is a startup snapshot, not a live facade over files or environment.

That snapshot exposes inert fields too. A script can observe config.journal.enabled = false while the interactive host has already opened its journal, or see a Leash path that was never loaded. The value accurately describes the resolved schema object but not necessarily effective host behavior. Internal diagnostics should eventually expose both configured and effective state.

Child evaluators are a second consistency problem. Several child construction paths do not inherit the parent’s config snapshot at all; others also omit Reef, event bus, or Leash state. See Evaluator state and host injection and Security and authority propagation.

Rich prompt configuration is a separate system🔗

The prompt renderer does not consume the typed Prompt { template } as its complete schema. Its host loader assembles these layers:

  1. /etc/shoal/shoal.toml’s [prompt] table;
  2. user shoal.toml’s [prompt] table;
  3. user prompt.toml as a prompt-root document;
  4. only cwd/.shoal.toml’s [prompt] table, without walking ancestors;
  5. prompt-specific environment overrides.

The rich schema supports left/right/continuation formats, transient mode, rendering budgets, palette and module settings. When format is absent, the loader can migrate the legacy prompt.template; if both are present, the rich format wins. Legacy {cwd} becomes the rich $directory module reference.

This means the following can all be true at once:

  • shoal-config chooses an ancestor project .shoal.toml;
  • the prompt loader ignores it because it is not literally in cwd;
  • the evaluator’s config snapshot reports its typed prompt.template;
  • the displayed prompt comes from prompt.toml or a rich prompt environment override.

The dedicated Prompt, editor, completion, and LSP internals chapter maps that path in detail.

Reef configuration is a separate system🔗

The typed Reef tables preserve and expose user declarations, but evaluator integration passes the user shoal.toml path to Reef, which reparses [reef] according to Reef’s richer grammar. Project tool scopes come from .reef.toml and foreign manifests, not from the typed project config object.

Consequences:

  • type success in shoal-config proves only that reef.tools and reef.runners are tables;
  • entry errors appear later from Reef parsing/resolution;
  • a typed deep merge is not necessarily the same as Reef scope precedence;
  • Config.reef.options.hermetic is not simply copied into an evaluator field—the Reef scope chain determines effective hermetic behavior.

See Reef resolution and executable views for the authoritative scope and locking behavior.

Failure and observability model🔗

The loader does not log. Returning warnings and source paths keeps it usable in CLI, tests, and future kernel hosts, but every host must choose how and when to render them. shoal doctor should report discovered sources, effective values, and consumer/wiring status rather than only proving that deserialization succeeded.

Safe extension procedure🔗

Adding a setting is a cross-layer change, not just a struct field:

  1. add the typed field and a usable default in shoal-config/src/lib.rs;
  2. add its expected shape to schema::ROOT;
  3. add semantic validation if the Rust type is too broad;
  4. add an explicit environment mapping only if environment control is intended;
  5. add loader tests for default round-trip, layer merge, bad type, and validation;
  6. wire the field into every applicable host surface;
  7. include it in effective-state or doctor output;
  8. add integration tests proving behavior, not merely successful parsing;
  9. update this table and the external configuration reference;
  10. if another parser owns the nested schema, document the ownership and precedence boundary.

A test that Config contains a value is not a wiring test. For security or persistence settings, the behavioral test must demonstrate the denied/redirected/disabled operation.

Current risks and ranked work🔗

PriorityGapWhy it mattersMinimum credible repair
P0leash.policy is accepted but local hosts do not load ita security-looking setting can create false assuranceload before any evaluation; fail closed on configured-policy errors; add denial integration tests
P0child evaluators can lose config and authority stateparent and child observe/enforce different worldsone audited child-context constructor with inheritance tests
P1journal enable/path settings are inertretention and state-location policy are misleadinghonor both during REPL assembly; expose effective path
P1rich prompt and core config use different project discoveryprompt can visibly disagree with config.allshare a discovered layer set or explicitly separate files
P1Reef user config is reparsed independentlyschema and precedence drift can surprise usersshare raw parsed layers or expose a structured handoff
P2render.width is inertconfiguration claims an output constraint that is ignoredthread an effective width through renderer entry points
P2kernel settings are inert in local shoalusers cannot infer whether evaluation is local/residenteither wire a kernel-client mode or move settings to kernel client config
P2alias/env name validation differs from source injectionaccepted config can fail latervalidate exact consumer grammar and environment-name rules
P3no live reloadlong sessions retain stale configurationdefine transactional reload and which state may change safely

Behavioral invariants worth preserving🔗

  • An absent configuration is a complete, usable configuration.
  • Missing optional files are not errors; unreadable present files are.
  • A file’s type error cannot be hidden by a later layer.
  • Tables merge deeply; scalars and arrays replace.
  • Only the nearest ancestor project config participates.
  • Unknown fixed keys remain visible as warnings with precise paths.
  • Environment override names are explicit and testable.
  • NO_COLOR presence always wins.
  • The language snapshot and host assembly begin from the same typed object.
  • Any exception to shared configuration—prompt and Reef today—is named as a separate parser and precedence system, never implied to be ordinary typed-config wiring.

Test map🔗

The core config tests cover:

  • default TOML round-trip and self-schema consistency;
  • precedence and key-preserving deep merge;
  • missing layers;
  • malformed TOML and exact source paths;
  • precise nested type errors;
  • environment boolean/integer parsing and NO_COLOR precedence;
  • unknown-key warnings and spelling suggestions;
  • project discovery;
  • version, enum, capacity, map-name, and ignore-pattern validation.

Host tests must carry the remaining burden: editor mode and custom bindings, history exclusion, completion limits, echo behavior, paging, alias/env injection, adapter directories, config language snapshot, and—once repaired—journal/Leash/kernel wiring. The implementation-status chapter treats a schema-only test as weaker evidence than a host behavioral test.

Source navigation🔗

ConcernPrimary source
typed schema and defaultscrates/shoal-config/src/lib.rs
discovery, merge, environment, validationcrates/shoal-config/src/load.rs
static shape and suggestionscrates/shoal-config/src/schema.rs
structured errorscrates/shoal-config/src/error.rs
noninteractive host wiringcrates/shoal/src/main.rs
interactive host wiringcrates/shoal/src/repl.rs
rich prompt layerscrates/shoal/src/prompt.rs, crates/shoal-prompt/src/config/
Reef user-config handoffcrates/shoal-eval/src/reef.rs, crates/shoal-reef/src/
Type to search every guide navigate open esc close
Diagram