fix(bridge): a refusal is not an effect, so it does not spend the key (Phase 16a) #25

Merged
whitlocktech merged 1 commits from fix/events-p16a-walk into edge 2026-09-09 13:47:10 +00:00
Member

Protocol 7, amended in place — the shape 12b already used. The at-most-once store had two cases and needed a third.

The defect

A handler that threw keeps its key and stores the failure. Correct: it may have applied half of itself, and that is the failure this file exists to prevent.

A handler that returned had its reply stored and replayed for ever.

There was no case for a handler that ran to completion and deliberately refused — and every refusal on this plane is a guard: a missing runId, an unknown item, a cap, a rate limit, a write that failed and left the value alone. Nothing happened. Remembering the refusal froze it as the key's answer, so a refusal that waiting fixes could never be retried past.

uo.world.save is the case the acceptance walk found it with, and the module states the expectation it could not meet:

429 is the shard's save rate limit, and it is the one refusal on this plane that waiting fixes … which is what makes a phase boundary retried rather than abandoned.

Six attempts over four minutes — three automatic, an operator retry, three more — every one answering the same frozen sentence:

"this shard saves at most every 300 seconds, and the last save was 227 seconds ago"

The number cannot age, because it is the first reply being replayed. A step's idempotency key is one value for the life of the step, so the operator's retry control could not escape it either. Proof it was a replay and not the truth: the same call under a fresh key answered live — first world.save.ok started:true, later "the last save was 26 seconds ago", a number that moves.

The fix

A refusal releases the key. The refusal is still emitted to the caller, which is what ends that attempt; it is simply not remembered as this key's answer.

A refusal is recognised by its kind ending in .error — matched on the suffix, so a handler family added later is covered without anyone remembering to extend a list. bridge.error is excluded deliberately: that is the reply this file writes when a handler threw, which is exactly the case whose key must be kept. Both completion paths are covered, including a deferred handler that refuses after its timer has armed.

The rule this rests on

The shard cannot verify "nothing happened"; it takes the .error kind as the claim. So it is written down as a rule handlers must keep:

Do not answer *.error after changing the world. Report a partial change in an ok reply, as the item grant does with granted/missed and the despawn with removed/gone/refused.

Audited across BridgeWorld, BridgeOneShots, BridgeLeases and BridgeParticipation before making the change: every Err(reqId, …) call site is a pre-mutation guard, or follows a Write that failed and left the value alone.

Verification

On a real shard, built offline and confirmed live (Scripts.dll rebuilt with the server stopped, 0 warnings):

same key, 25s apart   "the last save was 15 seconds ago"
                      "the last save was 40 seconds ago"     <- moves, no `replayed` marker
successful reply      still replayed unchanged, `"replayed": true`, identical `t`

So the at-most-once guarantee is intact exactly where it matters, and only the case that never had an effect is released.

Status() gains a refused= counter.

Pairs with Module-uo#33, website#198 and docs# (Phase 16a).

  • AI-assisted: written with Claude Code (Claude Opus 5).

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

