feat(sidecar): protocol 7 — the Event System's command plane (Phase 16b cutover, 1 of 6) #40

Merged
whitlocktech merged 8 commits from edge into main 2026-09-09 19:54:33 +00:00
Member

The Event System cutover, step 1 of 6 (EVENTS_PLAN.md Phase 16b). edgemain, a clean fast-forwardmain is 0 ahead.

Merge this and servuo-plugins#26 together

installer's bundle.yml Gate 1 reads the protocol number out of both released artefacts and refuses a pair that disagrees. Whichever merges first releases a component speaking 7 against a sibling still on 5, so the bundle run behind it composes nothing. Transient and self-healing — this repo's release.yml dispatches the bundle job as its final step, so the second merge recomposes immediately, and the nightly would catch it anyway — but it makes these two one step rather than two.

What lands (4 commits, PROTOCOL_VERSION 5 → 7)

Phase
11a The idempotency key carried through on every command, bridge.busy answered, and champ.boss.killed stored and served.
11b The event plane carried: lease deadlines and lease.list, and the run-scoped participation ledger.
12a The world verbs on the wire — world.spawn / world.despawn / world.owned.
12b The borrowed planes and the two one-shots: targeted config leases with values, the item grant, the world save.

The sidecar stays a dumb forwarder. Every new route is transport: correlate a request with its reply, carry a key it does not interpret, and pass a deadline through as a duration. No access control and no policy moved here — that lives on the website, which is the invariant this component is for.

spec of record: docs/link/v6.md (protocol 6) and docs/link/v7.md (protocol 7), both amended in place in later phases and both landing on main in step 5 of this cutover.

Verification

Walked on a real rig in every phase — real ServUO 57.4 → this sidecar built --release → core with module-uo installed from a release-shaped bundle. Phase 16a walked the whole of it once more, including the deliberate sidecar kill mid-phase: the run went degraded rather than failed, core.lease retried with a reason, the four world writes behind the gate parked at attempts = 0, and the shard reconnected on its own without operator action.

cargo fmt --check, clippy -D warnings and the test suite are green on edgepr-checks has run on every PR into it.

16b re-verifies this from the released binary, through the installer and a composed bundle onto a fresh ServUO copy.

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

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

The Event System cutover, step 1 of 6 (`EVENTS_PLAN.md` Phase 16b). **`edge` → `main`, a clean fast-forward** — `main` is 0 ahead. ## Merge this and `servuo-plugins#26` together `installer`'s `bundle.yml` **Gate 1 reads the protocol number out of both released artefacts and refuses a pair that disagrees.** Whichever merges first releases a component speaking 7 against a sibling still on 5, so the bundle run behind it composes nothing. Transient and self-healing — this repo's `release.yml` dispatches the bundle job as its final step, so the second merge recomposes immediately, and the nightly would catch it anyway — but it makes these two one step rather than two. ## What lands (4 commits, `PROTOCOL_VERSION` 5 → 7) | Phase | | |---|---| | **11a** | The **idempotency key** carried through on every command, `bridge.busy` answered, and `champ.boss.killed` stored and served. | | **11b** | The event plane carried: lease deadlines and `lease.list`, and the run-scoped participation ledger. | | **12a** | The world verbs on the wire — `world.spawn` / `world.despawn` / `world.owned`. | | **12b** | The borrowed planes and the two one-shots: targeted config leases with `values`, the item grant, the world save. | **The sidecar stays a dumb forwarder.** Every new route is transport: correlate a request with its reply, carry a key it does not interpret, and pass a deadline through as a duration. No access control and no policy moved here — that lives on the website, which is the invariant this component is *for*. `spec of record: docs/link/v6.md` (protocol 6) and `docs/link/v7.md` (protocol 7), both amended in place in later phases and both landing on `main` in step 5 of this cutover. ## Verification Walked on a real rig in every phase — real ServUO 57.4 → this sidecar built `--release` → core with `module-uo` installed from a release-shaped bundle. Phase 16a walked the whole of it once more, including the deliberate **sidecar kill mid-phase**: the run went `degraded` rather than failed, `core.lease` retried with a reason, the four world writes behind the gate parked at `attempts = 0`, and the shard reconnected on its own without operator action. `cargo fmt --check`, `clippy -D warnings` and the test suite are green on `edge` — `pr-checks` has run on every PR into it. **16b re-verifies this from the released binary**, through the installer and a composed bundle onto a fresh ServUO copy. - [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 8 commits 2026-09-09 15:34:34 +00:00
feat(sidecar): protocol 6 — carry the idempotency key, answer bridge.busy (Phase 11a)
Some checks failed
PR Checks / rust-gates (pull_request) Failing after -16s
5612fba744
PROTOCOL_VERSION 5 -> 6, and one behaviour: `bridge.busy` maps to 425 Too Early in
all three responders.

Everything else is free, and that is the point. The key rides in the command body,
which every write endpoint already passes through verbatim; `champ.boss.killed`
lands in `events` and on the feed through the generic forward path with no arm of
its own. No store migration — nothing gains a column.

Worth naming what the dumb-forwarder property means here specifically: the sidecar
makes no idempotency promise of its own. It does not dedupe, does not cache, and
does not know what a key means. The guarantee is the shard's, end to end, which is
the only place it can be.

