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>
This commit is contained in:
2026-09-04 19:31:20 -05:00
parent d2a12c46e2
commit 63a7dc4374
10 changed files with 2181 additions and 7 deletions

View File

@@ -239,9 +239,10 @@ namespace Server.Custom.Bridge
/// (so a repeat is answered `bridge.busy` rather than executed) and takes on the duty of
/// calling <see cref="Complete"/> with the reply it eventually emits.
///
/// Nothing in protocol 6 defers yet. The door exists because the leases and world verbs
/// that follow do, and because a deferred handler that had no way to hold its key would
/// quietly be the one place the guarantee did not hold.
/// 11a built this door and had nothing to walk through it. `participation.snapshot` is
/// the first: above a threshold it walks its members in chunks across Core ticks, so it
/// completes long after its inbound call returned, and a repeat arriving in between is
/// the first `bridge.busy` this shard can actually produce.
/// </summary>
public static void Hold(string key)
{
@@ -250,6 +251,15 @@ namespace Server.Custom.Bridge
if (entry == null)
return;
// The caller must be holding the key it was dispatched under. A mismatch would leave
// the OPEN key marked done by Finish while the named one stayed in flight forever, so
// it is refused rather than honoured: capture stays open and the ordinary path runs.
if (key == null || !_byKey.ContainsKey(key))
{
Console.WriteLine("[Bridge] idempotency: Hold called with an unknown key '{0}'; ignoring", key);
return;
}
// Close capture without marking done: the key stays in flight until Complete.
_open = null;
_openCorr = null;
@@ -373,8 +383,18 @@ namespace Server.Custom.Bridge
if (corrField != null)
sb.Str(corrField, corr);
// **`busyKind`, not `kind`, and the name is the whole bug.** `Begin` has already
// written this frame's own `kind` as `bridge.busy`, so a second `kind` field made the
// object carry two -- and every JSON parser worth the name takes the LAST. The sidecar
// matches `bridge.busy` to decide on a 425, read `participation.snapshot` instead, and
// answered an ordinary 200 with a body saying nothing had happened.
//
// It shipped in 11a and could not be seen there: with only synchronous handlers a
// repeat can never arrive mid-flight, so this arm was unreachable on a live shard and
// the unit test that covers the sidecar's mapping was, correctly, feeding it a frame
// built by hand. The first deferring handler produced it on its first collision.
sb.Str("idempotencyKey", key)
.Str("kind", prior.Kind)
.Str("busyKind", prior.Kind)
.Str("reason", "a command with this idempotency key is still in flight");
BridgeLink.Emit(sb.End());