Commit Graph

3 Commits

Author SHA1 Message Date
6061fe39ce fix(bridge): a refusal is not an effect, so it does not spend the key (Phase 16a)
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
2026-09-09 08:28:50 -05:00
63a7dc4374 feat(bridge): lease deadlines and the participation ledger (Phase 11b)
Protocol 6 amended in place. Two mechanisms behind one new default-off gate,
`Bridge.EventsEnabled` -- deliberately not `AdminWriteEnabled`, because enabling
the admin plane is consenting to staff moderation from a screen a human is
looking at, and this is consenting to the world being changed and watched on a
schedule, unattended.

BridgeLeases: a live config value held for a bounded time, with the deadline
honoured on the shard whether or not the website is heard from again, and a
compare-and-set restore that reports `drifted` rather than overwriting a GM's
deliberate change. Memory-only -- nothing calls Config.Save() -- so a restart is
a free restore.

BridgeParticipation: presence in a declared area plus kill credit inside it,
keyed by character serial, persisted in the world save. The Bridge's first
persisted state, because a run spans hours and an in-memory tally would regress
every attendee's score after one restart. Its snapshot is also the first handler
that DEFERS, which makes `bridge.busy` reachable for the first time.

And it immediately found a defect in 11a: BridgeIdempotency.Busy built its frame
with Begin("bridge.busy") and then appended a diagnostic `.Str("kind", ...)`, so
the object carried two `kind` fields and every JSON parser takes the last. The
sidecar answered 200 instead of 425. Renamed `busyKind`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 19:31:20 -05:00
7aa7bc8032 feat(bridge): protocol 6 — an idempotency key, and champ.boss.killed (Phase 11a)
A command carrying an `idempotencyKey` is now executed at most once: a repeat is
answered with the original reply rather than re-run. That is the precondition
every world verb in Phase 12 is waiting on, and it is what let `uo.broadcast`
stop being un-retryable.

The gate sits in BridgeBoot's inbound dispatch, not in each handler, so it covers
every kind including ones a later protocol adds. A command with no key behaves
exactly as it did before, which leaves the admin screens unchanged.

Four rules, each a decision rather than an implementation detail: reserve on
receipt (so a handler that defers is covered, answering `bridge.busy` to a repeat
in flight); a key that has begun is never released, not even when the handler
throws; a replay is stamped with the REPEAT's correlation id, because the
sidecar's reqId is fresh per call and replaying the original would hang the retry;
and the bound is loud, because an evicted key is the guarantee's one hole.

`champ.boss.killed` rides along because a bump costs a release, a bundle and an
operator update on every shard. It fires from EventSink.CreatureDeath, detected
by type so a boss that popped and died inside one sweep is still reported, and it
carries the damage table that exists at the death and nowhere else.

overlay.toml protocol = 6, in this commit rather than a later one.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 14:56:49 -05:00