Skip to content

Commit 778096f

Browse files
committed
fix(manifest): a key the schema does not define is now a load error
ROOT CAUSE of the busbar-150 mislabelling, not a workaround for it. Runtime::Docker has no 'image' field - the image lives on 'launch' - but serde ignored unknown keys, so writing runtime.image parsed cleanly, was read by nothing, and changed nothing. A box ran getbusbar/busbar:1.4.1 for 25 minutes under the gateway key busbar-150, and would have published 1.4.1's numbers in a row labelled 1.5.0: two columns of the same binary presented as a version comparison, with deltas in the noise that read as 'no regression'. Nothing in the harness objected, because from serde's point of view nothing was wrong. deny_unknown_fields on Manifest and Runtime turns that class of mistake into a parse error naming the field, at load, where the fix costs seconds. It proved itself immediately: applying it caught a SECOND phantom key I had added in the same edit - a top-level 'version' that no other gateway declares and the Rust schema never had. Both are now gone from busbar-150, whose launch.image is the only image field and reads 1.5.0. The cost of the silent version was a run. 609 engine tests, fmt and clippy clean, and every shipped manifest still parses - asserted by a test, since a guard that rejects the board is worth no more than one that rejects nothing.
1 parent 35884db commit 778096f

7 files changed

Lines changed: 277402 additions & 4 deletions

File tree

‎engine/src/manifest.rs‎

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,18 @@ use serde::{Deserialize, Serialize};
1414

1515
/// How the gateway runs, and therefore how its process tree is found. This is the single declaration
1616
/// every memory reader and the stop path derive from.
17+
/* AN UNKNOWN KEY IS A SILENT NO-OP, AND THAT COSTS A RUN.
18+
`Runtime::Docker` has no `image` field - the image lives on `launch` - but serde ignored unknown
19+
keys, so a `runtime.image` written into a manifest parsed cleanly, was read by nothing, and
20+
changed nothing. On 2026-08-02 that produced a box running getbusbar/busbar:1.4.1 for 25 minutes
21+
under the gateway key `busbar-150`, which would have published 1.4.1's numbers in a row labelled
22+
1.5.0 and shown two columns of the same binary as if they were a version comparison. Nothing in
23+
the harness objected, because from serde's point of view nothing was wrong.
24+
25+
A manifest field nobody reads is either a typo or a stale key, and both should fail at load where
26+
the fix is cheap - not at publish, where the evidence is a box that no longer exists. */
1727
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18-
#[serde(tag = "kind", rename_all = "snake_case")]
28+
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
1929
pub enum Runtime {
2030
/// A container. The root pid comes from the container runtime, and the tree walk starts there.
2131
Docker {
@@ -182,7 +192,10 @@ pub struct ConfigSetting {
182192
pub note: String,
183193
}
184194

195+
// Same rule as `Runtime` above, for the same reason: a key the schema does not define is a defect,
196+
// and one that parses is a defect nobody sees.
185197
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198+
#[serde(deny_unknown_fields)]
186199
pub struct Manifest {
187200
/// Directory name. The gateway's identity on the board.
188201
pub name: String,
@@ -2559,3 +2572,69 @@ mod unresolvable_header_tests {
25592572
);
25602573
}
25612574
}
2575+
2576+
#[cfg(test)]
2577+
mod unknown_field_tests {
2578+
use super::*;
2579+
2580+
/* A MANIFEST KEY NOBODY READS MUST FAIL AT LOAD. Serde ignored unknown fields, so writing
2581+
`runtime.image` - a field `Runtime::Docker` does not have, because the image lives on `launch`
2582+
- parsed cleanly and changed nothing. The box ran getbusbar/busbar:1.4.1 for 25 minutes under
2583+
the gateway key `busbar-150`, and the only reason it was caught was a human asking to log in
2584+
and look at the container rather than trusting the config.
2585+
2586+
The cost of the silent version is a run; the cost of the loud version is a parse error naming
2587+
the field. */
2588+
#[test]
2589+
fn an_image_written_where_the_schema_has_none_is_refused() {
2590+
let json = r#"{
2591+
"name": "x", "display": "X", "lang": "Rust", "class": "Gateway",
2592+
"port": 8000, "model": "m", "auth": "t",
2593+
"runtime": { "kind": "docker", "container": "c", "image": "getbusbar/busbar:9.9.9" },
2594+
"egress": ["openai"]
2595+
}"#;
2596+
let err = serde_json::from_str::<Manifest>(json)
2597+
.unwrap_err()
2598+
.to_string();
2599+
assert!(
2600+
err.contains("image"),
2601+
"the error must name the field that was ignored, got: {err}"
2602+
);
2603+
}
2604+
2605+
/// And a stray top-level key is refused for the same reason - a typo'd or stale field is a
2606+
/// defect whichever level it sits at.
2607+
#[test]
2608+
fn a_stray_top_level_key_is_refused() {
2609+
let json = r#"{
2610+
"name": "x", "display": "X", "lang": "Rust", "class": "Gateway",
2611+
"port": 8000, "model": "m", "auth": "t",
2612+
"runtime": { "kind": "docker", "container": "c" },
2613+
"egress": ["openai"],
2614+
"verison": "1.5.0"
2615+
}"#;
2616+
let err = serde_json::from_str::<Manifest>(json)
2617+
.unwrap_err()
2618+
.to_string();
2619+
assert!(err.contains("verison"), "got: {err}");
2620+
}
2621+
2622+
/// The real manifests must still parse - a guard that rejects the field is worthless if it also
2623+
/// rejects the board.
2624+
#[test]
2625+
fn every_shipped_manifest_still_parses_under_the_stricter_schema() {
2626+
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../gateways");
2627+
let mut n = 0;
2628+
for e in std::fs::read_dir(&dir).expect("gateways/") {
2629+
let p = e.expect("entry").path().join("definition.json");
2630+
if !p.exists() {
2631+
continue;
2632+
}
2633+
let text = std::fs::read_to_string(&p).expect("read");
2634+
serde_json::from_str::<Manifest>(&text)
2635+
.unwrap_or_else(|err| panic!("{} no longer parses: {err}", p.display()));
2636+
n += 1;
2637+
}
2638+
assert!(n >= 14, "expected the whole field, saw {n}");
2639+
}
2640+
}

‎gateways/busbar-150/definition.json‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,6 @@
6363
"repo": "https://github.com/GetBusbar/busbar",
6464
"runtime": {
6565
"container": "busbar-bench",
66-
"image": "getbusbar/busbar:1.5.0",
6766
"kind": "docker"
68-
},
69-
"version": "1.5.0"
67+
}
7068
}

0 commit comments

Comments
 (0)