Skip to content

feat(agents): rate limit, fan-out cap, and cross-project refusal - #208

Merged
eneskirca merged 3 commits into
mainfrom
feat/agent-message-flow-control
Aug 15, 2026
Merged

feat(agents): rate limit, fan-out cap, and cross-project refusal#208
eneskirca merged 3 commits into
mainfrom
feat/agent-message-flow-control

Conversation

@eneskirca

@eneskirca eneskirca commented Aug 15, 2026

Copy link
Copy Markdown
Owner

PR 4 of the agent-messaging plan: flow control. It ships inertparseControlRequest still
refuses send and reply as unknown verbs (asserted by running it), so nothing in this PR is
reachable by any agent until PR 5 lands the verbs. It builds directly on #207's decidePreProbe,
the 16-member outcome union, and RETRYABLE.

The pair limit is keyed by the PAIR, and the two alternatives each break something

checkPairRate(src, dst, now), PAIR_MIN_INTERVAL_MS = 10_000 (#98's number, now enforced).

Keying What it produces
by SENDER An orchestrator fanning out to four workers starves itself: four sends inside a second, three refused for being correct behaviour. (That shape is the right one for a turn cap, which is what the second limit is — with a much larger allowance.)
by TARGET One node's window is held shut by whoever wrote to it last, and every other conversation with that node pays for it. Trivially abusable: a flooder makes a target unreachable for everyone.
by PAIR The budget belongs to the conversation. A to B says nothing about A to C, and — asserted explicitly — nothing about B to A, so a reply is never throttled by the message it answers.

The map key writes its separator as the six-character escape (backslash-u-0000), never a raw byte:
a raw NUL makes git, grep and ripgrep skip the entire source file in silence (commit 4c0e8c3,
twice).

What identifies a turn

checkFanOut(src, now), FANOUT_PER_TURN = 4, reset by noteNewTurn(nodeId).

A turn is the budget entry itself — created by the turn's first send, destroyed by the sender's
own newTurn. That is the same edge the renderer's per-turn fan-out already uses (normalize.ts:
"true only for a genuine new turn … so the renderer can clear per-turn fan-out without clearing on
every mid-turn tool event"
). The three things that look like turn ids and are not:

  • A state transition (working to done) flips several times inside one turn on a tool-event
    burst. A cap that resets mid-turn is not a cap.
  • The session id spans hours and hundreds of turns: four messages per session is a permanent
    block wearing a budget's clothes.
  • A wall-clock window refills in the middle of a long turn and refuses at the start of a short
    one.

Only the SOURCE's own newTurn resets it — if the target's did, every delivery would refill the
budget that sent it, because a delivery is what starts the target's turn.

Where the state lives, and which way it fails

Process memory, module scope, swept on read. Nothing persisted, deliberately:

  • A lost limiter is a brief over-send. After a restart, one extra message per pair inside its
    10 s window and a full fan-out budget. Bounded, self-healing, invisible.
  • A stuck limiter is a permanently silent agent — the failure this module refuses to have. So
    every path that could produce one fails OPEN:
    • entries are evicted, not filtered at read time (a limiter that decides expiry on read while
      the map grows looks correct to every test that reads through the same bound, and leaks);
    • a clock jumping backwards drops the entry instead of parking it in the future (an NTP step
      would otherwise silence an agent for the length of the step);
    • a fan-out budget whose reset signal never arrives expires by itself after TURN_STALE_MS
      (5 min) — so a shell that forgets to wire noteNewTurn over-sends four messages per idle five
      minutes instead of silencing every sender for the rest of the run.

Both limits are pre-probe: they answer from a Map, so a refusal costs no tmux round-trip and,
on an SSH project whose ControlMaster has died, no real login. Asserted by running a delivery with a
probe that counts its calls (0 when rate-limited, 1 on the control). Both refuse with the existing
rateLimited member, so RETRYABLE already answers for them. A fan-out refusal reports the pair
interval as retryAfterMs — a floor, not a promise: 0 would be read as "no limit" by
decidePreProbe, whose refusal is retryAfterMs > 0, and the cap would silently stop existing.

A check never starts a window. Only noteSent records, and only a delivery that actually reached
a pane calls it. Otherwise a delivery refused as targetBusy — a RETRYABLE outcome the caller was
told to come back from — would burn the budget it never used, and four such retries would silence
the sender for its whole turn. The cost of that choice: refusals themselves are unthrottled, which
is acceptable precisely because they are free.

