zeroclaw_config/sections.rs
1//! Curated sections surface — a flat ordered set of [`Section`]s the
2//! operator walks (new install) or scans (returning user) to configure
3//! a working ZeroClaw deployment.
4//!
5//! Every fact about a section (its enum variant, its on-the-wire key,
6//! its UI shape, its help blurb, its canonical position) lives in ONE
7//! table — the `sections!` invocation below. The macro expands that
8//! table into the [`Section`] enum, every per-variant `match` helper,
9//! and the [`QUICKSTART_SECTIONS`] const, so adding a section is exactly
10//! one row, no hand-listed variant set anywhere else.
11//!
12//! Consumers (CLI runtime, gateway, dashboard) dispatch off this enum;
13//! drift is a compile error.
14
15use serde::{Deserialize, Serialize};
16
17/// UI rendering shape for a section. Drives picker / form dispatch on
18/// the `/config` curated section explorer and the Quickstart flow.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
21#[serde(rename_all = "snake_case")]
22pub enum SectionShape {
23 /// `<section>` renders a schema-driven form with no picker step.
24 DirectForm,
25 /// `<section>.<alias>` map of structured entries; the section page
26 /// shows an alias list with `+ Add` and clicking an alias opens its
27 /// schema form.
28 OneTierAliasMap,
29 /// `<section>.<type>.<alias>` two-tier map. Picker chooses `<type>`,
30 /// alias-list step chooses `<alias>`, then the schema form opens.
31 TypedFamilyMap,
32 /// Single non-alias choice (memory backend, tunnel provider). Picker
33 /// flips a top-level field, then the schema form for the chosen
34 /// backend/provider renders.
35 BackendPicker,
36}
37
38/// Humanize a section wire key for display (`risk_profiles` → `Risk profiles`,
39/// `providers.models` → `Model providers`). Single source of truth for section
40/// labels across the gateway dashboard, zerocode Config pane, and docs. Specific
41/// wording overrides are listed explicitly; everything else is mechanically
42/// title-cased from the key.
43#[must_use]
44pub fn humanize_section_key(key: &str) -> String {
45 match key {
46 "providers.models" => return "Model providers".to_string(),
47 "providers.tts" => return "TTS providers".to_string(),
48 "providers.transcription" => return "Transcription providers".to_string(),
49 _ => {}
50 }
51 let mut s = key.replace(['_', '-'], " ");
52 if let Some(c) = s.get_mut(0..1) {
53 c.make_ascii_uppercase();
54 }
55 s
56}
57
58/// Single source of truth for every pickable config section. Each row
59/// maps 1:1 to a dashboard `/config/<key>` page, a CLI
60/// `zeroclaw quickstart` flow and the gateway section picker handler.
61/// Adding/removing a section is one row here and every consumer's
62/// `match` either compiles cleanly or fails with an exhaustiveness
63/// error pointing at exactly what needs an arm.
64///
65/// Row order is the canonical order operators see in the dashboard
66/// and walk through in the CLI. It is dependency-correct: every
67/// downstream alias reference an Agent carries (model_provider,
68/// risk_profile, runtime_profile, channels, *_bundles) appears earlier
69/// in the list than [`Section::Agents`], so walking top-to-bottom
70/// never produces a dangling reference.
71macro_rules! sections {
72 (
73 $(
74 $var:ident => {
75 key: $key:literal,
76 shape: $shape:ident,
77 help: $help:expr $(,)?
78 }
79 ),+ $(,)?
80 ) => {
81 /// One pickable section. The variant ordering follows the
82 /// `sections!` macro invocation.
83 ///
84 /// With the `clap` feature on, this enum doubles as the
85 /// `zeroclaw quickstart` and curated-section endpoints — no separate
86 /// mirror enum in the binary crate.
87 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
88 #[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
89 #[cfg_attr(feature = "clap", derive(clap::Subcommand))]
90 #[serde(rename_all = "snake_case")]
91 pub enum Section {
92 $(
93 // Both clap (`--help`) and our runtime `help()` method
94 // need the same blurb; emit it once as a doc comment so
95 // the two surfaces share a single string per variant.
96 #[doc = $help]
97 #[cfg_attr(feature = "clap", command(name = $key))]
98 $var,
99 )+
100 }
101
102 impl Section {
103 /// Stable on-the-wire key. Also serves as the TOML
104 /// top-level prefix (e.g. `providers.models.<type>.<alias>`),
105 /// the curated section URL segment, and the
106 /// `SectionInfo.key` field returned by the gateway.
107 #[must_use]
108 pub const fn as_str(self) -> &'static str {
109 match self {
110 $( Self::$var => $key, )+
111 }
112 }
113
114 /// Editor shape — the dashboard and the CLI both
115 /// dispatch off this so the same component lights up for
116 /// the same section in both surfaces.
117 #[must_use]
118 pub const fn shape(self) -> SectionShape {
119 match self {
120 $( Self::$var => SectionShape::$shape, )+
121 }
122 }
123
124 /// Per-section help blurb — single source of truth for
125 /// the copy shown above the section's picker / form on
126 /// every surface (CLI `ui.note(...)`, TUI heading,
127 /// dashboard `SectionInfo.help`).
128 #[must_use]
129 pub const fn help(self) -> &'static str {
130 match self {
131 $( Self::$var => $help, )+
132 }
133 }
134
135 /// Human-readable section label shown in every Config surface
136 /// (gateway dashboard sidebar, zerocode Config pane, docs).
137 /// Single source of truth — derived from the canonical wire key
138 /// so the gateway, runtime, and docs cannot disagree.
139 #[must_use]
140 pub fn label(self) -> String {
141 humanize_section_key(self.key())
142 }
143
144 /// The canonical wire key for this section.
145 #[must_use]
146 pub const fn key(self) -> &'static str {
147 match self {
148 $( Self::$var => $key, )+
149 }
150 }
151
152 /// Parse a stable wire key, tolerating both the snake and
153 /// kebab spellings of any section. The schema mixes the two:
154 /// `model_providers` (snake) and `peer-groups` (kebab) are
155 /// both valid wire forms produced elsewhere in the codebase.
156 /// Callers (dashboard URL routing, gateway picker dispatch,
157 /// CLI clap subcommands) can pass either form; `from_key`
158 /// resolves to the same variant. Returns `None` for keys
159 /// outside the known section table. Named `from_key` rather
160 /// than `from_str` so clippy doesn't flag it as confusable
161 /// with `std::str::FromStr` (parse failure is `None`, not
162 /// `Err(_)`).
163 #[must_use]
164 pub fn from_key(s: &str) -> Option<Self> {
165 let try_match = |s: &str| -> Option<Self> {
166 match s {
167 $( $key => Some(Self::$var), )+
168 _ => None,
169 }
170 };
171 if let Some(v) = try_match(s) {
172 return Some(v);
173 }
174 if s.contains('_')
175 && let Some(v) = try_match(&s.replace('_', "-"))
176 {
177 return Some(v);
178 }
179 if s.contains('-')
180 && let Some(v) = try_match(&s.replace('-', "_"))
181 {
182 return Some(v);
183 }
184 None
185 }
186 }
187
188 /// Canonical ordering of sections enumerated by
189 /// the Quickstart flow and the curated section explorer. The
190 /// dashboard renders Next/Finish navigation against this list.
191 /// Every consumer that needs section ordering reads from here.
192 pub const QUICKSTART_SECTIONS: &[Section] = &[ $( Section::$var ),+ ];
193 };
194}
195
196sections! {
197 // Tier 1 — Brain. An agent cannot think without a model provider.
198 ModelProviders => {
199 key: "providers.models",
200 shape: TypedFamilyMap,
201 help: "Pick a model provider to configure (Anthropic, OpenAI, OpenRouter, \
202 Ollama, custom OpenAI-compatible gateways, etc.). Multiple aliases per \
203 provider are supported — e.g. anthropic.production and anthropic.dev \
204 can coexist.",
205 },
206
207 // Tier 2 — Behavior shape. agents.<alias>.risk_profile and
208 // .runtime_profile are required alias refs; both must exist before
209 // an Agent that points at them can resolve.
210 RiskProfiles => {
211 key: "risk_profiles",
212 shape: OneTierAliasMap,
213 help: "Named risk profiles binding allowlists, denylists, and approval \
214 thresholds. Agents reference one via `agents.<alias>.risk_profile`.",
215 },
216 RuntimeProfiles => {
217 key: "runtime_profiles",
218 shape: OneTierAliasMap,
219 help: "Named runtime tuning profiles (token limits, retry policy, timeouts). \
220 Agents reference one via `agents.<alias>.runtime_profile`.",
221 },
222
223 // Tier 3 — Storage. memory.backend points at a storage.<type>.<alias>
224 // instance, so storage must exist first.
225 Storage => {
226 key: "storage",
227 shape: TypedFamilyMap,
228 help: "SQLite is the safe default for single-node installs (file-based, \
229 zero-config, no extra services). Pick Postgres for shared or \
230 multi-instance deployments, Qdrant for vector search, Markdown or \
231 Lucid for human-readable files. Each backend supports multiple \
232 aliased instances; agents reference them via `memory.storage_ref`.",
233 },
234 Memory => {
235 key: "memory",
236 shape: BackendPicker,
237 help: "Persistent memory backend. SQLite is the default; pick `none` to \
238 disable long-term recall entirely.",
239 },
240
241 // Tier 4 — Capabilities. Bundles that agents reference via
242 // skill_bundles / mcp_bundle / knowledge_bundles.
243 Skills => {
244 key: "skills",
245 shape: DirectForm,
246 help: "Skills tool settings — where skill markdown lives on disk (defaults \
247 to the data dir), and how the skills loader handles community \
248 repositories. Add skill BUNDLES under `skill-bundles` below.",
249 },
250 SkillBundles => {
251 key: "skill_bundles",
252 shape: OneTierAliasMap,
253 help: "Named bundles of skill files. Agents reference a bundle to load a \
254 set of capabilities at startup.",
255 },
256 Mcp => {
257 key: "mcp",
258 shape: DirectForm,
259 help: "Model Context Protocol settings. Toggle `enabled` and pick deferred \
260 or eager loading. Individual MCP servers live under `mcp.servers[]`.",
261 },
262 McpBundles => {
263 key: "mcp_bundles",
264 shape: OneTierAliasMap,
265 help: "Named bundles of MCP servers. Agents reference a bundle to pull in \
266 a set of MCP tools as one unit.",
267 },
268 KnowledgeBundles => {
269 key: "knowledge_bundles",
270 shape: OneTierAliasMap,
271 help: "Named bundles of knowledge sources (RAG indexes, doc folders). Agents \
272 reference a bundle to surface relevant snippets at inference time.",
273 },
274
275 // Tier 5 — Modal IO. Optional voice in/out providers.
276 TtsProviders => {
277 key: "providers.tts",
278 shape: TypedFamilyMap,
279 help: "Text-to-speech providers (OpenAI, ElevenLabs, Google, Edge, Piper). \
280 Configure one per voice / language; agents reference them by alias.",
281 },
282 TranscriptionProviders => {
283 key: "providers.transcription",
284 shape: TypedFamilyMap,
285 help: "Speech-to-text providers (OpenAI Whisper, Groq, Deepgram, AssemblyAI, \
286 Google, local Whisper). Configure one per pipeline; agents reference \
287 them by alias.",
288 },
289
290 // Tier 6 — Channels. How agents listen. agents.<alias>.channels
291 // references channel aliases, so channels must exist first.
292 Channels => {
293 key: "channels",
294 shape: TypedFamilyMap,
295 help: "Pick which chat platforms ZeroClaw should listen on. You can \
296 configure multiple — each channel gets its own alias.",
297 },
298 Hardware => {
299 key: "hardware",
300 shape: DirectForm,
301 help: "Optional: hardware peripherals (Arduino, STM32, GPIO, etc.). \
302 Skip if you don't need them.",
303 },
304
305 // Tier 7 — Bind. Pulls tiers 1–6 together. Every alias ref an
306 // Agent carries exists by this point.
307 // Personality is intentionally NOT a top-level section —
308 // markdown personality files live per-agent and surface inside the
309 // agent edit form.
310 Agents => {
311 key: "agents",
312 shape: OneTierAliasMap,
313 help: "An agent binds a model provider, profiles, bundles, and channels \
314 into one dispatchable unit. Add one per persona; reuse the same \
315 alias across channels to share state.",
316 },
317
318 // Tier 8 — Topology. Multi-agent relationships and scheduled
319 // invocations; both reference agents and must follow Agents.
320 PeerGroups => {
321 key: "peer_groups",
322 shape: OneTierAliasMap,
323 help: "Named groups binding a channel, member agents, and external peers. \
324 Mutual opt-in: two agents become peers only when both appear in the \
325 same group's `agents` list.",
326 },
327 Cron => {
328 key: "cron",
329 shape: OneTierAliasMap,
330 help: "Scheduled tasks. Each cron entry binds a schedule expression to a \
331 prompt, channel, and target.",
332 },
333
334 // Tier 9 — Exposure. Gateway public-internet exposure. Only
335 // relevant when a webhook-mode channel needs a public URL.
336 Tunnel => {
337 key: "tunnel",
338 shape: BackendPicker,
339 help: "Optional: expose your gateway over the public internet via Cloudflare \
340 or ngrok. Pick `none` to keep it localhost-only.",
341 },
342
343 // Tier 10 — Lifecycle state. Not part of any agent dependency
344 // chain. Tracks whether the Quickstart has completed on this
345 // install; surfaces dispatch on it to decide whether to auto-open
346 // the Quickstart on launch. The on-disk TOML key stays
347 // `onboard_state` for backwards compatibility with installs that
348 // already wrote against it; only the in-code symbol is renamed.
349 QuickstartState => {
350 key: "onboard_state",
351 shape: DirectForm,
352 help: "Quickstart lifecycle state. `quickstart_completed` flips to true \
353 once the Quickstart finishes a successful run; while false, the \
354 web gateway and TUI auto-launch the Quickstart on startup. \
355 `completed_sections` is a legacy per-section ledger retained for \
356 backwards compatibility with prior data.",
357 },
358}
359
360impl std::fmt::Display for Section {
361 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362 f.write_str(self.as_str())
363 }
364}
365
366/// Canonical-order index of `section` in [`QUICKSTART_SECTIONS`].
367/// Always `Some` for any valid `Section` variant — the const includes
368/// every variant by construction. Returns `Option` for API symmetry
369/// with [`section_index_for_key`], which can fail on unknown keys.
370#[must_use]
371pub fn section_index(section: Section) -> Option<usize> {
372 QUICKSTART_SECTIONS.iter().position(|s| *s == section)
373}
374
375/// Canonical-order index for a wire key, or `None` if the key isn't a
376/// known [`Section`]. Used by gateway / dashboard sort comparators that
377/// take string keys from the HTTP layer.
378#[must_use]
379pub fn section_index_for_key(key: &str) -> Option<usize> {
380 Section::from_key(key).and_then(section_index)
381}
382
383/// True when `key` parses as a known [`Section`].
384#[must_use]
385pub fn is_known_section(key: &str) -> bool {
386 Section::from_key(key).is_some()
387}
388
389/// Help blurb for a section key, covering both `Section` variants and
390/// the long tail of top-level `Config` fields the dashboard / TUI config
391/// editor surface (gateway, scheduler, observability, …). Single source
392/// of truth shared by every surface — the gateway sidebar, the CLI
393/// Quickstart flow, and the future TUI config editor all call this rather
394/// than maintaining parallel tables.
395///
396/// Resolution order:
397/// 1. `Section` variants (curated `help` text next to the variant
398/// declaration in the `sections!` macro).
399/// 2. The `Config` struct's `#[nested]` field-level `///` docstring,
400/// harvested by the `Configurable` derive into
401/// `Config::nested_section_help`. This is what makes adding a new
402/// top-level section a one-line schema change with no parallel
403/// help table to update.
404///
405/// Returns `""` for keys without a docstring so callers can decide
406/// whether to omit the help row or show a fallback.
407#[must_use]
408pub fn section_help(key: &str) -> &'static str {
409 if let Some(s) = Section::from_key(key) {
410 return s.help();
411 }
412 crate::schema::Config::nested_section_help(key).unwrap_or("")
413}
414
415/// First segment of a dotted property path mapped back to the section
416/// it lives under, or `None` for non-section paths
417/// (`onboard_state.completed_sections`, etc.).
418#[must_use]
419pub fn section_for_path(path: &str) -> Option<Section> {
420 Section::from_key(path.split('.').next()?)
421}
422
423/// Does this section show any signal of having been touched on this
424/// install? Used by callers (RPC config-list filtering, lifecycle
425/// dispatch) to decide whether to surface a section as "untouched".
426///
427/// Each variant decides what counts as a real signal vs a default
428/// value that round-trips identically across a fresh install.
429pub fn section_has_signal(cfg: &crate::schema::Config, section: Section) -> bool {
430 match section {
431 Section::ModelProviders => !cfg.providers.models.is_empty(),
432 // `channels.cli: bool` is a default-true scalar that lives directly
433 // under `channels.*`, so a bare `starts_with("channels.")` check
434 // fires on every fresh install. Require a nested channel config
435 // (e.g. `channels.telegram.bot-token`) — anything with a second dot
436 // segment — to count as user-driven signal.
437 Section::Channels => cfg.prop_fields().iter().any(|f| {
438 f.name
439 .strip_prefix("channels.")
440 .is_some_and(|rest| rest.contains('.'))
441 }),
442 Section::Hardware => cfg.hardware.enabled,
443 // Memory's default backend is "sqlite" and Tunnel's is "none" —
444 // both are valid user choices indistinguishable from untouched
445 // defaults. TTS / transcription providers and agents start
446 // empty; their existence in the typed family map IS the signal,
447 // not a derivable default-divergence. Marker-only for these.
448 Section::TtsProviders
449 | Section::TranscriptionProviders
450 | Section::Memory
451 | Section::Tunnel
452 | Section::Agents
453 | Section::Skills
454 | Section::SkillBundles
455 | Section::RiskProfiles
456 | Section::RuntimeProfiles
457 | Section::PeerGroups
458 | Section::Storage
459 | Section::Cron
460 | Section::Mcp
461 | Section::McpBundles
462 | Section::KnowledgeBundles
463 | Section::QuickstartState => false,
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470
471 /// Round-trip every entry in the canonical list. `from_key`,
472 /// `as_str`, `section_index`, and `QUICKSTART_SECTIONS` are all
473 /// generated from the same `sections!` row, so this test exercises
474 /// the table — adding a row that breaks any of them fails here
475 /// without listing variants by hand.
476 #[test]
477 fn sections_round_trip() {
478 for s in QUICKSTART_SECTIONS {
479 assert_eq!(Section::from_key(s.as_str()), Some(*s), "{s} round-trip");
480 assert_eq!(
481 section_index(*s),
482 Some(QUICKSTART_SECTIONS.iter().position(|x| x == s).unwrap()),
483 );
484 }
485 assert_eq!(Section::from_key("gateway"), None);
486 assert_eq!(Section::from_key("not_a_section"), None);
487 }
488
489 /// Every section the dashboard URL surface points at must resolve
490 /// through `Section::from_key`. The dashboard URL form is kebab-case
491 /// (`peer-groups`), the canonical wire form may be snake_case
492 /// (`peer_groups`); both must parse to the same variant.
493 #[test]
494 fn dashboard_url_sections_round_trip_kebab_and_snake() {
495 let kebab_then_snake: &[(&str, &str, Section)] = &[
496 ("peer-groups", "peer_groups", Section::PeerGroups),
497 ("mcp-bundles", "mcp_bundles", Section::McpBundles),
498 (
499 "knowledge-bundles",
500 "knowledge_bundles",
501 Section::KnowledgeBundles,
502 ),
503 ("skill-bundles", "skill_bundles", Section::SkillBundles),
504 ("risk-profiles", "risk_profiles", Section::RiskProfiles),
505 (
506 "runtime-profiles",
507 "runtime_profiles",
508 Section::RuntimeProfiles,
509 ),
510 ("storage", "storage", Section::Storage),
511 ("cron", "cron", Section::Cron),
512 ("mcp", "mcp", Section::Mcp),
513 ];
514 for (kebab, snake, expected) in kebab_then_snake {
515 assert_eq!(
516 Section::from_key(kebab),
517 Some(*expected),
518 "kebab `{kebab}` should resolve to {expected:?}",
519 );
520 assert_eq!(
521 Section::from_key(snake),
522 Some(*expected),
523 "snake `{snake}` should resolve to {expected:?}",
524 );
525 assert!(
526 QUICKSTART_SECTIONS.contains(expected),
527 "{expected:?} must be in QUICKSTART_SECTIONS",
528 );
529 }
530 }
531
532 /// Every OneTierAliasMap section's wire key must appear verbatim
533 /// in `Config::map_key_sections()`. That table is what
534 /// `Config::create_map_key` dispatches off, so a mismatch silently
535 /// breaks the dashboard's `+ Add` affordance.
536 #[test]
537 fn alias_map_section_wire_keys_match_map_key_sections() {
538 use crate::schema::Config;
539 let sections = Config::map_key_sections();
540 let paths: std::collections::BTreeSet<&str> = sections.iter().map(|s| s.path).collect();
541 let alias_map_sections = [
542 Section::PeerGroups,
543 Section::Cron,
544 Section::McpBundles,
545 Section::KnowledgeBundles,
546 Section::SkillBundles,
547 Section::RiskProfiles,
548 Section::RuntimeProfiles,
549 ];
550 for section in alias_map_sections {
551 assert!(
552 paths.contains(section.as_str()),
553 "`Section::{section:?}.as_str() = {}` is not in map_key_sections; the \
554 picker's create_map_key call site will fail. Registered paths: {paths:?}",
555 section.as_str(),
556 );
557 }
558 }
559
560 /// Canonical order is dependency-correct: every Section that
561 /// `AliasedAgentConfig` references through an alias field appears
562 /// earlier in the list than `Section::Agents`. Walking
563 /// `QUICKSTART_SECTIONS` top-to-bottom never asks the operator to
564 /// configure an Agent before the things it has to bind to exist.
565 #[test]
566 fn ordering_respects_agent_dependency_tiers() {
567 let idx = |s: Section| {
568 QUICKSTART_SECTIONS
569 .iter()
570 .position(|x| *x == s)
571 .unwrap_or_else(|| panic!("{s:?} missing from QUICKSTART_SECTIONS"))
572 };
573
574 // Brain + behavior shape + bundles + channels all precede Agents.
575 for upstream in [
576 Section::ModelProviders,
577 Section::RiskProfiles,
578 Section::RuntimeProfiles,
579 Section::SkillBundles,
580 Section::McpBundles,
581 Section::KnowledgeBundles,
582 Section::Channels,
583 ] {
584 assert!(
585 idx(upstream) < idx(Section::Agents),
586 "{upstream:?} must precede Agents (Agent references it through an alias field)",
587 );
588 }
589
590 // Storage precedes Memory (memory.backend = "<storage_type>.<alias>").
591 assert!(
592 idx(Section::Storage) < idx(Section::Memory),
593 "Storage must precede Memory (memory.backend points at a storage instance)",
594 );
595
596 // Topology references agents.
597 for downstream in [Section::PeerGroups, Section::Cron] {
598 assert!(
599 idx(Section::Agents) < idx(downstream),
600 "{downstream:?} references agents and must follow Agents in the canonical order",
601 );
602 }
603 }
604
605 /// Storage help must steer first-time operators toward SQLite as the
606 /// safe default. Pins the contract: SQLite is named, flagged as a
607 /// default/safe/recommended choice, and positioned before the
608 /// alternatives so the recommendation lands first instead of being
609 /// buried in a closing list.
610 #[test]
611 fn storage_help_steers_to_sqlite_default() {
612 let help = section_help("storage").to_lowercase();
613 let sqlite_pos = help
614 .find("sqlite")
615 .expect("storage help must mention SQLite by name");
616 assert!(
617 help.contains("default") || help.contains("safe") || help.contains("recommend"),
618 "storage help must signal SQLite is the default/safe/recommended choice; got: {help}",
619 );
620 for other in ["postgres", "qdrant", "markdown", "lucid"] {
621 let other_pos = help.find(other).unwrap_or_else(|| {
622 panic!(
623 "storage help must still name `{other}` so operators know the alternatives \
624 exist; got: {help}",
625 )
626 });
627 assert!(
628 sqlite_pos < other_pos,
629 "SQLite (at {sqlite_pos}) must be mentioned before `{other}` (at {other_pos}) so \
630 the default recommendation lands first",
631 );
632 }
633 }
634}