Implement generic Prebid bid param override rules#618
Conversation
Renames the new field to match the existing `bid_param_zone_overrides` naming convention. Updates all references: struct field, env var key, doc comments, TOML example, tests, and .env.example. Also replaces production IDs in test fixtures and examples with generic placeholder values.
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Well-designed feature. The BidParamOverrideEngine with its canonical rule format and compatibility normalization from bid_param_overrides/bid_param_zone_overrides is a clean architecture. Good validation at startup via try_from_config and json_object_for_override. Test coverage is thorough — especially the engine unit tests and precedence ordering tests. A few suggestions below.
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Well-designed PR — the unified rule engine is clean, well-tested, and the compatibility migration strategy is sound. Approving with two recommended fixes.
aram356
left a comment
There was a problem hiding this comment.
Summary
Replaces the split bid-param override paths with a single generic BidParamOverrideEngine that normalizes all three config surfaces into an ordered rule list at startup. Clean design, solid compatibility story, comprehensive tests.
Non-blocking
♻️ refactor
- Redundant validation-only engine build in
PrebidIntegration::try_new: builds and discards the engine just for validation — same work is repeated inPrebidAuctionProvider::try_new(prebid.rs:233)
🤔 thinking
deny_unknown_fieldsonBidParamOverrideWhen: strict is good, but adding new matcher fields later becomes a breaking config change (prebid.rs:189)- Shallow merge semantics vs. nested override values: real-world bidder params can be nested objects — shallow merge replaces the entire value rather than deep-merging (prebid.rs:504)
⛏ nitpick
parse_prebid_toml_resulterror message: "should be enabled" is misleading whenNonecould also mean the section is missing entirely (prebid.rs:1627)
🌱 seedling
- Zone-only rules (no bidder): the engine requires at least one matcher, so "for all bidders in zone X" rules aren't possible yet — could be useful for zone-wide floor overrides
CI Status
- fmt: PASS
- clippy: PASS
- cargo test: PASS
- vitest: PASS
- integration tests: PASS
- browser integration tests: PASS
aram356
left a comment
There was a problem hiding this comment.
Summary
Adds a canonical ordered bid_param_override_rules engine and normalizes the two compatibility surfaces (bid_param_overrides, bid_param_zone_overrides) into the same runtime engine. Architecture is sound, validation fails startup fast, and the test coverage for parsing, determinism, and application order is strong. The blocker is a spec/impl/docs contradiction on merge semantics that the PR ships consistently wrong across six places.
Blocking
🔧 wrench
- Deep merge contradicts the design spec's explicit non-goal:
merge_bidder_param_objectincrates/trusted-server-core/src/integrations/prebid.rs:509recurses into nested objects, but the spec (docs/superpowers/specs/2026-04-08-prebid-generic-bid-param-override-rules-design.mdline 32 goal, line 40 non-goal, line 81 rule semantics) and the plan (docs/superpowers/plans/2026-04-08-prebid-generic-bid-param-override-rules.md:7) both explicitly say shallow. The rustdoc onBidParamOverrideRuleand the unit testbidder_param_override_deep_merges_nested_objectssay deep. Inline fix suggestion on the helper. - Silent behavior change for existing
bid_param_zone_overrides: the pre-PR code at the previous apply site didbase.extend(...)(shallow). This PR routes the same config through the recursive helper, so any deployment with a nested-object zone override gets a different outgoing request body with no opt-out. Compatibility sugar must preserve runtime semantics. Inline comment atcrates/trusted-server-core/src/integrations/prebid.rs:862. trusted-server.toml:66describes semantics incorrectly: ships in the operator template as "shallow last-write-wins merge" while the code is recursive. Inline comment on the file.docs/guide/configuration.mddescribes merge as shallow in both the table rows (lines 706-707) and the Bid Param Override Surfaces bullets (lines 774-775). Inline comment.docs/guide/integrations/prebid.mddescribes merge as shallow across the table rows (58-59), the TOML example comments (33, 38), thebid_param_overridesbehavior bullets (170), thebid_param_zone_overridesbehavior bullets (198), and the newBid Param Override Rulessection (234). Inline comment on the canonical-rules section.
The resolution is to pick one answer (shallow or deep) and make all six places agree. My recommendation is shallow, because (a) it matches the spec and plan, (b) it preserves existing bid_param_zone_overrides behavior with no silent break, and (c) deep merge is an operator footgun — a rule like set = { keywords = { genre = "news" } } will silently leak stale client-side keys (keywords.sport = "football") through to PBS, which is rarely what an operator intends when they write an override. If deep merge is the intentional choice, the spec, plan, trusted-server.toml:66, both docs pages, the PR description, and a prominent callout of the keywords footgun all need to be updated as part of this PR — the status quo of "code says deep, everything else says shallow" is not acceptable to ship.
Non-blocking
♻️ refactor
- Engine compiled twice on registration (
crates/trusted-server-core/src/integrations/prebid.rs:232-238):PrebidIntegration::try_newbuildsBidParamOverrideEngine, drops it, thenPrebidAuctionProvider::try_newrebuilds it from the same config. The comment in the code already acknowledges this. Cleanest fix is to cache the compiled engine onPrebidIntegrationand thread it intoPrebidAuctionProvider::try_new, so validation and runtime share one instance and can't drift. Alternatively, extract a cheapvalidate_override_config(&config) -> Result<(), …>for the validation-only path. or_insert(Json::Null)placeholder inmerge_bidder_param_object(crates/trusted-server-core/src/integrations/prebid.rs:513): writes aNulland immediately overwrites it on every new key. Aserde_json::map::Entrymatch is cleaner. Skip if you restructure for the shallow-merge fix anyway.
🤔 thinking
- Asymmetric whitespace trimming: matcher strings are
.trim()-ed at compile time (validate_override_matcher_string, line 327), but runtime facts (facts.bidder,facts.zone) are not. Today the runtime facts come from bidder-map keys andtrustedServer.zone, so mismatches are unlikely — but the asymmetry is subtle and worth either dropping the config-side trim (strict equality, "configure what you mean") or explicitly documenting it. Not blocking. - Undocumented case sensitivity: matching is exact and case-sensitive. An operator with
when.bidder = "Kargo"and a runtime bidderkargogets a silently non-matching rule. Recommend a one-line note in the prebid docs. A nice-to-have stretch goal is a startup warning when a rule'swhen.bidderis not inconfig.bidders/client_side_bidders— it catches typos without false positives.
📝 note
- Public API shape change on
PrebidIntegrationConfig.bid_param_zone_overrides(line 115): type went fromHashMap<String, HashMap<String, Json>>toHashMap<String, HashMap<String, serde_json::Map<String, Json>>>. Deserialization is unaffected for any real object-shaped TOML/JSON; it hardens against previously-accepted non-object values that the old runtime silently skipped. Flagging so downstream consumers constructing the struct programmatically are aware.
🌱 seedling
applyis O(bidders × rules) per imp. Fine at today's rule counts, but the rule engine is the natural landing spot for broader override catalogs. If the rule count grows, an index keyed by(bidder, zone)with a wildcard bucket forbidder = Noneturns this into O(matching-rules). Not a concern for this PR.
👍 praise
engine_compiles_compatibility_rules_in_sorted_matcher_orderruns the same config 16 times and asserts the compiled order on each iteration. Exactly the right instinct for catching HashMap iteration non-determinism. Nice defensive test.deny_unknown_fieldsonBidParamOverrideRuleandBidParamOverrideWhen(lines 178-198) turns operator typos likewhen.bidders = "kargo"into hard config errors at startup, which is the right failure mode.- Eager validation via
try_newin bothPrebidIntegrationandPrebidAuctionProviderpropagates config errors throughReport<TrustedServerError>, matching the project rule that invalid enabled config must surface as startup errors rather than be silently disabled.
CI Status
- fmt: PASS
- clippy: PASS
- rust tests: PASS
- vitest: PASS
- format-typescript: PASS
- format-docs: PASS
- integration tests: PASS
- browser integration tests: PASS
- CodeQL: PASS
| ); | ||
| } else { | ||
| *existing = v.clone(); | ||
| } |
There was a problem hiding this comment.
🔧 wrench — Deep merge contradicts the design spec's explicit non-goal.
This helper recurses into nested objects, but the spec at docs/superpowers/specs/2026-04-08-prebid-generic-bid-param-override-rules-design.md lists:
- Goal (line 32): "shallow merge into bidder params"
- Non-goal (line 40): "Deep JSON merge semantics."
- Line 81: "
set: a non-empty JSON object shallow-merged into bidder params"
The plan at docs/superpowers/plans/2026-04-08-prebid-generic-bid-param-override-rules.md:7 reiterates: "Keep the current shallow-merge semantics and last-write-wins precedence".
On top of that, every operator-facing doc that landed in this same PR (trusted-server.toml:66, docs/guide/configuration.md:706-775, docs/guide/integrations/prebid.md:33/38/58-59/170/198/234) tells operators the merge is shallow, while the rustdoc on BidParamOverrideRule.set and the test bidder_param_override_deep_merges_nested_objects say the opposite. Spec, code, tests, and docs are all out of sync on the one semantic that matters for this feature.
Concrete operator footgun: with deep merge, a rule like set = { keywords = { genre = "news" } } silently leaks client-side keys such as keywords.sport = "football" through to PBS. An operator reading the docs would reasonably expect the entire keywords object to be replaced.
Preferred fix — implement shallow merge as specified:
fn merge_bidder_param_object(params: &mut Json, override_obj: &serde_json::Map<String, Json>) {
match params {
Json::Object(base) => {
for (k, v) in override_obj {
base.insert(k.clone(), v.clone());
}
}
_ => {
*params = Json::Object(override_obj.clone());
}
}
}Then:
- flip
bidder_param_override_deep_merges_nested_objectsto assert nested objects are replaced, not merged; - update the rustdoc on
BidParamOverrideRule.set(line 183-184) andBidParamOverrideRule(line 176-177) to say "shallow-merged".
Alternative — intentionally adopt deep merge. If that's the call, the spec, plan, trusted-server.toml:66, both docs pages, and the PR description all need to be updated to advertise deep merge, and the prebid docs should call out the keywords footgun explicitly so operators aren't surprised. Either way, pick one answer and make everything agree.
| } | ||
| self.bid_param_override_engine | ||
| .apply(BidParamOverrideFacts { bidder: name, zone }, params); | ||
| } |
There was a problem hiding this comment.
🔧 wrench — Silent behavior change for existing bid_param_zone_overrides users.
Pre-PR, zone overrides were applied inline with:
base.extend(ovr.iter().map(|(k, v)| (k.clone(), v.clone())));That was a flat, shallow merge. This PR routes the same config through merge_bidder_param_object, which recurses into nested objects. Any existing deployment with a nested object under a zone override (for example header = { ext = { foo = "bar" } }) now gets a different outgoing request body with no opt-out and no release note.
bid_param_zone_overrides is explicitly framed as "compatibility sugar" in the rustdoc (lines 103-112) and the design doc (Goals → "Preserve the existing operator-friendly config shape"). Compatibility sugar must preserve runtime semantics byte-for-byte — otherwise the normalization is a breaking change in disguise.
Fixing the merge helper per the previous comment resolves this one as a side-effect. If you decide to keep deep merge, at minimum add an integration test that pins the old byte-for-byte behavior for a nested-object zone override and document the change in the PR description.
| # header = {placementId = "_abc"} | ||
|
|
||
| # Preferred canonical override format for future rules. | ||
| # Rules run in order with exact-match conditions and shallow last-write-wins merge. |
There was a problem hiding this comment.
🔧 wrench — Comment describes merge semantics incorrectly.
# Rules run in order with exact-match conditions and shallow last-write-wins merge.
The implementation (merge_bidder_param_object in crates/trusted-server-core/src/integrations/prebid.rs:509) is recursive, not shallow. This file ships in the repo template operators copy from, so the comment is load-bearing — it is the first thing a new operator reads.
Once the shallow-vs-deep question is resolved (see inline comment on merge_bidder_param_object), update this line to match. If deep merge stays, at minimum replace "shallow" with "deep" and add a one-line note that nested objects are merged recursively so operators aren't surprised when stale client sub-keys leak through.
| | `timeout_ms` | Integer | `1000` | Request timeout in milliseconds | | ||
| | `bidders` | Array[String] | `["mocktioneer"]` | List of enabled bidders | | ||
| | `bid_param_overrides` | Table | `{}` | Static per-bidder param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | | ||
| | `bid_param_zone_overrides` | Table | `{}` | Per-bidder, per-zone param overrides; normalized into the canonical override-rule engine and shallow-merged into bidder params | |
There was a problem hiding this comment.
🔧 wrench — Docs describe merge as shallow; implementation is recursive deep merge.
Both the bid_param_overrides (line 706) and bid_param_zone_overrides (line 707) table rows say "shallow-merged into bidder params", and the "Bid Param Override Surfaces" bullets below (lines 774-775) repeat the same. The code does recursive deep merge and has a unit test enforcing it.
This is the third copy of the same mismatch that lands in this PR (trusted-server.toml:66, crates/trusted-server-core/src/integrations/prebid.rs:509, and this file). Please resolve the spec-vs-impl question first (see inline comment on merge_bidder_param_object) and then make configuration.md, the prebid integration page, the inline toml comments, the rustdoc, the spec, and the plan all agree on one answer.
|
|
||
| ### Bid Param Override Rules | ||
|
|
||
| Use `bid_param_override_rules` for the canonical ordered override format. Each rule contains exact-match `when` conditions and a non-empty `set` object that is shallow-merged into bidder params when all populated matchers match. |
There was a problem hiding this comment.
🔧 wrench — "Bid Param Override Rules" section claims shallow merge.
This is the most user-visible description of the new canonical surface and it contradicts the implementation. The table rows (lines 58-59), the bid_param_overrides behavior bullet (line 170), the bid_param_zone_overrides behavior bullet (line 198), and the TOML-comment shallow-merge annotations (lines 33, 38) all carry the same claim.
If the implementation stays at deep merge, this section is also the natural place to document the footgun: a rule like set = { keywords = { genre = "news" } } will deep-merge into the client's keywords object, preserving keys like keywords.sport = "football" that the operator likely intended to replace. Operators writing rules against bidders that use nested param objects (appnexus keywords, openx customParams, etc.) need to know this up front.
Same resolution as the other 🔧 findings — pick shallow or deep, then update this page, configuration.md, trusted-server.toml, the rustdoc, the spec, and the plan together.
Summary
bid_param_overridesandbid_param_zone_overridesas compatibility config, while adding canonicalbid_param_override_rulesfor future config-driven overrides.bidder/zonematch with shallow last-write-wins merges.Changes
crates/trusted-server-core/src/integrations/prebid.rsBidParamOverrideRule,BidParamOverrideWhen, andBidParamOverrideEngine; normalize compatibility fields and canonical rules into one runtime path; apply rules during OpenRTB construction; update testscrates/trusted-server-core/src/settings.rsTRUSTED_SERVER__INTEGRATIONS__PREBID__BID_PARAM_OVERRIDE_RULEStrusted-server.tomlbid_param_override_rulesand clarify that compatibility fields normalize into the same enginedocs/superpowers/plans/2026-04-08-prebid-generic-bid-param-override-rules.mdCloses
Closes #617
Related: #339, #383, #384
Test plan
cargo fmt --all -- --checkcargo test -p trusted-server-corecargo clippy -p trusted-server-core --all-targets --all-features -- -D warningscargo test --workspacecargo clippy --workspace --all-targets --all-features -- -D warningsChecklist
logmacros (notprintln!)