Skip to content

fix(client): publish the revocations an xfer-full states by omission - #94

Open
XieX wants to merge 6 commits into
xie/python-agent-skills-review-fixesfrom
xie/python-skills-revoke-by-omission
Open

XieX wants to merge 6 commits into
xie/python-agent-skills-review-fixesfrom
xie/python-skills-revoke-by-omission

Conversation

@XieX

@XieX XieX commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

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_intent builds a fresh empty _pending for xfer-full, so a skill the full transfer omits is correctly dropped from the committed set at _payload_transferred. But _changes only ever accumulated from _put_object and _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 second xfer-full carrying no objects emptied the store while outcome.changes was [] and diagnostics.objects_revoked stayed 0.

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_skills then never reconciles and the skill's SKILL.md stays on disk until some unrelated change, which contradicts TESTING.md §3.26 ("a revocation delivered over a live stream prunes the skill's files within debounce of arriving") in exactly the case pruning exists for.

The fix

Ports the shape TypeScript already has (revocationsBetween / keysFullyRevoked in typescript/packages/client/src/skills-fdv2.ts, called from payloadTransferred under intent === INTENT_TRANSFER_FULL):

  • _identity_of, _revocations_between, _keys_fully_revoked — module-level, placed where TypeScript has them, just above _ProtocolReader.
  • _payload_transferred diffs the committed set against the incoming one before the swap when the intent is xfer-full, extends _changes with the tombstones, and adds _keys_fully_revoked(...) to objects_revoked.
  • The diff is at (key, version) granularity to match delete-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, where objects_revoked only increments when _SkillObjectSet.delete actually removed something). _keys_fully_revoked's docstring names that as the shared rule.

One doc touch: add_listener now says both ways of stating a revocation arrive as a tombstone — a delete-object, and a full transfer that stopped carrying the object.

Tests

Four protocol-reader cases:

  • a full transfer omitting a previously-held skill publishes a {key, version} tombstone and increments objects_revoked;
  • an omitted version-less object is reported with version: None, the way a delete-object naming no version spells it;
  • a version move publishes both ends and increments objects_revoked by zero;
  • an xfer-changes that 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_prunes over the fake FDv2 endpoint: queue a put, then full_payload(state="basis-2") with no objects, and assert the SKILL.md is pruned.

These discriminate — with the source reverted, all four of the new cases fail. (The xfer-changes guard 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 explicit delete-object events. On payload-transferred, the reader diffs committed vs pending at (key, version) granularity, extends listener changes with those tombstones, and updates objects_revoked per key (version moves count as zero). watch_skills can 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/none payloads 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 logs ld.skills.integrity_failure with reason_code: key_mismatch and served_key via record_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.

`_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>
XieX and others added 2 commits September 17, 2026 15:58
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>
@XieX
XieX requested review from knfreemLD and removed request for knfreemLD September 18, 2026 19:01
XieX and others added 3 commits September 18, 2026 16:14
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>
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.

1 participant