Skip to content

fix(transport): correctness pass on the notification link, RPC batch and plugin notifications - #24

Merged
matej21 merged 7 commits into
mainfrom
fix/transport-correctness
Aug 31, 2026
Merged

fix(transport): correctness pass on the notification link, RPC batch and plugin notifications#24
matej21 merged 7 commits into
mainfrom
fix/transport-correctness

Conversation

@matej21

@matej21 matej21 commented Aug 28, 2026

Copy link
Copy Markdown
Member

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 of Protocol had 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 and Connection.notify use is untouched. SafeParseResult becomes a public export, since Protocol.validateInput returns 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, ServerAdapter answers 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 — where ClientAdapter is 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 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 depends on the fail-fast for results.length — but the side-effect ordering does change, and one caller is affected. RpcClient.batch still collapses to the first failing envelope, while items after it have now executed and committed. session-store.ts batches user-chat.answerQuestion for every pending question: if the first fails, the rest are now submitted and succeed, the client still returns Err, the UI marks all of them errored, and a retry re-submits answers that already landed. answerQuestion was not idempotent — its handler minted a fresh messageId and its reducer appends unconditionally to pendingInbound, 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 value pins 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.batch still 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 every ctx.notify against the declared schema and drops what does not match, logging the plugin and the type. This surfaced the services plugin broadcasting serviceStatus without 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): _caller is 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; and fetch-guard validates a hostname but hands the name to fetch, 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

@matej21
matej21 force-pushed the fix/transport-correctness branch from b385172 to a42bb86 Compare August 28, 2026 16:08
matej21 and others added 5 commits August 28, 2026 18:40
`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
matej21 force-pushed the fix/transport-correctness branch from a42bb86 to 7a309c2 Compare August 28, 2026 16:44
matej21 and others added 2 commits August 31, 2026 14:10
…_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
@matej21
matej21 merged commit d23ad26 into main Aug 31, 2026
1 check passed
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