Hmbown/CodeWhale · warning
continue_goal with a wire-supplied schedule id still parses
Error message
continue_goal with a wire-supplied schedule id still parses
What it means
A test assertion panic from `serde_json::from_value::<Op>(...).expect(...)`: deserializing a `continue_goal` Op that includes a wire-supplied `engine_schedule_id` must still succeed, with serde silently dropping that field. The schedule id is host-injected on an in-process channel; accepting it from the wire would let a guessed counter hijack the pending schedule and skip the host-injected quiet period. The expect fails if the deserializer rejects the unknown/malformed field or the payload no longer maps to Op::ContinueGoal.
Solutions
- Ensure ContinueGoal's serde attributes ignore unknown fields (no deny_unknown_fields) so the wire-supplied engine_schedule_id is dropped rather than rejected.
- Verify engine_schedule_id is #[serde(skip_deserializing)] or not a deserialization target at all, so the field is always None from the wire.
- If the Op's kind tag or required fields changed, update the fixture JSON in the test to the current wire shape.
Example fix
// before (would error or accept the field)
#[derive(Serialize, Deserialize)]
#[serde(tag = "kind", deny_unknown_fields)]
pub enum Op { ContinueGoal { engine_schedule_id: Option<u64> } }
// after
#[derive(Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum Op {
ContinueGoal {
#[serde(skip_deserializing)]
engine_schedule_id: Option<u64>,
}
} Defensive patterns
Strategy: validation
Validate before calling
// confirm the Op payload parses and the sensitive field is absent
let parsed: Result<Op, _> = serde_json::from_value(json!({
"kind": "continue_goal",
"dynamic_tools": [],
"engine_schedule_id": 3,
}));
assert!(parsed.is_ok(), "Op failed to parse: {:?}", parsed.err()); Type guard
fn is_continue_goal(op: &Op) -> Option<&Option<u64>> {
match op {
Op::ContinueGoal { engine_schedule_id, .. } => Some(engine_schedule_id),
_ => None,
}
} Try / catch
let op: Op = serde_json::from_value(value)
.unwrap_or_else(|e| panic!("continue_goal parse failed: {e}")); Prevention
- Never place host-only fields (engine_schedule_id) in the deserialization target set; use #[serde(skip_deserializing)].
- Avoid deny_unknown_fields on wire-facing enums so untrusted extra keys are ignored, not fatal.
- Add a regression test any time the Op serde tagging strategy changes (internally vs externally tagged).
When it happens
Trigger: serde_json::from_value on {"kind":"continue_goal","dynamic_tools":[],"engine_schedule_id":3}: panics if the Op enum's serde representation changed (e.g. kind tag renamed, `dynamic_tools` no longer a defaultable/required field) or if an over-strict deny_unknown_fields/deserializer now errors on the extra `engine_schedule_id` key instead of ignoring it.
Common situations: Refactoring the Op enum's serde tagging (tag/deny_unknown_fields changes) breaking this security regression test; renaming `dynamic_tools` or changing ContinueGoal fields; accidentally making engine_schedule_id deserializable from the wire, which the follow-up assert then catches as a security bug.
Understand the failure class
Background: "failed to unmarshal" / json.Unmarshal errors: why parsing a response into a Go struct fails and how to fix it — this error's family across 23 libraries.
Related errors
- deserialize
- legacy route should parse
- serialize
- cached read tool executes
- effective permissions should round-trip
AI-assisted analysis of Hmbown/CodeWhale@433685b202 (2026-09-15).
Data as JSON: /api/errors/f983bf43829e2d5f.
Report an issue: GitHub.
Appendix: source
Thrown at crates/protocol/src/op.rs:709
#[test]
fn every_variant_is_listed_once() {
let kinds: Vec<&str> = every_variant().iter().map(Op::kind_str).collect();
assert_eq!(kinds, OP_KINDS, "OP_KINDS must list every variant in order");
}
#[test]
fn wire_supplied_engine_schedule_id_is_dropped() {
// The engine mints these on an in-process channel, so a value arriving
// through serde came from an out-of-process caller. Honouring it would
// let a guessed counter consume the pending schedule and skip the
// host-injected quiet period.
let op: Op = serde_json::from_value(json!({
"kind": "continue_goal",
"dynamic_tools": [],
"engine_schedule_id": 3,
}))
.expect("continue_goal with a wire-supplied schedule id still parses");
match op {
Op::ContinueGoal {
engine_schedule_id, ..
} => assert_eq!(
engine_schedule_id, None,
"engine_schedule_id must never be settable from the wire"
),
other => panic!("expected ContinueGoal, got {other:?}"),
}
}
#[test]
fn every_variant_round_trips_and_tags_by_kind() {
for op in every_variant() {
let value = serde_json::to_value(&op).unwrap();
assert_eq!(value["kind"], op.kind_str(), "{op:?}");
let back: Op = serde_json::from_value(value).unwrap();
assert_eq!(back, op);View on GitHub (pinned to 433685b202)