feat(web): the borrowed planes and the one-shots on the wire (Phase 12b) #39

Merged
whitlocktech merged 1 commits from feature/events-p12b-borrowed-and-oneshots into edge 2026-09-07 16:23:21 +00:00
Showing only changes of commit d83bb1748c - Show all commits

View File

@@ -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<Value, RpcError>) -> (StatusCode, Json<Value>) {
.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<AppState>) -> 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<AppState>, Query(q): Query<LeaseQuery>) -> 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<String>,
target: Option<String>,
}
/// Body: {"key":"...","value":"...","holdMs":<ms>,"untilMs":<opt>,"runId":<opt>,"idempotencyKey":<opt>}.
@@ -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<AppState>) -> impl IntoResponse {
event_call(&st, "item.catalog", Value::Null).await
}
/// Body: {"runId":"...","item":"gold","amount":N,"hue":<opt>,"name":<opt>,"where":<opt>,"idempotencyKey":<opt>}.
///
/// **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<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
event_call(&st, "item.grant", body).await
}
/// Body: {"idempotencyKey":<opt>}. 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<AppState>, Json(body): Json<Value>) -> 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() {