Skip to content

fix(config): reject keys the schema does not declare - #439

Merged
argszero merged 1 commit into
mainfrom
fix/config-reject-unknown-keys
Oct 25, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/config-reject-unknown-keys

Conversation

@argszero

@argszero argszero commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Summary

config.toml is the sole source of model prices, and every structure accepted any key it did not know: none of the fourteen in src/config.rs carried #[serde(deny_unknown_fields)]. toml discarded the unknown key, the field stayed at its #[serde(default)] value, and the process loaded, validated, started and settled money at a price the operator never declared. No error, no log line.

The value a typo leaves behind is 0.0, which is a legal price meaning "free", so validate() could not tell "the operator chose zero" from "the operator misspelled the key" — it checks signs, not presence. That is what made this silent rather than loud.

Measured on the shipped config/config.example.toml before the fix, with one edit per row:

Injected key Loader said Parsed result
cache_write_input_per_mm = 3.5 under [[models]] LOADED OK cache_write_input_per_m = 0.0 — 60,000 cache-write tokens settled at 0.000000 instead of 0.210000
from_tokens = 512001 under [[models]] instead of [[models.tiers]] LOADED OK tiers = 0 — believed tiered, was not
context_window for context_length LOADED OK context_length = 0

Related Issue

Closes #438

Changes

  • #[serde(deny_unknown_fields)] on all fourteen structures in src/config.rs — Config, Server, Mail, Log, Archive, UsageRetention, Rollup, Billing, Points, Provider, Plan, Endpoint, ModelTier, Model. Not narrowed to the price tables: a misspelling in [log] or [archive] is equally silent, and a subset invites the reader to guess which half is guarded.
  • Two tests, one per kind of guarantee:
    • unknown_config_keys_are_rejected_and_named — the three defect classes above, each of which must now fail to load and whose error must name the offending key. A valid baseline config is asserted to load first, so a failure cannot be blamed on the fixture.
    • config_load_rejects_the_typo_and_the_shipped_example_still_loads — the same rejection through the real entry point Config::load (file → toml::from_str → validate), because that is the gate a deployer actually meets at startup, plus an assertion that config.example.toml still loads.

Naming the key is part of the fix, not a nicety: a rejection that says "unknown field" and stops there is harder to act on than the defect it replaces. toml does name it, and both tests pin that.

Tests

  • cargo test — 586 passed, 0 failed (baseline 584, plus the two new tests).
  • cargo fmt --check — clean.
  • cargo clippy --all-targets -- -D warnings — clean (the CI invocation, .github/workflows/ci.yml:30).

Checked by mutation: stripping the attribute from Model alone turns exactly the two new tests red — with the message this test writes (Chinese in the source, translated here): "the misspelled price key (cache_write_input_per_m → _mm): must fail to load, yet it was accepted" — while the other 24 tests in the module stay green. Restored: 586 / 0.

The three in-tree literals were verified to still parse, since each one is a config the project ships or embeds:

  • DEFAULT_CONFIG in src/main.rs, which a fresh standalone install boots from — embedded_default_config_is_complete still green.
  • CONFIG_TOML in src/catalog_gate.rs, the catalogue gate's copy of config.example.toml — still green.
  • Rollup's documented legacy key retain_days is a declared field (Option<i64>), so configs that still use it keep loading — rollup_legacy_retain_days_still_selects_the_window still green. The hardening does not break the one compatibility path the file deliberately keeps.

Billing impact, and the upgrade note

For a correctly configured deployment no stored row changes and no bill changes: this changes what is accepted, not how anything is priced. Correct configs parse to exactly the same values as before, which the existing suites demonstrate.

What does change is that a config carrying a stray or outdated key now fails to start, naming the key. That is the intent — a config that says something the runtime ignores should not look healthy — but it makes the upgrade fail-closed rather than fail-silent.

Deployers should diff their config against config/config.example.toml before upgrading, or grep for the keys they actually use. Any key that survives the diff is one the schema declares; anything else was being silently ignored until now, and the fix will tell them which.

Deliberate boundaries

config.toml is the sole source of model prices, and every structure accepted
any key it did not know: none of the fourteen carried deny_unknown_fields.
toml discarded the unknown key, the field stayed at its serde(default) value,
and the process loaded, validated, started and settled money at a price the
operator never declared.

The value a typo leaves behind is 0.0, which is a legal price meaning "free",
so validate() could not tell "the operator chose zero" from "the operator
misspelled the key" -- it checks signs, not presence. Measured on the shipped
example config: adding cache_write_input_per_mm = 3.5 under [[models]] loaded
clean, left cache_write_input_per_m at 0.0, and settled 60,000 cache-write
tokens at 0.000000 instead of the 0.210000 the operator wrote.

All fourteen structures now refuse an undeclared key at load time, and the
error names it. Both in-tree literals still parse unchanged -- the embedded
DEFAULT_CONFIG that a fresh install boots from, and catalog_gate's copy of
config.example.toml -- as does the shipped example itself, and Rollup's
documented legacy retain_days remains a declared field, so old configs that
use it keep working.

Closes #438
@argszero
argszero merged commit db0160f into main Oct 25, 2026
2 checks passed
@argszero
argszero deleted the fix/config-reject-unknown-keys branch October 25, 2026 03:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

config.toml silently accepts a misspelled price key and then bills at zero

1 participant