Event feed connector: bind the seams to the generated operations (5/5) - #899
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.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1cf3551af9
ℹ️ 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".
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings remain in the documentation and live connector.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR binds the Go event-feed connector to generated minting and polling operations, adding redirect enforcement, error mapping, continuation handling, tests, and conformance documentation.
Changes:
- Added
NewLiveadapters for account and inbox feeds. - Added same-origin redirect protection and typed error handling.
- Updated generated SDK artifacts, specifications, fixtures, and documentation.
Findings:
- Critical (1 vote):
go/README.md:801is missingcontext,errors, andfmtimports in its copyable example. - Moderate (1 vote):
live.go:55can panic for invalid account IDs. - Moderate (1 vote):
live.go:61should reject invalid UTF-8 before canonicalizing the origin. - Moderate (1 vote):
live.go:185and:331incorrectly classify non-retryable responses withRetry-Afteras throttled. - Moderate (3 votes):
live.go:363loses unknown fields from raw event details. - Moderate (1 vote):
live.go:123retries refused redirects before mapping the error. - Moderate (1 vote):
live.go:68silently discardsWithHTTPClient. - Critical (1 vote):
live.go:121,:176, and:319do not safely classify redirects with missing or malformedLocationheaders.
File summaries
| File | Summary |
|---|---|
typescript/tests/services/event-feed.test.ts |
TypeScript event-feed service tests |
typescript/src/index.ts |
TypeScript public exports |
typescript/src/generated/services/index.ts |
Generated service exports |
typescript/src/generated/services/event-feed.ts |
Generated event-feed service |
typescript/src/generated/path-mapping.ts |
Generated route mapping |
typescript/src/generated/metadata.ts |
Generated metadata |
typescript/src/client.ts |
TypeScript client |
typescript/scripts/generate-services.ts |
TypeScript service generator |
swift/Sources/BasecampGenerator/Utilities.swift |
Swift generator utilities |
swift/Sources/Basecamp/Generated/Services/EventFeedService.swift |
Swift event-feed service |
swift/Sources/Basecamp/Generated/Models/WebhookEvent.swift |
Swift webhook model |
swift/Sources/Basecamp/Generated/Models/PollInboxResponseContent.swift |
Swift inbox response model |
swift/Sources/Basecamp/Generated/Models/PollEventsResponseContent.swift |
Swift events response model |
swift/Sources/Basecamp/Generated/Models/InboxItem.swift |
Swift inbox item model |
swift/Sources/Basecamp/Generated/Models/FeedEventDetails.swift |
Swift event details model |
swift/Sources/Basecamp/Generated/Models/FeedEvent.swift |
Swift feed event model |
swift/Sources/Basecamp/Generated/Models/Event.swift |
Swift event model |
swift/Sources/Basecamp/Generated/Models/CreateStreamTicketResponseContent.swift |
Swift ticket response model |
swift/Sources/Basecamp/Generated/Metadata.swift |
Swift generated metadata |
swift/Sources/Basecamp/Generated/AccountClient+Services.swift |
Swift service accessors |
swift/README.md |
Swift SDK documentation |
spec/tracking-issues.yml |
Tracking issue updates |
spec/overlays/tags.smithy |
Specification tags |
spec/bc3-route-allowlist.yml |
Route allowlist |
spec/api-gaps/README.md |
API gap documentation |
spec/api-gaps/event-feed.md |
Event-feed API gap |
spec/api-gaps/delegated-events-performed-by.md |
Delegated-events API gap |
SECURITY.md |
Security guidance |
scripts/check-idempotency-parity |
Idempotency parity check |
rust/generator/names.toml |
Rust generator naming |
rust/basecamp-sdk/tests/services.rs |
Rust service tests |
rust/basecamp-sdk/src/generated/types.rs |
Rust generated types |
rust/basecamp-sdk/src/generated/services/mod.rs |
Rust service exports |
rust/basecamp-sdk/src/generated/services/event_feed.rs |
Rust event-feed service |
rust/basecamp-sdk/src/generated/routes.rs |
Rust generated routes |
rust/basecamp-sdk/src/generated/mod.rs |
Rust generated module |
rust/basecamp-sdk/src/generated/metadata.rs |
Rust generated metadata |
rust/basecamp-sdk/src/generated/accessors.rs |
Rust accessors |
ruby/test/basecamp/services/event_feed_service_test.rb |
Ruby event-feed tests |
ruby/lib/basecamp/generated/types.rb |
Ruby generated types |
ruby/lib/basecamp/generated/services/event_feed_service.rb |
Ruby event-feed service |
ruby/lib/basecamp/generated/metadata.json |
Ruby generated metadata |
ruby/lib/basecamp/client.rb |
Ruby client |
python/tests/services/test_event_feed_service.py |
Python event-feed tests |
python/src/basecamp/generated/types.py |
Python generated types |
python/src/basecamp/generated/services/event_feed.py |
Python event-feed service |
python/src/basecamp/generated/services/__init__.py |
Python service exports |
python/src/basecamp/generated/metadata.json |
Python generated metadata |
python/src/basecamp/client.py |
Python client |
python/src/basecamp/async_client.py |
Python async client |
kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt |
Kotlin generated service types |
kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/event-feed.kt |
Kotlin event-feed service |
kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/ServiceAccessors.kt |
Kotlin service accessors |
kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/options-param-order.json |
Kotlin option ordering |
kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/WebhookEvent.kt |
Kotlin webhook model |
kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Event.kt |
Kotlin event model |
kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/Metadata.kt |
Kotlin generated metadata |
kotlin/README.md |
Kotlin SDK documentation |
kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt |
Kotlin generator configuration |
kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt |
Kotlin conformance runner |
go/README.md |
Go SDK documentation |
go/pkg/basecamp/webhooks.go |
Go webhook services |
go/pkg/basecamp/webhook_event.go |
Go webhook event model |
go/pkg/basecamp/url-routes.json |
Go route metadata |
go/pkg/basecamp/events.go |
Go event services |
go/pkg/basecamp/eventfeed/live.go |
Go live event-feed connector |
go/pkg/basecamp/eventfeed/doc.go |
Go event-feed documentation |
go/pkg/basecamp/client.go |
Go client configuration |
go/grouped-client-inventory.yml |
Client inventory |
conformance/tests/event_feed.json |
Event-feed conformance tests |
conformance/runner/typescript/runner.test.ts |
TypeScript conformance runner |
conformance/runner/swift/Sources/ConformanceRunner/Dispatch.swift |
Swift conformance runner |
conformance/runner/rust/src/operations.rs |
Rust conformance operations |
conformance/runner/ruby/runner.rb |
Ruby conformance runner |
conformance/runner/python/runner.py |
Python conformance runner |
conformance/runner/go/main.go |
Go conformance runner |
conformance/event-feed/schema.json |
Event-feed fixture schema |
conformance/event-feed/README.md |
Event-feed conformance documentation |
conformance/event-feed/fixtures/30-continuation-redirect-cross-origin.json |
Redirect fixture |
behavior-model.json |
Behavior model |
AGENTS.md |
Repository guidance |
Review details
Suppressed comments (8)
go/pkg/basecamp/eventfeed/live.go:57
ForAccountpanics when the account ID contains any non-ASCII digit, so this new constructor can panic instead of returning its documented construction/usage error (for example,accountID = "abc"). Validate the numeric account-ID invariant before callingForAccount.
if accountID == "" {
return nil, usageError("accountID must be non-empty")
}
go/pkg/basecamp/eventfeed/live.go:64
CanonicalOriginlowercases the host withstrings.ToLower, which replaces invalid UTF-8 bytes with U+FFFD and can make distinct raw origins share one checkpoint identity. The connector's normal configuration path rejects invalid UTF-8 before canonicalization;NewLiveshould apply the same check tocfg.BaseURLbefore computingorigin.
origin, err := CanonicalOrigin(cfg.BaseURL)
if err != nil {
return nil, usageError(err.Error())
}
go/pkg/basecamp/eventfeed/live.go:188
Error.RetryAfteris populated for every HTTP status, but a parsed delay only meansthrottledwhen the outcome is retryable. As written, a non-retryable 404 (or malformed 409/410) carryingRetry-Afteris reported asMintThrottledeven though the generated operation will not retry it; gate this case onapiErr.Retryableas well.
case apiErr.RetryAfter > 0:
// A retryable outcome exhausted inside the seam whose last response
// carried a parsed Retry-After, whatever its status (§6).
return &MintError{Kind: MintThrottled, RetryAfter: time.Duration(apiErr.RetryAfter) * time.Second, Err: err}
go/pkg/basecamp/eventfeed/live.go:332
- The poll adapter has the same precedence issue:
Error.RetryAfteris carried at every status, so a non-retryable 404 (or malformed typed 409/410) with that header becomesPollThrottledinstead ofPollUnrecoverable. Only classify the delay as throttling whenapiErr.Retryableis true.
case apiErr.RetryAfter > 0:
return &PollError{Kind: PollThrottled, RetryAfter: time.Duration(apiErr.RetryAfter) * time.Second, Err: err}
go/pkg/basecamp/eventfeed/live.go:180
- This branch treats every non-
*basecamp.Erroras transient, but generated response parsing returns plain JSON decode errors andEventFeedServicereturns a plain error for a 2xx response with no decoded body. Those are malformed response shapes, which the mint seam contract classifies asMintUnrecoverable; classifying them as transient causes the connector to retry a permanently invalid server response. Preserve a response-decode/shape distinction from the wrapper or handle it before defaulting non-API errors to transient.
if !errors.As(err, &apiErr) {
// A transport-level failure the generated call could not classify
// (DNS, TLS, a dropped connection): transient, it rides the
// reconnect cycle.
return &MintError{Kind: MintTransient, Err: err}
go/pkg/basecamp/eventfeed/live.go:322
- As in
mapMintError, this classifies plain generated JSON decode errors and the wrapper'sunexpected empty responseerror asPollTransient. An invalid 200 body is an unexpected response shape and must bePollUnrecoverable; otherwise every poll attempt can retry the same malformed payload indefinitely. Preserve or classify response-shape errors separately from transport failures.
var apiErr *basecamp.Error
if !errors.As(err, &apiErr) {
return &PollError{Kind: PollTransient, Err: err}
}
go/pkg/basecamp/eventfeed/live.go:127
- This refusal is returned from
http.Client.Do, but the generated retry loop treats every such error as a retryable network failure for idempotentPollEvents/PollInboxcalls. With the default three attempts, one cross-origin 302 therefore repeats the original poll (and waits) beforemapPollErrorseesredirect_refused, contrary to §23's requirement that this outcome is not retried; the Layer-1 test masks it by settingWithMaxRetries(0). Make the refusal non-retryable inside the generated call or intercept it before that retry loop.
if len(via) >= 10 {
return &redirectRefusedError{locationOrigin: locationOrigin(req)}
}
if terr := checkContinuation(origin, req.URL.String()); terr != nil {
return &redirectRefusedError{locationOrigin: locationOrigin(req)}
go/pkg/basecamp/eventfeed/live.go:71
NewLiveaccepts arbitraryClientOptions, butWithHTTPClientis silently lost:NewClientapplies it and then unconditionally replacesc.httpClientwhile constructing its logging client. A caller's customhttp.Client(for example its transport, jar, or timeout) therefore does not govern any live seam call orLive.Client, despite being passed here. Preserve the supplied client while installing the feed policy, or narrow the options this constructor accepts.
opts := make([]basecamp.ClientOption, 0, len(clientOpts)+1)
opts = append(opts, clientOpts...)
opts = append(opts, basecamp.WithCheckRedirect(feedRedirectPolicy(origin)))
client := basecamp.NewClient(cfg, tokens, opts...)
- Files reviewed: 51/90 changed files
- Comments generated: 3
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
1cf3551 to
000c400
Compare
000c400 to
d471d85
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d471d850ef
ℹ️ 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".
d471d85 to
b4bbcf4
Compare
|
Review threads: 73 resolved (57 fixed, 12 declined with the reasoning in each thread, 4 resolved by #898's merge), 0 open. Head b45b8eb: on #897 over main at a804626 with #912 merged — no merge commits; the diff against #897 is the adapters alone. Copilot re-requested once on 3dd0661 and reporting; Codex reports on every head. Closed this round: a since re-entry starts a new walk (a position continuation carries the order over); a relative Location resolves against the request before it is reduced to an origin; a page without its events/items member is malformed, not empty (reversing my earlier decline on r4023457209 — the wrapper preserves nil); a doubled slash is refused at the root too; §23 says in every passage that no hop is followed inside the seam. The six threads the operator's delegate decided are closed: a The head consumes the generated error shapes #912 shipped (FeedPositionGoneError / InboxPositionGoneError per lane, each resume held to its fence; FeedRequestError.Reason keying the 400 with the message as the only fallback when a server sends none). The redirect class is closed by one mechanism: a guard composed over the host's transport (basecamp.WithTransportWrapper) answers every 3xx of a seam call at the wire — the call recognized by its exact route beneath the canonical base path, the Location reduced to its origin and stripped with the body before net/http, hooks or logs see it — and leaves every other request untouched; the same guard marks seam bodies so a mid-body failure is transient. Details pass through as the server's bytes on both lanes under the push decoder's rule, with a poll-versus-push parity test. A walk is in strict key order within and across pages, per connector. Connect refuses a WithLane that conflicts with the binding. Declined:
Not in this PR: the Ruby runtime's single-attempt path for a declared-idempotent mint — #901; wrapper-level decode validation of the 409 digests and 400 reason — #915; both registered in spec/tracking-issues.yml. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3f9895ba18
ℹ️ 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".
3f9895b to
0fb41d0
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a019cd0ba3
ℹ️ 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".
a019cd0 to
a65e6f4
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a65e6f4a44
ℹ️ 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".
…r from, and render nothing of a continuation A mint answered without a positive lifetime is a malformed success. A followed continuation must carry exactly one cursor — a position or a since — before it is re-issued, so a server URL that omits or misspells it never becomes a bare present entry that commits the head; and a filter value the wrapper cannot parse yields a fixed cause, never the value, which is server-chosen text. Cancellation is judged on the connector's own context, so the HTTP client's timeout — a wrapped DeadlineExceeded with the context live — classifies as the transport failure it is. A 3xx whose Location net/http could not parse, refused before any policy runs, is a refused hop with no origin rather than a transport failure to retry into.
…ng of one reaches a rendering The feed's client composes a guard over the host's transport instead of installing a redirect policy: every 3xx is answered before net/http can parse a Location, follow it, or render it into a url.Error the operation hooks would receive. The Location is reduced to its origin for the seam and stripped, with the body, so one mechanism covers a foreign, same-origin, downgraded, or unparsable Location and a 3xx with none. basecamp.WithCheckRedirect goes away — a func field made Client non-comparable — in favour of WithTransportWrapper, an interface the client composes before its logging layer. The wrapper's continuation error names the offending filter and never its value; the api-gaps record states what shipped; Ruby's single-attempt mutation path for a declared-idempotent mint is registered against #901.
…ration guide loses a stray marker A base URL reaches the request hooks and logs whole through every request's URL, so one carrying userinfo is refused at construction with a fixed message rather than echoed. MIGRATING.md drops the diff3 marker a merge left behind.
…er sent, on either lane The generated layer now carries details verbatim, so the poll adapter passes the document through under the push decoder's rule — an object whole, null absent, anything else refused — and a parity test holds the two lanes to the same bytes, explicit nulls and unmodeled members included.
…e is the binding's, and a feed 410 names its epoch The guard recognizes a seam call by the per-call record it carries and leaves every other request through the same client untouched, so a host's download keeps the dispatching 302 the SDK follows itself. Connect refuses a WithLane naming the other lane instead of silently overriding it, and the README shows the inbox as its own binding. A feed 410 without epoch_after_id is malformed rather than a gap fenced at 0; the inbox's, which carries none by contract, stays a gap. NewLive refuses invalid UTF-8 in the base URL before canonicalization would fold it.
…cated body is transient A host hook that returns a fresh context drops the seam's per-call record, so the guard keys on the three feed routes instead and uses the record only to carry the origin back when it survives. A connection that ends mid-body is a transport failure the reconnect cycle absorbs, not a malformed response.
…entory names the adapters
…ation repeats no key, and a page is in strict order The feed routes are matched by their tail beneath whatever path prefix the base URL carries. A continuation that repeats a query key has no single meaning the wrapper and the API would read alike, so it is refused; a page whose rows are not in strict order of the lane's identity is malformed on either lane.
…arks the seams' bodies, and a walk keeps its order across pages The route match is exact beneath the configured base path, so a recording's audit trail and every other route a host issues through the client stay outside the guard. A seam call's body is marked so a read that fails after the headers — a reset stream, whatever type the stack chose — is transient rather than a decode failure. The binding's one poll source remembers the last key of the page it served and refuses a continuation whose first row does not follow.
… details held to the push lane's rule NewLive refuses a base URL path with dot segments or doubled slashes, which would resolve beneath a prefix the guard does not record. Polls hands each connector its own source, so two connectors over one binding never read each other's walk state. A polled details object must be valid UTF-8 without a lone surrogate escape, as the push decoder requires of the same bytes.
…, and hold a resume to its fence PollEvents' FeedPositionGoneError and PollInbox's InboxPositionGoneError map in their own arms on their own lanes, and a resume must re-enter at the fence its body declares — the feed's epoch, the inbox's 0 — with no position. The 400's reason keys position_invalid against filter_invalid; a server that sends none leaves its message as the fallback, never a guess. A 409 without both digests is malformed. NewLive refuses a base URL carrying a query or fragment, which the generated client would resolve past.
…the lane's own filters, and a 409 or 400 of the wrong shape is malformed A page's next must carry a position and never a since, as a 410's resume must carry a since and never a position; either mismatch makes the page malformed, refused the way a positionless page is. A followed URL's filter dimensions are no longer trusted: the re-issued request carries the lane's own canonical filters, and a URL whose filters differ from them is refused unissued. A 409 digest that is present but not bare 16-hex is malformed as a missing one is, and a 400 with a reason the contract does not name is surfaced as undifferentiated rather than read from its message.
…to its origin, and a page carries its collection A followed since re-entry resets the walk's order as a fresh cursor does; a position continuation carries it over. A relative or scheme-relative Location is resolved against the request before it is reduced to an origin. A 200 without its events or items member is malformed, not empty. A base URL whose path doubles a slash is refused even at the root, and §23 says in every passage that no hop is followed inside the seam, same-origin included.
b45b8eb to
56851cd
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 56851cdd32
ℹ️ 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".
… says so The inbox lane's 410 re-enters at since=0, and checkResumeCursor holds the resume to exactly that. The case that paired an epoch_after_id with the feed's fence proved only that the wrong fence is refused, while its name claimed the epoch was what did it. Split the two: the wrong-fence case keeps its own row, and a body carrying a stray epoch beside the inbox's own fence is accepted, at since=0, with no epoch — the fence is the control and the undeclared member drives nothing.
…corded, not unparsable The per-call record the redirect guard writes the refused Location's origin on travels the call's context, and a host hook that returns a context unrelated to the one it was handed drops it. The hop was still refused off the request's route, so zero egress holds; what is lost is the origin. The seam reported that loss as `unparsable`, which §9 reserves for a Location that was absent or yielded no complete origin — an operator reading it went looking for a malformed header the server never sent. Give the condition its own token, name both in the seam contract, and assert the reported origin in the hook test that had only asserted the kind.
|
Review threads: 6 resolved (2 fixed, 4 declined with the reasoning in each thread). Declined:
Fixed:
|
…misanchored
The redirect guard is anchored at the base URL's decoded path, and the client
sends the escaped one. `https://host/api%2f` decodes to `/api/`, passes the
dot-segment and doubled-slash rules, and goes on the wire as
`/api%2f/{account}/events.json` — where the guard, anchored at `/api/`, reads
an empty account segment, passes the 3xx through, and the HTTP stack follows
it off the API origin carrying the request's query. Zero foreign egress did
not hold for that base URL: a sentinel behind the foreign Location received
the request.
RawPath is non-empty exactly when a URL's escaped path is not the canonical
encoding of its decoded path, so refusing it in the constructor closes every
escaped spelling rather than the one that was found, and leaves the encodings
that anchor correctly — a percent-encoded space among them — still building.
There was a problem hiding this comment.
🟡 Changes recommended
Resolve the path-prefixed BaseURL and custom HTTP client issues, along with the remaining validation and documentation findings.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (6)
go/pkg/basecamp/client.go:265
NewLiveforwardsWithHTTPClient, but this construction path only wrapsc.httpOpts.Transport; the suppliedhttp.Client's transport and settings are discarded by the unconditionalc.httpClientcreation immediately below. Custom TLS/proxy/timeout clients therefore have no effect on Live calls; preserve or compose the supplied client while installing the feed guard.
if c.transportWrapper != nil {
transport = c.transportWrapper.WrapTransport(transport)
}
go/pkg/basecamp/eventfeed/doc.go:30
- The implementation intentionally refuses every 3xx from a feed operation, including same-origin redirects (see the guard below and SPEC §23), but this package documentation says only cross-origin and downgraded redirects are refused. Update the description so callers are not led to expect same-origin feed redirects to be followed.
// refuses cross-origin and downgraded redirects before any request is
// issued, which is how the two obligations the seam contracts place on
// whichever adapters back them — zero egress to a foreign redirect target
// and no automatic redirect-following on the poll lane — are met here. A
go/pkg/basecamp/eventfeed/live.go:251
- The PR description says a same-origin 302 is followed with the bearer intact, but this branch answers every feed 3xx—including same-origin—at the transport and
TestLivePolls_ASameOriginRedirectIsRefusedTooasserts that behavior. Please reconcile the advertised contract with the implementation/spec (or change the behavior and tests); otherwise users and reviewers get contradictory redirect guarantees.
// mid-body — reaches the seam as a bodyReadError the classifier can tell
// from a body that arrived whole and did not decode.
type redirectGuard struct {
inner http.RoundTripper
basePath string
go/pkg/basecamp/eventfeed/live.go:103
clientOptsare described as being forwarded, butbasecamp.NewClientappliesWithHTTPClientand then unconditionally replacesc.httpClientwhile building its internal client (go/pkg/basecamp/client.go:256-283). Consequently a caller that supplies a custom*http.ClienttoNewLivesilently loses it, including its transport and client settings; preserve that option while composing the feed redirect guard, or explicitly disallow it.
opts := make([]basecamp.ClientOption, 0, len(clientOpts)+1)
opts = append(opts, clientOpts...)
// The guard is anchored at the base URL's path, so that path must be
go/pkg/basecamp/eventfeed/live.go:719
- The conformance schema requires
epoch_after_idto be non-negative, but the generated response type is anint64and does not enforce that constraint. With a malformed 410 body containingepoch_after_id: -1andresume?...since=-1,checkResumeCursorsucceeds here and the connector emitsPollGone, allowing recovery to reissue an invalid negative cursor instead of rejecting the response as unrecoverable.
}
go/pkg/basecamp/eventfeed/live.go:888
performed_by_idis required by the poll-row contract, but the generated*int64field decodes both an omitted key and an explicit JSONnulltonil. This condition therefore accepts a row that omits the required key (when the other fields are valid) and delivers it; preserve field presence in the wrapper decode or validate the raw response before converting it toFeedEvent.
}
// eventFromFeed maps the wrapper's FeedEvent onto the connector's Event. The
- Files reviewed: 16/16 changed files
- Comments generated: 1
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved moderate findings affect client-option propagation, redirect guarding, malformed-response handling, and poll-row validation.
Review details
Suppressed comments (9)
Previously missed (1) — in code that hasn't changed since the last review.
go/pkg/basecamp/client.go:179
- The PR description and commit message say this change adds
basecamp.WithCheckRedirect, but the codebase defines no such option; the only new option here isWithTransportWrapper. Consumers following the stated API cannot compile, and a transport wrapper is not an equivalent public redirect-policy hook. Please add the promised option or update the PR contract/description to the actual API.
SPEC.md:4248
- This hunk still introduces the
redirect_refusedmatrix with the condition “whose Location fails” while the updated rule below says the adapter refuses every feed 3xx, including same-origin Locations. That leaves the normative error mapping internally contradictory; make this first matrix sentence describe the transport-boundary refusal of any feed redirect.
-- its origin — or, in its place, the fixed token `unparsable` when the Location yielded
-- no complete origin (§9) and `unrecorded` when the hop was refused but the adapter
-- could not attribute the origin to the call; the refusal and its zero egress never
-- depend on that attribution, and the two tokens are never interchanged, since one
-- reports what the server sent and the other what the adapter failed to keep
go/pkg/basecamp/client.go:264
NewLiveforwardsclientOpts, butWithHTTPClientis silently discarded byNewClient: the wrapper is applied toc.httpOpts.Transporthere andNewClientthen constructs a newhttp.Clientinstead of using the supplied one. Consequently a Live binding created with the documented custom client loses its transport, timeout, proxy, and redirect configuration. Please preserve/clone the supplied client's settings while installing the feed guard, or reject this option explicitly forNewLive.
if c.transportWrapper != nil {
transport = c.transportWrapper.WrapTransport(transport)
}
go/pkg/basecamp/eventfeed/live.go:525
- A generated 200 response can omit
position(or set it to null), and the wrapper decodes that as an empty string. This code returns it as a successful PollPage; the connector happens to reject it later, but directLive.Polls()consumers receive a malformed page and the PollSource contract says unexpected shapes are PollUnrecoverable. Validate a non-empty position here before returning the page for both lanes.
if len(page.Events) > 0 {
if page.Events[0].Key() <= p.lastKey {
return PollPage{}, &PollError{Kind: PollUnrecoverable, Err: fmt.Errorf("eventfeed: the continuation's first row %d does not follow the previous page", page.Events[0].Key())}
}
p.lastKey = page.Events[len(page.Events)-1].Key()
}
return page, nil
go/pkg/basecamp/eventfeed/live.go:135
- With a path-prefixed base URL, this client is initialized with
serverURLending in/api/v1becauseClient.initGeneratedClienttrims the slash. The generated operations resolve their./{account}/...paths relative to that URL, so/api/v1is treated as a file and the actual request becomes/api/{account}/events.json; the guard anchored at/api/v1/then does not recognize the feed call and a 3xx can be followed. Initialize this feed client with a copied config whoseBaseURLhas exactly one trailing slash (without mutating the host config) so generated calls and the guard use the same prefix.
client := basecamp.NewClient(cfg, tokens, opts...)
go/pkg/basecamp/eventfeed/live.go:455
io.EOFis also the error returned byjson.Unmarshalfor an empty 200 response. Because the generated response parsers unmarshal successful JSON bodies beforeEventFeedServicecan validate them, an empty/malformed success reaches this helper as plainio.EOFand is classified astransient, causing mint/poll retry or reconnect behavior instead of the documented unrecoverable malformed-response outcome. Body-read failures are already distinguished bybodyReadError/io.ErrUnexpectedEOF; do not treat plainio.EOFas transport failure here.
if errors.As(err, &bodyErr) || errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
return true
go/pkg/basecamp/eventfeed/live.go:900
- The poll contract requires
performed_by_idto be present on every row (null is the direct-action value), but the generated*int64field collapses an absent member and JSONnullto the same nil value. This check therefore accepts a row that omitsperformed_by_idand delivers it as valid on both feed and inbox lanes, despite the adapter claiming to validate required members. Preserve presence when decoding the generated response (or validate the raw row) before applying the nil/null rule.
if fe.ID < 1 || fe.BucketID < 1 || fe.CreatorID < 1 || fe.RecordingID < 1 ||
(fe.PerformedByID != nil && *fe.PerformedByID < 1) ||
fe.Kind == "" || fe.EventType == "" || fe.Action == "" || fe.CreatedAt.IsZero() {
go/pkg/basecamp/eventfeed/live.go:580
FeedEventis decoded withencoding/jsonbefore this conversion, so any unknown poll-row members are discarded. That means a response containing push-onlyactor_typeorvisible_to_clientsis accepted and delivered, although the event-feed schema requires both fields to be absent on poll rows (the detail-parity test currently sends exactly such a row). Validate the raw row shape or preserve member presence in the service response so the adapter rejects this class of malformed page.
events := make([]Event, 0, len(page.Events))
for _, fe := range page.Events {
ev, err := eventFromFeed(fe)
go/pkg/basecamp/eventfeed/live.go:65
clientOptsare passed toNewClient, butWithHTTPClientdoes not survive that call:NewClientapplies it and then unconditionally replacesc.httpClientwith a new client (client.go:269). ANewLivecaller's custom transport/proxy/TLS settings, cookie jar, timeout, or redirect behavior is therefore silently ignored, contrary to this pass-through contract. Preserve the supplied client's settings while installing the feed guard, or explicitly reject this option.
// NewLive builds the seams for accountID on lane over a basecamp.Client
// constructed from cfg, tokens and clientOpts — with the feed's redirect
// guard installed last, so it wins over any transport wrapper in clientOpts
// (the host's transport itself, from WithTransport, is what the guard
// composes over). The client's base URL is the connector's origin: the checkpoint key's and the
- Files reviewed: 16/16 changed files
- Comments generated: 0 new
- Review effort level: Lite (auto)
Note
Copilot is running an experiment and ran this review at Lite.
Fifth of the stack — stacked on #897 (inbox lane), which stacks on #778 → #705 → #777 — and it also needs #898 (the spec operations and generated clients). The branch merges #898's head (
7c1e8bef) so the adapters compile; until #898 merges, this PR's diff shows #898's changes too. The reviewable change is the last commit,1cf3551a— the merge commit before it is #898 verbatim (plus a one-paragraph SPEC merge resolution noting that the connector text now absorbs the contract deltas #898's "Wire Operations" section listed). Findings in #898's files belong on #898.What this is
eventfeed.NewLivebinds the connector's two seams to the generated operations through thebasecamp.EventFeedServicewrapper:Live.Minter()overCreateStreamTicket,Live.Polls()overPollEvents(account lane) orPollInbox(inbox lane), andLive.Connect(opts...)builds the connector over them. One seam call is one fully-governed generated call — the operation keeps its SPEC §7 retry budget, backoff and Retry-After inside the seam — and each adapter maps every §6/§7 outcome onto exactly one seam error kind:unauthorized; a parsed Retry-After →throttled(retry_after); a retryable or transport-level failure →transient; context cancellation passes through untouched.filter_changedwith both digests; 410 →gonewithepoch_after_id(0 on the inbox) and the resume URL; a 400 isposition_invalidwhen the server's message says so (Unrecognized position…, the one 400 asince=re-entry recovers) andfilter_invalidwith the message otherwise; anything else non-retryable →unrecoverable.PollEventsOptionsFromURL/PollInboxOptionsFromURL), never by fetching the URL raw — so "never construct API paths manually" and "one seam call is one generated call" both hold.The redirect policy, and why the adapters build their own client
The client's default redirect policy follows a cross-origin hop with the bearer stripped — which still egresses to the foreign origin, and §23 "Continuation and Resume URL Validation" requires none. A per-operation override isn't reachable through the wrapper, so this adds one client option,
basecamp.WithCheckRedirect, andNewLivebuilds the feed's client from the host'sConfig,TokenProviderand anyClientOptions with a policy installed last that validates every hop's resolvedLocationunder §8's same-origin rule plus downgrade rejection before any request is issued. A refused hop reaches the connector as the seam'sredirect_refusedkind carrying only the Location's origin: theurl.Errornet/http wraps the refusal in renders the refused URL whole, which is exactly the value a hostile redirect can put the bearer into, so the typed error alone travels. The host can useLive.Client()for its own refetches — refusing cross-origin redirects is safe for every API call.This is the Layer-1 302 test the tier-2 family assigned to this layer:
TestLivePolls_RefusesACrossOriginRedirectWithZeroEgresshas a real generatedPollEventscall meet a real 302, and a sentinel listener behind the foreign Location records zero hits. The deferrals that pointed at it (SPEC §23, the README's row-15 note and rows 15/30, fixture 30 and its schema variant, the fourspec/tracking-issues.ymlentries) are discharged.Tests
live_test.godrives the adapters against a loopback API through a realbasecamp.NewClient: the mint (success, 401/403, 429 with Retry-After, 503, 404, a malformed success), the poll query for every dimension and the page mapping (performed_by_id, verbatimdetails), continuation-following through the operation, the full error matrix on both lanes, inbox items with their addressing, the cross-origin 302 refusal with zero egress, a same-origin 302 followed with the bearer intact, cancellation pass-through, andNewLive's construction validation. Gates run locally:go test ./pkg/basecamp/...,golangci-lint run,make event-feed-fixtures-check,make doc-constants-check,make check-known-defect-issues-open— all clean.Summary by cubic
eventfeed.NewLivenow binds account and inbox connectors directly to generatedCreateStreamTicket,PollEvents, andPollInboxoperations instead of requiring host-supplied seams. Feed requests answer every 3xx at the transport boundary, so no feed redirect is ever followed; a refused hop reaches feed error handling asredirect_refusedwith a redacted origin, or with the fixed tokensunparsablewhen the Location yielded no complete origin andunrecordedwhen the hop was refused but the origin could not be attributed to the call. Unrelated client requests keep normal redirect behavior.Behavior
Retry-Afterhandling; malformed responses, transport failures, and client resilience refusals map to distinct seam errors.nextcarries a position,resumecarriessince, and each connector preserves walk state.detailsidentically on both lanes; an inbox 410 re-enters atsince=0, while a feed 410 must name its epoch.NewLiverejects invalid account IDs, non-canonical base URLs, and percent-encoded base paths that would misanchor the redirect guard;Connectrejects lane overrides, and each connector receives an independent poll source.CreateStreamTicketremains single-attempt; retry support is tracked in issue Ruby: declared-idempotent mutations such as CreateStreamTicket bypass the retry budget #901.Migration
EventandWebhookEventgainPerformedBy; use keyed composite literals.WebhookEvent.detailschanges fromString?toJSONValue?.EventandWebhookEventgainperformedBy, changing positional constructors and destructuring.Written for commit ebce4df. Summary will update on new commits.