Skip to content

docs: the bounded capacity queue, ADR 0042 and SDK 1.28.0 (#1033) - #1876

Merged
jhgaylor merged 1 commit into
mainfrom
stack/1033-7-docs
Sep 11, 2026
Merged

docs: the bounded capacity queue, ADR 0042 and SDK 1.28.0 (#1033)#1876
jhgaylor merged 1 commit into
mainfrom
stack/1033-7-docs

Conversation

@jhgaylor

@jhgaylor jhgaylor commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #1874. Last of seven for #1033, and the one that names the decision.

ADR 0042 records the whole thing, including the two places this re-cut departs from #1481's proposal:

  1. the claim is a compare-and-swap fenced on the version it observed, with stale-claim recovery folded into it rather than run as a separate pass (decision 6, and the reasoning is in feat(sandbox): claim before replay, fenced on the version claimed (#1033) #1870);
  2. the SDKs omit /api/sandbox-queue and the reason is written down rather than left implicit.

It also records that #1768's reset fence composes with this rather than fighting it: a slot an unconfirmed reset holds is simply capacity the replay does not find, and the request waits for it the way it waits for any other. Nothing here needs to change when that lands. (#1768 is not on main as of this writing.)

ADR 0005 gets the addendum saying its cap now has a bounded wait in front of it. decisions/index.md is regenerated with scripts/decisions-index.sh, and okf validate decisions passes.

Docs. docs/api.md gains a "Wait for capacity" section, docs/configuration.md the two environment variables, docs/architecture.md the drainer's cron row and the pruner's new window, and the prices guide the operator-facing version. The marketing claim "starts beyond your limit are refused, not queued" was true and no longer is, so it and its test change together. The sentence about persistent-mode turn capacity on the home page is deliberately untouched: that ceiling is sandbox_at_capacity on one machine, which this queue does not cover. All three prose gates pass on the changed pages.


⚠️ SDK version — re-check before merging

Bumped to 1.28.0. 1.27.0 was claimed and published to npm by the #1637 stack while this one was being written, and the release gate correctly refused it (@managoat/fountain-sdk@1.27.0 is already on npm). Concurrent bumps must land in ascending order, so immediately before merging this PR, check sdk/typescript/package.json on main again and, if it has moved past 1.27.0, re-roll the four edits: package.json, package-lock.json (two occurrences), src/http.ts (USER_AGENT), and sdk/typescript/CHANGELOG.md.


What this stack covers of #1481, and what it does not

Covered, all of it: the sandbox_requests table and context; the claim/drain state machine with transient, capacity and terminal classification; the event-driven drain with its Oban backstop; queue: true on POST /api/conversations; automatic queueing of scheduled teammate runs; the three /api/sandbox-queue endpoints; audit events on all five transitions; telemetry and the ops gauge; the RetentionPruner window; ADR 0042 and the 0005 addendum; .env.example, docs/api.md, docs/configuration.md, docs/architecture.md, the prices guide, the marketing copy, CHANGELOG.md, the SDK contract, omissions and generated types.

Deliberately different from #1481:

Not carried: nothing.

Verification

The whole stack, rebased onto main at ea71d0ed: core + ee 4,977 tests, 0 failures (seed 424242), fountain_buzz 144, 0, fountain_support 33, 0. mix format --check-formatted, scripts/sdk-contract/build.sh --check (167 operations, 193 schemas), npx tsc --noEmit, 104 TypeScript SDK tests, okf validate decisions, and all three prose gates.

Part 7 of 7 for #1033.

🤖 Generated with Claude Code

https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW


Review fixes (round 2)

ADR 0042 gained three things, each from a finding on the stack:

  • decision 2 states the general rule behind the sandbox_key_id fix: a replay carries the restrictions the door was under, not only its attributes. source and sandbox_key_id are named as the two fields stored for that reason.
  • decision 3 says what the schedule row reads while work waits (for every caller, the drainer's replay included), and that a waiting firing stamps no last_run_at and records no firing.
  • decision 6 says what the claim fence does not buy: it settles the bookkeeping, not the work. A zombie replay's conversation is orphaned from the request rather than written over the recovering drain's row.

The marketing copy now names the caller that can wait — "An API start beyond your limit can ask to wait in a bounded queue, and a scheduled run waits by itself" — because a console start does not queue, and #1027 touched that sentence for accuracy in the first place.

decisions/index.md needs no regeneration (frontmatter unchanged) and okf validate decisions passes.


Review fixes (round 3)

ADR 0042 decision 3 now covers the whole lifecycle of what the schedule row says:

  • during the wait, and which callers are silent about it (the cron caller queued, the drainer is replaying a firing already on the trail; everyone else fired and is recorded);
  • after the wait ends without a run — timed out waiting for a free sandbox slot on expiry, the queued run was cancelled on a cancellation — because neither transition reaches run_schedule/2 and a one_off has no next firing to self-correct.

Closes #1033

@jhgaylor
jhgaylor force-pushed the stack/1033-6-telemetry branch from 67e60cd to 8472183 Compare September 11, 2026 03:57
@jhgaylor jhgaylor changed the title docs: the bounded capacity queue, ADR 0042 and SDK 1.27.0 (#1033) docs: the bounded capacity queue, ADR 0042 and SDK 1.28.0 (#1033) Sep 11, 2026
@jhgaylor

Copy link
Copy Markdown
Collaborator Author

⚠️ SDK version collision — two open stacks both claim 1.28.0.

main is at 1.27.0 and npm's latest is 1.27.0, so both are individually correct today and both release gates are green. They cannot both merge.

This is the same trap that already bit each stack once: 1.26.0 was taken by #1634 mid-flight, then 1.27.0 was taken by #1849/#1637 mid-flight. The gate compares against the PR's base, not main, so a stale-but-green bump stays green right up until it merges and silently publishes nothing.

Before merging whichever of these goes first: leave its number alone, then re-number the other to 1.29.0 across all four edits — sdk/typescript/package.json, sdk/typescript/package-lock.json (two places), USER_AGENT in sdk/typescript/src/http.ts, and the CHANGELOG.md heading. Bumps have to land in ascending order, so the second one cannot simply keep 1.28.0 and be fixed afterwards.

Two further re-cut stacks (#1565 reapply, #1125 OAuth self-service) are in flight and will each want a number too. Worth deciding the merge order for all four in one go rather than discovering it a gate at a time.

@jhgaylor

Copy link
Copy Markdown
Collaborator Author

⚠️ Updated: this is now a three-way SDK version collision.

All three of these claim 1.28.0, against a main (and npm) at 1.27.0:

PR stack branch
#1860 detached permission requests (#1635) stack/1635-9-docs
#1876 bounded sandbox queue (#1033) stack/1033-7-docs
#1885 self-service OAuth clients (#1125) stack/1125-9-docs

Each is individually correct and all three release gates are green, because the gate compares against the PR's base, not main. Only the first to merge can keep the number.

Merging any one of these obliges re-numbering the other two before they merge1.29.0 and 1.30.0 in whatever order they land. Four edits each: sdk/typescript/package.json, sdk/typescript/package-lock.json (two places), USER_AGENT in sdk/typescript/src/http.ts, and the CHANGELOG.md heading. Bumps must land in ascending order, so this cannot be corrected after the fact.

A fourth stack (#1565, conversation reapply) is still being cut and will want a number too.

Given that, it is probably worth picking the merge order for all four now and assigning 1.28.01.31.0 up front, rather than resolving this one gate at a time. Each of these stacks has already been re-numbered once or twice mid-flight for exactly this reason (1.26.0 went to #1634, then 1.27.0 to #1637/#1849).

@jhgaylor

Copy link
Copy Markdown
Collaborator Author

⚠️ Updated again: four stacks now claim 1.28.0. main and npm are both at 1.27.0.

PR stack branch
#1860 detached permission requests (#1635) stack/1635-9-docs
#1876 bounded sandbox queue (#1033) stack/1033-7-docs
#1885 self-service OAuth clients (#1125) stack/1125-9-docs
#1893 conversation reapply (#1565) stack/1565-8-docs-sdks

All four are individually correct and all four release gates are green, because the gate compares against each PR's base, not main. Only the first to merge keeps the number, and bumps have to land in ascending order — so this cannot be corrected after the fact.

Recommendation: take the version bump out of all four, and do one SDK release afterwards.

Assigning 1.28.01.31.0 up front works only if the four stacks then merge in exactly that order, and each of these has already been re-numbered once or twice mid-flight precisely because merge order kept changing (1.26.0 went to #1634, then 1.27.0 to #1637/#1849). Pinning an order now just sets the trap again.

Instead:

  1. Label all four final PRs sdk-no-release and drop the four-file version bump from each, keeping their generated openapi.ts changes.
  2. Land the stacks in whatever order suits.
  3. Open one SDK release PR bumping 1.27.01.28.0 once, with a changelog entry covering all four features.

That removes the ordering constraint entirely, gives users one coherent release note instead of four near-simultaneous minors, and means no stack can silently publish nothing by merging second.

The cost is that the SDK types for whichever features land first sit unreleased on main until that final PR merges. That is exactly what sdk-no-release exists to express, and it is a far smaller risk than a bump that merges green and publishes nothing.

@BinaryBourbon BinaryBourbon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Part 7 of 7. ADR 0042, the docs and the SDK bump. Approving.

The ADR is the best part of the stack. It records the two departures from #1481 as departures with reasons, it names what the claim fence does and does not buy, and the Alternatives section argues against the four positions someone will actually raise — including the #1026 upgrade-lever argument that #1033 deliberately left open. "The queue delays the cap; it never raises it" is the right one-line summary and it is true, which I verified rather than took on faith: Quotas.with_sandbox_reservation/3 still runs check_fleet_ceilingCredits.gatecheck_sandbox_quota under both advisory locks on every replay, so docs/api.md's "must pass the credit gate and the inference gate again" holds.

Recording that #1768's reset fence composes with this rather than fighting it — a held slot is capacity the replay does not find — is exactly the kind of thing that is worth writing down while both are in flight and impossible to reconstruct later. Same for the 0005 addendum: the cap did not change, the wait in front of it is new, and that is the honest way to amend a decision rather than superseding it.

docs/api.md, docs/configuration.md, the architecture cron row and the pruner's window all match the code I read. The prices guide is the honest operator-facing version, and its "Other callers keep the immediate 429 or 503 answer" is the sentence that carries the whole opt-in design.

Three notes:

The marketing copy is less careful than the guide. The home and launch pages now read "Starts beyond your limit can wait in a bounded queue." A console start does not queue — nothing in StartLive sets queue: true — so for the person most likely to be reading that page, starts beyond the limit are still refused. "can wait" is hedged enough that I would not call it false, and it is a marketing page rather than a contract, but #1027 changed this exact sentence for accuracy, which makes it the one place in the repo where a vague claim has history. Something like "API starts can opt to wait in a bounded queue" costs four words and cannot be read as a promise to a console user. The persistent-mode sentence being deliberately left alone is right, and worth having said explicitly in the PR body.

The SDK collision is now three-way, not hypothetical. #1860 (#1635) and #1885 (#1125) both also claim 1.28.0. main is at 1.27.0 and npm's latest is 1.27.0, so all four edits here are correct today; whichever of the three lands second re-rolls package.json, both package-lock.json occurrences, USER_AGENT and the SDK CHANGELOG.md. The warning block in the description is the right thing to have written — just note it is a queue of three.

ADR 0042 decision 3's waiting for a free sandbox slot sentence needs to follow whatever #1872 does. As written it describes the row's state for one drain pass only. Details on that PR.

The verified/stale_after frontmatter and the regenerated decisions/index.md are in the same PR, which is what okf validate wants. "Not carried: nothing" is a claim I spot-checked against #1481's file list and did not find a counterexample to.

@jhgaylor

Copy link
Copy Markdown
Collaborator Author

ADR and copy updated. Force-pushed.

ADR 0042 gained three things, all from findings on the stack:

  • decision 2 now states the general rule the sandbox_key_id fix is an instance of: a replay carries the restrictions the door was under, not only its attributes, with source and sandbox_key_id named as the two fields stored for that reason. This will not be the last one, and "which narrowings survive an hour in a table" is the question a future launch key has to answer;
  • decision 3 says what the schedule row reads while work waits, for every caller including the drainer's own replay, and that a waiting firing stamps no last_run_at and records no team.schedule.fired because nothing fired;
  • decision 6 says what the claim fence does and does not buy — it settles the bookkeeping, not the work. A replay that outran its claim has already created its conversation, and after the swap fails that conversation is orphaned from the request rather than written over the row a recovering drain replayed. That is the honest version of what I wrote in feat(sandbox): claim before replay, fenced on the version claimed (#1033) #1870's description, which you flagged as overselling.

decisions/index.md needs no regeneration: only the ADR body changed, and the index row is built from the frontmatter description, which is untouched. okf validate decisions still passes.

The marketing copy names the caller that can wait. You were right that "Starts beyond your limit can wait in a bounded queue" reads as a promise to whoever is looking at the page, and a console start does not queue. Both pages now say:

An API start beyond your limit can ask to wait in a bounded queue, and a scheduled run waits by itself. The queue delays the cap; it never raises it.

Which is the same thing the prices guide already said honestly, and it keeps #1027's reason for touching this sentence intact. marketing_pricing_test.exs asserts both halves.

The SDK version is still 1.28.0 and main is still at 1.27.0, so the four edits remain correct as written. The three-way collision with #1860 and #1885 is real and unchanged — whichever of us lands second re-rolls. Worth noting the new merge queue does not help here: it tests the merge result, but two PRs each claiming 1.28.0 both build fine in isolation and the second one to merge is the one npm refuses.

@BinaryBourbon BinaryBourbon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed the delta since my approval (cc70a4615dc8ae7c): three ADR paragraphs and one marketing sentence on two pages. Approving.

The marketing copy is now true

"Starts beyond your limit can wait in a bounded queue" was a promise the server does not keep. A start waits only if it asks (queue: true), and the one caller that waits without asking is a cron firing. The replacement says both halves — "An API start beyond your limit can ask to wait in a bounded queue, and a scheduled run waits by itself" — which is the asymmetry decision 3 argues for, in a sentence somebody reading the pricing page can act on. Someone who read the old line and did not send the flag would have concluded the queue was broken.

marketing_pricing_test.exs asserts both halves on both pages rather than just the changed prefix, so the scheduled-run clause cannot be dropped later without the test noticing.

The three ADR additions

Decision 2's new paragraph is the one I wanted, and it generalises past the instance: "Anything that narrows what a request may do has to survive the wait, or the queue is a way around it." source and sandbox_key_id are then the two worked examples rather than two special cases. The next person adding a restriction to the door now has a rule to check against.

Decision 3 now separates the two questions that were tangled: who enqueues (the audit actor, which also keeps the replay out of its own queue) and what the row says while a request is live (whichever caller asks, including the drainer's replay). Recording that a waiting firing stamps no last_run_at and writes no team.schedule.fired is right to have in the ADR — it is a deliberate absence, and an undocumented absence reads as an oversight.

Decision 7's addition is the precision note from my #1870 review, and it is sharper than what I wrote: "What the fence removes is the state in which one request reads as one conversation while two are running." An orphaned conversation stays visible in the tenant's own list, which is the property that makes the residue tolerable.

One follow-up, not for this PR: decision 3 now describes what the row reads during the wait but not after it ends. I have blocked #1872 on the row still saying waiting for a free sandbox slot once the request expires or is cancelled — indefinitely, for a one-off. Whatever that fix writes, this paragraph should gain a sentence for it.

Nit

The reflow left one line unwrapped mid-sentence:

as `POST /api/conversations` answers without `queue: true`. Queueing there would answer a person with an error while a

decisions/ is deliberately unpublished, so no prose gate will catch it. Worth a wrap on the next touch; not worth a push on its own.

Verified locally

marketing_pricing_test.exs and docs_test.exs pass at the stack tip, along with the rest of the stack's files. okf frontmatter is untouched, so no index refresh is owed.

CI has reported no verdict on this head — the 05:26 jobs are cancelled by concurrency, which gh pr checks prints as fail.

@BinaryBourbon BinaryBourbon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed the delta since my approval (5dc8ae7c0d3df52c). Decision 3 only. Approving — this closes the follow-up I left last time.

Decision 3 now carries both halves of what part 4 actually does.

The separation is stated as a rule rather than as a case list: "What the row says and whether the caller fired are separate questions." Then the two silent callers and why each is silent — the cron queued instead of firing, the drainer is replaying a firing already on the trail — and everyone else recorded with the refusal they received, "even though the row goes on reporting the wait". Keeping that last clause matters: a reader who finds last_error saying "waiting" and an audit row saying "sandbox quota: 5/5" would otherwise file it as an inconsistency rather than read it as the row describing the schedule and the trail describing the caller.

And it ends with the failure it prevents — "Deciding this from 'is anybody waiting' rather than 'did this caller wait' is how a person's action came to leave no audit trace at all." An ADR that records the wrong version of a decision alongside the right one is worth more than one that only states the conclusion; the next person to simplify that cond now has the reason not to.

The new paragraph on the wait ending is the sentence I asked for, with both strings quoted and the one_off argument for why a cron's next firing is not a fix. Decision 3 now describes the row before, during and after the wait, which is the whole of what a person reading /team can see.

The unwrapped line is reflowed too.

Nothing else moved. Marketing copy, the SDK line and the rest of the ADR are unchanged from my last pass.

Verified locally

docs_test.exs and marketing_pricing_test.exs pass at the stack tip, with the rest of the stack's files — 302 tests, 0 failures. okf frontmatter untouched, so no index refresh is owed.

ADR 0042 records the whole decision, including the two places this re-cut
differs from the original proposal: the claim is a compare-and-swap fenced on
the version it observed, with stale-claim recovery folded into it rather than
run as a separate pass, and the SDKs omit `/api/sandbox-queue` with the
reason written down. It also records that #1768's reset fence composes with
this rather than fighting it: a slot an unconfirmed reset holds is simply
capacity the replay does not find.

ADR 0005 gets the addendum that says its cap now has a bounded wait in front
of it. `decisions/index.md` is regenerated by `scripts/decisions-index.sh`,
and `okf validate decisions` passes.

`docs/api.md` gains a "Wait for capacity" section, `docs/configuration.md`
the two new environment variables, `docs/architecture.md` the drainer's cron
row and the pruner's new window, and the prices guide the operator-facing
version. The marketing claim "starts beyond your limit are refused, not
queued" was true and is no longer, so it and its test change together. The
sentence about persistent-mode turn capacity is deliberately untouched: that
ceiling is `sandbox_at_capacity` on one machine, which this queue does not
cover.

All three prose gates pass on the changed pages.

**SDK version**: 1.28.0. 1.27.0 was claimed and published by the #1637 stack
while this one was being written, and the release gate refuses a version npm
already carries. Concurrent bumps land in ascending order, so re-check
`sdk/typescript/package.json` on `main` immediately before merging and
re-roll the four edits (`package.json`, `package-lock.json` twice,
`src/http.ts`, `CHANGELOG.md`) if `main` has moved past 1.27.0 again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9jevFQT5MkF3rJUieeiHW
@jhgaylor
jhgaylor changed the base branch from stack/1033-6-telemetry to main September 11, 2026 12:36
@jhgaylor
jhgaylor added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 3f59a76 Sep 11, 2026
26 checks passed
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.

Queue sandbox requests at the concurrency cap instead of refusing them

2 participants