v0.4.1 scope — App<C> typed-config integration¶
Status: IMPLEMENTED — landed on feat/app-typed-config over five commits matching the slicing plan in §8. v0.4 caveat in 2026-05-06-rtb-cli-ops-v0.1.md flipped accordingly.
Parent contract: rust-tool-base.md §3.2.
Driver: closes the App<C> gap deferred from v0.4. Removes the "TODO when Apprtb-cli config get/set/schema/validate, and unblocks downstream tools that want typed-config access from inside command bodies.
1. Motivation¶
The framework spec §3.2 describes App<C = ()> where C: AppConfig. The 0.3.0 release shipped with App non-generic — app.config: Arc<Config> is Arc<Config<()>> in practice. The consequences are scattered through the codebase:
rtb-cli'sconfig showprints a placeholder ("no typed configuration is installed on this App") rather than rendering the merged config.config get / set / validateoperate on the raw user-file path asserde_json::Valuerather than going throughConfig<C>. Schema validation is deferred.config schemaerrors with a help-laden "wire your typed config" message until Appexists. rtb-credentials::CredentialBearingis implemented on the user's typed config, butApponly stores the trait-object form viaArc<dyn CredentialProvider>. Tools wanting their own config back out ofApphave to keep a reference themselves.- Downstream tools (the
examples/minimalexample, the rtb-cli-bin scaffolder, anything built on the framework) carryArc<MyConfig>through their own state becauseApp.configdoesn't know aboutMyConfig.
These are all manageable workarounds, but they're scar tissue. v0.4.1 closes the gap.
2. The design tension¶
Command is a dyn-compatible trait used to populate the BUILTIN_COMMANDS distributed slice:
The dyn Command must not be generic over C, otherwise:
- The
BUILTIN_COMMANDSslice can't carry a uniform element type. - Framework-supplied commands (
version,doctor,init, theupdate / docs / mcp / credentials / telemetry / configsubtrees) would need aCparameter they have no business caring about. - Per-tool commands and framework commands could no longer share the same registry.
Yet typed-config consumers want App<C>::config() -> Arc<C> directly. The two requirements pull in opposite directions.
3. Resolution — type-erased App + downcast¶
Three options were considered:
3.1 Option (a) — App stays non-generic; expose Config via downcast. Chosen.¶
App keeps its current shape. App.config: Arc<dyn AnyConfig> (a small marker trait), but the underlying Arc<Config<C>> is recoverable via:
impl App {
/// Downcast `app.config` to a typed handle. Returns `None` when
/// the wired `C` does not match the requested type.
pub fn typed_config<C>(&self) -> Option<Arc<Config<C>>>
where
C: serde::de::DeserializeOwned + Send + Sync + 'static;
}
Implementation: App.config is Arc<dyn AnyConfig> where AnyConfig: Any + Send + Sync and impl<C> AnyConfig for Config<C>. typed_config does an Any::downcast round-trip.
Tools call let cfg = app.typed_config::<MyConfig>().expect("wired at startup") once at the top of every command body; the expect is safe because Application::builder().config(...) is required for any tool that uses typed_config.
Pros:
- Command stays non-generic and dyn-compatible.
- BUILTIN_COMMANDS continues to work unchanged.
- Framework-supplied commands that need typed config (only config show / get / set / schema / validate at v0.4.1) call typed_config themselves with a dyn-friendly wrapper trait.
- Backward compatibility is trivial: tools that don't use typed_config are completely unaffected.
- App.metadata, App.version, App.assets, App.shutdown, App.credentials_provider all stay non-generic — no rippling type-parameter bloat.
Cons:
- One runtime downcast per command body (O(1), no work after the first call thanks to Any::type_id).
- The signature app.typed_config::<MyConfig>() is slightly less ergonomic than the spec's aspirational app.config(). Mitigated by a prelude::TypedConfig extension trait that downstream tools can use to get app.config_as::<MyConfig>() shorthand.
3.2 Option (b) — App<C> generic; type-erase at the Command::run boundary. Rejected.¶
Application::builder() produces Application<C>, which dispatches to commands via a wrapper that downcasts before calling. Equivalent runtime behaviour to (a) but pushes a C parameter onto every public surface that touches App. Trait-object compatibility breaks for Application<C>-aware commands; framework commands would need bridge wrappers. Disrupts roughly 50+ call sites in our crates and the example without buying anything (a)'s downcast already provides.
3.3 Option © — App<C> generic with dyn-compatible Command<C = ()> default. Rejected.¶
Rust's GATs and trait-object lifetime rules make a default-typed Command<C = ()> work technically, but the dyn-compatibility constraints get tangled (Command<C> = Command<C2> is not the same trait, and downcasting through Box<dyn Command<C>> for a BUILTIN_COMMANDS slice element is brittle). Simplifies to (b) once you actually try to compile it.
4. Public API surface¶
4.1 rtb-app additions¶
// Crate-level: the storage trait.
pub trait AnyConfig: std::any::Any + Send + Sync {
fn as_any(&self) -> &(dyn std::any::Any + Send + Sync);
}
impl<C: 'static + Send + Sync> AnyConfig for rtb_config::Config<C> {
fn as_any(&self) -> &(dyn std::any::Any + Send + Sync) { self }
}
// On App.
impl App {
/// Downcast the wired typed config to `Config<C>`. `None` when
/// the wired type does not match.
pub fn typed_config<C>(&self) -> Option<Arc<rtb_config::Config<C>>>
where
C: serde::de::DeserializeOwned + Send + Sync + 'static;
/// Convenience: `typed_config().expect(...)` with a tool-friendly
/// panic message. Use only in command bodies that have already
/// asserted the tool wired its typed config at startup.
#[track_caller]
pub fn config_as<C>(&self) -> Arc<rtb_config::Config<C>>
where
C: serde::de::DeserializeOwned + Send + Sync + 'static;
}
The existing App.config: Arc<Config> field becomes pub(crate) config: Arc<dyn AnyConfig> — the field flips from pub to pub(crate) per A2 resolution so the type-erased shape never shows up in the public API. Downstream code that reads app.config.get() directly migrates to app.typed_config::<C>(); the workspace's only such site is rtb-cli's config_cmd.
4.2 rtb-cli additions¶
impl<M, V> ApplicationBuilder<M, V> {
/// Wire a typed config so command handlers can read it via
/// `app.typed_config::<C>()`. Required by tools that use the
/// framework-supplied `config show / get / set / validate`
/// subcommands; optional for tools that only consume `App`'s
/// existing fields.
pub fn config<C>(self, config: rtb_config::Config<C>) -> Self
where
C: serde::Serialize + serde::de::DeserializeOwned
+ schemars::JsonSchema + Send + Sync + 'static;
}
The config step is opt-in. The JsonSchema bound is required at the builder boundary per A3 resolution — schema-aware leaves (config schema / validate, plus set's pre-write validation) work for everyone who opts in. Tools with non-JsonSchema-able config shapes can keep the v0.4 raw-YAML behaviour by not calling .config(...). Tools that don't call it get the existing Arc<Config<()>> placeholder; the new config show / get / set / schema / validate leaves degrade to "no typed config wired" diagnostics rather than working against the user-file-as-serde_json::Value fallback we ship today.
4.3 rtb-cli config-subtree behaviour at v0.4.1¶
| Leaf | Behaviour with typed config wired | Behaviour without |
|---|---|---|
show |
Renders the merged C value as YAML. |
Prints the v0.4 placeholder ("no typed configuration is installed"). |
get <jsonpath> |
Validates the path against Config::schema(), then prints the value. |
Falls back to the v0.4 raw-YAML walk. |
set <jsonpath> <value> |
Validates the new value's type against Config::schema() before writing. Refuses on schema mismatch. |
Falls back to the v0.4 raw-YAML write (no type validation). |
schema |
Prints Config::schema() (works). |
Errors as today. |
validate [--file PATH] |
Validates against Config::schema(). |
Falls back to the v0.4 format-parse-only behaviour. |
Existing tools see no behaviour change unless they call .config(...); tools that opt in get the upgraded behaviour automatically.
4.4 rtb-credentials simplification (deferred)¶
The current CredentialProvider trait + blanket impl over CredentialBearing could in principle simplify to a direct App.typed_config::<C>().credentials() once App<C> lands. Out of scope for v0.4.1: Application::builder().credentials_from(Arc::new(my_config)) continues to work as the explicit wiring path. v0.5+ ergonomic enhancement.
5. Cross-cutting changes¶
rtb-app::App:config: Arc<Config>field →config: Arc<dyn AnyConfig>. Public access viatyped_config<C>()/config_as<C>(). The non-generic field becomespub(crate)to discourage direct access; existing public read sites in the workspace migrate to the new methods.rtb-cli::Application: new.config<C>(config)builder step.Application::rundoes not gain a generic; the typed config is stored type-erased on the constructedApp.rtb-cli::config_cmd: gains schema-awareget / set / validatepaths plus a workingschema. The v0.4 raw-YAML paths remain as the no-typed-config fallback so tools without.config(...)still get something useful.rtb-test-support:TestAppBuilder::builddefaults the typed config toConfig<()>::default()(matching today's behaviour). Two new builder steps per A4 resolution —TestAppBuilder::config(Config<C>)matches the production builder for full-fidelity tests, andTestAppBuilder::config_value(c)is the ergonomic shortcut that wrapscin aConfig<C>withcas the embedded default.examples/minimal: gains aconfig.yamloverlay +Application::builder().config(...)wiring + smoke tests for typedconfig show/get/schema.- Spec body updates: framework spec §3.2 examples updated to match the type-erased shape; v0.4 caveat sections in
2026-05-06-rtb-cli-ops-v0.1.mdflipped to "implemented" once this lands.
6. Acceptance criteria¶
The slice is implemented when:
App.typed_config::<C>()exists, returnsSome(Arc<Config<C>>)for the type wired viaApplication::builder().config(...)andNoneotherwise.App.config_as::<C>()panics with atrack_callerdiagnostic naming the requested type when no config has been wired.- All existing rtb-app / rtb-cli / rtb-test-support tests pass without modification beyond the field rename.
rtb-cli'sconfig show / get / set / schema / validatework againstapp.typed_config::<C>()when wired and fall back to the v0.4 raw-YAML behaviour when not.examples/minimalsmoke gains four cases:config showprints the wiredMyConfigas YAML.config get .anthropic.api.envreturns the configured env var name.config schemaround-trips throughserde_json.config validateexits 0 against the wired config.- Framework spec §3.2's example block compiles via a doctest. (Currently it doesn't, because the spec is aspirational.)
7. Resolutions¶
All five open questions resolved 2026-05-09. Recorded here for the audit trail — the spec body above carries the live behaviour.
- A1 — method name(s) on
App. Resolved: ship bothtyped_config<C>(returnsOption<Arc<Config<C>>>) andconfig_as<C>(panicking,#[track_caller]). Pick one as canonical at v0.5 once usage patterns surface. - A2 —
app.configfield visibility. Resolved: hardpub(crate)immediately. The type-erasedArc<dyn AnyConfig>has no useful direct API — making it the onlypub(crate)field onAppmatches its role as a storage detail. Pre-1.0 latitude applies; the change in storage type would force a downstream type-error anyway, so a#[deprecated]cycle softens nothing. Workspace-internal callers migrate trivially. - A3 —
JsonSchemabound onApplication::builder().config<C>. Resolved: requireJsonSchemaat the builder boundary so the schema-aware leaves work for everyone who opts in. Tools with non-JsonSchema-able shapes keep the v0.4 raw-YAML fallback by not calling.config(...). - A4 —
TestAppBuilderconfig-wiring shape. Resolved: ship both entry points —TestAppBuilder::config(Config<C>)matches production for full-fidelity tests; theTestAppBuilder::config_value(c)shortcut wrapscin aConfig<C>with the test value as the embedded default. - A5 — additional
App<C>generic on top of the type-erased shape. Resolved: no at v0.4.1. The type-erased shape covers every concrete need today; a generic on top would re-open the design tension §2 closed. Revisit only if a real consumer asks.
8. Slicing into PRs¶
Single PR for the whole slice (small enough surface; everything below ships together):
feat(app): AnyConfig trait + App::typed_config / config_asfeat(cli): Application::builder().config<C> step + config_cmd typed-aware pathsfeat(test-support): TestAppBuilder::config(c) + config_value(c)feat(minimal): wire .config(...) demo + 4 typed-config smoke casesdocs: flip v0.4 caveats to implemented + update component pages
Each commit ships green on its own.
9. Approval gate¶
This addendum is APPROVED as of 2026-05-09 — open questions §7
resolved. The slice is implemented when (a) acceptance §6
lands green with ≥ 90% coverage on the touched code, (b) the
v0.4 spec docs (2026-05-06-rtb-cli-ops-v0.1.md) have their caveat
blocks updated to point at this addendum as the closer, ©
status above flips to IMPLEMENTED.