Conversation
`_ProtocolReader` built a fresh empty `_pending` for `xfer-full`, so a skill the full transfer omitted was correctly dropped at `_payload_transferred` — but `_changes` only ever accumulated from `_put_object` and `_delete_object`, so nothing was published for the omitted skill. A full transfer carrying other puts still wakes a watcher through those puts, so the observable gap was the transfer carrying no puts at all: the environment's last skill revoked. The store emptied while `changes` stayed empty and `objects_revoked` stayed zero, `watch_skills` never reconciled, and the skill's `SKILL.md` survived on disk until some unrelated change — in exactly the case pruning exists for. Port TypeScript's `revocationsBetween` / `keysFullyRevoked`: diff the committed set against the incoming one before the swap, at `(key, version)` granularity so a key whose version moved yields both a put for the arrival and a tombstone for the departure, and count only keys that left the payload entirely toward `objects_revoked` — the same rule `_delete_object` follows in counting only a tombstone that took something away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tests for TESTING.md §3.21/§3.24 as just reconciled: a store answering under a different key records the `ld.skills.integrity_failure` log record with `reason_code: key_mismatch` and records no product signal. `key_mismatch` cannot join `REASON_CODE_CASES` — that table is uniformly driven through `all_skills`, and this code is decided at the retrieval boundary after `verify_raw_skill` has passed, so it is unreachable from a listing. It gets its own test and is unioned into the vocabulary assertion, with the reason recorded there so the next reader does not try to move it into the table. Adds the redaction guard, called directly since no store can drive it, and the listing-path case: an object listed under a disagreeing map key is filed under its own key with neither surface firing. That one passes already; it is a regression guard against a "fix" that would break every multi-version store. 3 failing: no record is emitted yet, the vocabulary is short a token, and `record_key_mismatch` does not exist. The no-signal half already passes — the implementation is additive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements the §3.21/§3.24 decision the previous commit's tests pin: a store answering under a different key now writes the `ld.skills.integrity_failure` log record with `reason_code: key_mismatch`, and still records no product signal. Adds `record_key_mismatch` beside `record_integrity_failure`, so the single-emission-site rule still holds by reading one module, and widens `IntegrityReasonCode` to nine tokens. The check stays where it was, at the retrieval boundary: `verify_raw_skill` is unary and this comparison is relational, so moving it inside would mean an optional expected-key parameter that silently disables the check when a caller omits it. The record carries both keys. `skill_key` keeps the meaning it has everywhere else — the key requested — and the key the store answered under goes in `served_key`, the one record-only field beyond the four, since it is what makes a broken adapter diagnosable and prose in `reason` is not parseable. No hash fields and no `version`: verification passed, so neither the hashes nor the version is what disqualified the answer, and reporting a served version beside a requested key would mix two frames in one record. Docs corrected in the same change: the README's `reason_code` table and record field table gain rows, `skill_key` is described as the *requested* key, and `AGENTS.md` records why `key_mismatch` cannot join `REASON_CODE_CASES` — that table is driven uniformly through `all_skills`, which cannot reach a code decided after verification passes. Verified the emitted JSON is byte-identical to the TypeScript SDK's for the same input, modulo `language`. 1875 tests passing, mypy and ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A transfer this layer declines as foreign kept its contents out of the store but still handed its selector up as the new basis, so the next poll or stream resumed from someone else's payload while every diagnostic read healthy. The check was also gated on a pending set, which a `none` intent never builds — so a `none` for another payload was not even recognised as foreign, and its selector was adopted outright. Ask the question unconditionally and drop the basis of a payload whose contents were thrown away, matching js-ai-sdk#67. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
``_poll_once`` held one ``_etag`` with no record of which request produced it, and adopted it before the body was applied. Two consequences: An ETag validates one representation of one resource, and the ``basis`` selector is part of the request that names it. Once a payload moved the basis on, the etag from the response before it was still offered, so a server validating it would be answering the question the store had stopped asking. Held as a pair now, and offered only while the pair holds — the base SDK keys its etag cache by request URL for the same reason. The cost is one unconditional request after each commit, which was never going to be a 304. A body that broke off partway — an ``error`` or ``goodbye`` after an announced transfer — left the payload it described unapplied, but its etag was already stored. The next 304 then reported that store as current and reset the failure count, where the 200 it replaced would have been retried and eventually given up on. Adopted after the body is applied in full. Ports js-ai-sdk#67. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The branch was two commits behind its base, which blocked CI: GitHub could not build a merge ref, so no workflow ran on the head. One conflict, in the stale-selector repair, where both sides added a line to the same block and both belong — the base's `repairing_state = True`, which keeps the one-shot repair out of the retry budget, and this branch's `self._etag_basis = None`, which drops the etag's pairing along with the etag. Resolved by keeping both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Stacks on #93 -> #87
This should be the last of the python SDK fixes, ported over from the Typescript SDK work.
The gap
_ProtocolReader._server_intentbuilds a fresh empty_pendingforxfer-full, so a skill the full transfer omits is correctly dropped from the committed set at_payload_transferred. But_changesonly ever accumulated from_put_objectand_delete_object, so nothing was published for an omitted skill.Verified against the reader directly before the fix: after a full transfer carrying a put for
pdf:3, a secondxfer-fullcarrying no objects emptied the store whileoutcome.changeswas[]anddiagnostics.objects_revokedstayed0.A full transfer that carries other puts still wakes a watcher through those puts, so the observable gap is the transfer carrying no puts at all — the environment's last skill revoked.
watch_skillsthen never reconciles and the skill'sSKILL.mdstays on disk until some unrelated change, which contradicts TESTING.md §3.26 ("a revocation delivered over a live stream prunes the skill's files withindebounceof arriving") in exactly the case pruning exists for.The fix
Ports the shape TypeScript already has (
revocationsBetween/keysFullyRevokedintypescript/packages/client/src/skills-fdv2.ts, called frompayloadTransferredunderintent === INTENT_TRANSFER_FULL):_identity_of,_revocations_between,_keys_fully_revoked— module-level, placed where TypeScript has them, just above_ProtocolReader._payload_transferreddiffs the committed set against the incoming one before the swap when the intent isxfer-full, extends_changeswith the tombstones, and adds_keys_fully_revoked(...)toobjects_revoked.(key, version)granularity to matchdelete-object, so a key whose version moved yields both a put for the arrival and a tombstone for the departure.The two counting rules stay consistent: per key for the omission diff (a version move counts zero), per applied revocation for
delete-object(#93's change, whereobjects_revokedonly increments when_SkillObjectSet.deleteactually removed something)._keys_fully_revoked's docstring names that as the shared rule.One doc touch:
add_listenernow says both ways of stating a revocation arrive as a tombstone — adelete-object, and a full transfer that stopped carrying the object.Tests
Four protocol-reader cases:
{key, version}tombstone and incrementsobjects_revoked;version: None, the way adelete-objectnaming no version spells it;objects_revokedby zero;xfer-changesthat mentions nothing revokes nothing — diffing a delta would drop every skill it simply had no reason to mention.Plus an end-to-end
test_a_full_transfer_that_omits_every_skill_prunesover the fake FDv2 endpoint: queue a put, thenfull_payload(state="basis-2")with no objects, and assert theSKILL.mdis pruned.These discriminate — with the source reverted, all four of the new cases fail. (The
xfer-changesguard passes either way, as it should; it guards against applying the diff to the wrong intent.) The e2e case was run five times for flakiness.Gate
From
python/:make test(1872 passed, 11 skipped),make typecheck(clean, 51 source files),make lint,make format-check— all clean.🤖 Generated with Claude Code, edited by @XieX
Note
Overview
Ports TypeScript-aligned fixes into the Python skills client so full FDv2 transfers (
xfer-full) emit tombstones for skills dropped by omission, not only for explicitdelete-objectevents. Onpayload-transferred, the reader diffs committed vs pending at(key, version)granularity, extends listenerchangeswith those tombstones, and updatesobjects_revokedper key (version moves count as zero).watch_skillscan prune when the last skill disappears via an empty full transfer, which previously emptied the in-memory store silently.FDv2 polling/resume behavior is tightened: ignored foreign/
nonepayloads no longer advance the stream basis, ETags are sent only when they match the current basis (_etag_basis), and response ETags are stored only after a payload fully applies—avoiding stale 304s and “healthy” diagnostics while updates stop.Separately, when a store returns verified content whose object
key≠ the requested key, retrieval now logsld.skills.integrity_failurewithreason_code: key_mismatchandserved_keyviarecord_key_mismatch, without the product integrity signal (documented in README/agents.md). Tests cover tombstones, basis/ETag wire cases, end-to-end prune, and the log-only mismatch path.Reviewed by Cursor Bugbot for commit b9b05de. Bugbot is set up for automated code reviews on this repo. Configure here.