Hmbown/CodeWhale · error
effective permissions should round-trip
Error message
effective permissions should round-trip
What it means
This is the panic from `back.effective_permissions.expect("effective permissions should round-trip")` in the `fleet_resolved_route_round_trips` test at crates/protocol/src/fleet.rs:1744. `FleetReceipt.effective_permissions` is an `Option<FleetEffectivePermissions>` describing the worker's actual runtime permissions (write, network, shell scope, tool list, spawn depth, profile). The test serializes a receipt that has `effective_permissions: Some(...)`, deserializes it back, and unwraps the option, asserting the permissions survived the JSON round trip. It panics when the deserialized receipt's `effective_permissions` is `None`, meaning serde dropped or failed to restore the field — typically a missing/incorrect `#[serde(default)]`/rename on `effective_permissions`, a field name mismatch in `FleetEffectivePermissions`, or the fixture accidentally producing a receipt without permissions.
Solutions
- Check the deserialized receipt directly (print or assert before unwrapping) to confirm whether the JSON even contains the `effective_permissions` key — if absent, the serialization side (skip attribute, renamed key) is at fault; if present, the struct deserialization side is.
- Verify `FleetReceipt.effective_permissions` and `FleetEffectivePermissions` still derive `Serialize`/`Deserialize` with matching field names, and that every field of `FleetEffectivePermissions` is JSON-compatible with its declared type (`shell`/`tool_scope`/`source` as `String`, `tools` as `Vec<String>`, `profile_id`/`profile_origin` as `Option<String>`).
- If the fixture regressed, restore `effective_permissions: Some(FleetEffectivePermissions { ... })` in `sample_receipt_with_route` as shown at fleet.rs:1702-1713.
- If a newly added permission field breaks parsing, annotate it `#[serde(default)]` (with `skip_serializing_if` for Options) so old receipts — like the pre-#3154 legacy ledger JSON covered by `fleet_receipt_without_resolved_route_still_deserializes` — still deserialize.
- Re-run: `cargo test -p codewhale-protocol fleet_resolved_route_round_trips`.
Example fix
// before
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effective_permissions: Option<FleetEffectivePermissions>,
pub struct FleetEffectivePermissions {
pub tool_scope: ToolScope, // renamed type/field: JSON key no longer matches, parse fails => None
}
// after
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effective_permissions: Option<FleetEffectivePermissions>,
pub struct FleetEffectivePermissions {
pub tool_scope: String, // or #[serde(alias = "old_name")] on the renamed field
} Defensive patterns
Strategy: try-catch
Validate before calling
// Confirm a receipt round-trips its permissions before unwrapping
fn permissions_preserved(before: &FleetReceipt, after: &FleetReceipt) -> bool {
after.effective_permissions == before.effective_permissions
}
// use: assert!(permissions_preserved(&receipt, &back)); Type guard
fn effective_permissions(r: &FleetReceipt) -> Option<&FleetEffectivePermissions> {
r.effective_permissions.as_ref()
}
// use: if let Some(perm) = effective_permissions(&back) { ... } else { /* handle missing */ } Try / catch
let Some(permissions) = back.effective_permissions.as_ref() else {
panic!("effective_permissions lost in round trip: json={} ", serde_json::to_string(&back).unwrap_or_default());
}; Prevention
- When adding fields to FleetEffectivePermissions, give each #[serde(default)] so both new and legacy receipts parse; the outer Option must never silently become None.
- Mirror field names/types exactly on the struct and the JSON the fixture produces (String vs enum matters).
- Never drop effective_permissions from the sample fixture when editing sample_receipt_with_route.
- Check round-tripped Options for Some before assert_eq so a serde regression surfaces with context, not a bare expect panic.
- Run `cargo test -p codewhale-protocol fleet_resolved_route_round_trips` after any serde/permissions change.
When it happens
Trigger: The panic occurs when `FleetReceipt::effective_permissions` deserializes to `None` despite being `Some` before serialization: (1) the field or struct lost/misspelled its serde attributes so the key is written under a different name and silently ignored on read; (2) `FleetEffectivePermissions` gained a required (non-default) field that the serializer omits, failing that branch — combined with `#[serde(default)]` on the outer Option this yields `None` instead of an error; (3) a test fixture builds the sample receipt with `effective_permissions: None` after an edit; (4) new fields in `FleetEffectivePermissions` use types that don't match their JSON representation so deserialization of the struct fails and a lenient `default` masks it as `None`.
Common situations: A developer adds a permission knob (e.g. a new tool-scope field) to `FleetEffectivePermissions` or renames one of its fields, runs `cargo test -p codewhale-protocol fleet_resolved_route_round_trips`, and the expect at line 1744 panics because the round-tripped option came back `None`. Also hit when serde attribute refactors accidentally apply `skip_serializing_if` to `effective_permissions` without a matching default path, or when the fixture helper `sample_receipt_with_route` is edited to drop the permissions.
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
- continue_goal with a wire-supplied schedule id still parses
- deserialize
- fixture catalog parses
- legacy route should parse
- roundtrip
AI-assisted analysis of Hmbown/CodeWhale@433685b202 (2026-09-15).
Data as JSON: /api/errors/8c738d381872a5d1.
Report an issue: GitHub.
Appendix: source
Thrown at crates/protocol/src/fleet.rs:1744
assert_eq!(route.wire_model_id, "deepseek-v4-pro");
assert_eq!(route.protocol, "chat_completions");
assert_eq!(route.role.as_deref(), Some("builder"));
assert_eq!(route.loadout.as_deref(), Some("auto"));
assert_eq!(route.model_class.as_deref(), Some("balanced"));
assert_eq!(route.model_route.as_deref(), Some("auto"));
assert_eq!(route.reasoning_effort.as_deref(), Some("high"));
assert_eq!(route.role_source.as_deref(), Some("task.role"));
assert_eq!(route.loadout_source.as_deref(), Some("task.loadout"));
assert_eq!(
route.model_class_source.as_deref(),
Some("task.model_class")
);
assert_eq!(route.model_source.as_deref(), Some("task.model"));
assert_eq!(route.source, "resolver");
let permissions = back
.effective_permissions
.expect("effective permissions should round-trip");
assert!(permissions.write);
assert!(permissions.network);
assert_eq!(permissions.shell, "full");
assert_eq!(permissions.tool_scope, "explicit");
assert_eq!(
permissions.tools,
vec!["read_file".to_string(), "apply_patch".to_string()]
);
assert!(permissions.background);
assert_eq!(permissions.max_spawn_depth, 2);
assert_eq!(permissions.profile_id.as_deref(), Some("builder"));
assert_eq!(permissions.profile_origin.as_deref(), Some("built_in"));
assert_eq!(permissions.source, "worker_runtime_profile");
}
#[test]
fn fleet_receipt_without_resolved_route_still_deserializes() {
// An old ledger receipt JSON written before #3154 has noView on GitHub (pinned to 433685b202)