Soul's Cut 2 verdicts left five findings after PA.f16/f17: two decode gaps,
two missing open-time refusals, one duplication/placement cleanup, and four
surviving mutations with no test naming them. All fix here.
PA.f18: LocalChatResponse.id and LocalChatMessage.tool_calls both integrity-
violated on shapes a nonstandard local server can plausibly send. An id is
used only for receipt bookkeeping, so #[serde(default)] tolerates an absent
one. tool_calls already tolerated an absent field through #[serde(default)]
but not an explicit "tool_calls": null, since default only fires when the
key is missing; deserialize_with = "null_as_default" covers both. Tool-call
ids still integrity-violate on absence, since the tool loop must correlate
them.
PA.f19: open_inference now refuses an empty local or SDK model prefix
(ControllerOpenError::EmptyModelPrefix) instead of letting it match every
model and silently take a transport's lanes. assemble_output now refuses a
reply carrying the same tool-call id twice, as integrity_violation, since the
port owns reply integrity and duplicate ids would otherwise reach the
evaluator unchecked.
PA.f20: call_id_is_valid and tool_name_is_valid are hoisted to one
pub(super) copy in controllers.rs; local_inference.rs and sdk_inference.rs
both import it instead of keeping their own. The loopback check moves out of
open_inference and into LocalInferencePort::new itself, so it holds by
construction for every caller, not only the one open_inference exercises;
open_local_port and open_inference propagate the constructor's Result. A
request offering no tools now omits `tools` and `parallel_tool_calls` from
the lowered body instead of sending an empty array. Test helper
two_tool_call_reply is folded into tool_call_reply, which now takes a slice
of calls.
PA.f22: one test per mutation that survived Soul's Cut 2 pass, each run by
hand against this commit and reverted after killing its test:
offered_tools_are_declared_in_the_request_body (S2, empty tools array),
the_model_prefix_is_stripped_in_the_request_body (S3, unstripped model),
infer_refuses_a_foreign_caller and infer_refuses_an_expired_invocation (S4,
both infer gates), the_receipt_digest_changes_with_the_response_id (S6,
response_id dropped from the receipt).
Local lib census: 9 (after the prior commit) -> 21 in local_inference::;
488 -> 502 in the full `cargo test -p ghostlight --lib` run (501 passed, 1
ignored).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>