14 KiB
Phase 1 Step 3 — Implementation Notes
Status
Done.
Plan reference
- Plan:
docs/PHASE-1-IMPLEMENTATION-PLAN.md - Section: "Step 3: Migrate global-read methods to AppConfig"
Summary
Added 7 global-read methods to AppConfig as inherent methods
duplicating the bodies that still exist on Config. The planned
approach (deprecated forwarders + caller migration) turned out to
be the wrong shape for this step because callers hold Config
instances, not AppConfig instances, and giving them an AppConfig
would require either a sync'd Arc<AppConfig> field on Config
(which Step 4's global-write migration would immediately break) or
cloning on every call. The clean answer is to duplicate during the
bridge window and let callers migrate naturally when Steps 8-9
switch them from Config to RequestContext + AppState. The
duplication is 7 methods / ~100 lines and deletes itself when
Config is removed in Step 10.
Three methods from the plan's Step 3 target list were deferred to Step 7 because they read runtime state, not just serialized state (see "Deviations from plan").
What was changed
Modified files
-
src/config/app_config.rs— added 6 new imports (MarkdownRender,RenderOptions,IS_STDOUT_TERMINAL,decode_bin,anyhow,env,ThemeSet) and a newimpl AppConfigblock with 7 methods under#[allow(dead_code)]:vault_password_file(&self) -> PathBufeditor(&self) -> Result<String>sync_models_url(&self) -> Stringlight_theme(&self) -> boolrender_options(&self) -> Result<RenderOptions>print_markdown(&self, text) -> Result<()>rag_template(&self, embeddings, sources, text) -> String
All bodies are copy-pasted verbatim from the originals on
Config, with the following adjustments for the new module location:EDITORstatic →super::EDITOR(shared across both impls)SYNC_MODELS_URLconst →super::SYNC_MODELS_URLRAG_TEMPLATEconst →super::RAG_TEMPLATELIGHT_THEME/DARK_THEMEconsts →super::LIGHT_THEME/super::DARK_THEMEpaths::local_path()continues to work unchanged (already in the right module from Step 2)
Unchanged files
src/config/mod.rs— the originalConfig::vault_password_file,editor,sync_models_url,light_theme,render_options,print_markdown,rag_templatemethod definitions are deliberately left intact. They continue to work for every existing caller. The deletion of these happens in Step 10 whenConfigis removed entirely.- All external callers (26 callsites across 6 files) — also
unchanged. They continue to call
config.editor(),config.render_options(), etc. on theirConfiginstances.
Key decisions
1. Duplicate method bodies instead of #[deprecated] forwarders
The plan prescribed the same shape as Step 2: add the new version,
add a #[deprecated] forwarder on the old location, migrate
callers, delete forwarders. This worked cleanly in Step 2 because
the new location was a free-standing paths module — callers
could switch from Config::method() (associated function) to
paths::method() (free function) without needing any instance.
Step 3 is fundamentally different: AppConfig::method(&self) needs
an AppConfig instance. Callers today hold Config instances.
Giving them an AppConfig means one of:
(a) Add an app_config: Arc<AppConfig> field to Config and have
the forwarder do self.app_config.method(). Rejected
because Step 4 (global-write) will mutate Config fields via
set_wrap, update, etc. — keeping the Arc<AppConfig>
in sync would require either rebuilding it on every write (slow
and racy) or tracking dirty state (premature complexity).
(b) Have the forwarder do self.to_app_config().method(). Rejected
because to_app_config clones all 40 serialized fields on
every call — a >100x slowdown for simple accessors like
light_theme().
(c) Duplicate the method bodies on both Config and AppConfig,
let each caller use whichever instance it has, delete the
Config versions when Config itself is deleted in Step 10.
Chosen.
Option (c) has a small ongoing cost (~100 lines of duplicated logic) but is strictly additive, has zero runtime overhead, and automatically cleans up in Step 10. It also matches how Rust's type system prefers to handle this — parallel impls are cheaper than synchronized state.
2. Caller migration is deferred to Steps 8-9
With duplication in place, the migration from Config to
AppConfig happens organically later:
- When Step 8 rewrites
main.rsto construct anAppStateandRequestContextinstead of aGlobalConfig, themain.rscallers ofconfig.editor()naturally becomectx.app.config.editor()— calling intoAppConfig's version. - Same for every other callsite that gets migrated in Step 8+.
- By Step 10, the old
Config::editor()etc. have zero callers and get deleted along with the rest ofConfig.
This means Step 3 is "additive only, no caller touches" — deliberately smaller in scope than Step 2. That's the correct call given the instance-type constraint.
3. EDITOR static is shared between Config::editor and AppConfig::editor
editor() caches the resolved editor path in a module-level
static EDITOR: OnceLock<Option<String>> in src/config/mod.rs.
Both Config::editor(&self) and AppConfig::editor(&self) read
and initialize the same static via super::EDITOR. This matches
the current behavior: whichever caller resolves first wins the
OnceLock::get_or_init race and subsequent callers see the cached
value.
There's a latent bug here (if Config.editor and AppConfig.editor
fields ever differ, the first caller wins regardless) but it's
pre-existing and preserved during the bridge window. Step 10 resolves
it by deleting Config entirely.
4. Three methods deferred to Step 7
See "Deviations from plan."
Deviations from plan
select_functions, select_enabled_functions, select_enabled_mcp_servers belong in Step 7
The plan's Step 3 table lists all three. Reading their bodies (in
src/config/mod.rs at lines 1816, 1828, 1923), they all touch
self.functions and self.agent — both of which are #[serde(skip)]
runtime fields that do NOT exist on AppConfig and will never
exist there (they're per-request state living on RequestContext
and AgentRuntime).
These are "mixed" methods in the plan's Step 7 taxonomy — they
conditionally read serialized config + runtime state depending on
whether an agent is active. Moving them to AppConfig now would
require AppConfig to hold functions and agent fields, which
directly contradicts the Step 0 / Step 6.5 design.
Action taken: left all three on Config unchanged. They get
migrated in Step 7 with the new signature
(app: &AppConfig, ctx: &RequestContext, role: &Role) -> Vec<...>
as described in the plan.
Action required from Step 7: pick up these three methods. The call graph is:
Config::select_functionsis called fromsrc/config/input.rs:243(one external caller)Config::select_functionsinternally calls the two private helpers- The private helpers read both
self.functions(runtime, per-request) andself.agent(runtime, per-request) — so they fundamentally needRequestContextnotAppConfig
Step 3 count: 7 methods, not 10
The plan's table listed 10 target methods. After excluding the
three select_* methods, Step 3 migrated 7. This is documented
here rather than silently completing a smaller Step 3 so Step 7's
scope is clear.
Verification
Compilation
cargo check— clean, zero warnings, zero errorscargo clippy— clean
Tests
cargo test— 63 passed, 0 failed (same as Steps 1–2)
Step 3 added no new tests because it's duplication — there's
nothing new to verify. The existing test suite confirms:
(a) the original Config methods still work (they weren't touched)
(b) AppConfig still compiles and its Default impl is intact
(needed for Step 1's bridge test which uses
build_populated_config() → to_app_config())
Running cargo test bridge specifically:
test config::bridge::tests::round_trip_default_config ... ok
test config::bridge::tests::to_app_config_copies_every_serialized_field ... ok
test config::bridge::tests::to_request_context_copies_every_runtime_field ... ok
test config::bridge::tests::round_trip_preserves_all_non_lossy_fields ... ok
test result: ok. 4 passed
The bridge's round-trip test still works, which proves the new
methods on AppConfig don't interfere with the struct layout or
deserialization. They're purely additive impl-level methods.
Manual smoke test
Not applicable — no runtime behavior changed. CLI and REPL still
call Config::editor() etc. as before.
Handoff to next step
What Step 4 can rely on
Step 4 (migrate global-write methods) can rely on:
AppConfignow has 7 inherent read methods that mirror the correspondingConfigmethods exactly#[allow(dead_code)]on theimpl AppConfigblock inapp_config.rs— safe to leave as-is, it'll go away when the first caller is migrated in Step 8+Configis unchanged for all 7 methods and continues to work for every current caller- The bridge (
Config::to_app_config,to_request_context,from_parts) from Step 1 still works - The
pathsmodule from Step 2 is unchanged Config::select_functions,select_enabled_functions,select_enabled_mcp_serversare still onConfigand must stay there through Step 6. They get migrated in Step 7.
What Step 4 should watch for
- The Step 4 target list (from
PHASE-1-IMPLEMENTATION-PLAN.md):set_wrap,update,load_envs,load_functions,load_mcp_servers,setup_model,setup_document_loaders,setup_user_agent. These are global-write methods that initialize or mutate serialized fields. - Tension with Step 3's duplication decision: Step 4 methods
mutate
Configfields. If we also duplicate them onAppConfig, then mutations through one path don't affect the other — but no caller ever mutates both, so this is fine in practice during the bridge window. load_functionsandload_mcp_serversare initialization- only (called once inConfig::init). They're arguably not "global-write" in the same sense — they populate runtime-only fields (functions,mcp_registry). Step 4 should carefully classify each: fields that belong toAppConfigvs fields that belong toRequestContextvs fields that go away in Step 6.5 (mcp_registry).- Strategy for Step 4: because writes are typically one-shot
(
updateis called from.setREPL command;load_envsis called once at startup), you can be more lenient about duplication vs consolidation. Consider: the write methods might not need to exist onAppConfigat all if they're only used duringConfig::initand never during request handling. Step 4 should evaluate each one individually.
What Step 4 should NOT do
- Don't add an
app_config: Arc<AppConfig>field toConfig(see Key Decision #1 for why). - Don't touch the 7 methods added to
AppConfigin Step 3 — they stay until Step 8+ caller migration, and Step 10 deletion. - Don't migrate
select_*methods — those are Step 7. - Don't try to migrate callers of the Step 3 methods to go
through
AppConfigyet. The call sites still holdConfig, and forcing a conversion would require either a clone or a sync'd field.
Files to re-read at the start of Step 4
docs/PHASE-1-IMPLEMENTATION-PLAN.md— Step 4 section- This notes file — specifically the "Deviations from plan" and "What Step 4 should watch for" sections
src/config/mod.rs— the currentConfig::set_wrap,update,load_*,setup_*method bodies (search forpub fn set_wrap,pub fn update,pub fn load_envs, etc.)src/config/app_config.rs— the current shape with 7 new methods
Follow-up (not blocking Step 4)
1. The EDITOR static sharing is pre-existing fragility
Both Config::editor and AppConfig::editor now share the same
static EDITOR: OnceLock<Option<String>>. If two Configs with
different editor fields exist (unlikely in practice but possible
during tests), the first caller wins. This isn't new — the single
Config version had the same property. Step 10's Config
deletion will leave only AppConfig::editor which eliminates the
theoretical bug. Worth noting so nobody introduces a test that
assumes per-instance editor caching.
2. impl AppConfig block grows across Steps 3-7
By the end of Step 7, AppConfig will have accumulated: 7 methods
from Step 3, potentially some from Step 4, more from Step 7's
mixed-method splits. The #[allow(dead_code)] currently covers
the whole block. As callers migrate in Step 8+, the warning
suppression can be removed. Don't narrow it prematurely during
Steps 4-7.
3. Imports added to app_config.rs
Step 3 added MarkdownRender, RenderOptions, IS_STDOUT_TERMINAL,
decode_bin, anyhow::{Context, Result, anyhow}, env,
ThemeSet. Future steps may add more. The import list is small
enough to stay clean; no reorganization needed.
References
- Phase 1 plan:
docs/PHASE-1-IMPLEMENTATION-PLAN.md - Step 2 notes:
docs/implementation/PHASE-1-STEP-2-NOTES.md - Modified file:
src/config/app_config.rs(imports + newimpl AppConfigblock) - Unchanged but relevant:
src/config/mod.rs(originalConfigmethods still exist for now),src/config/bridge.rs(still passes round-trip tests)