Cross-project, resolved off the store

resolveDeliveryScope(projects, src, dst) takes the serialized projects array — the same one
routeControlSource and storedNodeListing read — and has no parameter for a live node list.
That is the guarantee, and the test checks it by effect: a node the live canvas has but the store
does not is refused. It fails closed (a source in no known project, a target in no known
project, and a target in an unshared project are all cross-project, which is not retryable),
resolves a duplicate node id from a cloned project.json to the sender's own project, and does not
filter closed projects — their tmux sessions keep running and a delivery goes to a pane.

send/reply are declared outside needsLiveCanvas, so a delivery can never travel the camera:
travelling would hijack the human's view on a background agent's say-so and the setActive on the
way would clear that node's unread badge — a message silently erasing the signal a human relies on
(G5).

The self-send half was already landed by #207 (decidePreProbe, ahead of everything but an
explicit notPermitted) and is not re-implemented here. It is verified by running it, including
that both guards produce the identical notPermitted{reason:'self-send'} so a trace cannot depend
on which one fired, and that the backstop sits ahead of the rate limiter so a self-send is never
reported as a wait. The guard is exactly as strong as the plan describes; its only softness is that
it skips when either id is absent, which is documented in #207 and is the correct reading of
"absent" (a caller that supplies neither id has not made a self-send claim to check).

Three surfaces

  • Desktop (Electron): the only surface with the verbs, so the only one that consumes budget.
  • Server Edition: both modules ship (src/core ships on both shells) with no consumer —
    messaging does not exist there, and Task 5.3 makes that a named terminal refusal rather than a
    throttle-shaped one.
  • Mobile (phone): never a sender, so it never consumes budget. It is a valid target
    (Correction C1) and is covered like any other: the budget belongs to the pair, so two
    orchestrators messaging one phone-spawned node get two independent windows.

Mutation results

24 mutations applied one at a time (revert, run, restore) against the five suites this PR touches.
24/24 killed. One SURVIVED on the first pass and is the false green: dropping the separator from
pairKey entirely left the collision test green, because its id set contained no pair that collides
under plain concatenation ('a-b'+'c' is not 'a'+'b-c'). Adding 'ab'/'bc' — which do collide —
makes it red. The test looked exhaustive (81 pairs, all distinct) and proved nothing about the thing
it was named for.

Also caught while writing this: the test file itself was written with raw NUL bytes in that same
test — the exact hazard the key's docblock warns about, reproduced by the tool writing the file.
Both files were scanned for raw NULs before commit; there are none.

Killed mutations, in groups: pairKey without a separator; sweep without pair eviction; sweep
without the clock-backwards guard; sweep without turn-budget expiry; retryAfterMs reporting the
whole interval instead of the remainder; the fan-out cap off by one; the fan-out cap disabled;
noteSent forgetting the pair window; noteSent not incrementing; noteNewTurn as a no-op;
noteNewTurn resetting every sender; FANOUT_RETRY_AFTER_MS = 0; checkFlowLimits forgetting
either half; resetMessageFlow forgetting a map; flowStats returning a constant; the scope
resolver without its self-send arm, admitting any known target, resolving the owner off the target,
always claiming the target exists, and never refusing; #207's self-send backstop removed; and send
or reply dropped from the store-answered set.

One branch was deleted rather than kept: checkFlowLimits originally picked the larger of the
two waits, and the "pair is larger" arm is unreachable while FANOUT_RETRY_AFTER_MS equals
PAIR_MIN_INTERVAL_MS. It is now a fixed precedence (fan-out first) with a test pinning the
constant relationship that makes the precedence sound.

Tests

New: agent-message-flow.test.ts (29), agent-message-scope.test.ts (15). Extended:
controlRouting.test.ts (+1), canvas-control-core.test.ts (+1). npm run typecheck clean; full
npx vitest run = 6199 passed, 4 failed — the 3 node-pty-patch and 1 webgl-addon-pair
assertions that are environmental on this Linux host and fail identically on origin/main.

Deviation from the plan

Tasks 4.1 and 4.2 are one commit, not two: the sweep, the eviction contract and the failure
directions are shared between the two limits, and splitting the file down the middle would have
produced a first commit that could not state its own invariants.

Review fixes (commit 633a8e6)