425 rather than 409 because 409 is already the protocol-version gate's answer, and
the two want opposite dispositions from a client: a version mismatch is a
deployment fault nobody should retry, a busy shard is a retry that will succeed.
Sharing a status would make the difference readable only by inspecting the body,
which is how a retry loop ends up hiding a mismatched deployment. A replayed reply
is an ordinary 200 — `replayed: true` is for the log.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #36
feat(sidecar): carry the event plane (Phase 11b)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m6s
93411966d7
Protocol 6 amended in place, so PROTOCOL_VERSION is unchanged. Six routes and a
fourth responder; no store migration and no new machinery.

`event_call` is `admin_call` without the required `actor`: an event verb's author
is a RUN, which the body carries as `runId`, and demanding a human name for
something no human is doing would have the runner inventing one.

`respond_event` exists for two mappings the generic responder gets wrong. A
drifted lease is a 200 -- the shard was asked to compare and set, it compared,
and it declined to overwrite somebody's deliberate change, which is the mechanism
working -- and deliberately not the 409 the version gate owns, for the same
reason 425 is not. And the event plane being switched off is a 403 rather than a
reason-sniffed 400: it is an operator's deliberate refusal, and a 400 would send
an administrator hunting a bug in a step that is written correctly.

`participation.snapshot` is a POST for a read, because it carries the caller's
idempotency key and the shard may refuse it as a repeat in flight.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #37
feat(web): the world verbs on the wire (protocol 7, Phase 12a)
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 9s
5d909ca0a3
`POST /world` places, `GET /world/:runId` says what a run still owns, and
`POST /world/:runId/despawn` gives it back. One route family for five
author-facing verbs, because each of them ends in "an object exists and this run
owns it" -- the differences between a boss's multipliers, an oracle's lines and
a gate's destination are fields on one command, not five commands.

`PROTOCOL_VERSION` -> 7. The overlay's `overlay.toml` is bumped in the same
window; 12b amends 7 in place rather than bumping again, so an overlay and a
sidecar both declaring 7 are interchangeable only within one side of that merge
-- tolerable for the same single reason 6 was, and no other: nothing is released
from `edge`.

`world.owned` is a GET, unlike `participation.snapshot`: it carries no
idempotency key and the shard answers it in one pass. A run the shard has no
rows for answers with an EMPTY hand rather than a 404, and the distinction is
load-bearing for reconcile -- "owns nothing" and "never heard of it" are the
same fact once the registry is the only record of ownership, and they stay the
same fact across a restart, because the registry is written by the same world
save as the objects it describes.

Two tests pin what the world verbs depend on from `respond_event`, rather than
trusting that its reason-sniffing keeps covering a kind it predates: a ceiling
refusal is a 400 (permanent -- retrying "you asked for 80 and this shard places
30" gets the same answer forever), the event gate being off is still a 403, and
an empty owned list is a 200.

Refs: docs/link/v7.md, docs/website/EVENTS_PLAN.md Phase 12a

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Reviewed-on: #38
feat(web): the borrowed planes and the one-shots on the wire (Phase 12b)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m8s
d83bb1748c
The sidecar half of protocol 7 part b. `PROTOCOL_VERSION` stays 7: 12b amends 7
in place rather than bumping again, which is tolerable for the single reason 6
and 7 already are and no other -- nothing is released from `edge`.

The lease family gains a `target` rather than a family of its own. A property
lease, a seasonal toggle and a config key are one protocol with three catalogs,
so there is one deadline, one compare-and-set, one grace window and one set of
counters instead of three of each.

`GET /lease?key=&target=` narrows to one row, and a targeted key needs it.
`Spawner.MaxCount` is one capability over thousands of spawners, so it has no
single `current` and the catalog walk cannot fill one in -- while the website's
`read()` needs exactly one value for exactly one target BEFORE it applies
anything. Naming both answers that.

The frame also always carries `holds`: every lease the shard is actually holding,
whatever key or target it is on. A catalog walk can enumerate the KEYS but never
the holds on a targeted one -- there is no list of spawners to walk -- so without
it a reconcile after an outage would have no way to ask "what are you still
holding?". `inForce()` reads that.

Three new routes. `GET /items` is the shard's own grant allowlist, so the
website's dropdown offers what this shard will actually build. `POST
/items/grant` names a RUN and never a recipient list: the shard has held the
run's participation ledger since protocol 6 part b, keyed by the same character
serials the website's `member_key` holds, so sending a list would put it on the
wire twice with a window in which the two disagree. `POST /world/save` starts a
save; what actually happened rides `world.save.before`/`after`, which have been
on the stream since protocol 2.

Two status mappings are the point of the diff rather than plumbing:

A run with no ledger open is a 404 and a run whose ledger is open and empty is a
200 with `granted: 0`. "You never told me to count" and "nobody came" are
different facts, and only the first is a mistake -- an event nobody attended
still happened, and answering it as a failure would have the module retry against
a ledger that will be just as empty next time.

A save refused for coming too soon is a 429, not the 400 every other refusal on
this plane is. It is the one refusal here that the same request gets past by
waiting, so 429 says exactly that and keeps it out of the module's
permanent-status set -- which is what makes a phase boundary retried rather than
abandoned.

`cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` and `cargo test`
all clean: 51 passed (was 49). The two new tests pin those two mappings.

Also exercised end to end against the real local ServUO 57.4 world driving this
binary's REST -- including that `/world/save` is not eaten by `/world/:run_id`
next door. See servuo-plugins for the walk.

Refs: docs/link/v7.md §11-§13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
Reviewed-on: #39
whitlocktech approved these changes 2026-09-09 19:54:11 +00:00
whitlocktech merged commit 6c8a247761 into main 2026-09-09 19:54:33 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/link#40
No description provided.