feat(client): declare ?kinds=agent-skill, and treat a 422 as "no skills yet" - #109
Merged
Merged
Conversation
…ls yet" Delivery now narrows a connection to the payload kinds it declares and defaults to flags (launchdarkly/streamer#4730), so the store has to ask for the agent-skill payload or receive the environment's flags and no skills at all. The declaration goes on every request, before any basis exists as well as alongside one: it selects what the connection is served rather than describing what the store already holds. It also fixes something that was already wrong. A skill-enabled environment assigns two payloads, so the reader has been warning about the second and reading only the first intent, and the flag payload was re-downloaded and discarded on every reconnect because its basis was never adopted. Declaring one kind makes the connection single-payload, which is the shape the reader is built for. The 422 that comes with it is the interesting half. It is the answer when the credential is assigned no agent-skill payload, which is every project where no skill has ever been created -- gonfalon creates that row with the first skill and never lazily. As an ordinary recoverable failure it would spend max_consecutive_failures and then report "gave up after N consecutive failures: HTTP 422" for an ordinary configuration; as a fatal one, the skill created a minute later would never arrive without a process restart. So it is its own class: _NoSkillPayloadError, caught ahead of _RecoverableTransportError, said once, counted under the new payload_unavailable diagnostic, retried at max_backoff indefinitely, and kept off connection_failures, last_error and failed. The retry is at the cap because _failures deliberately never moves, so the exponential schedule would otherwise sit at the initial delay forever. Nine tests, each of which fails with the source reverted: the declaration on both endpoints and on a from-scratch retry, the two kind constants held apart, the 422's classification, that it never stops delivery and never counts, that it waits the cap and not the initial delay, that a skill arriving after it is picked up, and that it leaves the store uninitialised so a wildcard reconcile prunes nothing. The fake endpoint gained a standing default status, since "every request is answered 422" is not something a queue can express. Also corrects docs that described the payload as classified `generic` and the request as carrying `mv`. Gate from python/: make test (1888 passed, 11 skipped), typecheck, lint, format-check all clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"The answer for a project in which no skill has ever been created" appeared seven times in one file: on the diagnostic, on the exception class, in _classify_status, on the once-per-store flag, and twice in the delivery loop. _NoSkillPayloadError now owns the explanation, since it is what the other sites refer to, and each of those states only what is local to it: the diagnostic names the type and keeps the "not a connection_failures" distinction, the except block keeps why it is caught first and why it waits the cap, and _classify_status keeps nothing -- the type it returns and the message it builds already say it twice over. No behaviour change, and the user-facing 422 message is untouched. 15 lines of comment removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
XieX
added a commit
to launchdarkly/js-ai-sdk
that referenced
this pull request
Sep 24, 2026
Mirrors the Python trim (launchdarkly/python-ai-sdk#109): the same sentence had been repeated at every site that touches the 422, so NoSkillPayloadError now owns the explanation and the others state only what is local to them — the diagnostic links the type and keeps the "not a connectionFailures" distinction, the loop keeps why it waits the cap, and classifyStatus keeps nothing, since the type it returns and the message it builds already say it twice over. No behaviour change, and the user-facing 422 message is untouched. 12 lines of comment removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Base automatically changed from
xie/python-skills-revoke-by-omission
to
xie/python-agent-skills-review-fixes
September 24, 2026 19:33
Base automatically changed from
xie/python-agent-skills-review-fixes
to
xie/agent-skills
September 24, 2026 19:33
… the 422 The declaration changed what arrives on the connection, and three places still described the old shape: - the README told customers the connection also carries their flags and that `objects_ignored` counts them, which is now the opposite of what happens; - `objects_ignored`'s own docstring said the same; - `_REQUEST_ADVICE` enumerates what the request carries, and is the text a user reads on the 400/405/406/414/501 family — exactly what an endpoint that does not understand `kinds` would answer — so leaving the parameter out of it pointed at the base URI instead of the likely cause. Also, on the new text: the 422 message and the log line it lands in read as one 70-word paragraph that stated the retry cadence twice, so the cadence is now the log's alone; `payload_unavailable` is a public field and documented itself with a private class name, so it names the status instead; and the internal service name behind "created with the first skill" is out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…as missing Two small things in the README's store section. "`connection_failures` stays at zero" is only true of a store that has had no other trouble; what the handler guarantees is that the 422s are kept off that counter and off `last_error`, so say that instead. And the `StoreDiagnostics` field list omitted `payloads_ignored`, which the type has carried all along. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements the §3.21/§3.24 decision for the retrieval outcome that had no detection surface at all: a store answering a version pin with a different version now writes the `ld.skills.integrity_failure` log record with `reason_code: version_mismatch`, and records no product signal. Adds `record_version_mismatch` beside `record_key_mismatch`, so the single-emission-site rule still holds by reading one module, and widens `IntegrityReasonCode` to ten tokens. `SkillOutcomeReason` stays a closed five — this changes the detection surface, not the public outcome vocabulary, and both closed sets are asserted in this change so neither can drift. 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-version parameter that silently disables the check when a caller omits it. The signal stays out for the reason it stays out of a key mismatch: the usual cause is a broken custom store adapter, and LaunchDarkly's own counter must not fill with customers' adapter bugs. Pinned in both directions, since an implementation that emitted the signal too would look correct from every other angle. The record names both versions. `version` keeps the meaning it has everywhere else — the version requested — and the version the store answered with goes in a record-only `served_version`, paralleling `served_key`. No hash fields: verification passed, so the hashes are not what disqualified the answer. `served_version` is recorded as an integer and needs no redaction, being a number rather than a string off the wire; the requested `version` beside it is deliberately not shape-checked, since it is the caller's own pin and redacting it would only hide a caller's mistake from them. Neither shipped store can reach this outcome — both answer a pin with exactly that version or `None`, so an ordinary pin miss is `absent` — so the tests drive it through a hand-built store that answers with another version, on the accessors and on the `write_skills` path, which resolves each pinned reference through the same function. A version mismatch there is per-skill and not `unavailable`, so it takes the narrow retention: the copy on disk survives under an `error` action while a genuinely revoked skill in the same run is still removed. Also pins that the key check wins when a store disagrees on both key and version, since that ordering decides a caller-visible outcome token and not only a code. Docs updated in the same change: the README's `reason_code` table and record field table gain rows and the alerting guidance now names both record-only codes, and `agents.md`'s outcome table gains a detection surface column — including the key-mismatch row it never had — plus the four callers of the shared resolution path and the write-path retention. Verified the emitted JSON is byte-identical to the TypeScript SDK's for the same input, modulo `language`. 1892 tests passing, up from 1888; mypy and ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements ai-sdks-monorepo #29 (§3.25, A.12), which specifies the opposite of #23 and is expected to supersede it. 422 was classified as a third thing -- neither broken nor terminal -- and that shape is a retry loop with no bound and no budget: it never moved `_failures`, so it retried at `max_backoff` for the life of the process, invisible to both `failed` and `connection_failures`. The platform chose the status to be terminal rather than us inferring it. The streamer says so at both sites that produce it -- the narrow-assignment check and the status constant -- each noting the code exists so a misconfigured SDK stops instead of hammering the fleet. Retrying forever is the precise behavior it was picked to prevent. The stated cause was also wrong, which is what made the old handling look reasonable. The gate is `payloadvers.SkillDeliveryAllowed`: delivery enabled for the account, and a credential that is not view-scoped. Every way of failing it is permanent. Skill existence is not among the causes -- with the gate open and a non-view-scoped key, the assignment path creates the agent-skill payload row lazily, so an environment holding zero skills is assigned an empty payload that commits normally through `payload-transferred`. So the message is rewritten as well as the classification. It names the two causes a reader can act on, and drops the two false claims the old text made: that the condition is about whether any skill exists, and that a skill created later arrives without a restart. It is what a customer pastes into a support ticket, so it is asserted on substance. Routing 422 through `_give_up` needed no new code and buys the right accounting: `failed` and `last_error` are set and `connection_failures` is untouched, because that counter measures consecutive *recoverable* failures against the retry bound and a fatal never retries. It also makes `wait_for_skills` return `False` immediately, through the existing `_end_delivery` release -- the observable half of the classification, so a boot gated on skills stops paying its whole timeout. Both are asserted rather than implemented. `_NoSkillPayloadError` and `diagnostics.payload_unavailable` are deleted. Both absences are asserted by name, the way §3.22 asserts its unexported constants, since a reintroduction is otherwise visible only in a log line no test reads; the diagnostics field set is pinned whole alongside. Classification is asserted to answer every status in 300..599 with exactly one of the two classes, so the forbidden shape cannot return under a different name. The `?kinds=agent-skill` declaration and the kind constants are unchanged; they already conform. README and agents.md described the deleted field and the retry behaviour, so both are rewritten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stacked on #109 (`xie/python-skills-kinds-param`) — review that first, or read this diff alone, which touches only the 422 classification and the diagnostics field it fed. Implements ai-sdks-monorepo [#29](launchdarkly/ai-sdks-monorepo#29) (§3.25, A.12). That spec deliberately leaves this SDK non-conforming until this change lands. > [!IMPORTANT] > ai-sdks-monorepo [#23](launchdarkly/ai-sdks-monorepo#23) specifies the **opposite** — 422 as a third class, counted and retried at the backoff cap — and is expected to be closed as superseded. This PR implements #29, not #23. ## Why 422 is fatal The platform chose the status to be terminal; this SDK is not inferring it. The `streamer` repo says so in both places that produce it — `internal/fdcore/narrowassign/narrowassign.go:24` and `internal/fdcore/adapters/httpsrv/status/status.go` — each noting that SDKs treat a non-400 4xx as terminal, so a misconfigured SDK stops instead of hammering the fleet. Retrying forever is the precise behaviour the status was picked to prevent. The previous handling was a third class: neither broken nor terminal. It never moved `_failures`, so it retried at `max_backoff` for the life of the process, invisible to both `failed` and `connection_failures` — a loop with no bound and no budget. ## The stated cause was also wrong That is what made the old handling look reasonable. The gate is `payloadvers.SkillDeliveryAllowed` in gonfalon: delivery enabled for the account **and** a credential that is not view-scoped. Every way of failing it is permanent. **Skill existence is not among the causes.** With the gate open and a non-view-scoped key, the assignment path creates the agent-skill payload row *lazily*, so an environment holding zero skills is assigned an empty payload that commits normally through `payload-transferred`. Nothing here assumes gonfalon #73018 (eager row creation) lands — it is closed. So the message is rewritten alongside the classification. It names the two causes a reader can act on, and drops the two false claims the old text made: that the condition is about whether any skill exists, and that a skill created later arrives without a restart. It is what a customer pastes into a support ticket, so it is asserted on substance rather than prose. The third cause the spec lists — a typo'd `kinds` value — is deliberately **not** in the message: this SDK sends a module constant, so it is not reachable by a customer. ## What changed | File | Change | | --- | --- | | `skills_fdv2.py` | `_classify_status` returns `_FatalTransportError` for 422, in its own branch; `_NoSkillPayloadError`, the `except` block in `_run`, the `_warned_no_skill_payload` flag, and `StoreDiagnostics.payload_unavailable` are deleted | | `test_skills_fdv2.py` | Nine tests covering the new behaviour; the three that asserted the old behaviour are inverted or deleted | | `README.md`, `agents.md` | Both described the deleted field and the retry behaviour | A dedicated branch rather than folding 422 into the existing `(405, 406, 414, 501)` list: that list's `_REQUEST_ADVICE` points the reader at the base URI, which is not where the problem is. **Routing 422 through `_give_up` needed no new code**, and buys the right accounting for free — `failed` and `last_error` are set and `connection_failures` is untouched, because that counter measures consecutive *recoverable* failures against the retry bound and a fatal never retries. It also makes `wait_for_skills` return `False` immediately, through the existing `_end_delivery` release. Both are asserted rather than implemented. ## Tests 1132 pass; lint, format, and `mypy --strict` clean. Each new assertion was verified to bite, by mutating the source three ways: | Mutation | Result | | --- | --- | | 422 → recoverable | all five end-to-end 422 tests fail, plus the classification test | | 422 → bare `Exception` (a genuine third class) | shape test fails: `HTTP 422 classified as Exception, which is neither exactly recoverable nor exactly fatal` | | Reintroduce `_NoSkillPayloadError` and `payload_unavailable` | both absence tests fail | The shape test sweeps `range(300, 600)` and asserts exactly one class per status via XOR, so the forbidden shape cannot return under a different name. The two absences are asserted by name — the way §3.22 asserts its unexported constants — since a reintroduction is otherwise visible only in a log line no test reads; the diagnostics field set is pinned whole alongside. The `wait_for_skills` test asserts value *and* elapsed time against a 30s timeout. ## The one cost, from the spec When an account's gate opens, a process whose store already gave up needs a restart — nothing reopens delivery short of constructing a new store, since `close` is final and a later `start` raises. Acceptable for a beta enablement step, and deliberately preferred to retrying a permanent rejection for the life of the process. The README and `agents.md` both say so, so it is not rediscovered as a bug. ## Scope The `?kinds=agent-skill` declaration and the payload/object kind constants are unchanged — they already conform. Nothing else in the transport is touched. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
XieX
added a commit
to launchdarkly/js-ai-sdk
that referenced
this pull request
Sep 29, 2026
Stacked on #87 — base is `xie/skills-kinds-param`, not `main`. Declaring `?kinds=agent-skill` changed what arrives on the connection, and four places still described the pre-declaration shape. This is the JS counterpart of launchdarkly/python-ai-sdk#109 (`91c9e20`). ### README - **"The connection also carries your flags"** told customers a client cannot request only the skill payload, and that `objectsIgnored` counts the flag and segment objects arriving anyway. Both halves are now the opposite of what happens: `FDV2_PAYLOAD_KIND` is on every request, which is what makes the connection carry exactly one payload. - the **`addListener`** paragraph repeated the same claim in passing, as the reason a non-skill kind is never dispatched. `objectsIgnored` keeps an explanation rather than losing one, because the counter still exists and now means something narrower and worth saying: an object kind this version does not recognise, not the environment's flags. That wording matches Python's `objects_ignored`, which rewrote this paragraph rather than deleting it. ### Source and agents.md - **`objectsIgnored`'s own doc comment** said what the README paragraph did, so the public API docs still told a reader the counter tallies their flags. - **`REQUEST_ADVICE`** enumerates what the request carries and left `kinds` out. It is the text a user reads on the 400/405/406/414/501 family — exactly what an endpoint that does not understand `kinds` would answer — so the omission pointed at the base URI instead of the likely cause. - **agents.md** attributed single-payload delivery to delivery itself ("Delivery provides one payload per credential"), contradicting the paragraph twelve lines above: a skill-enabled environment assigns two, and it is the declaration that narrows the connection to one. ### Deliberately unchanged - `payloadUnavailable` documents itself with `{@link NoSkillPayloadError}`, which is fine here because that class is exported; Python's equivalent was private, which is why it was reworded there. - `payloadsIgnored` keeps "Zero while delivery sends one payload per connection" — the declaration makes that condition unconditional rather than false, and Python left its wording alone too. ### Known divergence Python's agents.md still carries the "one payload per credential" claim fixed here, so the two trees differ on that line until python-ai-sdk catches up. ### Gate `tsc --noEmit` clean, `biome check` clean, `vitest run` 1024 passed / 10 skipped in `packages/client` — the same counts #87 recorded, and the ten are the capability-gated TOCTOU tests. Docs only; no behaviour change, so no tests added. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The branch tip does not parse: the edit that shortened the 422 message dropped its closing quote, leaving an unterminated string literal in `_classify_status`. It also dropped both causes the message is asserted to name, so `test_the_422_message_names_both_of_its_real_causes` fails on the wording as well. Restored at roughly the shortened length, naming the account-level enablement, the view-scoped key and the restart that clears it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The new prose restated what the neighbouring function already explains. `record_version_mismatch` keeps what differs from `record_key_mismatch` -- the extra key argument, the custom adapter it exists for -- and defers the shared rationale to it rather than arguing it a second time. Its field comments keep the constraints a future edit could break (the integer, the unchecked pin) and drop the restatement around them. Same pass over the kinds declaration and the tests that cover it: the reason a request carries `kinds` belongs on `FDV2_PAYLOAD_KIND`, and `_url` and the test docstrings point at it instead of repeating it. No behaviour change, and no assertion removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
andrewklatzke
approved these changes
Sep 29, 2026
knfreemLD
approved these changes
Sep 29, 2026
jeffdupont
approved these changes
Sep 29, 2026
The 422 message and the README both sent the reader to restart their process, and that is not what clears it. `_give_up` ends the run, not the store: only `close` sets `_closed`, and `_closed` is the only thing `start` refuses -- `start`'s own docstring says so, and `test_a_restarted_store_does_not_report_the_old_failure` has covered the general case all along. So a store that stopped on a 422 resumes in place once the account is enabled, and the message asked for a service restart instead. It says `start()` now, and the claim is pinned by a test that drives the 422, opens the gate, and starts the same store. Four more, all in the text around it: - The README said `connection_failures` "stays at zero", which is only true of a run that had no other trouble -- a 503 then a 422 leaves it at 1, since `_give_up` does not reset it. This is the correction affb82c already made once, lost in the rewrite; it now says what the handler guarantees, which is that the 422 is kept off the counter. - The new 422 prose in both files named `lastError`, `connectionFailures`, `waitForSkills`, `classifyStatus` and `FatalTransportError`, and had `waitForSkills` "resolve" -- the TypeScript spellings, against a README that lists the snake_case ones 90 lines further down. - `record_version_mismatch` wrote `served_version` through unchecked, on the reasoning `record_key_mismatch` beside it explicitly rejects: the value is store-controlled, and its integer-ness is a property of the current call order rather than of the recorder. Guarded the way `served_key` is, which is also what keeps the field an integer for the byte-comparable JSON. Reverting it logs the test's secret body verbatim. - `_FakeFDv2Endpoint.default_poll_status` was scaffolding for the superseded design, assigned by no test, and its comment described the every-request-422 retry loop this branch deleted. `agents.md`'s 422 paragraph was one 587-character line; rewrapped to the width of the section it sits in, and it now records why the message must not drift back to "restart the process". 1898 tests passing, up from 1896; lint, format and mypy clean. Each new assertion verified to bite by mutating the source back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Byte-identical to the TypeScript SDK's message now, and verified so: LaunchDarkly will not deliver Agent Skills on this connection (HTTP 422). The usual cause is a view-scoped SDK key. Check your SDK key or contact LaunchDarkly support. The account-level condition behind the remaining 422s is out. It is not a customer's setting, so naming it spent the reader's attention on something they cannot change, and the phrasing implied Agent Skills is enabled per account when what is really being described is a rollout state -- not something an error message should narrate. Those cases are unexpected from the customer's side, so they are treated as unexpected: referred to support, unenumerated. `start()` goes too, for room. The recovery is real and still documented in the README, and `test_a_store_that_gave_up_on_a_422_resumes_on_start` still pins it -- the message simply is not where it fits any more. `test_the_422_message_names_both_of_its_real_causes` becomes `..._names_its_one_actionable_cause`: the cause and the referral are asserted, and so are the four absences, so the enumeration cannot creep back. Reverting either half of the message fails it. The README and `agents.md` carried the same two-cause framing; both now say what the message says. `agents.md` explains why the message stays short without describing the condition it leaves out -- this file is public too, and the reasoning that keeps a rollout out of an error string keeps it out of a contributor guide. 1898 passing, lint/format/mypy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last of the "restart your process" overstatements, and the one with the widest reach: `_give_up` writes a single line for a 401, a 403, a 404, a 422 and an exhausted retry budget alike, and it told the operator skills would not update "until the process restarts with a working connection". A fatal ends the run and not the store -- `close` is the only thing that forecloses a restart -- so an operator who had just fixed the cause was sent to bounce a service when `start()` on the same store would do. It now names both, cheapest first, and keeps the half that was already true: the content already held stays readable. The prefix is unchanged, because `test_a_restart_during_the_give_up_still_delivers` matches on it to hold the give-up/start race open. `test_the_give_up_line_points_at_start_not_a_process_restart` asserts the line against the 401 path, so the claim is pinned for every fatal that reaches it rather than for the 422 alone. Reverting the wording fails it. `agents.md` framed this as a 422 property; it is not, so the paragraph now covers fatals generally and names the three surfaces that carry the claim -- the log line, the behaviour, and the README -- with the test pinning each. 1899 passing, up from 1898; lint, format and mypy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
XieX
added a commit
to launchdarkly/js-ai-sdk
that referenced
this pull request
Sep 30, 2026
Mirrors the Python trim (launchdarkly/python-ai-sdk#109): the same sentence had been repeated at every site that touches the 422, so NoSkillPayloadError now owns the explanation and the others state only what is local to them — the diagnostic links the type and keeps the "not a connectionFailures" distinction, the loop keeps why it waits the cap, and classifyStatus keeps nothing, since the type it returns and the message it builds already say it twice over. No behaviour change, and the user-facing 422 message is untouched. 12 lines of comment removed. 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 #94 -> #93 -> #87. Spec: ai-sdks-monorepo#23. TypeScript counterpart: js-ai-sdk#87. Server side: streamer#4730.
This is the SDK half of the release blocker: other SDKs retry forever when they see a skill payload. Flag Delivery's answer is
?kinds=, which narrows a connection to the payload kinds it declares, defaulting to{flagging}(so we need to opt-in to see skills).The declaration
Every request now carries
kinds=agent-skill— both endpoints, and on the first request as well as the ones after it, since it selects what the connection is served rather than describing what the store already holds. Without it the store receives the environment's flag payload and no skills at all, so this is a functional requirement, not for correctness/optimization.It also fixes something that was already wrong. A skill-enabled environment assigns two payloads, so
_ProtocolReaderhas been warning about the second on every connection, reading only the first intent, and never adopting a basis for the flag payload — re-downloading and discarding it on every reconnect. Declaring one kind makes the connection single-payload, which is the shape the reader is built for. (That is also why declaringflagging,agent-skillis not the safe-looking option it appears to be.)No
mv, still — but for a corrected reason. It selects the flag data model, andobjectQueryForCommandoverrides whatever a request asks for with the payload's own default for any non-flagging payload, so sending it would state a preference that is ignored. The old comment said the connection would be refused over it.🤖 Generated with Claude Code, edited by @XieX
Note
Overview
FDv2 skill delivery now sends
kinds=agent-skillon every poll/stream request (withbasiswhen applicable), so connections receive only the agent-skill payload instead of flags plus skills.HTTP 422is treated as a fatal, non-retryable error (aligned with the TypeScript SDK): delivery stops,failed/last_errorare set,connection_failuresis unchanged,wait_for_skillsreturns immediately, and operators are pointed atstart()on the same store after fixing the cause (e.g. view-scoped SDK key) rather than only a process restart.Integrity logging gains a tenth
reason_code,version_mismatch, viarecord_version_mismatchwhen a pinned version does not match what the store returned (served_versionon the log record;wrong_versiononget_skill_result). Likekey_mismatch, it emits theld.skills.integrity_failurelog only—no product integrity signal. README/agents.md and tests cover 422,kinds, give-up messaging, and the write path for version mismatches.Reviewed by Cursor Bugbot for commit 260856b. Bugbot is set up for automated code reviews on this repo. Configure here.