Skip to content

Commit be012c9

Browse files
cailmdaleyclaude
andcommitted
Actions: make shuttle/review accessors total against malformed inline shapes
The inline-fiber resolve clause validated only `is_map(fiber)`, never the shape of fiber["shuttle"] or fiber["shuttle"]["review"]. So: • a non-map shuttle (string / list / number) — `shuttle/1` returned it as-is, then enabled?/standing? called Map.get on it → BadMapError; • a scalar/list review on a standing fiber — review_state/1's get_in(shuttle, ["review", "state"]) walked into a non-Access value → FunctionClauseError. Either raised through to a bare Phoenix 500 where the controller already produces a graceful 4xx (unknown_target → 400) for other bad input. The current Portolan client always builds well-formed shapes, so this is public-contract robustness for any other/future caller. `shuttle/1` now coerces a non-map to %{} and `review_state/1` guards the review subtree, degrading a malformed shape to the default resolution path. Both accessors back action_for_target, so this hardens BOTH the inline-resolve path and the felt-derived actions_for path (malformed frontmatter). Well-formed input is unchanged. Test: POST malformed inline fibers (shuttle as string/list/number; standing fiber with review as string/list/number) → graceful 200, never 500; two controls pin that a non-standing string review still resolves dispatch-ad-hoc and a well-formed awaiting standing role still resolves accept-run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a89bcf3 commit be012c9

2 files changed

Lines changed: 106 additions & 2 deletions

File tree

lib/shuttle/actions.ex

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,25 @@ defmodule Shuttle.Actions do
196196
defp normalize_target("active"), do: "inFlight"
197197
defp normalize_target(target), do: target
198198

199-
defp shuttle(fiber), do: Map.get(fiber, "shuttle", %{}) || %{}
199+
# Total accessors: a malformed inline fiber (shuttle/review as a scalar or
200+
# list rather than a map) must degrade to the default path, not crash the
201+
# resolver with BadMapError / ArgumentError → a bare Phoenix 500. The current
202+
# client always builds well-formed shapes, so this is public-contract
203+
# robustness for any other caller. (overnight-audit C10 / finding 3.)
204+
defp shuttle(fiber) do
205+
case Map.get(fiber, "shuttle", %{}) do
206+
m when is_map(m) -> m
207+
_ -> %{}
208+
end
209+
end
210+
200211
defp enabled?(shuttle), do: Map.get(shuttle, "enabled") == true
201212
defp standing?(shuttle), do: Map.get(shuttle, "kind", Map.get(shuttle, "mode")) == "standing"
202-
defp review_state(shuttle), do: get_in(shuttle, ["review", "state"]) || "scheduled"
213+
214+
defp review_state(shuttle) do
215+
case Map.get(shuttle, "review") do
216+
review when is_map(review) -> Map.get(review, "state") || "scheduled"
217+
_ -> "scheduled"
218+
end
219+
end
203220
end

test/shuttle_web/controllers/api_controller_test.exs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,93 @@ defmodule ShuttleWeb.APIControllerTest do
498498
end)
499499
end
500500

501+
# overnight-audit C10 / finding 3: the inline-fiber resolve clause validated
502+
# only `is_map(fiber)`, never the shape of fiber["shuttle"] / ["review"]. A
503+
# non-map shuttle raised BadMapError; a scalar/list review raised
504+
# FunctionClauseError — both bubbling to a bare Phoenix 500 where a graceful
505+
# response is the documented contract. The accessors are now total.
506+
@tag :capture_log
507+
test "actions resolve degrades a malformed shuttle/review shape instead of 500ing" do
508+
malformed = [
509+
%{"id" => "x", "status" => "open", "shuttle" => "enabled"},
510+
%{"id" => "x", "status" => "open", "shuttle" => [1, 2]},
511+
%{"id" => "x", "status" => "open", "shuttle" => 7},
512+
%{
513+
"id" => "x",
514+
"status" => "open",
515+
"shuttle" => %{"enabled" => true, "kind" => "standing", "review" => "awaiting"}
516+
},
517+
%{
518+
"id" => "x",
519+
"status" => "open",
520+
"shuttle" => %{"enabled" => true, "kind" => "standing", "review" => ["awaiting"]}
521+
},
522+
%{
523+
"id" => "x",
524+
"status" => "open",
525+
"shuttle" => %{"enabled" => true, "kind" => "standing", "review" => 3}
526+
}
527+
]
528+
529+
for fiber <- malformed do
530+
conn =
531+
post(
532+
api_conn(),
533+
"/api/v1/actions/resolve",
534+
Jason.encode!(%{fiber: fiber, target: "tempered"})
535+
)
536+
537+
# Graceful: a resolved action (degraded to the default path), never a 500.
538+
assert conn.status == 200, "expected graceful 200 for #{inspect(fiber)}, got #{conn.status}"
539+
assert Jason.decode!(conn.resp_body)["action"]["id"] != nil
540+
end
541+
end
542+
543+
# Controls: well-formed shapes still resolve correctly after the hardening.
544+
@tag :capture_log
545+
test "actions resolve controls: well-formed shapes still resolve correctly" do
546+
# A non-standing fiber's review key is irrelevant — even a string review
547+
# must not change the resolution (it degrades to the default path).
548+
nonstanding =
549+
post(
550+
api_conn(),
551+
"/api/v1/actions/resolve",
552+
Jason.encode!(%{
553+
fiber: %{
554+
"id" => "x",
555+
"status" => "open",
556+
"shuttle" => %{"enabled" => true, "kind" => "oneshot", "review" => "awaiting"}
557+
},
558+
target: "inFlight"
559+
})
560+
)
561+
562+
assert nonstanding.status == 200
563+
assert Jason.decode!(nonstanding.resp_body)["action"]["id"] == "dispatch-ad-hoc"
564+
565+
# A well-formed awaiting standing role still resolves accept-run.
566+
standing =
567+
post(
568+
api_conn(),
569+
"/api/v1/actions/resolve",
570+
Jason.encode!(%{
571+
fiber: %{
572+
"id" => "x",
573+
"status" => "active",
574+
"shuttle" => %{
575+
"enabled" => true,
576+
"kind" => "standing",
577+
"review" => %{"state" => "awaiting"}
578+
}
579+
},
580+
target: "tempered"
581+
})
582+
)
583+
584+
assert standing.status == 200
585+
assert Jason.decode!(standing.resp_body)["action"]["id"] == "accept-run"
586+
end
587+
501588
# Single-source invariant (C1/C2 dual-source fix). The by-fiber-id resolve
502589
# (`Poller.resolve_action`) and the by-fiber-id availability
503590
# (`Poller.actions_for`, which `validate_available` gates on) BOTH derive

0 commit comments

Comments
 (0)