Repository navigation
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
🟡 Changes recommended
Null or wrong-typed reason and digest fields can still bypass statusless malformed-response classification.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Validates Go event-feed responses before exposing recovery-driving typed values.
Changes:
- Validates 400 reasons, 409 digests, UTF-8, and durable cursors.
- Routes malformed responses terminally and removes duplicate adapter validation.
- Adds wrapper and end-to-end regression coverage.
[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or rungh pr ready --undo.
Click "Ready for review" or rungh pr readyto reengage.
File summaries
| File | Description |
|---|---|
SPEC.md |
Defines event-feed decode validation. |
go/pkg/basecamp/event_feed.go |
Implements response validation. |
go/pkg/basecamp/event_feed_test.go |
Tests malformed wrapper responses. |
go/pkg/basecamp/eventfeed/live.go |
Removes redundant adapter checks. |
go/pkg/basecamp/eventfeed/live_test.go |
Tests terminal connector routing. |
go/pkg/basecamp/eventfeed/loop_test.go |
Supports real poll sources in the harness. |
Review details
Suppressed comments (1)
go/pkg/basecamp/event_feed.go:499
- A wrong-typed digest (for example
"filters_digest": 42) makesjson.Unmarshalfail, so this branch returns the original statusful 409 instead of the promised statusless malformed-response error. Since such a value is also not a bare 16-lowercase-hex digest, classify decode failure the same way as the subsequent shape check rather than falling through.
if json.Unmarshal(body, &mismatch) == nil {
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8008853f8a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Review threads: 1 resolved (1 fixed + 1 declined in the same thread — Copilot raised two shapes on one thread).
Copilot's effort level on the reviewed head was Balanced, and its finding was in the suppressed-comments block as well as the thread. Awaiting Codex on |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: de1b5b36c5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Review threads: 5 resolved (4 answered structurally, 1 declined with the reasoning in the thread). The five findings were one class wearing different clothes — a null where a string belongs, a number where a string belongs, a missing required member, a body that is not an object, a top-level null — and they all escaped the same way. Each arm asked does this look like the documented shape, and whatever failed that test fell through to the canonical error, which still carries the status the recovery paths key off. Closing them one at a time closes one door each and leaves the structure that produces doors. The instrument, reassessed. The arms are now TOTAL over the body (7ab00a8). Each of the three typed statuses has exactly one documented body shape, so the arm returns either the typed value or the statusless malformed response and never hands the body back for something further down to interpret. Doors are not enumerable; the default is. Two rules fall out, both in SPEC §23:
Per thread:
CI was red on de1b5b3 for an unrelated gate: Bot effort levels seen: Copilot Balanced on 8008853 (its finding was in the suppressed-comments block as well as a thread); Codex reported on 8008853 and de1b5b3. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7ab00a8270
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Review threads: 4 resolved (4 fixed). Three of the four were the total-shape rule from 7ab00a8 applied unevenly, so they are fixed once rather than three times: one reader for required members ( The fourth was a class, not a site: the substitution check reached values but not member names. A substituted key does not arrive wrong, it arrives missing — an optional member reads as absent, a required one as omitted — so Two things went with it, to stop the rule having a hole where the next finding would land:
The two totality tables now carry 27 off-contract error bodies and 12 off-contract envelopes; every guard in this round was mutation-checked against the real code. Finding rate across the three review rounds on this PR: 1 → 5 → 4. Each round has been the same underlying question — does the decoder admit a shape the contract does not — answered at a different scope: first the values, then the arms' default, now the required-member reader and the keys. If a fourth round produces another batch of the same shape, the conclusion is not a fifth reader but that these arms want generating from the Smithy shape rather than hand-writing, which is a larger change than this PR and would be its own issue. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 350c785b84
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Review threads: 2 resolved (2 fixed). Both were a stated general property holding only at the shallow end, which is the third time on this PR — so both are pinned as the general claim now, not as the instance.
Cost note, since this runs on a continuous poller: a substitution can only come from a lone surrogate escape or an invalid raw byte, so a body with neither cannot contain one. The fast path is two linear scans with no allocation, and the walk never runs on a real page. The instrument question is answered in the PR body ("Is hand-written validation the right instrument here?"), since it is a decision a future reader will want rather than a thread. Short version: the shape class was a fallthrough defect, not a per-field-versus-typed one, and a typed decode is what produced the first of them; in Go a typed struct buys wrong-type rejection but no required-member or null semantics ( Finding rate across rounds: 1 → 5 → 4 → 2. Rebased onto |
There was a problem hiding this comment.
🟡 Changes recommended
Nested error-body keys and required row members can still bypass the newly documented validation.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Balanced
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6d9482e129
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Review threads: 2 resolved (1 fixed, 1 declined with the reasoning in the thread).
The claim the finding read it against was overreaching, so it is narrowed rather than left standing: SPEC §23 now says which members "required members are enforced" covers — the envelope's and the error shapes' — and why a row's are not among them. Copilot's effort level on this head: Balanced, 7/7 files, 2 comments, no suppressed block. Finding rate: 1 → 5 → 4 → 2 → 2. Final head |
The poll lanes shaped typed errors out of whatever the body carried. A 409's digests became FeedFilterMismatchError fields unchecked, so digests of "x" still triggered the position-reset recovery; a 400's reason became FeedRequestError.Reason unchecked, so a value nobody defined fell through to the message classifier that is documented for an absent reason alone, where "Unrecognized position" buys the same reset. On the success path Go's decoder substitutes U+FFFD for invalid UTF-8 and for a lone surrogate escape without failing, so position, next and a 410's resume could reach a caller as strings the server never wrote, to be persisted or followed. Hold all three at the decode, where every consumer of the wrapper inherits the verdict, as the SDK's malformed-response error: api_error, non-retryable and statusless. Statuslessness is load-bearing — a 400 left at 400 still reaches a consumer's status-keyed fallback, which is the path a reason nobody defined must not reach. The connector adapter's two stopgap selectors are deleted with this: they are unreachable once the wrapper refuses those bodies, and the end-to-end poll matrix that covered them stays green without them. Refusing is only half of it, so the routing is pinned too. A decode error mapped to a re-entry would reset the position just as the unchecked value did. TestMalformedConflictNeverReachesTheReset runs all three bodies through the whole connector over a real seam and asserts the absence of the recovery: no filter-conflict observer, no position rejection, no checkpoint write, one request. With the guards reverted each subtest hangs, resetting and re-entering into the same malformed response forever.
A typed whole-body decode conflates two different bodies: one member of the
wrong JSON type fails the Unmarshal outright, and the arm then falls through
to the canonical error the refusal was meant to displace. {"reason": 42}
reached the connector's message classifier by that door — carrying the
server's own "Unrecognized position" — with the enum check sitting right
above it.
Read the members off a raw map instead, so a wrong-typed member is a fact
about that member rather than a decode failure that discards its siblings and
the verdict with them. An explicit null reason still reads as absent, which is
what the optional member means and what the 410 arm already does with a nulled
epoch, and a body that is not a JSON object at all still keeps its canonical
status: a 500 behind an HTML error page is not a malformed feed body.
Four review findings in a row were the same defect wearing different clothes: a null where a string belongs, a number where a string belongs, a missing required member, a body that is not an object. Each slipped past the arm's "does this look like the documented shape" test and landed on the fallthrough, which returns the canonical error — still carrying the status the recovery paths key off. Closing them one at a time closes one door each and leaves the structure that keeps producing doors. Invert the default instead. The contract gives each of the three typed statuses exactly one body shape, so each arm now decides between the typed value and a malformed response and never hands the body back: not that shape means refused. Doors are not enumerable; the default is. The 410 arm loses its fallthrough with the others — the connector's verdict there is unchanged, but a caller can no longer key recovery off a 410 status the body does not support. Required members are enforced; unknown members are ignored. One rule from both ends: the contract binds what the server must send, not what it may add, and a body that grew a member is not malformed. Null is not a way to satisfy a required member — encoding/json accepts null into any type and leaves the zero value, which is the one decoder behaviour all the null findings came through, so it is answered once in the member readers rather than at each site. For the single optional member, reason, null IS absent. The substitution check covers every string the page decodes, not only the cursors: a rewritten event_type is a token a consumer dispatches on while the position commits over it. Details stays out because it is json.RawMessage — never decoded into a string, so no substitution can reach it — and a reflection test drives the real enumeration so a member added to FeedEvent without being added to it fails, as does one dropped from it. The registry entries for the two discharged #915 promises go with them.
…d on Four more findings, and three were the new rule applied unevenly: `error` is @required on all three error shapes but was enforced only on the 400; an empty `error` counted as present, even though §6's body parse then falls back to a `message` member and carries a message the declared member never supplied into the consumer's classifier; and a refused body dropped the response's request id, which errors.As never reaches on the cause. One reader for required members now, so "required" means the same thing at every site rather than whatever each site remembered. The 200 envelope goes through the same rule — an object carrying its position and its rows — and the lanes split request from parse, as the documents and schedules composites do, so a body that never decodes into the envelope is the same malformed response as one that decodes into the wrong shape rather than the decoder's own error a caller switching on *Error never sees. The fourth was the substitution class reaching member NAMES: a key the decoder substituted into does not arrive wrong, it arrives missing, so `reason` reads as absent and the body as an undifferentiated 400 it is not. Checked over every key rather than the ones each arm happens to read, because which key was mangled is exactly what cannot be known in advance.
…here Two findings, and both were a stated general property holding only at the shallow end. The every-key check inspected the envelope's top level: each row stays a json.RawMessage, so the walk stopped at depth one. A row key spelled performed_by_i\ud800d therefore decoded as if performed_by_id were absent, the event read as performed directly by its creator, and the page's position committed over that attribution — which is also what the exclude_performers loop guard is computed from. The walk now descends, with one stopping boundary written into it: structural member names are validated, opaque details contents are not, because those bytes are carried verbatim and never decoded into strings. It is affordable on a continuous poller because a substitution can only come from a lone surrogate escape or an invalid raw byte, so a body with neither cannot contain one and the walk never runs — two linear scans with no allocation on every real page. The request id was sniffed off the cause, which worked only where the cause happened to be the canonical error and silently left every 200-path refusal without one, contradicting the claim this change makes. It is a parameter now: a question the compiler makes each call site answer.
The depth rule landed on the page envelope and stopped at the top level for
the three error shapes, while the contract it is written into claims every
depth for all of them. A valid 409 carrying {"metadata":{"bad\ud800key":1}}
was typed as the documented conflict. Same walk, all four bodies these lanes
decode, one rule.
The required-member rule gets its scope written down alongside it: the
envelope's and the error shapes' members, not a row's. Both lanes must refuse
identical row shapes for the two to deliver identically, so that rule belongs
where the lanes meet rather than half-moved to the wrapper.
Retry-After went the way the request id did: parsed onto the canonical error by checkResponse and then dropped by the refusal that wraps it, where errors.As stops. A caller who reschedules the work themselves reads that delay, and §6 carries it at every status. Both fields travel in one struct now rather than as hand-copied assignments, because they are lost together and were lost one at a time. Retryable stays false: that is this SDK's verdict about repeating the call, which is a different question from the delay the server named.
|
Review threads: 2 resolved (2 fixed). Rebased onto
On the instrument question, sharpened against exactly these cases (full answer in the PR body): the real structural weakness of a hand-rolled validator is not per-field checking, it is that it must be wired at each call site — and three times it was not (the walk not called, the request id on one path only, Finding rate: 1 → 5 → 4 → 2 → 2 → 2. Final head |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2c04ead534
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Both reviewers landed on the same overreach, and they were right: the walk skipped any member named details anywhere in a page, while the contract this PR writes says the exemption is a FeedEvent's. I had declined it as a bounded residue — the interior of a member nothing reads — but the sentence in SPEC claimed exactness the code did not have, and defending the approximation was costing more than removing it. The walk carries each container's member path with array indices elided, so the exemption is events[*].details and items[*].event.details and nothing else. An additive member that happens to be named details carries no raw bytes the SDK reads, and is walked like any other subtree.
|
Review threads: 1 resolved (1 fixed — a decline I reversed). Both reviewers landed on the same point, and re-reading my own SPEC sentence they were right: it said the exemption is a FeedEvent's Fixed in Head |
There was a problem hiding this comment.
🔵 Needs a closer look
The page-wide details exemption violates the new shape-scoped validation contract, and one helper’s documentation is misplaced.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
go/pkg/basecamp/event_feed.go:774
carriesRawDetailsis page-wide, so this skips the contents of every property nameddetails, not justFeedEvent.details. The new inbox test demonstrates that an undeclaredInboxItem.detailscan therefore hide substituted or duplicate member names, contradicting SPEC.md:3492-3501, which says the exception belongs to the shape declaring the raw member and all other names are checked at every depth. Track enough path/shape state to skip only a feed event's declared rawdetailsvalue (and update the additive-details test accordingly).
go/pkg/basecamp/event_feed.go:946- This
checkPollPagedocumentation is contiguous with the followingfeedDecodeErrorcomment and is therefore attached tofeedDecodeError, whilecheckPollPageitself has no adjacent explanation. Move this block to immediately abovecheckPollPageso each helper's documentation describes the function it precedes.
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Balanced
It had drifted onto feedDecodeError — contiguous with that function's own block, so it read as one comment on the wrong function while checkPollPage had none. Moved, and its last paragraph updated: it argued against a whole-body byte scan that no longer exists, where the live distinction is that this half reads decoded values and feedBodyWellFormed reads the same page's member names.
|
Copilot's latest review (on
Flagging the mechanism as much as the fixes: both items were invisible in the thread count, which was zero at the time. Reading the review body is what surfaced them. Head |
|
@codex review Three pushes landed close together while a run was in flight ( What changed since your last completed review (
|
There was a problem hiding this comment.
🟡 Changes recommended
Path copying makes deep-body validation quadratic and capable of excessive memory consumption.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 1
- Review effort level: Balanced
Making the exemption exact reintroduced the cost it had just lost, in the other register: every object-valued member copied its ancestor path and every copy stayed live on the stack, so a 9,000-level body retained tens of millions of string slots — 655 MiB measured — on a poller that runs continuously. The full path was being carried so that two checks could be exact. Two exemptions need two facts: how many named segments lead here, and the last two of them. Both are O(1) per push and neither allocates, and pathLen is what keeps a deeper path from matching on its tail alone. The test states the cost as a ceiling in bytes rather than demonstrating the blowup: restoring the copy allocates 655 MiB against a 32 MiB bound and fails on that number. No depth cap goes with it, deliberately — a cap would refuse a page over the shape of a member nothing reads, which the additive-member rule forbids, and encoding/json already refuses beyond its own nesting limit.
|
Review threads: 1 resolved (1 fixed). The walk was quadratic in memory — my own regression from making the Fixed in The test now states the bound instead of demonstrating the blowup — total allocation for that body under a 32 MiB ceiling, mutation-checked by restoring the copy (fails at 655 MiB) — beside the 10-second guard that catches the quadratic-reads version. No depth cap, deliberately, written into the code and SPEC §23 rather than left unsaid: a cap would refuse a page over the shape of a member nothing reads, which is what the additive-member rule forbids; the pass is linear with O(1) state per level, and Three defects in this one walk now — quadratic reads, duplicate names accepted, quadratic memory — none a variation of one bypass, all ordinary implementation bugs in new code. That is the honest cost of hand-writing ~120 lines that must be correct about JSON's shape rather than the feed's, and it is now written into the instrument section of the PR body: generating this from the Smithy shape would remove the surface rather than debug it, and these three are the argument for doing that when a second SDK grows a typed layer. Head |
There was a problem hiding this comment.
🔵 Needs a closer look
The raw-details exemption incorrectly applies both lane-specific paths to every poll response.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
go/pkg/basecamp/event_feed.go:847
- This predicate exempts both lane paths for every 200 response. Consequently, a valid PollEvents page with an additive
items[*].event.detailssubtree (or a PollInbox page with additiveevents[*].details) skips that subtree, even though it is not the current operation's declared rawFeedEvent.details; a substituted or duplicate key there is accepted despite the at-every-depth rule. Pass the current collection/lane into the walk and enable onlyevents[*].detailsfor PollEvents or onlyitems[*].event.detailsfor PollInbox.
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Balanced
The predicate accepted both lanes' paths on every 200, so a feed page carrying an additive items[*].event.details subtree skipped it, and an inbox page an additive events[*].details. Those are members nothing reads on that response, and the exemption belongs to the raw bytes the response actually carries — one path per operation, not two. The walk takes the lane's collection rather than a flag: "events", "items", or "" for the error bodies, which declare no verbatim member. Naming the lane is what makes it impossible to exempt the other one.
|
Copilot's review of The The walk takes the lane's collection now rather than a boolean — That is the second time a residue in this exemption produced a finding (name-shaped, then lane-shaped), and both times the fix was to make the rule exact rather than to argue the residue was harmless. Noted in the instrument section for what it says about hand-written validation surface. Also confirming from the other side: Codex completed on Head |
There was a problem hiding this comment.
🔵 Needs a closer look
Both newly wired body-read error paths need regression tests proving transient read failures remain transport errors.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
go/pkg/basecamp/event_feed.go:298
- This new parse-error path has no event-feed regression test for its crucial transport-error exception. If
markBodyReadFailuresis omitted or stops wrapping a partial body read,feedDecodeErrorturns a transientio.ErrUnexpectedEOFinto a statusless non-retryable API error, and the connector terminates instead of retrying. Add a custom response body that fails mid-read and assertPollEventsreturns the original transport error.
This issue also appears on line 357 of the same file.
go/pkg/basecamp/event_feed.go:360
PollInboxwires the body-read marker separately fromPollEvents, but this branch is likewise untested. Add the same mid-bodyio.ErrUnexpectedEOFregression case for this method so an accidental omission cannot reclassify a transient read failure as terminal malformed data.
httpResp.Body = markBodyReadFailures(httpResp.Body)
resp, decodeErr := generated.ParsePollInboxResponse(httpResp)
if decodeErr != nil {
err = feedDecodeError(factsOf(httpResp), decodeErr)
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Balanced
The split that lets a non-decoding page be one verdict rests on markBodyReadFailures: io.ReadAll runs inside the generated parse (#773), so a body that never finished arriving reaches feedDecodeError by the same path as one that failed to decode, and only the marker tells them apart. The two are opposite verdicts — a truncated read is a transport failure the connector retries, a malformed body is the statusless refusal that terminates it — and nothing tested the difference. Dropping the marker turns io.ErrUnexpectedEOF into a statusless non-retryable api_error on both lanes, which is what the test now catches. Both lanes wire it separately, so both are pinned.
|
Copilot's review of The body-read marker had no test. The split that lets a non-decoding page be one verdict rests on The test serves a truncated body (Content-Length 4096, connection closed mid-JSON) via the existing Also confirming the other side: Codex completed on Head |
|
Converged on
One note for the record, since it produced a red I chased: an earlier Review rounds on this PR: 1 → 5 → 4 → 2 → 2 → 2 → 1 → 3 → 2 → 1 → 1 → 0 findings, across 11 commits. Every fix was mutation-checked against the real code, and the three declines (strict unknown fields, null Not merging — that is the operator's call, and the approval gate is the only thing outstanding. |
11 version files and 8 lockfiles, written by scripts/bump-version.sh. MIGRATING.md's '# Unreleased' section is promoted to '# v0.20.0'. Thirteen commits since v0.19.0 (2026-09-16), the substantial ones being the #925 chain — #931 finding operations by exclusion so no HTTP verb can drop one in silence, #932 stopping a refusal from destroying generated output, and #938 deriving gen-catalog's emission bound from the shared declaration — plus #926's embedded tool Catalog, #930's Automation split, #924's event-feed body validation at the wrapper's decode, and #937's anyio bump for CVE-2026-63374. Ran the release's own preflight here rather than discovering it at tag time: promote-migrating --check passes, rs-publish-check packages and dry-run-uploads 0.20.0 clean, and all four lockfile freshness assertions hold.
Closes #915.
The event feed's poll lanes shape typed errors out of whatever the body carried. A 409's
position_digest/filters_digestbecameFeedFilterMismatchErrorfields unchecked, and a400's
reasonbecameFeedRequestError.Reasonunchecked — so a 409 whose digests were"x"still triggered the position-reset recovery, and a 400 carrying a reason nobody defined fell
through to the message classifier that is documented for an ABSENT reason alone, where
Unrecognized positionbuys the same reset. The 200 path had the third variant: Go's JSONdecoder substitutes U+FFFD for invalid UTF-8 and for a lone surrogate escape without failing,
so
position/next/ a 410'sresumecould reach a caller as strings the server neverwrote, to be persisted or followed.
All three are now refused at the decode, in
checkFeedResponseand at the poll lanes' serviceboundary, as the SDK's malformed-response error:
api_error, non-retryable, statusless.The connector adapter's two stopgap selectors are deleted — the class has one home.
Generated, or hand-written per language?
Hand-written, and Go-only today. The evidence:
response structs; it emits no error-body → typed-error mapping and no constraint validation
at all.
@pattern("^[0-9]+$")onAccountIdproduces nothing ingo/pkg/generated/client.gen.go— grep for the pattern and there is no hit. There is notemplate to put this in.
FeedFilterMismatchError,FeedRequestError,FeedPositionGoneError,InboxPositionGoneError) and their decode live only ingo/pkg/basecamp/event_feed.go, whichis a hand-written wrapper over the generated client.
(
python/src/basecamp/generated/services/event_feed.py,rust/basecamp-sdk/src/generated/services/event_feed.rs, and their peers) return the genericerror type. They do not lift the digests or the reason out of the body at all, so today there
is nothing there to validate — which SPEC §23 already says: "the other SDKs surface the
canonical error and leave the typed members to their connector adapters."
So this is Go first and complete, with no five-language tracking issue to open: the port of the
§23 connector to the other SDKs is already the tracked work, and the rule now rides in SPEC §23
("Decode validation") so each SDK inherits it with its typed layer rather than rediscovering it.
That is the one-change-covers-all lever available here; a generator change is not, because
nothing is generated at this layer.
What the decode now refuses
Each of the four bodies the lanes decode — the 400, the 409, the two 410s, and the 200 envelope
— is decided totally: the arm returns either the typed value or a statusless malformed
response, and never hands the body back for something further down to interpret.
*FeedFilterMismatchError→PollFilterChanged→ position discarded, re-entryreasonis present and notinvalid_position/invalid_filter(including"", a number, an array)*FeedRequestError{Reason: …}→ the legacy message classifiererror, or with a body that is not a JSON objectresume, or a feed 410 without a numericepoch_after_id0boundarypositionor its rows, or a body that never decodesposition,next, a 410resume, a row'skind/action/event_type, the inbox'sreason, or any member name at any depth carrying U+FFFDTwo asymmetries, both following the contract rather than a preference. A 400 with no
reason— including an explicit
null, since the member is optional — is still the undifferentiated*FeedRequestError, because that is the documented case the message fallback exists for. Anddetailsis outside the substitution walk entirely: it isjson.RawMessage, never decoded intoa string, so nothing in it can be substituted, and refusing an escape there would contradict the
byte-identical carriage the push lane depends on.
Required members are enforced; unknown members are ignored. One rule from both ends — the
contract binds what the server must send, not what it may add. A body that grew a member is not
malformed, which is also the answer to the strict-unknown-fields half of #915.
Statuslessness is load-bearing, not cosmetic. A status is the server's verdict on the
request; these bodies are how the verdict is acted on. Left at 400, a refused body still reaches
a consumer's status-keyed fallback — the exact path a reason nobody defined must not reach. The
mutation check proves it: setting
HTTPStatus: 400on the malformed error turns threeeventfeedend-to-end cases fromunrecoverableintofilter_invalid. The canonical errorrides as
Cause, and the response's request id rides on the refusal itself, sinceerrors.Asstops there.
The adapter's selectors are deleted, not duplicated
eventfeed/live.goloses its 409 digest check (andisServerDigest) and its unknown-reasondefault:arm. They are unreachable once the wrapper refuses those bodies, and the existingend-to-end table in
live_test.go—409 with a malformed digest is unrecoverable,409 missing a digest is unrecoverable,400 with an unknown reason is surfaced, never guessed— stays greenwithout them. That table is the proof the deletion is safe: same inputs, same verdicts, one layer
fewer.
The refusal is routed, not just returned
Refusing at the decode closes these defects only if the connector takes the refusal somewhere
terminal. A decode error mapped to a re-entry, a retry, or a swallow would discard the held
position exactly as the unchecked value did, and a unit test that stops at "the decode returned an
error" passes either way. So the routing is traced and pinned:
PollUnrecoverable→recoverPoll→ terminalReasonPollFailed, andl.positionis nevercleared — only
PollFilterChanged(transition 19,recovery.go) discards it and firesObserver.FilterConflict. The statusless malformed error reaches that verdict throughmapPollError's default arm: no typed error matches, no status arm matches, not retryable.TestMalformedConflictNeverReachesTheResetdrives all three malformed bodies through the WHOLEconnector — real
Liveseam over a loopback API, real decode, scripted mint and socket — andasserts the ABSENCE of the recovery, not merely the presence of a terminal: no
FilterConflict,no
PositionRejected, no checkpoint write (the held position survives the run), and exactly onerequest. Reverting the three decode guards makes all three subtests fail — each one by hanging
until the harness watchdog fires, because the connector resets its position and re-enters into
the same malformed response forever. That loop is the defect, and it is what this test now holds
shut.
Declined: rejecting unknown fields
Not done, deliberately, and not because it is hard.
Strict unknown-field rejection is right for what the CALLER controls — request bodies, config,
anything where an unrecognized key is a typo the caller can fix before shipping. It is close to
always wrong for a RESPONSE from a live server that is expected to grow: the day BC3 adds a member
to a feed response, every already-deployed copy of the SDK starts failing on valid traffic, and SDK
users upgrade on their own schedule. That is a self-inflicted outage traded for nothing, because an
unknown field harms no decision the connector makes — unlike a malformed digest or a substituted
cursor, which corrupt one.
The repo already commits to the tolerant side, explicitly:
detailsis carried verbatim as rawbytes so that "members of newly cataloged types survive" (SPEC §23), and
reasonitself shipped asan additive member to servers that had none. A strict decode would have made bc3 #13362 a breaking
change. Nothing in #915's text names unknown fields either.
Scope boundaries, said out loud
urlis not held to the substitution check. It is checked against cable-URLpolicy before it is dialed and the ticket is signed, and neither is persisted or re-issued —
the property that makes a substitution durable.
detailsis outside the walk, with the reason above, and both directions are pinned:the recursion refuses a substituted row key, and removing the
detailsskip fails the testthat says its bytes come through verbatim.
retryable 500; only the three statuses whose body shape the contract fixes are decided here.
conformance/tests/*.jsonasserts one behavior for all sevenrunners, and an SDK that never types these bodies cannot answer differently for them. Pinning
it there would need per-SDK expectations the suite does not express. Go's tests carry it and
SPEC §23 carries the rule.
Is hand-written validation the right instrument here?
Asked because three review rounds produced eleven findings, and it is a fair question whether
per-field checks over a permissive decode were fighting the grain. The honest answer is that the
findings split into two classes with different answers.
The shape class — nine of the eleven — was a structural defect, and it was not "per-field
versus typed". It was that every arm had a fallthrough: judge the body if it looks like the
documented shape, otherwise hand it back to the canonical error, which still carries the status
the recovery paths key off. Each new shape that failed the "looks like" test escaped through that
door, and the doors are not enumerable. Inverting the default closed the class: not that shape
means refused. Worth noting that a typed decode is what produced the first of them — a
wrong-typed member fails
Unmarshalwhole, and the arm then falls through carrying the server'sown
Unrecognized positioninto the classifier. The detection was never the problem; therouting of the detection was.
A typed struct with presence and nullity in the types would not have made these impossible,
and in Go it buys less than it sounds.
encoding/jsonhas no required-member concept and nonull-rejection:
nullunmarshals into any type and leaves the zero value, which is the singlebehavior every null finding came through. A
*stringdistinguishes absent from present butstill accepts
nullas absent, and nothing in the standard decoder enforces "this member mustbe there". So the presence and nullity checks would still be hand-written — moved, not removed.
What the typed struct does buy is wrong-type rejection for free; what it costs is that the
rejection arrives as a whole-body decode failure, which is exactly the fallthrough that has to be
routed anyway.
DisallowUnknownFieldsis the one real lever it adds, and this PR declines itdeliberately (above).
The substitution class — two of the eleven — is immune to decoder strictness altogether. No
typed struct, schema validator, or strict decoder in Go rejects a lone surrogate escape:
encoding/jsonsubstitutes U+FFFD and reports success, in keys as in values. It is visible onlyin the decoded value, which is why it is checked there, and why the recursive name walk exists at
all. The walk is hand-written because there is nothing to generate it from.
Schema-driven validation was checked and is not available. The Go SDK ships no runtime
schema:
openapi.jsonis a build input,client.gen.gocarries no constraint metadata, and thegenerator emits no validation —
@pattern("^[0-9]+$")onAccountIdproduces nothing in thegenerated client. Driving these arms from the Smithy shape would mean teaching the generator to
emit response validation, which is a real option and the right one if a second SDK grows a typed
layer — but it is a generator change, not a change to this file, and it is larger than this PR.
The one thing that IS a hand-rolled validator's structural weakness: it has to be wired at each
call site, and three times it was not. The recursive name walk existed and
jsonObjectdid notcall it. The request id was carried on the error paths and not the 200 ones.
Retry-Afterwascarried on neither. Each was a stated general property holding only where it happened to be
wired, and that is a fair indictment of "remember to validate here" as a design.
The answer that actually removes the question is not a typed struct — it is making the shared
reader the ONLY door, which is what this PR converged on:
jsonObjectperforms the name walk sono arm can skip it,
requiredStringis the one definition of "required", and the response's factstravel as one struct rather than as fields each site copies by hand. Those are the three places
the recurrences happened, and each is now a single door rather than a convention. What remains
genuinely unguarded is a NEW body being decoded by a new code path that forgets to use them —
which a typed decode would not fix either, since in Go the presence and nullity checks would
still be hand-written beside it. The thing that would make it a non-question is generating these
arms from the Smithy shape, where every required member and type is already declared. That is a
generator change, it is the right follow-up if a second SDK grows a typed layer, and it is out of
scope here.
And the part I have now paid for three times: the walk itself is a lot of surface to get
right. It has had three defects of its own — quadratic in reads (it decoded each nested subtree
from raw bytes), duplicate member names accepted, and quadratic in memory (it copied each
container's path). None was a variation of one bypass; they were ordinary implementation bugs in
new code. But that is the honest cost of hand-writing a structural validator: roughly 120 lines
that have to be correct about JSON's shape, not about the feed's, and each round of review found
another way they were not. Generating this from the Smithy shape would remove the surface rather
than debug it — every required member, type and nesting fact is already declared there. That is
the follow-up this PR argues for, and the three defects are the argument.
So: the instrument is right for this PR, with the inversion that was missing. The finding
rate across rounds was 1 → 5 → 4 → 2, falling as the scope of each answer widened from the values
to the arms' default to the member readers and the keys. If a fourth round produces another batch
of the same shape, the conclusion is not a fifth reader but that these arms want generating from
the Smithy shape — worth its own issue rather than more of this file.
Tests
Every refusal has a test whose name states the claim, and the general claims are stated as
totality rather than as a list of known doors — two tables (27 off-contract error bodies, 12
off-contract envelopes) whose shapes are not enumerated anywhere in the code. Writing the first
of them found two doors that had not been raised in review.
Every guard was mutation-checked against the real code:
isFeedDigestaccepting anything → the 409 table fails.HTTPStatus: 400→ threeeventfeedend-to-end cases flip fromunrecoverabletofilter_invalid.survivedDecodingalways true → all five value-substitution cases fail.detailsskip removed → theverbatim-carriage case fails. The boundary is pinned in both directions.
requiredStringaccepting an empty string → four cases across three statuses fail.and the 200 paths.
32 MiB ceiling. The cost is stated as a bound in bytes rather than demonstrated as a blowup.
feedEventStringslosing a member → the reflection test names it; the test drives the realfunction with a sentinel per string member, so it fails for a member added and not held as
well as one dropped.
by hanging until the harness watchdog fires, because the connector resets its position and
re-enters into the same malformed response forever. That loop is the defect.
go build,go vet,go test -race -count=1 ./...,make go-lintandmake checkare green.Summary by cubic
Closes #915. The event feed's poll lanes now validate responses at the wrapper's decode, so malformed bodies surface as a statusless, non-retryable
api_errorinstead of typed errors that could reset a held position or persist substituted cursors.Behavior
error.detailspasses through verbatim only at the declaring lane's path —items[*].event.detailson feed pages,events[*].detailson inbox pages.reasonstill yields the undifferentiated 400.Retry-After; the connector maps them to terminalPollUnrecoverable, so the held position survives andFilterConflictnever fires.Migration
eventfeedconnector's duplicate digest and reason checks are deleted as unreachable; treat refusals as terminal rather than recovery signals.HTTPStatusorerrors.Aswill stop matching for malformed responses.MIGRATING.mdand SPEC §23 document the rule; other SDKs are unaffected.Written for commit be93ef5. Summary will update on new commits.