fix(transport): correctness pass on the notification link, RPC batch and plugin notifications - #24
Merged
Merged
Conversation
matej21
force-pushed
the
fix/transport-correctness
branch
from
August 28, 2026 16:08
b385172 to
a42bb86
Compare
`method()`, `MethodDef`, the caller/handler types built on them and the method-only half of `Protocol` had no callers: the router, the connection base and both SDK adapters are notification-only, and HTTP `/rpc` carries request/response. Keeps the notification protocol API the router and `Connection.notify` actually use. `validateInput` now parses through a generic schema type instead of an `as` cast on the result. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM
The 10s probe was one-directional. The client sent it and drew no conclusion from silence, so from the agent's side a link that had gone dead was indistinguishable from a healthy one. The probe and its answer are now complete frames that each side reads before acting on, `ServerAdapter` answers one, and three unanswered probes in a row force a reconnect. Detection arms only against a peer that answers the probe, and re-arms per connection, so a reconnect onto a host that does not answer cannot start a teardown loop. The answering half for hosts outside this repo is a separate change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM
The batch loop broke on the first non-ok item and still returned HTTP 200, so `results` could be shorter than `batch` with nothing in the body to say so — a caller could not tell a completed batch from a truncated one. Every item now runs and gets its own envelope, so `results.length` always matches `batch.length`. No caller in this repo depends on the fail-fast. Running every item does make the batch length the amount of dispatch one request buys on a client-facing endpoint, so the length is now capped at 100. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM
`.notification(name, { schema })` described a contract nothing checked, so
a plugin could broadcast any shape under any name. The plugin builder now
validates every `ctx.notify` against the declared schema and drops what
does not match, logging the plugin and the type.
Enforcing it surfaced the `services` plugin broadcasting `serviceStatus`
without declaring it at all; the declaration is added here. The guard sits
in the builder rather than in the session so the agent-side hook contexts
are covered by the same check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM
`_caller` decides which `beforeMethod` gates apply, so it is an authority claim a host must stamp from what it authenticated. Nothing said so, which invited a second host implementation to read it from the request body. `fetch-guard` resolves a hostname to check it and then hands the name to `fetch`, so it cannot see a second lookup resolving elsewhere. The limit now sits in the module header instead of only next to the resolver. Comments only — no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM
matej21
force-pushed
the
fix/transport-correctness
branch
from
August 28, 2026 16:44
a42bb86 to
7a309c2
Compare
…_caller note HEARTBEAT_TYPE and HEARTBEAT_ACK_TYPE were declared in both adapter/types.ts and adapter/heartbeat.ts, and nothing imported the pair in types.ts — two sources of truth for a wire constant. The `_caller` comment asserted an invariant the route does not enforce: the value is still read straight off the request body with no shape check, so in standalone mode the browser can set it. Say what is true instead. SafeParseResult reaches the /client and /server subpaths too, which export Protocol but not the type its validateInput returns. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM
Settling every batch item made a partial failure resubmittable: the client still collapses to the first failing envelope, so a retry re-sends answers that already landed. answerQuestion minted a fresh messageId each time and the reducer appends unconditionally, so the agent read the same answer twice. The guard sits in the handler, not the reducer. A recorded answer event still replays exactly as written — `replays each immutable answer value` pins that, and a log that already carries a duplicate must keep reducing to what it always did. What changes is that no new duplicate gets written. This does not remove the need to surface per-item results from RpcClient.batch; it removes the damage a retry does in the meantime. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM
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.
Six independent corrections to the transport layer, one per commit.
Drop the dead request/response protocol layer.
method(),MethodDef, the caller/handler types built on them and the method-only half ofProtocolhad no callers: the router and connection base are notification-only, both SDK adapters serialise raw frames, and request/response is carried by HTTP/rpc. Removed together with its re-exports from every entry point; the notification protocol API the router andConnection.notifyuse is untouched.SafeParseResultbecomes a public export, sinceProtocol.validateInputreturns it by name.Let the agent notice a heartbeat that is never answered. The 10s probe was one-directional: the client sent it and drew no conclusion from silence, so a dead link looked healthy from the agent's side. The probe and its answer are now complete frames that each side reads before acting on,
ServerAdapteranswers one, and three unanswered probes in a row force a reconnect.Scope, plainly: detection arms only against a peer that answers the probe, and re-arms per connection. The only answering implementation in this repository is
ServerAdapter, which runs in standalone mode — whereClientAdapteris not used. So against a host that does not yet answer, behaviour is unchanged by design: the client keeps its link rather than tearing it down. The answering half for such hosts is a separate change, not in this PR.Settle every item in an RPC batch. The loop broke on the first non-ok item and still returned HTTP 200, so
resultscould be shorter thanbatchwith nothing in the body to say so — a caller could not tell a completed batch from a truncated one. Every item now runs and gets its own envelope, soresults.lengthalways matchesbatch.length. No caller depends on the fail-fast forresults.length— but the side-effect ordering does change, and one caller is affected.RpcClient.batchstill collapses to the first failing envelope, while items after it have now executed and committed.session-store.tsbatchesuser-chat.answerQuestionfor every pending question: if the first fails, the rest are now submitted and succeed, the client still returnsErr, the UI marks all of them errored, and a retry re-submits answers that already landed.answerQuestionwas not idempotent — its handler minted a freshmessageIdand its reducer appends unconditionally topendingInbound, so the agent read the answer twice. This PR now drops an answer to a question that already has one, in the handler. The guard is deliberately not in the reducer: a recorded event still replays exactly as written (replays each immutable answer valuepins that a second answer overwrites the first on replay), so only new duplicates are prevented.That bounds the damage but does not remove the cause:
RpcClient.batchstill tells the caller nothing about which items succeeded. Surfacing per-item results is a required follow-up before the client-side batch is relied on for anything with side effects. Because running every item makes the batch length the amount of dispatch one request buys on a client-facing endpoint, the length is now capped at 100 (batch_too_large, HTTP 400).Enforce declared notification schemas.
.notification(name, { schema })described a contract nothing checked. The plugin builder now validates everyctx.notifyagainst the declared schema and drops what does not match, logging the plugin and the type. This surfaced theservicesplugin broadcastingserviceStatuswithout declaring it; the declaration is added here. The guard sits in the builder rather than in the session so agent-side hook contexts are covered by the same check — seven context kinds share the seam; method handlers and session hooks are covered by test.Document two invariants (comments only, no behaviour change):
_calleris read straight off the request body with no shape check, so anything holding the bearer token can set it — it is a hint about the caller, not proof of one, and validating it is a follow-up; andfetch-guardvalidates a hostname but hands the name tofetch, so it filters where a request may point rather than guaranteeing where the connection lands.🤖 Generated with Claude Code
https://claude.ai/code/session_01CbVWntVv8xSdtn6DC6ZApM