Skip to content

Withdraw the account-wide boosts feed - #427

Merged
jeremy merged 1 commit into
masterfrom
withdraw-all-boosts-feed
Jul 30, 2026
Merged

Withdraw the account-wide boosts feed#427
jeremy merged 1 commit into
masterfrom
withdraw-all-boosts-feed

Conversation

@jeremy

@jeremy jeremy commented Jul 30, 2026

Copy link
Copy Markdown
Member

The account-wide boosts feed, GET /boosts.json, is withdrawn. The endpoint's current query doesn't hold up on our largest accounts, so we're pulling it while we rework how it fetches boosts at scale. We expect to bring it back.

Everything else is unchanged: the other Everything feeds (/messages.json, /comments.json, /checkins.json, /forwards.json, /files.json, and the to-do/card filters), the project-scoped boosts endpoints under buckets/…/recordings/…/boosts.json, and the boosts_count/boosts_url attributes on recording payloads all remain as documented.


Synced from bc3 doc/api/ by script/api/sync_to_bc3_api — not a hand-edit.

GET /boosts.json is withdrawn while we rework how it queries at scale.
The rest of the Everything feeds and the project-scoped boosts endpoints
are unchanged.
Copilot AI review requested due to automatic review settings July 30, 2026 03:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Ready to approve

The withdrawn endpoint is fully removed from the Everything docs without leaving broken references, and the remaining feeds are unchanged.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Withdraws the account-wide Everything boosts feed (GET /boosts.json) from the Basecamp API documentation due to scalability concerns, while keeping the rest of the Everything feeds and project-scoped boosts documentation intact.

Changes:

  • Removed /boosts.json from the list of account-wide Everything root collections.
  • Removed the “Boosts / Get boosts” endpoint section (including example response and cURL) from sections/everything.md.
  • Cleaned up related endpoint/link references in the Everything section.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File summaries
File Description
sections/everything.md Removes the account-wide /boosts.json feed documentation from the Everything endpoints section.
Review details
  • Files reviewed: 1/1 changed files
  • Comments generated: 1
  • Review effort level: Low

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment thread sections/everything.md
jeremy added a commit to basecamp/basecamp-cli that referenced this pull request Jul 30, 2026
BC3's brief supersedes the guesswork in the previous commit on two points, both
worth having in the tree rather than only in an issue thread.

The cause is not the derived-table materialization this repo inferred: the
query's cost is proportional to the account's accessible recordings rather than
to its boosts. That explains the ~44s and the flat cost curve without needing
the EXPLAIN we asked for.