FIX A — non-finite now. sweep now clears both maps when now is not finite. Every
comparison in it is false for NaN, so an entry became immortal and the budgets then failed in
opposite directions: the pair limiter open by accident (retryAfterMs is NaN, and
decidePreProbe refuses on > 0), the fan-out cap closed forever once at the cap — a permanently
silent sender, the failure the header claims immunity from. NaN is the only value the line is
load-bearing for; +Infinity is dropped by the staleness arm and -Infinity by the future arm, so
!Number.isFinite vs Number.isNaN is an equivalent mutation (measured), and the test now says
that instead of claiming to pin it.

FIX B — isSafeNodeId at the boundary. resolveDeliveryScope refuses either id outside
[A-Za-z0-9._-], ahead of the self-send arm, as its own reason unaddressable-node-id rather
than cross-project — such an id can be listed in the sender's own project, so the scope word would
be a false statement about the reason (new member on NotPermittedReason; RETRYABLE is keyed by
outcome kind, so nothing else changes). This closes the separator collision in pairKey and the
v/1 vs v_1 tmux fold. Did the widened id set kill the separator mutation? No — and it could
not:
with a NUL separator only a NUL-bearing id can collide, and no id in that 81-pair set can
contain one. The false green did move one layer down, exactly as the review said. It is now killed
by an explicit test that exhibits the collision (built with String.fromCharCode(0), so no escape
survives a file write) and then pins the validator that makes the premise true.

FIX C — the two false comments. Corrected in place, not coded around: the duplicate-id note now
says it is a routing answer and not an isolation guarantee (sessions key on the bare node id, so a
project.json listing another project's id lands in that node's one global pane), and records that
PR 5's delivery call must run isSafeNodeId(targetNodeId) before paneOwner. The
needsLiveCanvas rationale now names the right half: Canvas routes by SOURCE, so the declaration
stops a trip to the sender's project; never travelling to the target's is resolveDeliveryScope
having no live-node parameter. Both files cross-reference each other so PR 5 cannot inherit the
wrong model.

Also added: the fan-out floor's known cost (30 refused attempts before the backstop opens when
newTurn never arrives), and a note that ScopeProject.nodes is required — a synthesised array
without it throws rather than failing closed, which is the caller's to own.

Mutations after the fixes: the original 24 re-run and all still red; 8 new ones on the fixed
lines, 7 red (guard removed, guard clears only one map, validator removed, validator checks only the
source, only the target, reports cross-project, runs after the self-send arm) and 1 equivalent
(Number.isFinite to Number.isNaN), documented in the code rather than papered over. 31 killed,
1 equivalent, 0 survivors.

Tests: agent-message-flow.test.ts 33, agent-message-scope.test.ts 36,
controlRouting.test.ts 13, canvas-control-core.test.ts 25. Full run 6223 passed, same 4
environmental failures.

🤖 Generated with Claude Code

eneskirca and others added 3 commits August 15, 2026 05:55
The control surface has never had a throttle. What it has is timeouts
(SLOWLORIS_MS -> CONTROL_CEILING_MS, the 120s pendingControl bound) and a
one-at-a-time confirmation serializer. A timeout is not a throttle and a modal
is not a budget — and messaging skips the modal entirely once the per-project
switch is on, so even the accidental brake is gone.

Two limits, both PRE-PROBE, both refusing with the existing `rateLimited`
member of the outcome union rather than a new shape:

- Per PAIR, 10s (#98's number, now enforced). Keyed by sender alone, an
  orchestrator fanning out to four workers starves itself; keyed by target
  alone, one node's window is held shut by whoever wrote to it last and every
  other conversation with it pays. Keyed by the pair, the budget belongs to the
  conversation — and B->A is a different pair, so a reply is never throttled by
  the message it answers.
- Per TURN, 4 per sender. A turn is identified by the budget entry itself,
  created by the turn's first send and destroyed by the sender's own `newTurn`
  — the same edge the renderer's per-turn fan-out already uses. A state
  transition would reset it many times inside one turn; a session id would
  never reset it at all.

State is process memory, swept on read. A LOST limiter is one extra message per
pair after a restart; a STUCK one is a permanently silent agent, so every path
that could produce one fails open: entries are evicted rather than filtered,
a backwards clock jump drops an entry instead of parking it in the future, and
a fan-out budget whose reset signal never arrives expires after TURN_STALE_MS.

Ships inert: nothing calls any of it until the verbs land.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d off the store

`resolveDeliveryScope` answers "is this target mine to write to?" from the
SERIALIZED projects store — the same array `routeControlSource` and
`storedNodeListing` already read — and produces the `notPermitted` fact
`decideDelivery` already takes. There is no parameter for a live node list,
which is the point: React Flow holds only the active project's nodes, so
resolving a target against `nodesRef` answers "not on the canvas" for every
node outside the current tab.

Fails CLOSED, because what is being authorised is a write into someone else's
terminal: a source in no known project, a target in no known project, and a
target in a project the sender does not share are all `cross-project`. A
duplicate node id (a cloned project.json) resolves to the sender's own project.
Closed projects are deliberately not filtered — their tmux sessions keep
running, and a delivery goes to a pane, not to a canvas.

`send`/`reply` are declared outside `needsLiveCanvas` so a delivery can never
travel the camera: travelling would hijack the human's view on a background
agent's say-so, and the `setActive` on the way would clear that node's unread
badge — a message silently erasing the signal a human relies on (G5).

The SELF-SEND half was already added to `decidePreProbe` in #207 and is not
re-implemented here; it is verified by running it, including that the two
guards produce the identical refusal so a trace cannot depend on which fired.

Ships inert: `parseControlRequest` still refuses `send` and `reply` as unknown
verbs, asserted by running it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d id could collide two conversations

Review fixes for #208.

FIX A — `sweep` now drops everything on a non-finite `now`. Every comparison
in it is false for NaN, so an entry became immortal, and the two budgets then
failed in OPPOSITE directions: the pair limiter fails open by accident (its
`retryAfterMs` is NaN and `decidePreProbe` refuses on `> 0`) while a fan-out
budget already at the cap returns a real 10_000 forever — a permanently silent
sender, the exact failure the module's header claims not to have. Unreachable
while `now` is `Date.now()`. NaN is the only value the guard is load-bearing
for: +Infinity is already dropped by the staleness arm and -Infinity by the
future arm, so swapping `!Number.isFinite` for `Number.isNaN` is an equivalent
mutation, and the test says so instead of pretending to pin it.

FIX B — `resolveDeliveryScope` refuses an id outside `isSafeNodeId`, as its own
`unaddressable-node-id` reason rather than as `cross-project`: such an id can be
listed in the sender's OWN project, so the scope word would be a false statement
about the reason. Node ids come off `.nodeterm/project.json` and the load path
validates nothing (`isSafeNodeId` is applied at `PtyManager.create()`, which is
not that path), so this was the only place on the messaging path that asks. It
closes two live holes: `pairKey` is injective only over ids that cannot contain
its NUL separator (`pairKey('x'+NUL+'y','z') === pairKey('x','y'+NUL+'z')`, a
10 s throttle landing on the wrong conversation), and `sessionName()` sanitises
rather than refuses, so declared `v/1` and `v_1` name ONE tmux session.

FIX C — two false comments corrected, not coded around:
1. "the ambiguity never reaches the pane" was wrong. A session's only key is the
   bare node id (`byPersistKey`, `paneOwner(persistKey)`, tmux `nt-<nodeId>`);
   no project id appears in that namespace and nothing de-duplicates ids, so a
   project.json that merely lists another project's node id resolves
   `same-project` and lands in that node's one global pane. The comment now says
   this is a routing answer, not an isolation guarantee, and records that PR 5's
   delivery call must run `isSafeNodeId(targetNodeId)` before `paneOwner`.
2. The `needsLiveCanvas` rationale named the wrong half. Canvas routes by
   SOURCE, so the declaration stops a trip to the SENDER's project; never
   travelling to the TARGET's is `resolveDeliveryScope` having no live-node
   parameter. Both comments now say which guarantee is whose.

Also documents the known cost the review measured: obeying the fan-out floor
when `newTurn` never arrives costs 30 refused attempts before the backstop
opens (TURN_STALE_MS / FANOUT_RETRY_AFTER_MS).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eneskirca
eneskirca merged commit 0c34527 into main Aug 15, 2026
4 checks passed
@eneskirca
eneskirca deleted the feat/agent-message-flow-control branch August 15, 2026 03:32
pull Bot pushed a commit to jasonkneen/nodeterm that referenced this pull request Aug 15, 2026
…gacy path

The two messaging verbs exist end to end. At the route, `requiresVerified` in hook-server.ts
admits only a `verified` caller, checked on the VERDICT before identityGate's decision is
consulted: an invented kid is FOREIGN, therefore legacy, therefore never verified — the escape
that defeats the latch and the dated window does not defeat this — and
`settings.hookIdentityStrict: false` releases neither. There is no upgrade population to
protect, so the routes are fail-closed from day one, with a flat named refusal
("Agent messaging refused.") that offers no token or restart advice.

Canvas.tsx handles send/reply BEFORE its source-routing machinery — they are store-answered
(needsLiveCanvas false, pre-positioned), so a background orchestrator's message can never travel
the human's view or clear an unread badge on the way (G5) — validates the args and the source's
control capability, wraps the call in guardConcurrentRestart so a delivery cannot land inside a
wake's un-submitted resume line, and forwards to main.

Main's new agent-messaging service is the ONE caller of deliverAgentMessage (eneskirca#207): scope off the
serialized store (isSafeNodeId first), the per-project switch (constraint 11 — wired () => false
until PR 6 ships the validated field, so every delivery answers notPermitted switch-off), flow
control (eneskirca#208; only a write that reached the pane consumes budget), the deps record over
PtyManager/mirror/token files, the trace through the same board-log router the IPC handler uses,
and a rendered reply whose retry advice is sourced from RETRYABLE in words — stalled answers ok
with 'do not retry', because an ok:false stalled is how a message gets delivered twice.

Delivery transport: the envelope is framed by bracketedInjection in core and moved byte-for-byte
by a new framed paste plan (load-buffer stdin → gated copy-mode cancel → paste-buffer -d -r, no
-p, no separate Enter) — stdin keeps every payload off argv and off the remote shell line, and
assertFramedPayload makes the one unsanitized path refuse anything that is not bracketedInjection
output. agent-message.realtty.test.ts now drives this exact plan against a real tmux and a real
paste-aware bash. The #{bracket_paste_flag} probe is NOT reintroduced (pty-manager.ts's deleted
note stands); bracketPasteRequested is wired true with the residual named as a TODO.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull Bot pushed a commit to jasonkneen/nodeterm that referenced this pull request Aug 15, 2026
…h the one delivery path

PR eneskirca#98 (parsa222) designed `notify` as a fixed, app-authored prompt a canvas agent can send a
linked agent, with the rule that made it safe: "The app owns the entire prompt so the source
cannot inject instructions through command arguments." This commit SUPERSEDES eneskirca#98 rather than
rejecting it: the verb, its `--node` requirement and its exact `--text` refusal ('notify does
not accept --text') survive verbatim, and its 10-second per-pair throttle survives as the flow
module's PAIR_MIN_INTERVAL_MS (eneskirca#208) — the same number, now shared with send/reply so one
conversation has one window.

What changed versus eneskirca#98's implementation: the body is substituted in MAIN (NOTIFY_BODY), so even
a forged IPC request cannot put caller text into the envelope — the renderer's --text refusal is
UX, the substitution is the boundary, and the test proves a hostile body never reaches the pane.
Delivery goes through the identical deliverAgentMessage pipeline (identity gates, idle gate, pane
probes, receipt, trace) instead of a bare sendText, notify is verified-only like its siblings,
and the per-project switch governs it. eneskirca#98's "check your configured inbox" wording is dropped —
the product has no inbox concept — in favour of pointing at the linked context the notified agent
can actually read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
eneskirca added a commit that referenced this pull request Aug 15, 2026
…rant can never speak for another's pane

Closes PR #237 review I-1 (confused deputy, proved by execution): panes are
keyed by the BARE node id globally, so a hostile/cloned project A with
agentMessaging granted that merely LISTS ungranted project B's node id
resolved same-project to A, passed A's switch, and delivered into B's one
global pane the moment the user kept A's clone notice.

The grant now gates the project that OWNS the target pane, resolved at
scope time: resolveDeliveryScope computes the set of projects claiming the
target id; more than one owner is refused as the new, named
notPermitted (ambiguous-target-node-id) — never 'pick the sender's', which
was exactly the hole — and a unique target resolves to ITS owning project
(by construction the one the sender shares), whose id is what the switch
is then evaluated against. The #208 residual note in agent-message-scope.ts
documented this as 'cannot close'; it is now closed at the reachable layer
and the docblock rewritten to say what holds instead of what leaks.

Tests: the reviewer's exact scenario over a REAL WorkspaceStore (granted A
listing ungranted B's live node id -> refused, nothing written) plus scope
unit coverage: both directions of a duplicate refused, the refusal carries
its own reason through decideDelivery and is terminal, and a unique target
still resolves. Mutation-checked 2/2: reverting to source-project grant
resolution turns the confused-deputy test back into 'delivered' (3 red);
renaming the refusal to cross-project also goes red.

Co-Authored-By: Claude Fable 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