From d83bb1748c55638d4b53e32e0e1949e1f0fab20a Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 7 Sep 2026 08:07:34 -0500 Subject: [PATCH] feat(web): the borrowed planes and the one-shots on the wire (Phase 12b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- sidecar/src/web.rs | 165 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 162 insertions(+), 3 deletions(-) diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 147c780..3611768 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -97,6 +97,16 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/world", post(world_spawn)) .route("/world/:run_id", get(world_owned)) .route("/world/:run_id/despawn", post(world_despawn)) + // The one-shots (protocol 7 part b, EVENTS_PLAN.md Phase 12b). Neither owned nor + // borrowed: an item put into somebody's hands, and a world save. Both are `done is + // done`, which is why they are not in the world family -- there is nothing to give + // back and no ledger row core would come back for. + // + // `GET /items` is the shard's own grant allowlist, so the website's dropdown offers + // what this shard will actually build rather than what a module guessed. + .route("/items", get(item_catalog)) + .route("/items/grant", post(item_grant)) + .route("/world/save", post(world_save)) // Help-page (support) queue: snapshot the open queue, respond to / close a page. .route("/pages", get(pages_list)) .route("/pages/:id/respond", post(page_respond)) @@ -390,9 +400,23 @@ fn respond_event(result: Result) -> (StatusCode, Json) { .unwrap_or("request rejected"); let code = if reason.contains("disabled") { StatusCode::FORBIDDEN - } else if reason.contains("no lease is offered") || reason.contains("not counting") + } else if reason.contains("no lease is offered") + || reason.contains("not counting") + // Phase 12b. A grant against a run this shard has never been told to count + // is the same shape as an unknown lease key: the caller named something that + // does not exist here, which is a 404 and never a retry. It is deliberately + // NOT the same as a run whose ledger is open and empty -- that is a 200 with + // `granted: 0`, because "nobody came" is a result rather than a mistake. + || reason.contains("no participation ledger") { StatusCode::NOT_FOUND + } else if reason.contains("saves at most every") { + // A save refused because one just happened is the shard's rate limit, and it + // is TRANSIENT in a way nothing else on this plane is: the same request will + // succeed once the interval passes. 429 says exactly that, and keeps it out of + // the module's permanent-status set so a phase boundary is retried rather than + // abandoned. + StatusCode::TOO_MANY_REQUESTS } else { StatusCode::BAD_REQUEST }; @@ -652,8 +676,36 @@ async fn event_call(st: &AppState, kind: &str, body: Value) -> (StatusCode, Json /// overridden".** A lease whose deadline has already fired stays listed, with `expired: true`, /// until teardown collects its verdict -- otherwise a reconcile in that window would report it gone /// and the website would write off a correctly-working backstop as an orphaned resource. -async fn lease_list(State(st): State) -> impl IntoResponse { - event_call(&st, "lease.list", Value::Null).await +/// **`?key=` and `?target=` narrow it to one row, and a targeted key needs them** (protocol 7 part +/// b). `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 carries `holds`: every lease this 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?". +async fn lease_list(State(st): State, Query(q): Query) -> impl IntoResponse { + let mut body = serde_json::Map::new(); + if let Some(key) = q.key { + body.insert("key".to_string(), json!(key)); + } + if let Some(target) = q.target { + body.insert("target".to_string(), json!(target)); + } + let arg = if body.is_empty() { + Value::Null + } else { + Value::Object(body) + }; + event_call(&st, "lease.list", arg).await +} + +/// Narrowing for `GET /lease`. Both optional: absent means the whole catalog, as before. +#[derive(Debug, Deserialize)] +struct LeaseQuery { + key: Option, + target: Option, } /// Body: {"key":"...","value":"...","holdMs":,"untilMs":,"runId":,"idempotencyKey":}. @@ -773,6 +825,51 @@ async fn world_despawn( event_call(&st, "world.despawn", Value::Object(obj)).await } +// ---- the one-shots (protocol 7 part b) ---- + +/// What this shard is willing to grant, and the bounds it will grant within. +/// +/// A read, so the website's option source offers what this shard will actually build. The module +/// holds the same list, which is two copies of a short allowlist on purpose and exactly how the +/// lease bounds are already carried: the module's copy is what makes a bad value a refusal on a +/// form, and this one is what is true when the website is wrong. +async fn item_catalog(State(st): State) -> impl IntoResponse { + event_call(&st, "item.catalog", Value::Null).await +} + +/// Body: {"runId":"...","item":"gold","amount":N,"hue":,"name":,"where":,"idempotencyKey":}. +/// +/// **The recipients are not in the body, and that is the design.** The shard already holds the +/// run's participation ledger (protocol 6 part b), keyed by the same character serials the +/// website's `member_key` holds, so the grant names a run and the shard resolves who was there. +/// Sending a list would mean the same list crossing the wire twice with a window in which the two +/// disagree -- and it would have needed a core surface handing a module core's own participants. +/// +/// A run with no ledger open is a 404, not an empty success: "nobody came" and "you never told me +/// to count" are different facts, and only the first is a result a run should record. +/// +/// **Retryable, and protocol 6 is why.** `EVENTS.md` §G called a grant un-retryable because a lost +/// acknowledgement and a grant that never applied looked the same -- exactly the argument that made +/// `uo.broadcast` answer `retry: false` in Phase 9. An `idempotencyKey` closes that: a repeat is +/// answered by the original reply, so a retried grant cannot be one winner receiving two. +async fn item_grant(State(st): State, Json(body): Json) -> impl IntoResponse { + event_call(&st, "item.grant", body).await +} + +/// Body: {"idempotencyKey":}. Starts a world save, useful as a phase boundary. +/// +/// The reply says the save was STARTED and nothing more. What actually happened rides +/// `world.save.before` / `world.save.after`, which have been on the event stream since protocol 2 -- +/// so this route asserts nothing it cannot know, and a caller that needs the completion watches the +/// stream it is already connected to. +/// +/// **A save too soon after the last one is refused, not queued**, and the shard counts ServUO's own +/// autosave as the last one. A save stops the world; a queued one would land at a moment nobody +/// chose, in the middle of whatever the next step is doing. +async fn world_save(State(st): State, Json(body): Json) -> impl IntoResponse { + event_call(&st, "world.save", body).await +} + // ---- help-page queue handlers ---- /// The open help-page queue, correlated on reqId. Returns a pages.list. @@ -1401,6 +1498,68 @@ mod tests { ); } + /// A run this shard was never told to count is a 404; a run that WAS counted and had no + /// attendees is a 200. Protocol 7 part b. + #[test] + fn an_uncounted_run_is_a_404_and_an_empty_one_is_not() { + assert_eq!( + respond_event(Ok(json!({ + "kind": "oneshot.error", + "reason": "run 42 has no participation ledger open on this shard" + }))) + .0, + StatusCode::NOT_FOUND + ); + // The distinction the 404 exists to preserve. "Nobody came" is a RESULT -- an event + // nobody attended still happened -- and answering it as a failure would have the module + // retry a grant against a ledger that will be just as empty next time. + assert_eq!( + respond_event(Ok(json!({ + "kind": "item.grant.ok", + "runId": "42", + "granted": 0, + "missed": [] + }))) + .0, + StatusCode::OK + ); + } + + /// The save rate limit is the one refusal on this plane that the same request will get past + /// by waiting, so it is a 429 rather than the 400 every other refusal is. + #[test] + fn a_save_refused_for_coming_too_soon_is_a_429() { + assert_eq!( + respond_event(Ok(json!({ + "kind": "oneshot.error", + "reason": "this shard saves at most every 300 seconds, and the last save was 12 seconds ago" + }))) + .0, + StatusCode::TOO_MANY_REQUESTS + ); + // And an ordinary refusal on the same plane is still a 400, so the 429 is not swallowing + // the class it sits beside: a grant this shard does not offer will never succeed, however + // long the caller waits. + assert_eq!( + respond_event(Ok(json!({ + "kind": "oneshot.error", + "reason": "this shard does not grant 'castle'" + }))) + .0, + StatusCode::BAD_REQUEST + ); + // The event gate being off stays a 403 on this plane too -- it is an operator's deliberate + // refusal, not a bad request. + assert_eq!( + respond_event(Ok(json!({ + "kind": "oneshot.error", + "reason": "the event plane is disabled on this shard (Bridge.EventsEnabled)" + }))) + .0, + StatusCode::FORBIDDEN + ); + } + /// A lease taken, a tally answered: an ordinary success carries straight through. #[test] fn event_successes_are_200s() { -- 2.49.1