Skip to content

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 App lands" caveat from rtb-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's config show prints a placeholder ("no typed configuration is installed on this App") rather than rendering the merged config.
  • config get / set / validate operate on the raw user-file path as serde_json::Value rather than going through Config<C>. Schema validation is deferred.
  • config schema errors with a help-laden "wire your typed config" message until App exists.
  • rtb-credentials::CredentialBearing is implemented on the user's typed config, but App only stores the trait-object form via Arc<dyn CredentialProvider>. Tools wanting their own config back out of App have to keep a reference themselves.
  • Downstream tools (the examples/minimal example, the rtb-cli-bin scaffolder, anything built on the framework) carry Arc<MyConfig> through their own state because App.config doesn't know about MyConfig.

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:

#[distributed_slice]
pub static BUILTIN_COMMANDS: [fn() -> Box<dyn Command>];

The dyn Command must not be generic over C, otherwise:

  • The BUILTIN_COMMANDS slice can't carry a uniform element type.
  • Framework-supplied commands (version, doctor, init, the update / docs / mcp / credentials / telemetry / config subtrees) would need a C parameter 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 via typed_config<C>() / config_as<C>(). The non-generic field becomes pub(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::run does not gain a generic; the typed config is stored type-erased on the constructed App.
  • rtb-cli::config_cmd: gains schema-aware get / set / validate paths plus a working schema. 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::build defaults the typed config to Config<()>::default() (matching today's behaviour). Two new builder steps per A4 resolution — TestAppBuilder::config(Config<C>) matches the production builder for full-fidelity tests, and TestAppBuilder::config_value(c) is the ergonomic shortcut that wraps c in a Config<C> with c as the embedded default.
  • examples/minimal: gains a config.yaml overlay + Application::builder().config(...) wiring + smoke tests for typed config 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.md flipped to "implemented" once this lands.

6. Acceptance criteria

The slice is implemented when:

  • App.typed_config::<C>() exists, returns Some(Arc<Config<C>>) for the type wired via Application::builder().config(...) and None otherwise.
  • App.config_as::<C>() panics with a track_caller diagnostic 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's config show / get / set / schema / validate work against app.typed_config::<C>() when wired and fall back to the v0.4 raw-YAML behaviour when not.
  • examples/minimal smoke gains four cases:
  • config show prints the wired MyConfig as YAML.
  • config get .anthropic.api.env returns the configured env var name.
  • config schema round-trips through serde_json.
  • config validate exits 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 both typed_config<C> (returns Option<Arc<Config<C>>>) and config_as<C> (panicking, #[track_caller]). Pick one as canonical at v0.5 once usage patterns surface.
  • A2 — app.config field visibility. Resolved: hard pub(crate) immediately. The type-erased Arc<dyn AnyConfig> has no useful direct API — making it the only pub(crate) field on App matches 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 — JsonSchema bound on Application::builder().config<C>. Resolved: require JsonSchema at 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 — TestAppBuilder config-wiring shape. Resolved: ship both entry points — TestAppBuilder::config(Config<C>) matches production for full-fidelity tests; the TestAppBuilder::config_value(c) shortcut wraps c in a Config<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):

  1. feat(app): AnyConfig trait + App::typed_config / config_as
  2. feat(cli): Application::builder().config<C> step + config_cmd typed-aware paths
  3. feat(test-support): TestAppBuilder::config(c) + config_value(c)
  4. feat(minimal): wire .config(...) demo + 4 typed-config smoke cases
  5. docs: 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.