fix(sdk): 0.3.4 must-fixes — encode module loading + raw response bodies (+5 hardening commits) - #18
Conversation
Codec.encode/1 gated the struct path on function_exported?(module,
:to_map, 1), which never loads the module. When a payload struct's
module was not yet loaded (normal for compile-time struct literals
under interactive-mode code loading, e.g. mix test / iex), encode fell
back to Map.from_struct and returned without recursing: the
:__archastro_unset__ sentinel reached the wire as a literal string,
renamed fields kept their struct keys (alias_ instead of "alias"),
keys stayed atoms, and nested structs and DateTimes went un-encoded.
The fallback re-fires independently at every nesting level, so the fix
belongs in the encode clause itself, mirroring the existing
Code.ensure_loaded?/1 guard on the decode-side {:ref, module} matcher.
Regression test encodes every generated Input/Params struct with
all-unset optionals under purged modules and asserts wire cleanliness,
plus a renamed-field wire-name check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Raw operations (raw: true) return the Req.Response so callers get the
body exactly as served, but Req's decode_body step ran first: a raw
blob served with Content-Type: application/json arrived as a decoded
map instead of the raw binary. Combined with the generator emitting
decode: :string for GET /trajectories/{trajectory}/contents, that op
raised on every successful non-empty fetch. Pass decode_body: false
for raw requests so the body arrives untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A naked Codec.decode in the Socket GenServer meant one malformed
broadcast, reply, or join response raised inside the socket process,
killing every channel, subscription, pending push, and the linked
owner. A payload-less {:reply, :error, socket} from the server
(collapsed to the bare atom :error by Slipstream) hit the same decode
path and had the same blast radius.
- broadcasts: drop the message for that subscriber and emit
[:archastro, :channel, :decode_failure] telemetry
- replies: resolve the push with {:error, %Error{code: "decode_failure"}}
- join responses: fail the waiting joiners, leave the topic, keep the
socket alive
- bare :error replies: resolve as a channel error instead of decoding
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A NaiveDateTime or Time in a request payload fell through encode's struct fallback into Map.from_struct, surfacing later as an opaque Jason tuple crash deep inside Req. Raise an ArgumentError at the encode boundary that names the value and the conversion the caller needs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The internal default Req.new(retry: false) in Client.build and TokenServer.Default left Req's redirect-following on. A 302 from the platform silently rewrote a token-refresh POST into a body-less GET against the Location target. A 3xx is never a valid platform response; surface it as the error it is. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A nil scalar in a plain-map query serialized as the literal string "null" (scalar/1 had a nil clause), and the unset-sentinel filter was dead code because Codec.encode rewrites sentinels to nil before the filter runs. Treat nil — top-level or inside a repeated param — as "not provided" and omit it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pending_pushes and pending_joins entries had no expiry: a push for a
fire-and-forget event (or a join the server never acks) parked its
entry forever on a long-lived socket. Entries now carry an insertion
timestamp and a periodic sweep fails anything older than a full
interval (60s, so an effective 60-120s grace — far above the 5s
default Channel call timeout) with {:error, %Error{code: "no_reply"}}.
Replies to callers that already timed out are no-ops.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pull-request runs pinned the harness checkout to a long-dead SDK development branch that predates the harness's nullable-schema normalization, so it exits at boot against the 0.3.3 spec and all 15 channel-contract tests fail on any PR (main pushes already use main and are green). Verified locally: harness built from main passes all 15 against this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review — round 1 (head d97a362)Verdict on the diff: no findings — all seven commits verified against the consolidated slice-4 ask. But CI is red for a reason outside this PR (root-caused below); one workflow-fix commit is required before merge. Verified:
CI red — root cause is a pre-existing workflow landmine, not this diff. All channel-contract failures are the harness subprocess exiting 1 at boot. Required change (new commit, no amend): |
Codec.encode ran inside the Socket GenServer for pushes and joins, so any unencodable payload raised there and killed the shared socket with every other channel, subscription, and in-flight push on it. That was survivable while encode was total, but this branch makes it raise on NaiveDateTime/Time — an easy value to hand it, since Ecto timestamps() defaults to :naive_datetime. Encode in Channel.push/join instead, so a bad payload fails only its own caller. Found by an adversarial audit subagent over this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Suppressing Req's body decoding for raw requests also stripped the
error envelope off failure responses: Error.from_response discards a
non-map body, so a 404 carrying {"error": {"message", "code"}}
degraded to the generic "ArchAstro API returned HTTP 404" with no
code or details. Decode a binary body before building the error, the
same way the SSE path already does; a non-JSON body is left alone.
Found by an adversarial audit subagent over this branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous commit removed the sentinel clause on the premise that Codec.encode always rewrites :__archastro_unset__ to nil first. That holds only for structs exporting to_map/1; the Map.from_struct fallback leaves sentinels intact, so a caller-built params struct following the SDK's own sentinel convention crashed in scalar/1 where it used to produce the correct URL. Drop both nil and the sentinel, top-level and inside repeated params. Found by an adversarial audit subagent over this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A join acked after its waiters are gone (swept, or callers that gave up) left Slipstream's join status at :joined while no channel existed in assigns. Slipstream's join/3 no-ops unless the status is nil or :closed, so every later join of that topic queued a pending entry that could never be satisfied — the topic stayed unjoinable until the socket reconnected. Leave the topic instead, returning it to a closed state. Established topics rejoined after a reconnect keep their channel and are not left. Found by an adversarial audit subagent over this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round 2 (head 3c7702f) — CI green, four self-found fixes verifiedVerdict: meets bar. No findings. The workflow fix worked: both matrix legs pass, which confirms the root cause (harness built from the Aug-7 branch on PRs). Merge-ready from my side. The four new fixes beyond the CI change are all real, and two of them close holes the round-1 hardening itself opened — good catches:
Chain after merge: main CI → |
Review on ArchCode
Problem and author intent
Remote-eval slice 4's strict typed proof and the follow-up adversarial sweep found two must-fix bugs in archastro 0.3.3, plus five hardening findings. A post-open adversarial audit of this branch then found four more defects — one of them introduced by the hardening in this PR — which are fixed in commits 9–12. Evidence and repro scripts live in the slice-4 consolidated ask (wt7 scratchpad,
sdk-0.3.4-consolidated-ask.md).@calvin: commits 3–7 are the hardening set; ride-0.3.4-or-fast-follow is your call. Note commits 9 and 12 fix defects in that hardening, so peeling 3–7 means peeling those too.
Must-fixes
1 —
Codec.encodemodule-loading fallback (lib/archastro/codec.ex).encode(%module{})gated the struct path onfunction_exported?(module, :to_map, 1), which never loads the module. An unloaded payload-struct module (normal for compile-time struct literals under mix test / iex — exactly where consumers' merge gates run) silently fell back toMap.from_struct, which also returns without recursing::__archastro_unset__sentinels hit the wire as literal strings (crashed the platform chat channel live), renamed fields kept struct keys (alias_vs"alias"), keys stayed atoms, nested structs/DateTimes went un-encoded. Re-fires at every nesting level, so the fix is in the encode clause itself:Code.ensure_loaded?(module) and function_exported?(...), mirroring the in-file precedent on the decode-side{:ref, module}matcher. Unreachable in OTP embedded-mode releases; fully reachable everywhere else.2 — raw responses arrive Req-decoded (
lib/archastro/http.ex). Raw ops return theReq.Response, but Req'sdecode_bodystep ran first, so a raw blob served withContent-Type: application/jsonarrived as a decoded map. Raw requests now passdecode_body: false. The generator half is archastro-openapi PR 79.Two production ops are affected, and both flip signature on regen (enumerated over the spec — these are its only
format: binaryoccurrences):GET /api/v1/trajectories/{trajectory}/contentsGET /api/v1/private_service_definitions/{app_id}/{private_service_id}Both are emitted today as typed
decode: :stringops that raise on every successful non-empty response, so no working consumer exists for either. On regen their signatures change in every SDK: Elixir{:ok, String.t()}→{:ok, Req.Response.t()}, TypeScriptPromise<string>→Promise<{content, mimeType}>, and the analogous raw shapes in Python/Go/Swift/Rust. Nothing functioning breaks; the ops become usable for the first time.Hardening (commits 3–7, Calvin's ruling)
harden(socket): survive undecodable channel payloads— nakedCodec.decodein the Socket GenServer meant one malformed broadcast, reply, or join response — or a payload-less{:reply, :error, socket}(Slipstream collapses it to the atom:error) — killed the whole socket: every channel, subscription, pending push, and the linked owner. Broadcasts drop with[:archastro, :channel, :decode_failure]telemetry; replies resolve as{:error, %Error{code: "decode_failure"}}; join failures fail the joiners and leave the topic.harden(codec): reject NaiveDateTime and Time inputs with a clear error— previously an opaque Jason tuple crash deep inside Req.harden(client): disable redirect-following on default requests— a 302 silently rewrote a token-refresh POST into a body-less GET.harden(query): drop nil params instead of sending the string null.harden(socket): sweep pushes and joins that are never acknowledged— pending entries had no expiry; a 60s sweep fails anything older than a full interval withcode: "no_reply"(60–120s effective grace vs the 5s default call timeout). Most new behavior of the set — flag if you want it fast-followed or made configurable.Audit round (commits 9–12)
An adversarial audit subagent over this branch found four defects, all fixed red-first:
fix(channel): encode outbound payloads in the caller, not the socket— the highest-severity finding, and self-inflicted:Codec.encoderan inside the Socket GenServer for pushes and joins, so commit 4's new raise killed the shared socket and every channel on it. Ecto'stimestamps()defaults to:naive_datetime, making that an easy value to hand it. Encoding moved toChannel.push/joinso a bad payload fails only its own caller. Verified against the real channel harness (15/15).fix(http): keep structured API errors on raw requests—decode_body: falsealso stripped the error envelope off failure responses (Error.from_responsediscards a non-map body), degrading a 404's{"error": {message, code}}to a generic message with no code. Binary bodies are now Jason-decoded before building the error, the same way the SSE path already does; non-JSON bodies are left alone.fix(query): keep dropping unset sentinels, not just nils— commit 6 removed the sentinel clause on the premise thatCodec.encodealways rewrites sentinels to nil. That holds only for structs exportingto_map/1; theMap.from_structfallback leaves them intact, so a caller-built params struct crashed inscalar/1where it used to work.fix(socket): leave a topic whose join response has no waiter— a join acked after its waiters were gone (swept, or callers gave up) left Slipstream's status at:joinedwith no channel in assigns; sincejoin/3no-ops unless the status is nil or:closed, every later join of that topic queued a pending entry that could never be satisfied. The topic is now left, returning it to a closed state; established topics rejoined after reconnect are kept.Commit 8 (
ci:) is unrelated to the SDK: PR runs built the contract harness from a long-dead openapi branch predating the harness's nullable normalization, so all 15 channel-contract tests failed at harness boot on any PR while main stayed green. The PR checkout ref is nowmain.Scope
SDK-only, hand-written files (
codec.ex,http.ex,socket.ex,channel.ex,client.ex,token_server/default.ex,query.ex) plus the CI workflow ref. No generated files touched — regen picks up both raw ops after the generator release. No spec or platform changes.Risk assessment
Low-to-medium. Must-fix 1 makes the loaded and unloaded paths identical (the loaded path was always correct); previously-corrupt wire bodies become correct ones. Must-fix 2 changes raw-op bodies to always be the raw binary; the only JSON-content raw ops are the two that crash today. The hardening commits change failure modes (crash → typed error / drop-with-telemetry) rather than success paths; commit 7's sweep timer is the only genuinely new runtime behavior. Commit 9 moves work from the socket process to the caller process — verified against the real harness, which exercises join/push/broadcast over a real websocket.
User impact
Consumers on 0.3.4 stop hitting sentinel-corrupted request bodies under mix test / iex, can fetch trajectory contents and private service definitions, keep structured errors on raw endpoints, and (if the hardening rides) keep their sockets alive through malformed payloads and bad outbound values.
Testing
All red-first. Full suite 1345 passing, channel-contract suite 15/15 against the real harness subprocess, dialyzer clean,
mix format --check-formattedclean.test/codec_unloaded_modules_test.exs,"generated input/params structs encode identically when their modules are not loaded"— enumerates all 211 generated Input/Params modules from the app spec, encodes each with all-unset optionals, purges the module (:code.purge+:code.delete, assertingto_map/1is no longer exported — the exact production precondition), re-encodes, and asserts byte-identical output plus no sentinel and no atom key anywhere in the tree. Watched fail on 0.3.3 with the exact live corruption.test/http_test.exs,"raw requests deliver an application/json body as the raw binary"— drives the realArchAstro.SDK.HTTP.request/4stack (TokenServer → auth headers → full Req pipeline) against a Plug-servedapplication/jsonbody and asserts the raw string arrives; sibling tests pin structured errors on raw failures, non-JSON failure bodies, and that typed requests still decode.test/socket_test.exs,"encode failures raise in the caller process, never inside the socket"— a stand-in socket process reports anything it receives; the test asserts the raise happens in the caller and the socket is never messaged. Backed by the real-harness channel suite.Follow-ups and chain
regenerate-sdk.yml(needs @archastro/sdk-generator 0.11.2 from openapi PR 79 first; regen PR gets no CI — main CI after merge is the gate) →release.ymlpatch → v0.3.4 → merge the tracking PR → hand version + Hex checksum to the slice-4 orchestrator.🤖 Generated with Claude Code