fix(client): a 304 confirms a payload rather than establishing one - #129
Merged
Merged
Conversation
A poll adopted the response `ETag` after any body that did not raise, and a
304 published a first payload unconditionally. Between them, a body this SDK
could not apply lent its etag to the next request, and the 304 that answered it
reported the empty committed set as current: initialized, healthy, and with
nothing in a 304 to notice it on.
`is_initialized()` is what authorizes `write_skills("*")` to prune, so that
store read as an environment whose every skill was revoked and deleted the last
known good `SKILL.md` files on disk — the widest version of the hazard the
probe exists to prevent.
Three claims narrowed to what actually happened:
- `payload-transferred` reports a commit only when it applied a pending set. A
`none` intent builds none, nor does an `intentCode` this SDK does not
recognise, and a foreign payload's contents are declined — those transfers
now report nothing, where they reported a commit. They no longer adopt the
selector of a payload that was never applied either, which on its own left a
store resuming from content it did not hold while every diagnostic read
healthy.
- A poll adopts the response `ETag` only from a body that completed an
exchange: a committed payload, or a `none` intent, which is the server saying
the content held is what the etag describes. An unrecognised intent says the
opposite — the body carried objects this reader dropped — so leaving the poll
unconditional is also what keeps that body arriving and visible instead of
silenced behind a 304.
- A 304 no longer publishes a first payload. It confirms the payload the store
holds; the exchange it stands in for, the `none` intent, does not publish one
either.
TESTING.md §3.25 defines `is_initialized()` as true "once a payload has
committed", and §3.21/§3.22 turn prune suppression on it, so none of this is a
new rule — it is the existing one reaching the poll path. The test that pinned
the old behaviour justified it as "a reconnect with a cached basis", which this
transport has no mechanism for: `_basis` and `_etag` both start as `None` with
no injection point, so a 304 reaching a store that holds nothing takes a server
answering a request that carried no etag at all. It now fails closed, and the
test asserts that.
The over-cap poll test was leaning on the same path for its "delivery carries
on" assertion; it now gets a real payload on the retry, which proves more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Oct 2, 2026
Merged
The three blocks the previous commit added were arguing the whole case inline —
the prune chain, the forward-compatibility reasoning, the cross-language note —
in 50-odd lines of comment on a 14-line change. This source gets read by
customers and their agents working out how to consume the SDK, and that much
nuance about internals they cannot reach is a wall to read past rather than
help.
Each block now states the rule and the one consequence that is visible from
outside: `is_initialized()` / `isInitialized()` goes true on a commit, and that
is what authorizes a prune of the files on disk. The long version already has
two homes it belongs in — `agents.md` in this package, and TESTING.md §3.25 —
so nothing is lost, and the clause that stops a plausible wrong fix ("not up to
date either: only the `none` intent says that") is kept.
Comments only; no behaviour change. Tests untouched, where the long-form
reasoning is the point.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
XieX
marked this pull request as ready for review
October 2, 2026 15:00
knfreemLD
approved these changes
Oct 2, 2026
Resolves conflicts with the comment/docs trim on the base (744199f, c547be2): - skills_fdv2.py: keep this branch's 304 and etag-adoption logic and comments. The base's `not foreign` guard on `basis` is subsumed by the `applied` early return, so it is dropped. - agents.md: keep this branch's "a 304 is not one" section alongside the base's trimmed first-payload-intent section. - wait_for_skills docstring: the base's trim said a 304 releases it; under this branch only a commit does, so the wording is corrected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
XieX
added a commit
that referenced
this pull request
Oct 5, 2026
Resolves conflicts with #129 (a 304 confirms a payload rather than establishing one): - FDv2SkillStore._apply keeps this branch's recycled= flag on the recoverable error and takes the base's new boolean return, which _poll_once uses to decide when to adopt an etag. - The over-cap poll test keeps this branch's version: an oversized response is now fatal, so the base's retry-then-recover variant no longer applies. The recovery path still queues a full payload before start(), so it does not rely on a 304 to release the waiter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
XieX
added a commit
to launchdarkly/js-ai-sdk
that referenced
this pull request
Oct 5, 2026
) ## Summary Fixes a path in the FDv2 delivery transport that could report a store holding **nothing** as initialized, which authorizes `writeSkills('*')` to prune every managed `SKILL.md` on disk. Found by reviewing a Bugbot comment on the Python side and checking whether this SDK shared the gap — it did, line for line, including the tests. A poll adopted the response `ETag` after any body that did not throw, and a 304 published a first payload unconditionally. Between them, a body this SDK could not apply — a future `intentCode`, or a `payload-transferred` with no recognized intent — lent its etag to the next request, and the 304 that answered it reported the empty committed set as current: initialized, healthy, and with nothing in a 304 to notice it on. Three claims narrowed: - `payloadTransferred` reports a commit only when it applied a pending set. A `none` intent builds none, nor does an unrecognized `intentCode`, and a foreign payload's contents are declined — those transfers now report nothing, where they reported a commit. They no longer adopt the selector of a payload that was never applied either, which on its own left a store resuming from content it does not hold while every diagnostic read healthy. - A poll adopts the response `ETag` only from a body that **completed an exchange**: a committed payload, or a `none` intent, which is the server saying the content held is what the etag describes. An unrecognized intent says the opposite — the body carried objects `ignoreUnderUnknownIntent` dropped — so leaving that poll unconditional is also what keeps the body arriving and its warning repeating, rather than silenced behind a 304. Gating on the commit alone would have broken the steady state, since the `none` body's etag is how a poll goes from a tiny 200 every interval to a bodiless 304. - A 304 no longer publishes a first payload. It confirms the payload the store holds; the exchange it stands in for, the `none` intent, does not publish one either. Not a new rule: TESTING.md §3.25 already defines `isInitialized()` as true "once a payload has committed", and §3.21/§3.22 turn prune suppression on it. The spec gains the bullets that were missing for the poll path in launchdarkly/ai-sdks-monorepo#34, and python-ai-sdk carries the identical fix in launchdarkly/python-ai-sdk#129 (where Bugbot raised it, on launchdarkly/python-ai-sdk#87). Five tests had pinned the old behavior here, against Python's one. Three were only this side's optional `changes` / `basis` reading `undefined` where Python's dataclass defaults give `[]` / `null`; the new outcome states both, so the two languages return the same object. The other two were real, and the second is the clearest evidence this drifted rather than being designed: - `releases waitForSkills on a 304 before any payload` justified it as "a reconnect with a cached basis", which this transport has no mechanism for — `basis` and `etag` both start `null` with no injection point. - A JS-only lifecycle test asserted `a 304 counts as initialized — the payload held is confirmed current`, over a store holding no payload. It now exercises the 304 this store can actually reach, offering an etag it earned. ## Test plan - [x] `yarn build && yarn test` — full workspace green; client 1040 passed, 10 skipped - [x] `tsc --noEmit` and `biome check` clean - [x] New: a body under an unrecognized intent leaves the next request carrying no `If-None-Match`, the store uninitialized, nothing held, and no selector adopted - [x] New: all four shapes that reach `payloadTransferred` with no pending set report neither a commit nor an up-to-date answer, and still count the transfer - [x] Rewritten: a 304 answering a request that carried no etag leaves `waitForSkills` false and `isInitialized()` false without recording a failure 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > **Fixes FDv2 poll delivery so an empty store cannot become initialized and authorize `writeSkills('*')` to prune every managed skill on disk.** > > `payload-transferred` now reports a **commit** only when a pending set was actually applied. Transfers with no pending work (`none`, unknown intent, lone transfer) return no commit, no up-to-date signal, and **no resume `basis`**—they still increment `payloadsTransferred`. > > Polling no longer calls **`markFirstPayload()` on HTTP 304**; a 304 only confirms content already held. Response **ETags are adopted only after a completed exchange** (committed payload or `none` intent on its own event), so bodies under unrecognized intents cannot lend an etag that makes the next 304 look current over an empty store. > > `agents.md` documents the three linked rules (commit vs 304 vs etag adoption). Tests were updated and added to match—e.g. 304 before any payload leaves `waitForSkills` false and `isInitialized()` false, and unrecognized-intent polls do not send `If-None-Match`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fdb2b54. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
XieX
added a commit
that referenced
this pull request
Oct 5, 2026
Follow-up to #129, and the Python half of the review comment on launchdarkly/js-ai-sdk#104. TESTING.md §3.25 says to assert all four shapes that reach `payload-transferred` with nothing to apply: a `none` intent, an unrecognised `intentCode`, a lone transfer with no intent, and a declined foreign payload. `test_a_transfer_that_applied_nothing_is_not_a_commit` covered three. The foreign case was missing. **Why it matters:** if `not foreign` is dropped from `applied` and the old `basis` guard is put back, the full suite still passes, because the foreign-payload tests check only the basis, `payloads_ignored` and the held set. The outcome would report `committed=True`, which resets `connection_failures` and lets a poll keep the etag for content it never applied. **Change:** - The foreign shape joins the loop. A payload only counts as foreign once a skill payload has committed, so each shape can now run an earlier payload first. - The test now checks that the committed set is unchanged and that `payloads_transferred` went up by one, instead of checking for an empty store and a count of 1. **Verified:** - The test passes on the current code. - With the mutation above it fails. - `make lint`, `format-check`, `typecheck` and `test` all pass (2200 passed). The matching JS change is pushed to launchdarkly/js-ai-sdk#104. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > Extends **`test_a_transfer_that_applied_nothing_is_not_a_commit`** so it matches TESTING.md §3.25: the loop now covers all four ways a transfer can finish with nothing applied, including a **declined foreign payload** (`env-flags` after a real skill commit). > > Each scenario can run an optional **`prior`** event sequence (needed so “foreign” only applies once the skill payload is known). Assertions were generalized to **`committed` / `up_to_date` / `basis` unchanged**, **held objects unchanged** vs a snapshot, and **`payloads_transferred` increments by one**—so a regression that wrongly sets `committed=True` on ignored transfers would fail even when basis-only tests still pass. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 4f672f6. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes a path in the FDv2 delivery transport that could report a store holding nothing as initialized, which authorizes
write_skills("*")to prune every managedSKILL.mdon disk. Raised by Bugbot on #87 (comment r4146430929); verified reachable, and traced to two claims that were wider than what happened.A poll adopted the response
ETagafter any body that did not raise, and a 304 published a first payload unconditionally. Between them, a body this SDK could not apply — a futureintentCode, or apayload-transferredwith no recognized intent — lent its etag to the next request, and the 304 that answered it reported the empty committed set as current: initialized, healthy, and with nothing in a 304 to notice it on.Three claims narrowed:
payload-transferredreports a commit only when it applied a pending set. Anoneintent builds none, nor does an unrecognizedintentCode, and a foreign payload's contents are declined — those transfers now report nothing, where they reported a commit. They no longer adopt the selector of a payload that was never applied either, which on its own left a store resuming from content it does not hold while every diagnostic read healthy.ETagonly from a body that completed an exchange: a committed payload, or anoneintent, which is the server saying the content held is what the etag describes. Gating on the commit alone would have broken the steady state — thenonebody's etag is how a poll goes from a tiny 200 every interval to a bodiless 304.noneintent, does not publish one either.Not a new rule: TESTING.md §3.25 already defines
is_initialized()as true "once a payload has committed", and §3.21/§3.22 turn prune suppression on it. The spec gains the bullets that were missing for the poll path in launchdarkly/ai-sdks-monorepo#34, and js-ai-sdk carries the identical fix in launchdarkly/js-ai-sdk#104.test_a_304_before_any_payload_still_releases_wait_for_skillsjustified the old behavior as "a reconnect with a cached basis", which this transport has no mechanism for —_basisand_etagboth start asNonewith no injection point — so a 304 reaching a store that holds nothing takes a server answering a request that carried no etag at all. It now fails closed, and the test asserts that.Test plan
pytest— 2164 passed, 11 skippedruff check/ruff format --check/mypy packages/*/srccleanIf-None-Match, the store uninitialized, nothing held, and no selector adoptedpayload-transferredwith no pending set report neither a commit nor an up-to-date answer, and still count the transferwait_for_skillsfalse andis_initialized()false without recording a failure🤖 Generated with Claude Code
Note
Overview
Tightens FDv2 poll/stream semantics so an empty or unapplied store cannot be treated as initialized and authorize
write_skills("*")to prune every managed skill on disk.payload-transferrednow reports a commit (and adopts a resumebasis) only when a pending set was actually applied. Transfers withnone, unknown intent codes, foreign payloads, or no pending work return an empty outcome—still counted in diagnostics but not a commit or up-to-date signal.Poll etag handling adopts the response
ETagonly after a body completed an exchange (committed payload ornoneonserver-intent). Unapplied bodies no longer lend their etag to a follow-up 304.HTTP 304 no longer calls
_publish_first_payloador satisfieswait_for_skills; it only confirms content already held after a real commit.Agent docs in
agents.mdspell out the three linked invariants. Tests were added/rewritten for 304-before-payload, unrecognized-intent etag chains, and non-committing transfers.Reviewed by Cursor Bugbot for commit daaf208. Bugbot is set up for automated code reviews on this repo. Configure here.