And the withdrawal is temporary. The feed returns on a boost-proportional query
via a boosts.bucket_id denormalization (basecamp/bc3#12463); the withdrawal
itself is basecamp/bc3#12464, with public notice at basecamp/bc-api#427. So the
docs now describe a listing that is gone for now, not one judged a bad idea —
and boost.go says where the account-wide branch goes back when it returns.

The SDK keeps Everything().Boosts() deliberately: removing a generated
operation for a temporary withdrawal would churn every generated client twice.
It 404s in the interim; the CLI simply does not call it.

e2e/boost.bats needed no change — it covers help only, with no account-wide
case to remove.
@jeremy
jeremy merged commit d28c953 into master Jul 30, 2026
1 check passed
@jeremy
jeremy deleted the withdraw-all-boosts-feed branch July 30, 2026 21:44
jeremy added a commit to basecamp/basecamp-cli that referenced this pull request Jul 31, 2026
* Bound the account-wide listings instead of crawling the account

Soaking v0.8.0-rc.1 against a ~80-project account showed the account-wide
listings are unusable at real scale. The cause is a contract decision, not a
bug: I5 pinned each command's account-wide default to its project-scoped
default. But project-scoped "all" is one project's items and account-wide
"all" is the whole account, so the same word bought very different work.

  files list --all-projects              HTTP 500 mid-crawl, no flag to recover with
  cards list --all-projects --status completed   timed out at 94s (--page 1: 24s)
  checkins answers --all-projects        timed out (one page: 1s)

I5 also blessed "fetch page 0, then truncate", which made --limit useless
exactly when it mattered: cards --limit 3 fetched all 2142 cards before
trimming.

Add accountWideCollect, one bounded walk over positive pages shared by all
eight paths. It stops on an empty page, a page that adds no items, the cap, or
the server's total. Two rules callers must know, so they are stated on the
helper: Meta.TotalCount compares against len(items) — the top-level elements,
which for a grouped payload are the groups, not the items nested inside — and
the walk does not trim, so every caller cuts to the exact cap itself.

Default to 100 for the paginated listings; --all is how you ask for the
account. This changes the default for cards, checkins, forwards, and files.
Project-scoped defaults are untouched.

Give boost list and bare files list the --limit/--page/--all they never had. A
listing with no escape hatch cannot recover from a server error mid-crawl,
which is exactly how the files 500 became unrecoverable. Where a path has no
pagination to thread — project-scoped files list, item-scoped boost list — it
rejects all three by name rather than accepting and ignoring them.

Both overdue paths now accept --all and reject --page. They are unpaginated, so
--page addresses nothing, but capping at 100 while also rejecting --all left
item 101 unreachable by any combination of flags.

boost keeps a first-page default: /boosts.json?page=1 alone measured 93s, so
walking toward 100 by default would multiply an already unacceptable wait. The
exemption covers the default only — its flags walk like everywhere else — and
its notices are per-mode, since "Showing first page" is false for --page 2. The
slowness is server-side and tracked there.

Dropping accountWideRespOpts's moreFlag parameter falls out of this: it existed
so the two flagless commands would not be told to use an --all they did not
have. They have one now.

* Record the measured cause of the boost feed's latency

The contract doc carried a hypothesis; production tracing settles it. Server
timings for one page=40 request, 44,630ms total:

  Boost Load   22,097.6ms   the paginated SELECT
  ~60 preloads      <2.3ms each
  Boost Count  22,265.7ms   GearedPagination's total-count query
  Completed 200 OK in 44630ms (Views: 244.7ms | ActiveRecord: 44370.9ms)

99.5% database time, in two statements of near-identical cost. That corrects
the record twice over: the N+1 hypothesis is dead — the controller's preloads
work, and 60 of 64 queries are trivial — and ordering the derived table, which
the doc named as the suspect, is only half the story. The count pays the same
materialization again for pagination metadata, so fixing the ORDER BY alone
would leave roughly half the latency behind.

The 93s figure was also wrong: that was the client giving up after three 30s
attempts. A page costs ~44s, and page 40 costs the same as page 1 — the flat
cost curve the materialization predicts.

Since ~44s exceeds the SDK's 30s client timeout, boost list --all-projects does
not merely run slow, it cannot complete. Filed as basecamp/bc3#12458, tracked
alongside the files.json 500 (basecamp/bc3#12459) in basecamp-cli#589.

* Stop reporting a listing that ends at the cap as truncated

accountWideCollect checked the cap before exhaustion, so a listing whose total
equalled the limit came back capped=true and the callers turned that into
"more may exist" about a listing with nothing left in it. Test the known total
first, and let the trim decide: holding every item still counts as capped when
the caller is about to cut down to the cap.

The exhaustion bound also read TotalCount from the current page while the
helper documents the first page's as authoritative. A later page omitting
X-Total-Count switched the bound off mid-walk and kept requesting past the
declared end of the listing.

messages and comments were discarding capped entirely, which made truncation
silent on exactly the feeds that withhold a total — the case the notice exists
for. messages also had a local `capped` meaning "this invocation applies a
cap", which is a different question from "the walk stopped short"; it is now
`bounded`, and the sorted path sets capped when its own trim removes anything.

* Separate what the boost tracing measured from what it infers

The doc claimed the derived-table materialization as established. The tracing
established the shape — two ~22s statements, 99.5% db time, N+1 disproved —
but not the query plan behind it, and bc3#12458 correctly asks for an EXPLAIN
rather than asserting one. Say the same thing here.

* Drop the account-wide boost listing rather than page around it

The /boosts.json aggregate is an easter egg: unlinked from the Basecamp web UI,
and served by a query that spends ~44s in MySQL on every page regardless of
depth (basecamp/bc3#12458). It is being withdrawn server-side, so the CLI stops
calling it instead of building a contract on top of that.

boost list now requires an item ID. It loses --all-projects along with the
--limit/--page/--all this branch had given it: an item's boosts arrive in one
unpaginated response, and the SDK documents BoostListOptions.Page as not
honoring a page number, so there was never anything for those flags to address
once the aggregate went away. rejectScopedPaginationFlags is no longer needed
here — flags that do not exist cannot be silently ignored.

Everything().Boosts() stays in the SDK. Whether it should is the SDK's call;
this repo just stops reaching for it, and API-COVERAGE.md now records 16 of the
17 aggregates as surfaced with the reason for the sixteenth.

Seven commands list account-wide, not eight.

On the surface baseline: --limit/--page/--all on boost were added and removed
inside this unmerged branch, so they never shipped and recording them in
.surface-breaking would claim a break that never happened. They are rewound out
of the baseline instead. The two changes that did ship in v0.8.0-rc.1 --
`--all-projects`, and the argument going from optional to required -- are
acknowledged as real removals. Net surface change against rc.1 is exactly
those two.

* Record that the boosts withdrawal is temporary, and BC3's own cause

BC3's brief supersedes the guesswork in the previous commit on two points, both
worth having in the tree rather than only in an issue thread.

The cause is not the derived-table materialization this repo inferred: the
query's cost is proportional to the account's accessible recordings rather than
to its boosts. That explains the ~44s and the flat cost curve without needing
the EXPLAIN we asked for.

And the withdrawal is temporary. The feed returns on a boost-proportional query
via a boosts.bucket_id denormalization (basecamp/bc3#12463); the withdrawal
itself is basecamp/bc3#12464, with public notice at basecamp/bc-api#427. So the
docs now describe a listing that is gone for now, not one judged a bad idea —
and boost.go says where the account-wide branch goes back when it returns.

The SDK keeps Everything().Boosts() deliberately: removing a generated
operation for a temporary withdrawal would churn every generated client twice.
It 404s in the interim; the CLI simply does not call it.

e2e/boost.bats needed no change — it covers help only, with no account-wide
case to remove.

* The SDK is dropping the boosts aggregate too, not just the CLI

The withdrawal brief asked that Everything().Boosts() stay in the SDK, on the
grounds that churning every generated client twice for a temporary withdrawal
was worse than letting it 404. That call has changed: the endpoint is going
away for real server-side, so shipping an operation that cannot work is worse
than the churn.

Nothing to do here beyond correcting the docs, which said the opposite. The CLI
carries zero references to the aggregate — verified across code and tests, not
just assumed — so whichever SDK release removes it lands as a no-op, and the
next bump needs no CLI change. API-COVERAGE.md records that its "16 of 17"
becomes 16 of 16 once the CLI pins an SDK without it, rather than pre-claiming
a version that isn't pinned yet.

Also drops a stale I6 line about EverythingBoost's pointer fields: nothing
flattens that payload any more.

* Stop the bounded walk dropping every page after a sparse one

Five review findings, two of them correctness.

A grouped page whose groups all happen to be empty is sparse, not terminal, and
the walk returned on it — silently dropping every project on every later page,
and suppressing the truncation notice while doing so. My own test enshrined the
data loss, asserting page 3 was never requested. It now asserts the opposite.

The justification for that guard was wrong on its face: it claimed to prevent
requesting "the same page shape forever", but the loop increments page, so it
was never at risk of re-requesting anything. The empty page is the server's
actual end-of-listing signal and is now the only content-based stop. A page
ceiling replaces the guard as the runaway backstop, and hitting it reports
capped rather than passing a bounded walk off as a complete listing.

The collected slice started nil, so an empty first page marshalled to
"data": null while the SDK-backed --all path returns []. Consumers iterating
.data[] had to handle both. Verified against production: an account-wide
listing with no matches now returns [].

Three contract defects alongside them. cards, checkins and forwards still
advertised "--limit 0 = all" while their account-wide default had become 100.
accountWideRespOpts told users to "drop --limit for more", which returns fewer
once the explicit limit exceeds the default. And accountWideCapNotice claimed
to defer whenever the server reported a total but only deferred when that total
exceeded the count, so it could contradict the server by saying "more may
exist" when the server said there was nothing more.

* Stop telling agents to use --page on the overdue listings

SKILL.md said every account-wide listing takes --page. The two overdue
variants come from unpaginated endpoints and reject it, so an agent following
the skill would generate a command the CLI refuses. Scope --page to the
paginated listings and name the exception, with what the overdue paths do
accept: --limit and --all.

Verified against production rather than read off the source: both overdue
variants reject --page 2 and return rows for --all.

Note the drift checker cannot catch this class of error. It validates that a
flag exists on a command, and --page does exist on todos list and cards list;
it is rejected at runtime only in combination with --overdue. The prose is the
only guard, which is the reason to keep it exact.
jeremy added a commit that referenced this pull request Jul 31, 2026
* origin/master:
  Withdraw the account-wide boosts feed (#427)
  Files feed: document parent for images hotlinked into chats (#426)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants