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

  1. Ensure ContinueGoal's serde attributes ignore unknown fields (no deny_unknown_fields) so the wire-supplied engine_schedule_id is dropped rather than rejected.
  2. Verify engine_schedule_id is #[serde(skip_deserializing)] or not a deserialization target at all, so the field is always None from the wire.
  3. 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

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


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)