Protocol 7, amended in place — the shape 12b already used. The at-most-once store had two cases and needed a third. ## The defect A handler that **threw** keeps its key and stores the failure. Correct: it may have applied half of itself, and that is the failure this file exists to prevent. A handler that **returned** had its reply stored and replayed for ever. There was no case for a handler that ran to completion and deliberately **refused** — and every refusal on this plane is a guard: a missing `runId`, an unknown item, a cap, a rate limit, a write that failed and left the value alone. Nothing happened. Remembering the refusal froze it as the key's answer, so a refusal that *waiting fixes* could never be retried past. `uo.world.save` is the case the acceptance walk found it with, and the module states the expectation it could not meet: > 429 is the shard's save rate limit, and it is **the one refusal on this plane that waiting fixes** … which is what makes a phase boundary retried rather than abandoned. Six attempts over four minutes — three automatic, an operator retry, three more — every one answering the same frozen sentence: ``` "this shard saves at most every 300 seconds, and the last save was 227 seconds ago" ``` The number cannot age, because it is the first reply being replayed. A step's idempotency key is one value for the life of the step, so the operator's retry control could not escape it either. Proof it was a replay and not the truth: the same call under a **fresh** key answered live — first `world.save.ok started:true`, later *"the last save was 26 seconds ago"*, a number that moves. ## The fix A refusal releases the key. The refusal is still **emitted** to the caller, which is what ends that attempt; it is simply not remembered as this key's answer. A refusal is recognised by its `kind` ending in `.error` — matched on the suffix, so a handler family added later is covered without anyone remembering to extend a list. **`bridge.error` is excluded deliberately**: that is the reply this file writes when a handler threw, which is exactly the case whose key must be kept. Both completion paths are covered, including a deferred handler that refuses after its timer has armed. ## The rule this rests on The shard cannot verify "nothing happened"; it takes the `.error` kind as the claim. So it is written down as a rule handlers must keep: > **Do not answer `*.error` after changing the world.** Report a partial change in an `ok` reply, as the item grant does with `granted`/`missed` and the despawn with `removed`/`gone`/`refused`. Audited across `BridgeWorld`, `BridgeOneShots`, `BridgeLeases` and `BridgeParticipation` before making the change: every `Err(reqId, …)` call site is a pre-mutation guard, or follows a `Write` that failed and left the value alone. ## Verification On a real shard, built offline and confirmed live (`Scripts.dll` rebuilt with the server stopped, 0 warnings): ``` same key, 25s apart "the last save was 15 seconds ago" "the last save was 40 seconds ago" <- moves, no `replayed` marker successful reply still replayed unchanged, `"replayed": true`, identical `t` ``` So the at-most-once guarantee is intact exactly where it matters, and only the case that never had an effect is released. `Status()` gains a `refused=` counter. Pairs with `Module-uo#33`, `website#198` and `docs#` (Phase 16a). - [x] AI-assisted: written with Claude Code (Claude Opus 5). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 1 commit 2026-09-09 13:33:16 +00:00
Protocol 7, amended in place. The at-most-once store had two cases and needed a
third.

A handler that THREW keeps its key and stores the failure — correct, because it
may have applied half of itself, and that is the failure this file exists to
prevent. A handler that RETURNED had its reply stored and replayed for ever. There
was no case for a handler that ran to completion and deliberately REFUSED, and
every refusal on this plane is a guard: a missing runId, an unknown item, a cap, a
rate limit, a write that failed and left the value alone. Nothing happened — and
remembering the refusal froze it as the key's answer, so a refusal that WAITING
FIXES could never be retried past.

`uo.world.save` is the case the acceptance walk found it with, and the module says
in as many words what it expected: "429 is the shard's save rate limit, and it is
the one refusal on this plane that waiting fixes … which is what makes a phase
boundary retried rather than abandoned." It was not achievable. Six attempts over
four minutes — three automatic, an operator retry, three more — every one
answering the same frozen sentence, "the last save was 227 seconds ago". The
number could not age because it was the first reply being replayed, and a step's
idempotency key is one value for the life of the step, so the operator's retry
control could not escape it either.

So a refusal releases the key. The refusal is still emitted to the caller, which
is what ends the attempt; it is simply not remembered as this key's answer. A
refusal is recognised by its `kind` ending in `.error`, matched on the suffix so a
handler family added later is covered without extending a list here.
`bridge.error` is excluded deliberately — that is the reply this file writes when
a handler threw, which is exactly the case whose key must be kept. Both completion
paths are covered, including a deferred handler that refuses after its timer arms.

The safety argument is that "nothing happened" holds for every `*.error` reply
here — audited across BridgeWorld, BridgeOneShots, BridgeLeases and
BridgeParticipation, where every refusal is a pre-mutation guard or follows a
`Write` that failed. It is a property this file cannot verify, so it is written
down as a rule handlers must keep: do not answer `*.error` after changing the
world. Report a partial change in an `ok` reply, as the item grant does with
`granted`/`missed` and the despawn with `removed`/`gone`/`refused`.

Verified on a real shard: the same key 25 seconds apart now answers "the last save
was 15 seconds ago" then "40 seconds ago" — a number that moves, with no
`replayed` marker — while a SUCCESSFUL reply is still replayed unchanged, so the
at-most-once guarantee is intact where it matters.

`Status()` gains a `refused=` counter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
whitlocktech merged commit a740365131 into edge 2026-09-09 13:47:10 +00:00
whitlocktech deleted branch fix/events-p16a-walk 2026-09-09 13:47:12 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/servuo-plugins#25
No description provided.