diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml
index bb240426..7d41d3b7 100644
--- a/.github/workflows/verify.yml
+++ b/.github/workflows/verify.yml
@@ -98,7 +98,7 @@ jobs:
run: pnpm --dir services/auth-bridge run check
- name: Audit bridge dependencies
- run: pnpm --dir services/auth-bridge audit --audit-level high
+ run: pnpm --dir services/auth-bridge audit --audit-level low
spacetimedb-module:
runs-on: ubuntu-latest
@@ -147,7 +147,8 @@ jobs:
run: |
pnpm --dir spacetimedb run verify
npm run stdb:verify-bindings
+ npm run stdb:verify-worker-migration
npm run stdb:verify-additive-migration
- name: Audit module dependencies
- run: pnpm --dir spacetimedb audit --audit-level high
+ run: pnpm --dir spacetimedb audit --audit-level low
diff --git a/.gitignore b/.gitignore
index b153080f..ad3fbacd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,8 +6,14 @@ dist
.DS_Store
coverage
*.local
-.cache/warpkeep-assets/
-.cache/warpkeep-tools/
+/pnpm-lock.yaml
+/pnpm-workspace.yaml
+
+# Machine-local caches and deployment state.
+.cache/
+.wrangler/
+.dev.vars*
+!.dev.vars.example
# Local credentials, operator output, and recovery residue must never be staged.
credentials.json
@@ -17,6 +23,18 @@ credentials.json
*.key
*.p12
*.pfx
+*.jks
+*.keystore
+*.crt
+*.cer
+*.jwk
+*.token
+id_rsa*
+id_ed25519*
+admin-secret*
+secret.json
+secrets.json
+.secrets/
*.log
*.har
*.trace
diff --git a/ASSETS-LICENSE.md b/ASSETS-LICENSE.md
index 3dcdf0b8..bb567272 100644
--- a/ASSETS-LICENSE.md
+++ b/ASSETS-LICENSE.md
@@ -292,6 +292,27 @@ The live integration mounts the card as decorative inspection art
only; it grants no resource, currency, reward, entitlement, map-placement, or
Wood authority. No Pages deployment is authorized by this record.
+## Hegemony Worker inspection artwork
+
+On 2026-07-19, the Warpkeep project owner supplied a transparent Worker
+illustration for the reviewed Worker UI slice. That authorization covers this
+exact checked-in derivative in the public Warpkeep repository and an eventual
+official `warpkeep.com` Pages runtime only after separately approved deployment.
+It is use authorization only: it does not establish ownership, grant a public
+open-content licence or general redistribution rights, or create worker,
+resource, route, cargo, reward, settlement, or SpacetimeDB authority.
+
+| Intended use | Repository file | Technical record |
+| --- | --- | --- |
+| Decorative Worker inspection artwork | `public/images/realm/hegemony-worker-record.webp` | 1024×1024 transparent WebP, 86,984 bytes, SHA-256 `ff758ecbf520b05ccf0a2fa490bcafa6c564514de5ee56ef5a720fd6da24193e`; prepared from the supplied transparent PNG through the exact recorded Sharp 0.35.3 encoding. |
+
+The supplied source is not committed. Its exact hash, the runtime decoded-RGBA
+hash, alpha profile, visible bounds, processing settings, and narrow UI-only
+boundary are recorded in the dated [Worker inspection-art record](docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json).
+The runtime file remains `LicenseRef-Warpkeep-Provenance-Required` and is loaded
+only as same-origin decorative art by `WorkerInspectionPanel`; it does not
+provide or imply identity, ownership, balance, command, or gameplay authority.
+
## Hegemony Logging Camp runtime assets
On 2026-07-18, the Warpkeep project owner supplied the named Logging Camp
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6e2db587..1b848908 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,20 @@ full engineering record.
## [Unreleased]
+## [0.3.14] — 2026-07-22
+
+- Made the Realm recover from temporary graphics interruptions while preserving
+ selection and camera intent, and let castles continue at compact detail when
+ optional richer models cannot load.
+- Gave river and ocean surfaces gentle motion and selectable, read-only public
+ records, including source-to-mouth river navigation. Reduced-motion play
+ keeps the water still.
+- Refined the Lowlands toward a clearer green palette and denser grass coverage
+ without changing authoritative terrain, ownership, or resource rules.
+- Staged a server-authoritative four-worker foundation behind inactive migration
+ and activation gates. Workers are not live in Alpha 0.3.14; the existing
+ expedition flow remains in place.
+
## [0.3.13] — 2026-07-19
- Let the old scattered lakes return to lowland while preserving twelve
@@ -111,7 +125,8 @@ full engineering record.
Lowlands, a first keep, Farcaster sign-in, and an admission-gated shared-world
foundation.
-[Unreleased]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.13...HEAD
+[Unreleased]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.14...HEAD
+[0.3.14]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.13...v0.3.14
[0.3.13]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.12...v0.3.13
[0.3.12]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.11...v0.3.12
[0.3.11]: https://github.com/ael-dev3/Warpkeep/compare/v0.3.8...v0.3.11
diff --git a/README.md b/README.md
index 486cb73a..5a4865e5 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
## What is this?
-Genesis 001 is a persistent, invite-only 10,000-cell Lowlands realm with 100 permanent castle sites kept close to its founding district. Each founder signs in with a verified Farcaster identity, receives one durable keep, and privately holds Food / Wood / Stone / Gold. Food, Wood, and Stone are governed by authoritative terrain yield, while dedicated expeditions can gather all four resources. Alpha 0.3.13 is live but early; founders can explore its coast, twelve rivers, clustered forests, and resource sites, follow their supply wagons, and return to a world that remembers them, while the intended core strategy loop is not playable yet. Warpkeep is a one-person experiment—not a finished MMO or financial product; there are no token rewards, no financial promises, and joining does not earn an airdrop or financial return or guarantee a reward or future value.
+Genesis 001 is a persistent, invite-only 10,000-cell Lowlands realm with 100 permanent castle sites kept close to its founding district. Each founder signs in with a verified Farcaster identity, receives one durable keep, and privately holds Food / Wood / Stone / Gold. Food, Wood, and Stone are governed by authoritative terrain yield, while dedicated expeditions can gather all four resources. Alpha 0.3.14 is live but early; founders can explore its coast, twelve rivers, clustered forests, and resource sites, follow their supply wagons, and return to a world that remembers them, while the intended core strategy loop is not playable yet. Warpkeep is a one-person experiment—not a finished MMO or financial product; there are no token rewards, no financial promises, and joining does not earn an airdrop or financial return or guarantee a reward or future value.

@@ -27,7 +27,7 @@ Open the local URL Vite prints; shared Alpha access stays off by default. Contri
| State | Today |
| --- | --- |
-| ✅ Live | Alpha 0.3.13 is live and invite-only. |
+| ✅ Live | Alpha 0.3.14 is live and invite-only. |
| ✅ World | Genesis 001 persists 10,000 cells, a coastline, twelve one-cell rivers, and 100 permanent castle sites near the founding district. Founders return to one durable keep, explore the Lowlands up to its fog, and inspect nearby founders through their public username / portrait / castle. The same authoritative world waits across sessions. |
| ✅ Authority | FID is the durable identity; handles and portraits are bounded presentation metadata. Farcaster sign-in uses a browser-bound, least-privilege bridge. The browser presents. The server decides admission and ownership. It also owns resources, timers, and saved state. |
| ✅ Resources | Each keep privately holds Food / Wood / Stone / Gold. Food, Wood, and Stone come from authoritative terrain yield and can also be gathered at Wheat Farms, Logging Camps, and Stone Quarries; Gold comes from Gold Mines. The resource rail shows stored and ready amounts, and hover, focus, or tap explains current behavior. The browser never invents balances. |
@@ -52,7 +52,7 @@ Open the local URL Vite prints; shared Alpha access stays off by default. Contri
- **Architecture:** The [technical architecture](docs/technical-architecture.md) explains what the browser shows and what the server decides.
- **Roadmap:** The [roadmap](docs/design/roadmap.md) and [game direction](docs/design/warpkeep-direction.md) separate today's game from later plans.
- **Authentication:** The [Farcaster integration](docs/farcaster-integration.md) guide covers sign-in, privacy, and public configuration.
-- **Release:** The [Alpha 0.3.13 release notes](CHANGELOG.md#0313--2026-07-19) record exactly what is live.
+- **Release:** The [Alpha 0.3.14 release notes](CHANGELOG.md#0314--2026-07-22) record exactly what is live.
- **Licensing:** [LICENSING.md](LICENSING.md) explains release rules; [asset provenance](ASSETS-LICENSE.md) records where media came from and what permissions apply.
- **Contributing:** [CONTRIBUTING.md](CONTRIBUTING.md) covers checks and provenance; the [Realm Council issue forms](https://github.com/ael-dev3/Warpkeep/issues/new/choose) accept privacy-safe bugs and ideas.
- **Security:** Report sensitive issues privately through [SECURITY.md](SECURITY.md), never through a public issue.
diff --git a/docs/design/realm-renderer-recovery.md b/docs/design/realm-renderer-recovery.md
new file mode 100644
index 00000000..8754c939
--- /dev/null
+++ b/docs/design/realm-renderer-recovery.md
@@ -0,0 +1,39 @@
+# Realm renderer recovery
+
+The Realm keeps a real WebGL scene as the source of truth once it has become
+ready. The renderer lifecycle is explicit: `probing`, `loading`, `ready`,
+`recovering`, `static-unsupported`, and `failed`.
+
+`static-unsupported` is reserved for a device that cannot create WebGL before
+the first successful scene. It is an accessible, bounded illustrated view; it
+is never a post-ready error surface. A renderer construction error, failed
+castle assembly, castle-count mismatch, or synchronization failure remains an
+explicit loading/recovery/failed state instead of silently replacing a real
+world with a full-world SVG.
+
+Context loss calls `preventDefault`, pauses ambient work and rendering, and
+retains React selection, camera intent, and the scene attestation. Pointer,
+wheel, label-click, and camera input are synchronously suspended while the
+context is lost so a partially disposed scene cannot consume a gesture. The
+restored event starts a bounded scene rebuild and records loss/restore counts on
+the canvas for DOM diagnostics. If the browser does not restore the context in
+time, the user sees an explicit retry surface. All renderer surfaces share one
+cached, non-destructive WebGL2 capability probe. No capability check calls
+`WEBGL_lose_context` or otherwise tears down a context; a probe only reads the
+optional texture-size limit.
+
+Castle loading is staged: Compact is mandatory and retried once for transient
+transport failures after a deterministic short yield; Balanced and High are
+optional upgrades. A missing optional LOD records the active quality in
+`data-realm-castle-active-lod` and continues with Compact. Pairing, integrity,
+and Compact failures are reported with stable failure codes for telemetry and
+QA. Each controlled load is assigned a monotonic renderer generation. Scene
+callbacks carry that generation and stale callbacks from a disposed scene are
+ignored by both the React boundary and the pure lifecycle reducer. The DOM
+exposes the active generation and the last generation that rendered a
+successful frame, making recovery assertions deterministic. A ready renderer
+can never transition into static compatibility mode.
+
+The recovery contract is intentionally frontend-only. Durable world state,
+authorization, and SpacetimeDB subscriptions remain outside the renderer and
+are never mutated by recovery code.
diff --git a/docs/design/roadmap.md b/docs/design/roadmap.md
index e4ec225e..5eef97f0 100644
--- a/docs/design/roadmap.md
+++ b/docs/design/roadmap.md
@@ -3,7 +3,7 @@
Warpkeep is building a persistent strategy world one playable loop at a time.
Dates and feature order may change as the Alpha is tested.
-## Live now — Alpha 0.3.13
+## Live now — Alpha 0.3.14
- Farcaster-gated entry to the persistent Genesis 001 realm
- 10,000 world cells and 100 permanent castle sites near the founding district
diff --git a/docs/farcaster-integration.md b/docs/farcaster-integration.md
index de8a1494..a778f71c 100644
--- a/docs/farcaster-integration.md
+++ b/docs/farcaster-integration.md
@@ -3,7 +3,7 @@
Warpkeep uses standard website Sign In with Farcaster (SIWF). It is not a Mini
App, Quick Auth, wallet connection, or a client-only permanent identity system.
-Alpha 0.3.13 uses backend protocol 3 and authentication contract v2; admission
+Alpha 0.3.14 uses backend protocol 3 and authentication contract v2; admission
remains gated. Production configuration and founder identities belong in the
private operator record, not this guide. This document describes the contract
but does not authorize admission or a production change.
@@ -30,6 +30,12 @@ that boundary and never enter the session family or player JWT. The bridge
accepts only the configured `FARCASTER_DOMAIN` and exact
`FARCASTER_SIWE_URI`.
+Production proof verification uses two official Farcaster verifier instances
+backed by distinct public HTTPS RPC origins. Both must succeed with the same
+canonical FID. A provider outage, partial result, or disagreement fails closed
+as temporarily unavailable. A single RPC endpoint is permitted only for an
+explicit development profile and must be loopback-local.
+
The intended production coordinates remain:
```txt
@@ -82,6 +88,11 @@ URLs, or logs:
- player/admin/resolver JWTs;
- signing keys, session-cookie key, RPC credential, or admin secret.
+The private admin configuration attestation exposes only domain-separated
+SHA-256 fingerprints of the normalized RPC URLs and the active signing public
+key's RFC 7638 thumbprint. Those values make endpoint or key drift detectable
+without returning an RPC URL, credential, or private scalar.
+
After a fresh signature and an exchange whose bridge-verified FID exactly
matches it, the browser may write a tab-scoped `sessionStorage` presentation
cache. It contains only the sanitized public FID, username, display name, and
@@ -238,7 +249,7 @@ VITE_WARPKEEP_OIDC_AUDIENCE=warpkeep-spacetimedb
The Worker configuration is documented in
[`services/auth-bridge/README.md`](../services/auth-bridge/README.md). Its
-checked-in `PUBLIC_AUTH_ENABLED` remains false, while the recorded Alpha 0.3.13
+checked-in `PUBLIC_AUTH_ENABLED` remains false, while the recorded Alpha 0.3.14
production override is true. Before any future enable, the server-only v2
configuration attestation must match the reviewed issuer, origins, SIWF
coordinates, key ID, Maincloud coordinates, S256 binding, 600-second access
diff --git a/docs/legal/license-inventory.md b/docs/legal/license-inventory.md
index 9d806d7c..b34d5351 100644
--- a/docs/legal/license-inventory.md
+++ b/docs/legal/license-inventory.md
@@ -34,6 +34,7 @@ public license or expand the recorded reuse and redistribution terms.
| `/public/images/realm/hegemony-gold-mine-record.webp` | Transparent decorative Gold Mine inspection-card artwork | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | On 2026-07-18, the project owner authorized the exact background-cleaned derivative for PR #49 and the reviewed Gold Wagon integration in the public Warpkeep GitHub repository, plus an eventual official `warpkeep.com` Pages runtime only after separately approved deployment; this is not deployment approval or Gold Mine economic authority | The dated Gold Mine inspection-art record pins the supplied input, generated/chroma inputs, exact output, alpha audit, UI-only boundary, and authorization scope | Preserve the exact hash and decorative-only use; do not infer a node placement, gathering action, balance, reward, Marks linkage, public relicensing, or general redistribution authority |
| `/public/images/realm/hegemony-wheat-farm-record.webp` | Transparent decorative Wheat Farm inspection-card artwork | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | On 2026-07-18, the project owner authorized the exact background-cleaned derivative for PR #57 in the public Warpkeep GitHub repository, plus an eventual official `warpkeep.com` Pages runtime only after separately approved deployment; this is not deployment approval or Food economic authority | The dated Wheat Farm inspection-art record pins the supplied input, generated/chroma inputs, exact output, alpha audit, UI-only boundary, and authorization scope | Preserve the exact hash and decorative-only use; do not infer a Food-site placement, gathering action, balance, reward, Marks linkage, public relicensing, or general redistribution authority |
| `/public/images/realm/hegemony-logging-camp-record.webp` | Transparent decorative Logging Camp inspection-card artwork | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | On 2026-07-19, the project owner authorized the exact background-cleaned derivative for PR #62 in the public Warpkeep GitHub repository, plus an eventual official `warpkeep.com` Pages runtime only after separately approved deployment; this is not merge or deployment approval or Wood economic authority | The dated Logging Camp inspection-art record pins the supplied RGB preview, local alpha-matte input, exact output, alpha/spill audit, UI-only boundary, and authorization scope | Preserve the exact hash and decorative-only use; do not infer a Wood-site placement, gathering action, balance, reward, Marks linkage, public relicensing, or general redistribution authority |
+| `/public/images/realm/hegemony-worker-record.webp` | Transparent decorative Worker inspection artwork | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | On 2026-07-19, the project owner supplied the exact transparent Worker illustration for the reviewed Worker UI slice in this public repository and an eventual official `warpkeep.com` Pages runtime only after separately approved deployment; this is not merge, deployment, worker activation, or economic authority | The dated Worker inspection-art record pins the supplied source hash, exact runtime hash, decoded-RGBA hash, alpha audit, visible bounds, same-origin UI-only boundary, and authorization scope | Preserve the exact hash and decorative-only use; do not infer worker identity, ownership, command, route, cargo, balance, reward, settlement, public relicensing, or general redistribution authority |
| `/public/images/realm/hegemony-stone-quarry-record.webp` | Transparent decorative Stone Quarry inspection-card artwork | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | On 2026-07-19, the project owner authorized the exact background-cleaned derivative for draft PR #65 in the public Warpkeep GitHub repository, plus an eventual official `warpkeep.com` Pages runtime only after separately approved deployment; this is not deployment approval or Stone-site/economic authority | The dated Stone Quarry inspection-art record pins the supplied input, generated/chroma inputs, exact output, alpha audit, UI-only boundary, and authorization scope | Preserve the exact hash and decorative-only use; do not infer a node placement, gathering action, balance, reward, Marks linkage, public relicensing, or general redistribution authority |
| `/docs/reference/resources/2026-07-18-hegemony-gold-mine/runtime-candidates/**` | Historical Hegemony Gold Mine technical-review candidates | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | The owner supplied the exact three source inputs; their historical candidate bytes themselves do not authorize browser delivery, gameplay, deployment, or public relicensing | The candidate record pins supplied hashes and the known atlas-metadata discrepancy; the separate runtime record documents the reviewed promotion | Keep outside `public/` and never import candidate paths; preserve them as audit evidence for the separately named digest-bearing runtime outputs |
| `/public/models/hegemony/gathering-nodes/gold-mine/hegemony-gold-mine-*.glb` | Active Hegemony Gold Mine visual runtime LODs | Not present | `LicenseRef-Warpkeep-Provenance-Required`; no separate public open license asserted | On 2026-07-18, the project owner authorized the named reviewed outputs for the Gold Wagon integration in this public Warpkeep GitHub repository and an eventual official `warpkeep.com` Pages runtime after separately approved deployment; this is not deployment approval | The separate runtime record pins source inputs, exact output hashes, bounded Balanced/Compact atlas metadata normalization, orientation, and visual-only boundary | Preserve immutable hash-bearing paths; do not infer site placement, occupation, dispatch, travel, balance, reward, Marks linkage, public relicensing, or general redistribution authority |
diff --git a/docs/operations/alpha-activation.md b/docs/operations/alpha-activation.md
index 67578b20..5fb443f5 100644
--- a/docs/operations/alpha-activation.md
+++ b/docs/operations/alpha-activation.md
@@ -68,6 +68,16 @@ For releases after the Water/Stone suffix has been published, also run:
npm run stdb:inspect-alpha-v10 -- --json
```
+After the Worker v12 suffix exists, its separate aggregate inspection is:
+
+```sh
+npm run stdb:inspect-alpha-v12 -- --json
+```
+
+The first v12 publication cannot run that procedure beforehand. Its guarded
+publisher instead requires an anonymous schema description of the immutable
+database identity to match the exact 47-table v11 predecessor.
+
The first additive publication that introduces v8 cannot use it as a
pre-publication check. Record counts privately. The v8 status contains only
schema/backend versions, resource/forest policy identifiers and digests, and
@@ -83,7 +93,8 @@ SpacetimeDB with deletion disabled.
```sh
npm run stdb:publish:dev -- --dry-run \
--resource-rollout-stage=ready \
- --genesis-world-stage=expanded
+ --genesis-world-stage=expanded \
+ --worker-rollout-stage=empty
```
Those stage values describe the current production predecessor; do not copy
@@ -93,13 +104,20 @@ it does not inspect Maincloud or publish. Review the result, then use the same
explicit stage arguments without `--dry-run` and with the publisher's exact
confirmation variable set through the private operator environment.
Do not substitute raw `spacetime publish` commands. If publication times out or
-returns an ambiguous result, do not retry until fresh read-only inspection
-establishes the live schema and counts.
-
-After publication, the publisher reruns v3, v4, v8, and v10 aggregate checks.
-A failed check blocks component setup. Previously deployed counts must remain
-unchanged; new component tables should be empty unless they were activated in
-an earlier release.
+returns an ambiguous result, do not republish. A fresh read-only inspection must
+establish the live schema and counts before any further release decision.
+
+For the one-time v11-to-v12 boundary, the publisher anonymously describes the
+same immutable identity before and after publication. It requires all 47 v11
+table signatures to remain unchanged and exactly six reviewed Worker tables to
+be appended. The local proof receipt pins SHA-256 digests of the complete v11
+and v12 table descriptors, row types, indexes, constraints, and every reachable
+typespace reference; reducer- and procedure-only schema is excluded. The live
+anonymous pre- and post-publication descriptions must match those exact proven
+boundaries. The publisher then reruns v3, v4, v8, v10, and v12 aggregate checks.
+The v12 checkpoint must prove those tables are empty and the Worker system
+remains absent and fail-closed. Worker seeding, backfill, or activation needs separate
+approval and is not performed by publication.
## 4. Activate reviewed components
@@ -169,7 +187,7 @@ to create evidence.
tables inert, stop component setup, and restore service compatibility through
a reviewed forward change.
- Ambiguous operator result: disconnect, obtain fresh credentials, and inspect
- counts before deciding whether any retry is safe.
+ schema and counts before deciding any next step.
- Suspected credential exposure: stop and use the private credential-rotation
procedure in [reconstruction/credential-rotation.md](reconstruction/credential-rotation.md).
diff --git a/docs/operations/reconstruction/credential-rotation.md b/docs/operations/reconstruction/credential-rotation.md
index 2751d5cc..f2668852 100644
--- a/docs/operations/reconstruction/credential-rotation.md
+++ b/docs/operations/reconstruction/credential-rotation.md
@@ -14,7 +14,8 @@ Credential values live only in their authorized platform or local secret store.
- `ADMIN_TOKEN_SECRET`: Worker/operator Hermes authentication boundary.
- `SESSION_COOKIE_KEY`: independent Worker HMAC boundary for the
`__Host-warpkeep_session` rotating family reference.
-- `FARCASTER_RPC_URL`: server-only Farcaster verifier/provider endpoint.
+- `FARCASTER_RPC_URL` and `FARCASTER_RPC_URL_SECONDARY`: independent server-only
+ Farcaster verifier/provider endpoints on distinct public HTTPS origins.
- Maincloud CLI authorization: inspect/build/generate/publish access.
Never put values in a recovery manifest, `.env.example`, command-line argument, shell history, issue, screenshot, log, or chat transcript.
@@ -30,7 +31,9 @@ For every rotation record only timestamp, affected service, public key ID or dep
Verify exact `__Host-`, Secure, HttpOnly, SameSite=Strict attributes, tokenless
pending behavior, generation rotation/replay revocation, and configuration
attestation before any auth enable.
-- **FARCASTER_RPC_URL:** update the managed secret; verify normal SIWF resolution and fail-closed provider outage behavior.
+- **Farcaster RPC pair:** rotate one managed endpoint at a time; verify its
+ privacy-safe attestation fingerprint, matching dual-provider SIWF resolution,
+ and fail-closed outage/disagreement behavior before rotating the other.
- **SIGNING_KEY_JWK:** update the private key and matching public key ID, deploy, confirm JWKS contains no private `d`, and re-verify module/browser behavior.
The current bridge publishes one JWKS key. Do not claim seamless overlapping signing-key rotation; compromise rotation intentionally invalidates old tokens. Planned overlap requires a separately reviewed multi-key implementation. The module normally does not require republishing when only the issuer's signing key changes, but verify deployed runtime behavior. Access tokens are maximum 600 seconds and memory-only in the auth-v2 target; the separate session family remains server-revocable.
diff --git a/docs/operations/reconstruction/deployment-recovery.md b/docs/operations/reconstruction/deployment-recovery.md
index 654e5910..1916c9d6 100644
--- a/docs/operations/reconstruction/deployment-recovery.md
+++ b/docs/operations/reconstruction/deployment-recovery.md
@@ -64,8 +64,10 @@ pnpm --dir services/auth-bridge exec wrangler deploy --dry-run
Restore secret values only through managed secret prompts or an approved
non-logging secret-manager pipe. The required names are `SIGNING_KEY_JWK`,
-`ADMIN_TOKEN_SECRET`, `SESSION_COOKIE_KEY`, and `FARCASTER_RPC_URL`; never
-record their values. The signing, admin, and session secrets must be distinct.
+`ADMIN_TOKEN_SECRET`, `SESSION_COOKIE_KEY`, `FARCASTER_RPC_URL`, and
+`FARCASTER_RPC_URL_SECONDARY`; never record their values. The two production RPC
+endpoints must use distinct public HTTPS origins. The signing, admin, and
+session secrets must be distinct.
Recovery begins with `PUBLIC_AUTH_ENABLED=false`. Deploy the Worker only when
its reviewed source or required binding changed. Any Durable Object migration
diff --git a/docs/operations/reconstruction/service-inventory.md b/docs/operations/reconstruction/service-inventory.md
index f117f506..fcddc41f 100644
--- a/docs/operations/reconstruction/service-inventory.md
+++ b/docs/operations/reconstruction/service-inventory.md
@@ -97,13 +97,15 @@ Those public routes are active only while `PUBLIC_AUTH_ENABLED=true` and return
the paused profile when it is false.
Public v1 challenge/exchange are retired with `410`; admin `/v1` routes are a
separate server-only namespace. Secret names are `SIGNING_KEY_JWK`,
-`ADMIN_TOKEN_SECRET`, `SESSION_COOKIE_KEY`, and `FARCASTER_RPC_URL`; see
+`ADMIN_TOKEN_SECRET`, `SESSION_COOKIE_KEY`, `FARCASTER_RPC_URL`, and
+`FARCASTER_RPC_URL_SECONDARY`; see
[`credential-rotation.md`](credential-rotation.md). Never record their values.
The server-only config attestation profile is `warpkeep-auth-v2`. Its
fail-closed recovery target has `publicAuthEnabled: false`; the current Alpha
0.3.13 active target has `publicAuthEnabled: true`. It covers
-issuer/origins/SIWF coordinates, gameplay key/Maincloud coordinates, the
+issuer/origins/SIWF coordinates, both privacy-safe RPC endpoint fingerprints,
+the active signing-public-key thumbprint, gameplay key/Maincloud coordinates, the
observer URI/database/audience tuple and gate, S256, the 600-second access TTL,
15-second resolver TTL, five-second resolver timeout, five-minute challenge TTL,
maximum-30-day family, and exact `__Host-` cookie attributes. Record only the
diff --git a/docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json b/docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json
new file mode 100644
index 00000000..b47ff3e3
--- /dev/null
+++ b/docs/reference/resources/2026-07-19-hegemony-worker/record-art/manifest.json
@@ -0,0 +1,80 @@
+{
+ "schemaVersion": 1,
+ "id": "hegemony-worker-record-art-v1",
+ "recordedAt": "2026-07-19",
+ "purpose": "Transparent decorative Worker art for the Worker inspection panel",
+ "projectAuthorization": {
+ "authorizedBy": "Warpkeep project owner",
+ "instructionDate": "2026-07-19",
+ "scope": "Use the supplied transparent Worker illustration in the Warpkeep Worker UI slice stacked on the stable Realm PR. This authorizes repository/runtime integration only after review; it does not authorize merge, deployment, production activation, worker seeding, or generic worker authority.",
+ "notGranted": [
+ "proof of underlying copyright ownership",
+ "a public open-content licence",
+ "general third-party derivative or redistribution permission",
+ "trademark or canonical-identity rights",
+ "worker ownership, status, route, cargo, reward, or settlement authority"
+ ]
+ },
+ "sourceInputs": [
+ {
+ "role": "owner-supplied transparent Worker illustration",
+ "repositoryRetained": false,
+ "originalName": "codex-clipboard-1312042e-175b-466f-adda-9bef7b12c1f0.png",
+ "bytes": 478174,
+ "sha256": "f6ae700affb5ce981074c7952bc81f90b60e2dab947b94867c5394d3e23b4d6d",
+ "image": {
+ "format": "png",
+ "width": 1024,
+ "height": 1024,
+ "channels": 4,
+ "alpha": true
+ }
+ }
+ ],
+ "processing": {
+ "tool": "sharp 0.35.3",
+ "operation": "lossy WebP derivative with alpha preserved",
+ "outputEncoding": "quality 92, alphaQuality 100, effort 6, smartSubsample true",
+ "runtimeAsset": {
+ "path": "public/images/realm/hegemony-worker-record.webp",
+ "format": "webp",
+ "width": 1024,
+ "height": 1024,
+ "bytes": 86984,
+ "sha256": "ff758ecbf520b05ccf0a2fa490bcafa6c564514de5ee56ef5a720fd6da24193e",
+ "decodedRgbaSha256": "2e77492f76801576adcc9cfe660fa15494123bc43fcd767e005cd5ad95d8b047",
+ "alpha": {
+ "transparentPixels": 858605,
+ "partiallyTransparentPixels": 39551,
+ "opaquePixels": 150420
+ },
+ "visibleBoundsAlpha16": {
+ "minX": 256,
+ "minY": 51,
+ "maxX": 863,
+ "maxY": 730
+ }
+ }
+ },
+ "presentationBoundary": {
+ "component": "src/components/realm/WorkerInspectionPanel.tsx",
+ "runtimeUse": "same-origin decorative hero art in a focus-safe Worker inspector",
+ "forbiddenClaims": [
+ "private FID",
+ "private cargo or account balance",
+ "browser-invented worker ownership",
+ "browser-derived dispatch or recall authority",
+ "reward or Marks linkage"
+ ]
+ },
+ "licence": {
+ "spdx": "LicenseRef-Warpkeep-Provenance-Required",
+ "policy": "The exact project instruction authorizes this Warpkeep runtime use but does not establish a public relicensing grant. See ASSETS-LICENSE.md."
+ },
+ "visualQa": {
+ "transparentSourceVerified": true,
+ "visibleBoundsRecorded": true,
+ "runtimeDerivativeViewedOnTransparentViewer": true,
+ "externalRuntimeUrl": false
+ }
+}
diff --git a/docs/releases/versioning.md b/docs/releases/versioning.md
index 50e20077..d225bb40 100644
--- a/docs/releases/versioning.md
+++ b/docs/releases/versioning.md
@@ -1,7 +1,7 @@
# Versioning and releases
Warpkeep uses semantic versions for the product and Git commit SHAs for builds.
-The current Alpha is `0.3.13`.
+The current Alpha is `0.3.14`.
## Version numbers
diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md
index e7942e7f..bba3bc10 100644
--- a/docs/security/threat-model.md
+++ b/docs/security/threat-model.md
@@ -2,7 +2,7 @@
## Status and scope
-This document describes the current security model for Warpkeep Alpha 0.3.13. It
+This document describes the current security model for Warpkeep Alpha 0.3.14. It
covers the browser application, Farcaster Sign In with Farcaster (SIWF), the Cloudflare authentication
bridge, SpacetimeDB game authority, local operator tools, GitHub Actions, and GitHub Pages delivery.
@@ -65,7 +65,9 @@ operation. Anonymous visitors do not connect to the game database.
1. **Browser to Farcaster.** Relay responses are untrusted until the bridge verifies the completed proof.
2. **Browser to authentication bridge.** Request bodies, headers, origins, and proof material are hostile
input. The bridge applies strict schemas, size limits, deadlines, origin checks, and replay protection.
-3. **Bridge to Farcaster verifier.** Provider failures or malformed responses must not produce a token.
+3. **Bridge to Farcaster verifier.** Two independent production RPC origins
+ must both validate the proof to the same FID. Failure, partial success,
+ disagreement, or malformed responses must not produce a token.
4. **Bridge to SpacetimeDB.** Admission lookup uses a short-lived resolver principal bound to one FID and
fixed service coordinates.
5. **Browser to SpacetimeDB.** The module repeats issuer, audience, subject, role, epoch, expiry, admission,
@@ -95,6 +97,9 @@ operation. Anonymous visitors do not connect to the game database.
wallet links, and browser parameters are presentation data only.
- SIWF validation binds the proof to the configured domain, URI, nonce, request, and expiry. The proof FID
and requested FID must agree.
+- Production runs the official verifier against two distinct public HTTPS RPC
+ origins and requires matching successful FIDs. One loopback endpoint is
+ accepted only in an explicit development profile.
- Challenges are random, expire, and are claimed atomically. Successful or definitively invalid exchanges
consume them.
- Missing, disabled, malformed, or epoch-mismatched admission returns no access token or game state.
@@ -180,7 +185,7 @@ operation. Anonymous visitors do not connect to the game database.
| Risk | Treatment and remaining exposure |
| --- | --- |
-| Client substitutes another FID | Independent proof verification and module-side caller derivation prevent browser choice; verifier compromise remains an external incident. |
+| Client substitutes another FID | Dual-RPC proof consensus and module-side caller derivation prevent browser choice; correlated provider or verifier-library compromise remains an external incident. |
| Proof or session replay | Expiring challenges, atomic claim, rotation, epoch checks, and revocation limit replay; host compromise can still defeat the boundary. |
| Access token stolen by script or extension | Memory-only storage and short lifetime limit exposure, but a compromised origin or device can use the token until expiry. |
| Resolver token stolen while fresh | One-FID binding and least-privilege guards limit access; a fresh token may still expose that FID's admission status and public subscriptions until disconnect. |
@@ -210,7 +215,8 @@ operation. Anonymous visitors do not connect to the game database.
- Per-client rate limits do not prevent distributed traffic from reaching
provider or account quotas. Monitoring, alerting, and incident drills remain
areas for improvement.
-- Third-party services—including Farcaster, Cloudflare, GitHub, package
+- Third-party services—including both configured Farcaster RPC providers,
+ Farcaster, Cloudflare, GitHub, package
registries, and SpacetimeDB—are trusted dependencies and are not audited by
this project.
- Branch protection and automated checks reduce supply-chain risk but do not
diff --git a/package-lock.json b/package-lock.json
index cdb7e0ac..fdd7d6da 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "warpkeep",
- "version": "0.3.13",
+ "version": "0.3.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "warpkeep",
- "version": "0.3.13",
+ "version": "0.3.14",
"license": "Apache-2.0",
"dependencies": {
"@farcaster/auth-client": "0.7.1",
diff --git a/package.json b/package.json
index 556acada..de0bc4dc 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "warpkeep",
"private": true,
- "version": "0.3.13",
+ "version": "0.3.14",
"description": "A Farcaster-connected persistent strategy world in active Alpha development.",
"license": "Apache-2.0",
"homepage": "https://warpkeep.com",
@@ -69,6 +69,7 @@
"stdb:generate": "node scripts/generate-spacetime-bindings.mjs",
"stdb:verify-bindings": "node scripts/verify-spacetime-bindings.mjs",
"stdb:verify-additive-migration": "node scripts/verify-spacetime-additive-migration.mjs",
+ "stdb:verify-worker-migration": "node scripts/verify-castle-worker-additive-migration.mjs",
"stdb:publish:dev": "node scripts/publish-spacetime-dev.mjs",
"stdb:seed-world": "tsx scripts/hermes-admin.ts seed-world",
"stdb:expand-world-v3": "tsx scripts/hermes-admin.ts expand-world-v3",
@@ -82,6 +83,7 @@
"stdb:inspect-alpha-v4": "tsx scripts/hermes-admin.ts inspect-alpha-v4",
"stdb:inspect-alpha-v8": "tsx scripts/hermes-admin.ts inspect-alpha-v8",
"stdb:inspect-alpha-v10": "tsx scripts/hermes-admin.ts inspect-alpha-v10",
+ "stdb:inspect-alpha-v12": "tsx scripts/hermes-admin.ts inspect-alpha-v12",
"stdb:seed-alpha-component": "tsx scripts/hermes-admin.ts seed-alpha-component",
"stdb:activate-alpha-water": "tsx scripts/hermes-admin.ts activate-alpha-water",
"stdb:inspect-water-revision": "tsx scripts/water-revision-operator.ts inspect",
diff --git a/public/images/realm/hegemony-worker-record.webp b/public/images/realm/hegemony-worker-record.webp
new file mode 100644
index 00000000..7a8bfa3a
Binary files /dev/null and b/public/images/realm/hegemony-worker-record.webp differ
diff --git a/scripts/hermes-admin.ts b/scripts/hermes-admin.ts
index 41e9ebae..65922e47 100644
--- a/scripts/hermes-admin.ts
+++ b/scripts/hermes-admin.ts
@@ -82,11 +82,12 @@ type Command =
| 'inspect-alpha-v4'
| 'inspect-alpha-v8'
| 'inspect-alpha-v10'
+ | 'inspect-alpha-v12'
| 'seed-alpha-component'
| 'activate-alpha-water'
| 'backfill-resources';
-type AlphaStatusVersion = 'v1' | 'v2' | 'v3' | 'v4' | 'v8' | 'v10';
+type AlphaStatusVersion = 'v1' | 'v2' | 'v3' | 'v4' | 'v8' | 'v10' | 'v12';
type SeedableAlphaComponent = AlphaActivationComponent | AlphaV10ActivationComponent;
const DEFAULT_DATABASE = 'warpkeep-89e4u';
@@ -108,6 +109,57 @@ const MAX_ENTRY_AGREEMENT_ACCEPTANCE_ROWS_PER_PLAYER = BigInt(
);
const HEGEMONY_WORLD_SEED = 3_445_214_658;
const HEGEMONY_WORLD_SEED_NAME = 'HEGEMONY_GENESIS_001';
+const U64_MAXIMUM = (1n << 64n) - 1n;
+const WORKER_STATUS_V12_U64_FIELDS = Object.freeze([
+ 'systemRows',
+ 'expectedCastleCount',
+ 'expectedWorkerCount',
+ 'actualWorkerCount',
+ 'castlesMissingWorkers',
+ 'castlesWithExtraWorkers',
+ 'duplicateOrdinals',
+ 'malformedWorkerIds',
+ 'invalidWorkerStates',
+ 'idleWorkers',
+ 'outboundWorkers',
+ 'gatheringWorkers',
+ 'returningWorkers',
+ 'assignments',
+ 'occupations',
+ 'schedules',
+ 'orphanWorkers',
+ 'orphanAssignments',
+ 'assignmentsMissingOccupation',
+ 'assignmentsWithoutSingleSchedule',
+ 'orphanOccupations',
+ 'orphanSchedules',
+ 'invalidSchedules',
+ 'assignmentPublicMismatches',
+ 'occupationSiteMismatches',
+ 'invalidAssignments',
+ 'idempotencyReceipts',
+ 'invalidIdempotencyReceipts',
+ 'idempotencyOverflowFids',
+ 'legacyExpeditions',
+ 'legacyOccupations',
+ 'legacySchedules',
+] as const);
+const WORKER_STATUS_V12_BOOLEAN_FIELDS = Object.freeze([
+ 'systemConfigValid',
+ 'legacyDrainRequired',
+ 'expectedCountsMatch',
+ 'rosterDigestMatches',
+] as const);
+const WORKER_STATUS_V12_STRING_FIELDS = Object.freeze([
+ 'mode',
+ 'rosterDigest',
+ 'rosterDigestExpected',
+] as const);
+const WORKER_STATUS_V12_KEYS = Object.freeze([
+ ...WORKER_STATUS_V12_U64_FIELDS,
+ ...WORKER_STATUS_V12_BOOLEAN_FIELDS,
+ ...WORKER_STATUS_V12_STRING_FIELDS,
+].sort());
export const FOUNDER_ADMISSION_SOURCE_CONFIGURATION_DIGEST = createHash('sha256')
.update(JSON.stringify({
@@ -266,6 +318,7 @@ function commandFrom(value: string | undefined): Command {
|| value === 'inspect-alpha-v4'
|| value === 'inspect-alpha-v8'
|| value === 'inspect-alpha-v10'
+ || value === 'inspect-alpha-v12'
|| value === 'seed-alpha-component'
|| value === 'activate-alpha-water'
|| value === 'backfill-resources'
@@ -274,7 +327,7 @@ function commandFrom(value: string | undefined): Command {
}
fail(
'Usage: hermes-admin.ts '
- + ' '
+ + ' '
+ '[...args] [--dry-run] [--confirm]. admit-founder requires private stdin: '
+ '--input-stdin --dry-run creates a reviewed plan; --input-stdin --confirm consumes it; '
+ 'allow-fid only re-enables an existing complete founder.',
@@ -302,7 +355,8 @@ export function parseHermesArguments(arguments_: readonly string[] = process.arg
|| command === 'inspect-alpha-v3'
|| command === 'inspect-alpha-v4'
|| command === 'inspect-alpha-v8'
- || command === 'inspect-alpha-v10';
+ || command === 'inspect-alpha-v10'
+ || command === 'inspect-alpha-v12';
const expectedPositionals = command === 'allow-fid'
|| command === 'disable-fid'
|| command === 'bump-auth-epoch'
@@ -411,6 +465,51 @@ function printable(value: unknown): unknown {
return value;
}
+/**
+ * Keep the v12 operator surface aggregate-only and fail closed if the generated
+ * procedure contract changes. Raw u64 values must still be canonical SDK
+ * bigints here; decimal-string conversion happens only at the JSON boundary.
+ */
+export function projectWorkerSystemStatusV12(value: unknown) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ fail('Worker procedure-v12 returned an invalid status object.');
+ }
+ const status = value as Record;
+ const actualKeys = Object.keys(status).sort();
+ if (
+ actualKeys.length !== WORKER_STATUS_V12_KEYS.length
+ || actualKeys.some((key, index) => key !== WORKER_STATUS_V12_KEYS[index])
+ ) {
+ fail('Worker procedure-v12 returned unexpected fields.');
+ }
+ for (const field of WORKER_STATUS_V12_U64_FIELDS) {
+ const count = status[field];
+ if (typeof count !== 'bigint' || count < 0n || count > U64_MAXIMUM) {
+ fail('Worker procedure-v12 returned an invalid aggregate count.');
+ }
+ }
+ for (const field of WORKER_STATUS_V12_BOOLEAN_FIELDS) {
+ if (typeof status[field] !== 'boolean') {
+ fail('Worker procedure-v12 returned an invalid status flag.');
+ }
+ }
+ if (
+ (status.mode !== 'absent' && status.mode !== 'staged' && status.mode !== 'active')
+ || (status.rosterDigest !== ''
+ && (typeof status.rosterDigest !== 'string'
+ || !/^[0-9a-f]{16}$/.test(status.rosterDigest)))
+ || typeof status.rosterDigestExpected !== 'string'
+ || !/^[0-9a-f]{16}$/.test(status.rosterDigestExpected)
+ ) {
+ fail('Worker procedure-v12 returned invalid worker metadata.');
+ }
+ return Object.freeze(Object.fromEntries(
+ [...WORKER_STATUS_V12_U64_FIELDS, ...WORKER_STATUS_V12_BOOLEAN_FIELDS,
+ ...WORKER_STATUS_V12_STRING_FIELDS]
+ .map(field => [field, status[field]]),
+ ));
+}
+
type ResourceAggregateV4 = Readonly<{
allowedFids: bigint;
castles: bigint;
@@ -988,6 +1087,12 @@ export async function readStatus(
expectedResourceFounderCount?: bigint,
emit = true,
) {
+ if (version === 'v12') {
+ const status = await withOperationTimeout(connection.procedures.adminGetWorkerSystemStatusV1({}));
+ const verified = projectWorkerSystemStatusV12(status);
+ if (emit) console.log(JSON.stringify(printable(verified)));
+ return verified;
+ }
if (version === 'v10') {
const status = await withOperationTimeout(connection.procedures.adminGetAlphaStatusV10({}));
const verified = projectAlphaStatusV10(status);
@@ -1466,6 +1571,8 @@ async function main() {
? 'v8'
: command === 'inspect-alpha-v10'
? 'v10'
+ : command === 'inspect-alpha-v12'
+ ? 'v12'
: 'v1';
if (!mutationStatusHandled) {
await readStatus(
diff --git a/scripts/publish-spacetime-dev.d.mts b/scripts/publish-spacetime-dev.d.mts
index a1a417ed..560722e7 100644
--- a/scripts/publish-spacetime-dev.d.mts
+++ b/scripts/publish-spacetime-dev.d.mts
@@ -1,8 +1,19 @@
export const GENESIS_WORLD_PUBLISH_STAGE: Readonly>;
+export const PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS: Readonly>;
export const RESOURCE_PUBLISH_ROLLOUT_STAGE: Readonly>;
+export const WORKER_PUBLISH_ROLLOUT_STAGE: Readonly>;
+export const WORKER_V12_TABLE_CONTRACTS: Readonly>>;
export function alphaV8AggregateChildArguments(...args: any[]): any;
export function alphaV10AggregateChildArguments(...args: any[]): any;
+export function alphaV12AggregateChildArguments(...args: any[]): any;
+export function canonicalSchemaDescribeChildArguments(...args: any[]): any;
+export function createPrivatePublishSnapshot(...args: any[]): any;
+export function parseCanonicalSchemaDescription(...args: any[]): any;
export function parseMigrationProofReceipt(...args: any[]): any;
export function parsePublishArguments(...args: any[]): any;
export function publishChildEnvironment(...args: any[]): any;
@@ -14,6 +25,8 @@ export function validateIssuerDeployment(...args: any[]): any;
export function verifyCanonicalDatabaseList(...args: any[]): any;
export function verifyFreshAlphaStatusV8Aggregate(...args: any[]): any;
export function verifyFreshAlphaStatusV10Aggregate(...args: any[]): any;
+export function verifyFreshAlphaStatusV12Aggregate(...args: any[]): any;
+export function verifyFreshProductionV11Schema(...args: any[]): any;
export function verifyFreshFoundedProtocolV3Aggregate(...args: any[]): any;
export function verifyFreshResourceProtocolV4PrebackfillAggregate(...args: any[]): any;
export function verifyFreshResourceProtocolV4ReadyAggregate(...args: any[]): any;
@@ -21,9 +34,15 @@ export function verifyMigrationArtifactReceipt(...args: any[]): any;
export function verifyPinnedCliAttestation(...args: any[]): any;
export function verifyPostPublishAlphaStatusV8Aggregate(...args: any[]): any;
export function verifyPostPublishAlphaStatusV10Aggregate(...args: any[]): any;
+export function verifyPostPublishAlphaStatusV12Aggregate(...args: any[]): any;
export function verifyPostPublishFoundedProtocolV3Aggregate(...args: any[]): any;
+export function verifyPostPublishProductionV12Schema(...args: any[]): any;
export function verifyPostPublishResourceProtocolV4PrebackfillAggregate(...args: any[]): any;
export function verifyPostPublishResourceProtocolV4ReadyAggregate(...args: any[]): any;
export function verifyPostPublishResourcePublicationCheckpoints(...args: any[]): any;
export function verifyPrivacySafeAlphaStatusV8Output(...args: any[]): any;
export function verifyPrivacySafeAlphaStatusV10Output(...args: any[]): any;
+export function verifyPrivacySafeAlphaStatusV12Output(...args: any[]): any;
+export function verifyEmptyAlphaStatusV12(...args: any[]): any;
+export function verifyExactProductionV11Schema(...args: any[]): any;
+export function verifyExactProductionV12Schema(...args: any[]): any;
diff --git a/scripts/publish-spacetime-dev.mjs b/scripts/publish-spacetime-dev.mjs
index 16ffde22..471c7ead 100644
--- a/scripts/publish-spacetime-dev.mjs
+++ b/scripts/publish-spacetime-dev.mjs
@@ -1,7 +1,23 @@
import { spawn, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
-import { constants, accessSync, closeSync, fstatSync, openSync, readFileSync, realpathSync } from 'node:fs';
+import {
+ constants,
+ accessSync,
+ chmodSync,
+ closeSync,
+ fchmodSync,
+ fstatSync,
+ fsyncSync,
+ mkdtempSync,
+ openSync,
+ readFileSync,
+ realpathSync,
+ rmSync,
+ statSync,
+ writeFileSync,
+} from 'node:fs';
import { readFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -18,6 +34,9 @@ import {
ADDITIVE_MIGRATION_PROOF_SPACETIME_CLI_VERSION,
parseAdditiveMigrationProofReceipt,
} from './spacetime-additive-migration-proof.mjs';
+import {
+ canonicalTableSchemaBoundaryDigest,
+} from './spacetime-table-schema-attestation.mjs';
import {
WARPKEEP_ENTRY_AGREEMENT_ACCEPTANCE_RECORDS_PER_FID_MAXIMUM,
} from './entry-agreement-policy.mjs';
@@ -56,6 +75,7 @@ const MAX_ENTRY_AGREEMENT_ACCEPTANCE_ROWS_PER_PLAYER =
WARPKEEP_ENTRY_AGREEMENT_ACCEPTANCE_RECORDS_PER_FID_MAXIMUM;
const MAX_ENTRY_AGREEMENT_ACCEPTANCE_COUNT =
100 * MAX_ENTRY_AGREEMENT_ACCEPTANCE_ROWS_PER_PLAYER;
+const SHA256_DIGEST = /^[0-9a-f]{64}$/;
export const RESOURCE_PUBLISH_ROLLOUT_STAGE = Object.freeze({
PREBACKFILL: 'prebackfill',
@@ -65,6 +85,118 @@ export const GENESIS_WORLD_PUBLISH_STAGE = Object.freeze({
PRE_EXPANSION: 'pre-expansion',
EXPANDED: 'expanded',
});
+export const WORKER_PUBLISH_ROLLOUT_STAGE = Object.freeze({
+ EMPTY: 'empty',
+});
+
+export const PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS = Object.freeze({
+ allowed_fid: 0,
+ world_tile: 1,
+ player: 2,
+ castle: 3,
+ admin_audit: 4,
+ player_v2: 5,
+ player_ownership_v2: 6,
+ realm_v1: 7,
+ world_tile_meta_v1: 8,
+ castle_slot_v1: 9,
+ castle_slot_claim_v1: 10,
+ realm_profile_v1: 11,
+ mark_account_v1: 12,
+ snap_burn_credit_v1: 13,
+ fid_wallet_attribution_v1: 14,
+ wallet_attribution_snapshot_v1: 15,
+ snap_scan_cursor_v1: 16,
+ snap_scan_batch_v1: 17,
+ alpha_terms_acceptance_v1: 18,
+ resource_account_v1: 19,
+ gold_site_v1: 20,
+ gold_node_occupation_v1: 21,
+ gold_expedition_v1: 22,
+ gold_expedition_idempotency_v1: 23,
+ gold_expedition_schedule_v_1: 24,
+ realm_forest_layout_v1: 25,
+ realm_forest_instance_v1: 26,
+ food_site_v1: 27,
+ food_node_occupation_v1: 28,
+ food_expedition_v1: 29,
+ food_expedition_idempotency_v1: 30,
+ food_expedition_schedule_v_1: 31,
+ wood_site_v1: 32,
+ wood_node_occupation_v1: 33,
+ wood_expedition_v1: 34,
+ wood_expedition_idempotency_v1: 35,
+ wood_expedition_schedule_v_1: 36,
+ realm_water_layout_v1: 37,
+ realm_water_body_v1: 38,
+ realm_water_cell_v1: 39,
+ realm_environment_v1: 40,
+ stone_site_v1: 41,
+ stone_node_occupation_v1: 42,
+ stone_expedition_v1: 43,
+ stone_expedition_idempotency_v1: 44,
+ stone_expedition_schedule_v_1: 45,
+ realm_water_revision_v1: 46,
+});
+export const WORKER_V12_TABLE_CONTRACTS = Object.freeze({
+ realm_worker_system_v1: Object.freeze({
+ productTypeRef: 47,
+ access: 'Public',
+ fields: Object.freeze([
+ 'realm_id', 'policy_version', 'workers_per_castle', 'expected_castle_count',
+ 'expected_worker_count', 'roster_digest', 'mode', 'legacy_drain_required',
+ 'created_at', 'activated_at',
+ ]),
+ }),
+ castle_worker_v1: Object.freeze({
+ productTypeRef: 48,
+ access: 'Public',
+ fields: Object.freeze([
+ 'worker_id', 'origin_castle_id', 'ordinal', 'status', 'resource_kind',
+ 'site_id', 'started_at_micros', 'arrives_at_micros',
+ 'gathering_ends_at_micros', 'return_started_at_micros',
+ 'returns_at_micros', 'route_steps', 'return_start_progress_basis_points',
+ 'timeline_revision', 'revision',
+ ]),
+ }),
+ worker_assignment_v1: Object.freeze({
+ productTypeRef: 49,
+ access: 'Private',
+ fields: Object.freeze([
+ 'assignment_id', 'worker_id', 'fid', 'origin_castle_id', 'resource_kind',
+ 'site_id', 'phase', 'started_at_micros', 'arrives_at_micros',
+ 'gathering_ends_at_micros', 'return_started_at_micros',
+ 'returns_at_micros', 'route_steps', 'return_start_progress_basis_points',
+ 'settled_through_micros', 'accrued_amount', 'materialized_amount',
+ 'timeline_revision', 'policy_version', 'created_at', 'updated_at',
+ ]),
+ }),
+ worker_node_occupation_v1: Object.freeze({
+ productTypeRef: 50,
+ access: 'Public',
+ fields: Object.freeze([
+ 'node_key', 'resource_kind', 'site_id', 'worker_id', 'worker_ordinal',
+ 'origin_castle_id', 'phase', 'started_at_micros', 'arrives_at_micros',
+ 'gathering_ends_at_micros', 'timeline_revision',
+ ]),
+ }),
+ worker_command_idempotency_v1: Object.freeze({
+ productTypeRef: 51,
+ access: 'Private',
+ fields: Object.freeze([
+ 'request_key', 'fid', 'worker_id', 'command_kind', 'resource_kind',
+ 'site_id', 'assignment_id', 'result_revision', 'created_at',
+ ]),
+ }),
+ worker_assignment_schedule_v_1: Object.freeze({
+ productTypeRef: 52,
+ access: 'Private',
+ fields: Object.freeze([
+ 'schedule_id', 'scheduled_at', 'assignment_id', 'worker_id',
+ 'timeline_revision', 'stage',
+ ]),
+ }),
+});
const ALPHA_V8_COUNT_FIELDS = Object.freeze([
'goldSites',
@@ -148,6 +280,86 @@ const ALPHA_V10_STATUS_KEYS = Object.freeze([
...ALPHA_V10_DIGEST_FIELDS,
...ALPHA_V10_COUNT_FIELDS,
].sort());
+const ALPHA_V12_U64_FIELDS = Object.freeze([
+ 'systemRows',
+ 'expectedCastleCount',
+ 'expectedWorkerCount',
+ 'actualWorkerCount',
+ 'castlesMissingWorkers',
+ 'castlesWithExtraWorkers',
+ 'duplicateOrdinals',
+ 'malformedWorkerIds',
+ 'invalidWorkerStates',
+ 'idleWorkers',
+ 'outboundWorkers',
+ 'gatheringWorkers',
+ 'returningWorkers',
+ 'assignments',
+ 'occupations',
+ 'schedules',
+ 'orphanWorkers',
+ 'orphanAssignments',
+ 'assignmentsMissingOccupation',
+ 'assignmentsWithoutSingleSchedule',
+ 'orphanOccupations',
+ 'orphanSchedules',
+ 'invalidSchedules',
+ 'assignmentPublicMismatches',
+ 'occupationSiteMismatches',
+ 'invalidAssignments',
+ 'idempotencyReceipts',
+ 'invalidIdempotencyReceipts',
+ 'idempotencyOverflowFids',
+ 'legacyExpeditions',
+ 'legacyOccupations',
+ 'legacySchedules',
+]);
+const ALPHA_V12_BOOLEAN_FIELDS = Object.freeze([
+ 'systemConfigValid',
+ 'legacyDrainRequired',
+ 'expectedCountsMatch',
+ 'rosterDigestMatches',
+]);
+const ALPHA_V12_STRING_FIELDS = Object.freeze([
+ 'mode',
+ 'rosterDigest',
+ 'rosterDigestExpected',
+]);
+const ALPHA_V12_STATUS_KEYS = Object.freeze([
+ ...ALPHA_V12_U64_FIELDS,
+ ...ALPHA_V12_BOOLEAN_FIELDS,
+ ...ALPHA_V12_STRING_FIELDS,
+].sort());
+const EMPTY_WORKER_V12_ZERO_FIELDS = Object.freeze([
+ 'systemRows',
+ 'expectedCastleCount',
+ 'expectedWorkerCount',
+ 'actualWorkerCount',
+ 'castlesWithExtraWorkers',
+ 'duplicateOrdinals',
+ 'malformedWorkerIds',
+ 'invalidWorkerStates',
+ 'idleWorkers',
+ 'outboundWorkers',
+ 'gatheringWorkers',
+ 'returningWorkers',
+ 'assignments',
+ 'occupations',
+ 'schedules',
+ 'orphanWorkers',
+ 'orphanAssignments',
+ 'assignmentsMissingOccupation',
+ 'assignmentsWithoutSingleSchedule',
+ 'orphanOccupations',
+ 'orphanSchedules',
+ 'invalidSchedules',
+ 'assignmentPublicMismatches',
+ 'occupationSiteMismatches',
+ 'invalidAssignments',
+ 'idempotencyReceipts',
+ 'invalidIdempotencyReceipts',
+ 'idempotencyOverflowFids',
+]);
const U64_MAXIMUM = (1n << 64n) - 1n;
class SafePublishError extends Error {}
@@ -156,6 +368,140 @@ function fail(message) {
throw new SafePublishError(message);
}
+const PRIVATE_SNAPSHOT_DIRECTORY_MODE = 0o700;
+const PRIVATE_SNAPSHOT_ARTIFACT_MODE = 0o400;
+const PRIVATE_SNAPSHOT_EXECUTABLE_MODE = 0o500;
+const MAX_PRIVATE_SNAPSHOT_BYTES = 128 * 1_024 * 1_024;
+const PRIVATE_SNAPSHOT_KINDS = Object.freeze({
+ ARTIFACT: 'artifact',
+ EXECUTABLE: 'executable',
+});
+
+function readExactVerifiedSourceBytes(sourcePath, expectedDigest, kind) {
+ if (
+ typeof sourcePath !== 'string'
+ || !isAbsolute(sourcePath)
+ || typeof expectedDigest !== 'string'
+ || !SHA256_DIGEST.test(expectedDigest)
+ || typeof kind !== 'string'
+ || !Object.values(PRIVATE_SNAPSHOT_KINDS).includes(kind)
+ ) {
+ fail('The private publication snapshot request was invalid.');
+ }
+
+ let descriptor;
+ try {
+ descriptor = openSync(sourcePath, constants.O_RDONLY | constants.O_NOFOLLOW);
+ const before = fstatSync(descriptor);
+ if (
+ !before.isFile()
+ || before.size < 1
+ || before.size > MAX_PRIVATE_SNAPSHOT_BYTES
+ ) {
+ fail('The private publication snapshot source was not a regular file.');
+ }
+ const bytes = readFileSync(descriptor);
+ const after = fstatSync(descriptor);
+ if (
+ before.dev !== after.dev
+ || before.ino !== after.ino
+ || before.size !== after.size
+ || before.mtimeMs !== after.mtimeMs
+ || before.ctimeMs !== after.ctimeMs
+ || bytes.byteLength !== after.size
+ ) {
+ fail('The private publication snapshot source changed while it was read.');
+ }
+ const digest = createHash('sha256').update(bytes).digest('hex');
+ if (digest !== expectedDigest) {
+ fail(kind === PRIVATE_SNAPSHOT_KINDS.ARTIFACT
+ ? 'The proven SpacetimeDB artifact changed after migration verification.'
+ : 'The exact reviewed SpacetimeDB CLI binary was not active on this platform.');
+ }
+ // The caller copies this exact verified buffer. It never reopens the
+ // mutable source path between attestation and snapshot creation.
+ return Object.freeze({ bytes, digest });
+ } catch (error) {
+ if (error instanceof SafePublishError) throw error;
+ fail('The private publication snapshot source could not be read safely.');
+ } finally {
+ if (descriptor !== undefined) closeSync(descriptor);
+ }
+}
+
+export function createPrivatePublishSnapshot(sourcePath, expectedDigest, kind) {
+ const verified = readExactVerifiedSourceBytes(sourcePath, expectedDigest, kind);
+ let directory;
+ let descriptor;
+ try {
+ directory = mkdtempSync(join(tmpdir(), 'warpkeep-publish-snapshot-'));
+ chmodSync(directory, PRIVATE_SNAPSHOT_DIRECTORY_MODE);
+ const directoryMetadata = statSync(directory);
+ if (
+ !directoryMetadata.isDirectory()
+ || (directoryMetadata.mode & 0o777) !== PRIVATE_SNAPSHOT_DIRECTORY_MODE
+ ) {
+ fail('The private publication snapshot directory permissions were not exact.');
+ }
+
+ const snapshotPath = join(
+ directory,
+ // The pinned CLI is a multicall binary and dispatches from argv[0]. Keep
+ // its reviewed command name while changing only the private directory.
+ kind === PRIVATE_SNAPSHOT_KINDS.EXECUTABLE ? 'spacetime' : 'module.js',
+ );
+ const snapshotMode = kind === PRIVATE_SNAPSHOT_KINDS.EXECUTABLE
+ ? PRIVATE_SNAPSHOT_EXECUTABLE_MODE
+ : PRIVATE_SNAPSHOT_ARTIFACT_MODE;
+ descriptor = openSync(
+ snapshotPath,
+ constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
+ PRIVATE_SNAPSHOT_DIRECTORY_MODE,
+ );
+ writeFileSync(descriptor, verified.bytes);
+ fchmodSync(descriptor, snapshotMode);
+ fsyncSync(descriptor);
+ const snapshotMetadata = fstatSync(descriptor);
+ if (
+ !snapshotMetadata.isFile()
+ || snapshotMetadata.size !== verified.bytes.byteLength
+ || (snapshotMetadata.mode & 0o777) !== snapshotMode
+ ) {
+ fail('The private publication snapshot was not created exactly.');
+ }
+ closeSync(descriptor);
+ descriptor = undefined;
+
+ let cleaned = false;
+ const cleanup = () => {
+ if (cleaned) return;
+ try {
+ rmSync(directory, { recursive: true, force: true });
+ cleaned = true;
+ } catch {
+ fail('Private publication snapshot cleanup failed; no further publication is safe.');
+ }
+ };
+ return Object.freeze({
+ path: snapshotPath,
+ directory,
+ digest: verified.digest,
+ cleanup,
+ });
+ } catch (error) {
+ if (descriptor !== undefined) {
+ try { closeSync(descriptor); } catch { /* Cleanup below remains mandatory. */ }
+ }
+ if (directory !== undefined) {
+ try { rmSync(directory, { recursive: true, force: true }); } catch {
+ fail('Private publication snapshot cleanup failed; no further publication is safe.');
+ }
+ }
+ if (error instanceof SafePublishError) throw error;
+ fail('The private publication snapshot could not be created safely.');
+ }
+}
+
function requireHttpsOrigin(value, label) {
if (typeof value !== 'string' || value.length === 0) {
fail(`${label} is required.`);
@@ -294,6 +640,7 @@ export function parsePublishArguments(arguments_ = process.argv.slice(2)) {
let dryRun = false;
let resourceRolloutStage;
let genesisWorldRolloutStage;
+ let workerRolloutStage;
for (const argument of arguments_) {
if (argument === '--dry-run' && !dryRun) {
dryRun = true;
@@ -319,7 +666,17 @@ export function parsePublishArguments(arguments_ = process.argv.slice(2)) {
continue;
}
}
- fail('Usage: publish-spacetime-dev.mjs [--dry-run] --resource-rollout-stage= --genesis-world-stage=. Unknown or duplicate arguments are rejected.');
+ if (
+ argument.startsWith('--worker-rollout-stage=')
+ && workerRolloutStage === undefined
+ ) {
+ const value = argument.slice('--worker-rollout-stage='.length);
+ if (Object.values(WORKER_PUBLISH_ROLLOUT_STAGE).includes(value)) {
+ workerRolloutStage = value;
+ continue;
+ }
+ }
+ fail('Usage: publish-spacetime-dev.mjs [--dry-run] --resource-rollout-stage= --genesis-world-stage= --worker-rollout-stage=empty. Unknown or duplicate arguments are rejected.');
}
if (resourceRolloutStage === undefined) {
fail('An explicit resource rollout stage is required: prebackfill for the first additive publication or ready for an already-backfilled republish.');
@@ -327,7 +684,15 @@ export function parsePublishArguments(arguments_ = process.argv.slice(2)) {
if (genesisWorldRolloutStage === undefined) {
fail('An explicit Genesis world stage is required: pre-expansion for the exact 1,261-cell predecessor or expanded for the exact 10,000-cell target.');
}
- return Object.freeze({ dryRun, resourceRolloutStage, genesisWorldRolloutStage });
+ if (workerRolloutStage === undefined) {
+ fail('An explicit empty Worker rollout stage is required for the one-time additive v12 publication.');
+ }
+ return Object.freeze({
+ dryRun,
+ resourceRolloutStage,
+ genesisWorldRolloutStage,
+ workerRolloutStage,
+ });
}
export function requireCanonicalPublishCoordinates(source = process.env) {
@@ -464,15 +829,28 @@ export function attestPinnedSpacetimeCli(
) {
const environment = publishChildEnvironment(sourceEnvironment);
const executablePath = resolveExecutablePath(executable, environment);
- const digest = createHash('sha256').update(readFileSync(executablePath)).digest('hex');
- const result = runBoundedSync(
+ const expectedDigest = EXPECTED_CLI_BINARY_SHA256[`${process.platform}-${process.arch}`];
+ if (typeof expectedDigest !== 'string') {
+ fail('The exact reviewed SpacetimeDB CLI binary was not active on this platform.');
+ }
+ const snapshot = createPrivatePublishSnapshot(
executablePath,
- ['--version'],
- { env: environment, timeout: 10_000 },
- spawnSyncProcess,
+ expectedDigest,
+ PRIVATE_SNAPSHOT_KINDS.EXECUTABLE,
);
- verifyPinnedCliAttestation(result.stdout, digest);
- return executablePath;
+ try {
+ const result = runBoundedSync(
+ snapshot.path,
+ ['--version'],
+ { env: environment, timeout: 10_000 },
+ spawnSyncProcess,
+ );
+ verifyPinnedCliAttestation(result.stdout, snapshot.digest);
+ return snapshot;
+ } catch (error) {
+ snapshot.cleanup();
+ throw error;
+ }
}
export function verifyCanonicalDatabaseList(output) {
@@ -498,6 +876,237 @@ export function attestCanonicalDatabase(executable, spawnSyncProcess = spawnSync
verifyCanonicalDatabaseList(result.stdout);
}
+export function canonicalSchemaDescribeChildArguments() {
+ return [
+ 'describe',
+ '--json',
+ '--anonymous',
+ '--server', CANONICAL_MAINCLOUD_URI,
+ '--no-config',
+ CANONICAL_DATABASE_IDENTITY,
+ ];
+}
+
+export function parseCanonicalSchemaDescription(output) {
+ let description;
+ try {
+ description = JSON.parse(output);
+ } catch {
+ fail('The canonical schema inspection did not return machine-readable JSON.');
+ }
+ if (
+ !description
+ || typeof description !== 'object'
+ || Array.isArray(description)
+ || !Array.isArray(description.tables)
+ || !description.typespace
+ || typeof description.typespace !== 'object'
+ || !Array.isArray(description.typespace.types)
+ ) {
+ fail('The canonical schema inspection returned an invalid description.');
+ }
+ return description;
+}
+
+function canonicalJson(value) {
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
+ if (value && typeof value === 'object') {
+ return `{${Object.keys(value).sort().map(key => (
+ `${JSON.stringify(key)}:${canonicalJson(value[key])}`
+ )).join(',')}}`;
+ }
+ return JSON.stringify(value);
+}
+
+function schemaTableSignature(description, name) {
+ const matches = description.tables.filter(candidate => candidate?.name === name);
+ if (matches.length !== 1 || !Number.isSafeInteger(matches[0].product_type_ref)) {
+ fail('The canonical schema did not contain one exact required table.');
+ }
+ const table = matches[0];
+ const rowType = description.typespace.types[table.product_type_ref];
+ if (!rowType || typeof rowType !== 'object' || Array.isArray(rowType)) {
+ fail('The canonical schema did not contain one exact required row type.');
+ }
+ return { ...table, rowType };
+}
+
+function verifyExactTableIdentities(description, expectedRefs) {
+ const expectedNames = Object.keys(expectedRefs).sort();
+ const actualNames = description.tables.map(table => table?.name).sort();
+ if (
+ actualNames.length !== expectedNames.length
+ || actualNames.some((name, index) => name !== expectedNames[index])
+ ) {
+ fail('The canonical schema table set did not match the exact publication boundary.');
+ }
+ for (const [name, expectedRef] of Object.entries(expectedRefs)) {
+ const signature = schemaTableSignature(description, name);
+ if (signature.product_type_ref !== expectedRef) {
+ fail('The canonical schema product-type references did not match the exact publication boundary.');
+ }
+ }
+}
+
+function schemaFieldNames(description, name) {
+ const elements = schemaTableSignature(description, name).rowType?.Product?.elements;
+ if (!Array.isArray(elements)) {
+ fail('The canonical Worker schema row fields were absent.');
+ }
+ const fields = elements.map(element => element?.name?.some);
+ if (fields.some(field => typeof field !== 'string')) {
+ fail('The canonical Worker schema row fields were invalid.');
+ }
+ return fields;
+}
+
+function schemaTableAccess(description, name) {
+ const access = schemaTableSignature(description, name).table_access;
+ if (!access || typeof access !== 'object' || Array.isArray(access)) {
+ fail('The canonical Worker schema table access was invalid.');
+ }
+ const keys = Object.keys(access);
+ if (keys.length !== 1) fail('The canonical Worker schema table access was invalid.');
+ return keys[0];
+}
+
+/**
+ * Require the live predecessor to be exactly the deployed v11 table boundary.
+ * The returned canonical signatures are retained in memory and compared after
+ * publication so no pre-existing table can drift unnoticed.
+ */
+export function verifyExactProductionV11Schema(description, expectedTableSchemaDigest) {
+ verifyExactTableIdentities(description, PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS);
+ try {
+ if (
+ typeof expectedTableSchemaDigest !== 'string'
+ || !SHA256_DIGEST.test(expectedTableSchemaDigest)
+ || canonicalTableSchemaBoundaryDigest(
+ description,
+ Object.keys(PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS),
+ ) !== expectedTableSchemaDigest
+ ) {
+ fail('The canonical v11 table schema did not match the proven publication boundary.');
+ }
+ } catch (error) {
+ if (
+ error instanceof SafePublishError
+ && error.message === 'The canonical v11 table schema did not match the proven publication boundary.'
+ ) throw error;
+ fail('The canonical v11 table schema did not match the proven publication boundary.');
+ }
+ return Object.freeze(Object.fromEntries(
+ Object.keys(PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS).map(name => [
+ name,
+ canonicalJson(schemaTableSignature(description, name)),
+ ]),
+ ));
+}
+
+/** Require an exact v12 suffix while preserving every captured v11 signature. */
+export function verifyExactProductionV12Schema(
+ predecessorSignatures,
+ description,
+ expectedTableSchemaDigest,
+) {
+ if (
+ !predecessorSignatures
+ || typeof predecessorSignatures !== 'object'
+ || Array.isArray(predecessorSignatures)
+ || Object.keys(predecessorSignatures).sort().join(',')
+ !== Object.keys(PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS).sort().join(',')
+ || Object.values(predecessorSignatures).some(value => typeof value !== 'string')
+ ) {
+ fail('The captured production v11 schema boundary was invalid.');
+ }
+ const v12Refs = Object.freeze({
+ ...PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS,
+ ...Object.fromEntries(Object.entries(WORKER_V12_TABLE_CONTRACTS)
+ .map(([name, contract]) => [name, contract.productTypeRef])),
+ });
+ verifyExactTableIdentities(description, v12Refs);
+ for (const name of Object.keys(PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS)) {
+ if (canonicalJson(schemaTableSignature(description, name)) !== predecessorSignatures[name]) {
+ fail('A pre-existing production table changed during the v12 publication.');
+ }
+ }
+ for (const [name, contract] of Object.entries(WORKER_V12_TABLE_CONTRACTS)) {
+ if (
+ schemaTableAccess(description, name) !== contract.access
+ || canonicalJson(schemaFieldNames(description, name)) !== canonicalJson(contract.fields)
+ ) {
+ fail('The appended Worker schema did not match the exact v12 contract.');
+ }
+ }
+ try {
+ if (
+ typeof expectedTableSchemaDigest !== 'string'
+ || !SHA256_DIGEST.test(expectedTableSchemaDigest)
+ || canonicalTableSchemaBoundaryDigest(
+ description,
+ Object.keys(v12Refs),
+ ) !== expectedTableSchemaDigest
+ ) {
+ fail('The canonical v12 table schema did not match the proven publication boundary.');
+ }
+ } catch (error) {
+ if (
+ error instanceof SafePublishError
+ && error.message === 'The canonical v12 table schema did not match the proven publication boundary.'
+ ) throw error;
+ fail('The canonical v12 table schema did not match the proven publication boundary.');
+ }
+ return Object.freeze({
+ predecessorTableCount: Object.keys(PRODUCTION_V11_TABLE_PRODUCT_TYPE_REFS).length,
+ appendedWorkerTableCount: Object.keys(WORKER_V12_TABLE_CONTRACTS).length,
+ totalTableCount: Object.keys(v12Refs).length,
+ });
+}
+
+export function verifyFreshProductionV11Schema(
+ executable,
+ expectedTableSchemaDigest,
+ spawnSyncProcess = spawnSync,
+) {
+ try {
+ const result = runBoundedSync(
+ executable,
+ canonicalSchemaDescribeChildArguments(),
+ { timeout: 30_000 },
+ spawnSyncProcess,
+ );
+ return verifyExactProductionV11Schema(
+ parseCanonicalSchemaDescription(result.stdout),
+ expectedTableSchemaDigest,
+ );
+ } catch {
+ fail('Exact production v11 schema preflight failed. No publish was attempted.');
+ }
+}
+
+export function verifyPostPublishProductionV12Schema(
+ executable,
+ predecessorSignatures,
+ expectedTableSchemaDigest,
+ spawnSyncProcess = spawnSync,
+) {
+ try {
+ const result = runBoundedSync(
+ executable,
+ canonicalSchemaDescribeChildArguments(),
+ { timeout: 30_000 },
+ spawnSyncProcess,
+ );
+ return verifyExactProductionV12Schema(
+ predecessorSignatures,
+ parseCanonicalSchemaDescription(result.stdout),
+ expectedTableSchemaDigest,
+ );
+ } catch {
+ fail('Post-publication v12 schema checkpoint is indeterminate; a fresh anonymous read-only schema inspection is required before any merge, client deployment, Worker seed, backfill, activation, or further publication decision.');
+ }
+}
+
function digestArtifact(artifactPath) {
let descriptor;
try {
@@ -515,26 +1124,36 @@ function digestArtifact(artifactPath) {
}
}
-export function verifyMigrationArtifactReceipt(receipt) {
+function validateMigrationArtifactReceiptShape(receipt) {
if (
receipt === null
|| typeof receipt !== 'object'
- || Object.keys(receipt).sort().join(',') !== 'artifactDigest,artifactPath'
+ || Object.keys(receipt).sort().join(',')
+ !== 'artifactDigest,artifactPath,v11TableSchemaDigest,v12TableSchemaDigest'
|| receipt.artifactPath !== PROVEN_ARTIFACT_PATH
- || !/^[0-9a-f]{64}$/.test(receipt.artifactDigest ?? '')
+ || !SHA256_DIGEST.test(receipt.v11TableSchemaDigest ?? '')
+ || !SHA256_DIGEST.test(receipt.v12TableSchemaDigest ?? '')
+ || !SHA256_DIGEST.test(receipt.artifactDigest ?? '')
) {
fail('The additive migration proof artifact receipt was invalid.');
}
- const currentDigest = digestArtifact(receipt.artifactPath);
- if (currentDigest !== receipt.artifactDigest) {
- fail('The proven SpacetimeDB artifact changed after migration verification.');
- }
return Object.freeze({
artifactPath: receipt.artifactPath,
+ v11TableSchemaDigest: receipt.v11TableSchemaDigest,
+ v12TableSchemaDigest: receipt.v12TableSchemaDigest,
artifactDigest: receipt.artifactDigest,
});
}
+export function verifyMigrationArtifactReceipt(receipt) {
+ const validated = validateMigrationArtifactReceiptShape(receipt);
+ const currentDigest = digestArtifact(validated.artifactPath);
+ if (currentDigest !== validated.artifactDigest) {
+ fail('The proven SpacetimeDB artifact changed after migration verification.');
+ }
+ return validated;
+}
+
export function parseMigrationProofReceipt(output) {
let proofReceipt;
try {
@@ -544,6 +1163,8 @@ export function parseMigrationProofReceipt(output) {
}
return verifyMigrationArtifactReceipt({
artifactPath: PROVEN_ARTIFACT_PATH,
+ v11TableSchemaDigest: proofReceipt.v11TableSchemaDigest,
+ v12TableSchemaDigest: proofReceipt.v12TableSchemaDigest,
artifactDigest: proofReceipt.artifactDigest,
});
}
@@ -681,6 +1302,15 @@ export function alphaV10AggregateChildArguments(tsxCli) {
];
}
+export function alphaV12AggregateChildArguments(tsxCli) {
+ return [
+ tsxCli,
+ 'scripts/hermes-admin.ts',
+ 'inspect-alpha-v12',
+ '--json',
+ ];
+}
+
/**
* The Hermes child already verifies exact v8 policy identities and catalog
* shape. This second boundary accepts only its closed, aggregate-only JSON
@@ -838,6 +1468,104 @@ export function verifyFreshAlphaStatusV10Aggregate(
return verifyPrivacySafeAlphaStatusV10Output(result.stdout);
}
+/** Accept only the exact aggregate-only Worker v12 JSON envelope from Hermes. */
+export function verifyPrivacySafeAlphaStatusV12Output(output) {
+ let status;
+ try {
+ status = JSON.parse(output);
+ } catch {
+ fail('Alpha procedure-v12 inspection did not return machine-readable JSON.');
+ }
+ if (!status || typeof status !== 'object' || Array.isArray(status)) {
+ fail('Alpha procedure-v12 inspection returned an invalid status object.');
+ }
+ const actualKeys = Object.keys(status).sort();
+ if (
+ actualKeys.length !== ALPHA_V12_STATUS_KEYS.length
+ || actualKeys.some((key, index) => key !== ALPHA_V12_STATUS_KEYS[index])
+ ) fail('Alpha procedure-v12 inspection returned unexpected fields.');
+ for (const field of ALPHA_V12_U64_FIELDS) {
+ const value = status[field];
+ if (
+ typeof value !== 'string'
+ || !/^(?:0|[1-9]\d*)$/.test(value)
+ || value.length > 20
+ || BigInt(value) > U64_MAXIMUM
+ ) fail('Alpha procedure-v12 inspection returned an invalid aggregate count.');
+ }
+ for (const field of ALPHA_V12_BOOLEAN_FIELDS) {
+ if (typeof status[field] !== 'boolean') {
+ fail('Alpha procedure-v12 inspection returned an invalid status flag.');
+ }
+ }
+ if (
+ (status.mode !== 'absent' && status.mode !== 'staged' && status.mode !== 'active')
+ || (status.rosterDigest !== ''
+ && (typeof status.rosterDigest !== 'string'
+ || !/^[0-9a-f]{16}$/.test(status.rosterDigest)))
+ || typeof status.rosterDigestExpected !== 'string'
+ || !/^[0-9a-f]{16}$/.test(status.rosterDigestExpected)
+ ) fail('Alpha procedure-v12 inspection returned invalid Worker metadata.');
+ return Object.freeze({ ...status });
+}
+
+export function verifyEmptyAlphaStatusV12(status, expectedFounderCount) {
+ if (
+ !Number.isSafeInteger(expectedFounderCount)
+ || expectedFounderCount < 1
+ || expectedFounderCount > 100
+ ) fail('The empty Worker checkpoint expected founder count was invalid.');
+ if (
+ status.mode !== 'absent'
+ || status.systemConfigValid !== false
+ || status.legacyDrainRequired !== true
+ || status.expectedCountsMatch !== false
+ || status.rosterDigestMatches !== false
+ || status.castlesMissingWorkers !== String(expectedFounderCount)
+ || status.rosterDigest !== ''
+ || EMPTY_WORKER_V12_ZERO_FIELDS.some(field => status[field] !== '0')
+ ) {
+ fail('Alpha procedure-v12 did not prove an empty, inert Worker suffix.');
+ }
+ return status;
+}
+
+export function verifyFreshAlphaStatusV12Aggregate(
+ secret,
+ expectedFounderCount,
+ spawnSyncProcess = spawnSync,
+) {
+ const secretBytes = typeof secret === 'string' ? new TextEncoder().encode(secret).byteLength : 0;
+ if (secretBytes < 32 || secretBytes > 512) {
+ fail('A local 32-to-512-byte Hermes credential is required for the fresh Alpha v12 checkpoint.');
+ }
+ if (
+ !Number.isSafeInteger(expectedFounderCount)
+ || expectedFounderCount < 1
+ || expectedFounderCount > 100
+ ) fail('The Alpha v12 checkpoint expected founder count was invalid.');
+ const tsxCli = resolve(repositoryRoot, 'node_modules/tsx/dist/cli.mjs');
+ const result = runBoundedSync(
+ process.execPath,
+ alphaV12AggregateChildArguments(tsxCli),
+ {
+ env: {
+ WARPKEEP_SPACETIMEDB_URI: CANONICAL_MAINCLOUD_URI,
+ WARPKEEP_SPACETIMEDB_DATABASE: CANONICAL_DATABASE_IDENTITY,
+ WARPKEEP_AUTH_BRIDGE_URL: CANONICAL_BRIDGE,
+ WARPKEEP_ADMIN_TOKEN_SECRET_STDIN: '1',
+ },
+ input: secret,
+ timeout: 30_000,
+ },
+ spawnSyncProcess,
+ );
+ return verifyEmptyAlphaStatusV12(
+ verifyPrivacySafeAlphaStatusV12Output(result.stdout),
+ expectedFounderCount,
+ );
+}
+
export function verifyPostPublishFoundedProtocolV3Aggregate(
secret,
expectations,
@@ -853,8 +1581,8 @@ export function verifyPostPublishFoundedProtocolV3Aggregate(
);
} catch {
// Publication has already returned success. Never surface a preflight-style
- // "no publish attempted" message or invite an unsafe retry when only the
- // bounded post-publication inspection failed.
+ // "no publish attempted" message or invite another publication when only
+ // the bounded post-publication inspection failed.
fail('Post-publication protocol-v3 verification is indeterminate; a fresh read-only inspection is required before any backfill or further publication decision.');
}
}
@@ -916,10 +1644,27 @@ export function verifyPostPublishAlphaStatusV10Aggregate(
}
}
+export function verifyPostPublishAlphaStatusV12Aggregate(
+ secret,
+ expectedFounderCount,
+ spawnSyncProcess = spawnSync,
+) {
+ try {
+ return verifyFreshAlphaStatusV12Aggregate(
+ secret,
+ expectedFounderCount,
+ spawnSyncProcess,
+ );
+ } catch {
+ fail('Post-publication Alpha procedure-v12 checkpoint is indeterminate; a fresh read-only v12 inspection is required before any merge, client deployment, Worker seed, backfill, activation, or further publication decision.');
+ }
+}
+
export function verifyPostPublishResourcePublicationCheckpoints(
secret,
expectations,
resourceRolloutStage,
+ workerRolloutStage,
spawnSyncProcess = spawnSync,
genesisWorldRolloutStage = GENESIS_WORLD_PUBLISH_STAGE.PRE_EXPANSION,
) {
@@ -927,6 +1672,9 @@ export function verifyPostPublishResourcePublicationCheckpoints(
if (!Object.values(RESOURCE_PUBLISH_ROLLOUT_STAGE).includes(resourceRolloutStage)) {
fail('The post-publication resource rollout stage was invalid.');
}
+ if (workerRolloutStage !== WORKER_PUBLISH_ROLLOUT_STAGE.EMPTY) {
+ fail('The post-publication Worker rollout stage was invalid.');
+ }
verifyPostPublishFoundedProtocolV3Aggregate(
secret,
exactExpectations,
@@ -948,6 +1696,11 @@ export function verifyPostPublishResourcePublicationCheckpoints(
}
verifyPostPublishAlphaStatusV8Aggregate(secret, spawnSyncProcess);
verifyPostPublishAlphaStatusV10Aggregate(secret, spawnSyncProcess);
+ verifyPostPublishAlphaStatusV12Aggregate(
+ secret,
+ exactExpectations.expectedFounderCount,
+ spawnSyncProcess,
+ );
}
export async function publishModule(
@@ -959,84 +1712,93 @@ export async function publishModule(
if (targetDatabase !== CANONICAL_DATABASE_IDENTITY) {
fail('The production publish target was not the pinned canonical database identity.');
}
- const artifact = verifyMigrationArtifactReceipt(artifactReceipt);
+ const artifact = validateMigrationArtifactReceiptShape(artifactReceipt);
+ const artifactSnapshot = createPrivatePublishSnapshot(
+ artifact.artifactPath,
+ artifact.artifactDigest,
+ PRIVATE_SNAPSHOT_KINDS.ARTIFACT,
+ );
const arguments_ = [
'publish',
'--server', CANONICAL_MAINCLOUD_URI,
- '--js-path', artifact.artifactPath,
+ '--js-path', artifactSnapshot.path,
'--delete-data=never',
'--yes=remote',
'--no-config',
targetDatabase,
];
- await new Promise((resolvePromise, rejectPromise) => {
- let settled = false;
- let timedOut = false;
- let outputExceeded = false;
- let outputBytes = 0;
- let deadline;
- let forcedKill;
- const settle = (callback) => {
- if (settled) return;
- settled = true;
- if (deadline !== undefined) clearTimeout(deadline);
- if (forcedKill !== undefined) clearTimeout(forcedKill);
- callback();
- };
+ try {
+ await new Promise((resolvePromise, rejectPromise) => {
+ let settled = false;
+ let timedOut = false;
+ let outputExceeded = false;
+ let outputBytes = 0;
+ let deadline;
+ let forcedKill;
+ const settle = (callback) => {
+ if (settled) return;
+ settled = true;
+ if (deadline !== undefined) clearTimeout(deadline);
+ if (forcedKill !== undefined) clearTimeout(forcedKill);
+ callback();
+ };
- let child;
- try {
- child = spawnProcess(spacetimeCommand, arguments_, {
- cwd: repositoryRoot,
- // A compatibility or break-clients prompt must see EOF and abort. The
- // bounded output is consumed without mirroring private process detail.
- stdio: ['ignore', 'pipe', 'pipe'],
- // The CLI uses local config/Home and standard network settings. It
- // never receives ambient Warpkeep signing, admin, RPC, or review data.
- env: publishChildEnvironment(),
+ let child;
+ try {
+ child = spawnProcess(spacetimeCommand, arguments_, {
+ cwd: repositoryRoot,
+ // A compatibility or break-clients prompt must see EOF and abort. The
+ // bounded output is consumed without mirroring private process detail.
+ stdio: ['ignore', 'pipe', 'pipe'],
+ // The CLI uses local config/Home and standard network settings. It
+ // never receives ambient Warpkeep signing, admin, RPC, or review data.
+ env: publishChildEnvironment(),
+ });
+ } catch (error) {
+ settle(() => rejectPromise(error));
+ return;
+ }
+ const observeOutput = (stream) => {
+ if (!stream || typeof stream.on !== 'function') return;
+ stream.on('data', chunk => {
+ outputBytes += chunk.byteLength;
+ if (outputBytes <= MAX_CHILD_OUTPUT_BYTES || outputExceeded) return;
+ outputExceeded = true;
+ try { child.kill('SIGKILL'); } catch { /* The bounded failure remains generic. */ }
+ forcedKill = setTimeout(() => {
+ settle(() => rejectPromise(new Error('SpacetimeDB publish output exceeded its fixed bound.')));
+ }, PUBLISH_KILL_GRACE_MILLISECONDS);
+ });
+ };
+ observeOutput(child.stdout);
+ observeOutput(child.stderr);
+ child.on('error', (error) => {
+ // A signal-delivery error can arrive after the deadline. Keep the forced
+ // SIGKILL timer alive in that case instead of abandoning the child. Keep
+ // this listener installed so a second kill-delivery error is not emitted
+ // as an unhandled EventEmitter error after forced settlement.
+ if (!timedOut) settle(() => rejectPromise(error));
});
- } catch (error) {
- settle(() => rejectPromise(error));
- return;
- }
- const observeOutput = (stream) => {
- if (!stream || typeof stream.on !== 'function') return;
- stream.on('data', chunk => {
- outputBytes += chunk.byteLength;
- if (outputBytes <= MAX_CHILD_OUTPUT_BYTES || outputExceeded) return;
- outputExceeded = true;
- try { child.kill('SIGKILL'); } catch { /* The bounded failure remains generic. */ }
+ child.once('close', (code) => settle(() => {
+ if (!timedOut && !outputExceeded && code === 0) resolvePromise();
+ else rejectPromise(new Error('SpacetimeDB publish did not complete successfully.'));
+ }));
+
+ deadline = setTimeout(() => {
+ timedOut = true;
+ try { child.kill('SIGTERM'); } catch { /* Fall through to the forced deadline. */ }
forcedKill = setTimeout(() => {
- settle(() => rejectPromise(new Error('SpacetimeDB publish output exceeded its fixed bound.')));
+ try { child.kill('SIGKILL'); } catch { /* The outcome remains indeterminate. */ }
+ // Do not wait indefinitely for a child that ignores termination or
+ // withholds its close event. Treat the publication outcome as
+ // indeterminate and require a fresh read-only inspection.
+ settle(() => rejectPromise(new Error('SpacetimeDB publish exceeded its hard deadline.')));
}, PUBLISH_KILL_GRACE_MILLISECONDS);
- });
- };
- observeOutput(child.stdout);
- observeOutput(child.stderr);
- child.on('error', (error) => {
- // A signal-delivery error can arrive after the deadline. Keep the forced
- // SIGKILL timer alive in that case instead of abandoning the child. Keep
- // this listener installed so a second kill-delivery error is not emitted
- // as an unhandled EventEmitter error after forced settlement.
- if (!timedOut) settle(() => rejectPromise(error));
+ }, PUBLISH_TIMEOUT_MILLISECONDS);
});
- child.once('close', (code) => settle(() => {
- if (!timedOut && !outputExceeded && code === 0) resolvePromise();
- else rejectPromise(new Error('SpacetimeDB publish did not complete successfully.'));
- }));
-
- deadline = setTimeout(() => {
- timedOut = true;
- try { child.kill('SIGTERM'); } catch { /* Fall through to the forced deadline. */ }
- forcedKill = setTimeout(() => {
- try { child.kill('SIGKILL'); } catch { /* The outcome remains indeterminate. */ }
- // Do not wait indefinitely for a child that ignores termination or
- // withholds its close event. The CLI outcome must be inspected before
- // any operator retries the publish.
- settle(() => rejectPromise(new Error('SpacetimeDB publish exceeded its hard deadline.')));
- }, PUBLISH_KILL_GRACE_MILLISECONDS);
- }, PUBLISH_TIMEOUT_MILLISECONDS);
- });
+ } finally {
+ artifactSnapshot.cleanup();
+ }
}
async function main() {
@@ -1044,6 +1806,7 @@ async function main() {
dryRun,
resourceRolloutStage,
genesisWorldRolloutStage,
+ workerRolloutStage,
} = parsePublishArguments();
requireCanonicalPublishCoordinates();
if (database !== CANONICAL_DATABASE) fail('The production publisher target was not canonical.');
@@ -1058,42 +1821,65 @@ async function main() {
fail(`Set WARPKEEP_PUBLISH_CONFIRM=${database} after reviewing the target database; publish was not attempted.`);
}
const foundedExpectations = readFoundedPublishExpectations();
- const executable = attestPinnedSpacetimeCli(command);
- const artifactReceipt = runCurrentAdditiveMigrationProof(executable);
- if (dryRun) {
+ // Remove the Hermes credential from the ambient environment before the
+ // long-running proof spawns any children. The bounded aggregate helpers
+ // receive it only through stdin and every child environment stays allowlisted.
+ let adminTokenSecret = process.env.WARPKEEP_ADMIN_TOKEN_SECRET;
+ delete process.env.WARPKEEP_ADMIN_TOKEN_SECRET;
+ const executableSnapshot = attestPinnedSpacetimeCli(command);
+ try {
+ // Keep every proof, inspection, publish, and checkpoint bound to the one
+ // attested CLI copy for this complete publication lifecycle.
+ const executable = executableSnapshot.path;
+ const artifactReceipt = runCurrentAdditiveMigrationProof(executable);
+ if (dryRun) {
+ await validateIssuerDeployment(issuer);
+ console.log(`Dry run: verified the pinned CLI, current additive migration, founded-state expectation contract, explicit ${resourceRolloutStage} resource stage, explicit ${genesisWorldRolloutStage} Genesis world stage, explicit ${workerRolloutStage} Worker stage, and ${issuer}; would update the canonical existing database without deleting data.`);
+ return;
+ }
await validateIssuerDeployment(issuer);
- console.log(`Dry run: verified the pinned CLI, current additive migration, founded-state expectation contract, explicit ${resourceRolloutStage} resource stage, explicit ${genesisWorldRolloutStage} Genesis world stage, and ${issuer}; would update the canonical existing database without deleting data.`);
- return;
- }
- await validateIssuerDeployment(issuer);
- attestCanonicalDatabase(executable);
- verifyFreshFoundedProtocolV3Aggregate(
- process.env.WARPKEEP_ADMIN_TOKEN_SECRET,
- foundedExpectations,
- spawnSync,
- genesisWorldRolloutStage,
- );
- if (resourceRolloutStage === RESOURCE_PUBLISH_ROLLOUT_STAGE.READY) {
- verifyFreshResourceProtocolV4ReadyAggregate(
- process.env.WARPKEEP_ADMIN_TOKEN_SECRET,
- foundedExpectations.expectedFounderCount,
+ attestCanonicalDatabase(executable);
+ const predecessorSchema = verifyFreshProductionV11Schema(
+ executable,
+ artifactReceipt.v11TableSchemaDigest,
+ );
+ verifyFreshFoundedProtocolV3Aggregate(
+ adminTokenSecret,
+ foundedExpectations,
+ spawnSync,
+ genesisWorldRolloutStage,
+ );
+ if (resourceRolloutStage === RESOURCE_PUBLISH_ROLLOUT_STAGE.READY) {
+ verifyFreshResourceProtocolV4ReadyAggregate(
+ adminTokenSecret,
+ foundedExpectations.expectedFounderCount,
+ );
+ }
+ await publishModule(executable, CANONICAL_DATABASE_IDENTITY, artifactReceipt);
+ verifyPostPublishProductionV12Schema(
+ executable,
+ predecessorSchema,
+ artifactReceipt.v12TableSchemaDigest,
+ );
+ verifyPostPublishResourcePublicationCheckpoints(
+ adminTokenSecret,
+ foundedExpectations,
+ resourceRolloutStage,
+ workerRolloutStage,
+ spawnSync,
+ genesisWorldRolloutStage,
);
+ } finally {
+ adminTokenSecret = undefined;
+ executableSnapshot.cleanup();
}
- await publishModule(executable, CANONICAL_DATABASE_IDENTITY, artifactReceipt);
- verifyPostPublishResourcePublicationCheckpoints(
- process.env.WARPKEEP_ADMIN_TOKEN_SECRET,
- foundedExpectations,
- resourceRolloutStage,
- spawnSync,
- genesisWorldRolloutStage,
- );
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
main().catch((error) => {
console.error(error instanceof SafePublishError
? error.message
- : 'Non-destructive publish did not complete. The outcome may be indeterminate; inspect Maincloud before retrying.');
+ : 'Non-destructive publish did not complete. The outcome may be indeterminate; perform a fresh read-only Maincloud inspection before any further publication decision.');
process.exitCode = 1;
});
}
diff --git a/scripts/qa-observer/local-vite-fs-deny.d.mts b/scripts/qa-observer/local-vite-fs-deny.d.mts
new file mode 100644
index 00000000..70261731
--- /dev/null
+++ b/scripts/qa-observer/local-vite-fs-deny.d.mts
@@ -0,0 +1,5 @@
+import type { Plugin } from 'vite';
+
+export declare const WARPKEEP_LOCAL_VITE_FS_DENY: readonly string[];
+
+export declare function warpkeepLocalPublicBoundaryPlugin(): Plugin;
diff --git a/scripts/qa-observer/local-vite-fs-deny.mjs b/scripts/qa-observer/local-vite-fs-deny.mjs
new file mode 100644
index 00000000..ad1e5eaa
--- /dev/null
+++ b/scripts/qa-observer/local-vite-fs-deny.mjs
@@ -0,0 +1,201 @@
+import { lstatSync, readdirSync } from 'node:fs';
+import { join } from 'node:path';
+
+/**
+ * Vite replaces, rather than extends, its default deny list when `server.fs.deny`
+ * is configured. Keep one shared contract for manual and automated local QA.
+ */
+export const WARPKEEP_LOCAL_VITE_FS_DENY = Object.freeze([
+ '.env',
+ '.env.*',
+ '.dev.vars*',
+ '.envrc',
+ '.npmrc',
+ 'credentials.json',
+ 'admin-secret*',
+ 'secret.json',
+ 'secrets.json',
+ 'id_rsa*',
+ 'id_ed25519*',
+ '*.{crt,pem}',
+ '*.{cer,key,p12,pfx,jks,keystore,jwk,token}',
+ '*.local',
+ '*.{log,har,trace}',
+ '*.{bak,backup,tmp}',
+ '*.{sqlite,sqlite3,db,dump}',
+ '*.{zip,tar,tar.gz,tgz,7z}',
+ '**/.git/**',
+ '**/.cache/**',
+ '**/.wrangler/**',
+ '**/.secrets/**',
+]);
+
+const SENSITIVE_PUBLIC_EXACT_NAMES = new Set([
+ '.env',
+ '.envrc',
+ '.npmrc',
+ 'credentials.json',
+ 'secret.json',
+ 'secrets.json',
+]);
+const SENSITIVE_PUBLIC_DIRECTORIES = new Set([
+ '.git',
+ '.cache',
+ '.wrangler',
+ '.secrets',
+]);
+const SENSITIVE_PUBLIC_SUFFIXES = Object.freeze([
+ '.crt',
+ '.pem',
+ '.cer',
+ '.key',
+ '.p12',
+ '.pfx',
+ '.jks',
+ '.keystore',
+ '.jwk',
+ '.token',
+ '.local',
+ '.log',
+ '.har',
+ '.trace',
+ '.bak',
+ '.backup',
+ '.tmp',
+ '.sqlite',
+ '.sqlite3',
+ '.db',
+ '.dump',
+ '.zip',
+ '.tar',
+ '.tar.gz',
+ '.tgz',
+ '.7z',
+]);
+
+function sensitivePublicEntryName(name) {
+ const lower = name.toLowerCase();
+ return SENSITIVE_PUBLIC_EXACT_NAMES.has(lower)
+ || SENSITIVE_PUBLIC_DIRECTORIES.has(lower)
+ || lower.startsWith('.env.')
+ || lower.startsWith('.dev.vars')
+ || lower.startsWith('admin-secret')
+ || lower.startsWith('id_rsa')
+ || lower.startsWith('id_ed25519')
+ || SENSITIVE_PUBLIC_SUFFIXES.some((suffix) => lower.endsWith(suffix));
+}
+
+function readPublicEntryStats(path, allowMissing) {
+ try {
+ return lstatSync(path);
+ } catch (error) {
+ if (
+ allowMissing
+ && error
+ && typeof error === 'object'
+ && 'code' in error
+ && error.code === 'ENOENT'
+ ) return undefined;
+ throw new Error('Warpkeep could not attest the public directory boundary.');
+ }
+}
+
+function unsafePublicEntry(stats) {
+ return stats.isSymbolicLink() || (!stats.isDirectory() && !stats.isFile());
+}
+
+function assertSafePublicTree(directory, allowMissing = true) {
+ const directoryStats = readPublicEntryStats(directory, allowMissing);
+ if (directoryStats === undefined) return;
+ if (!directoryStats.isDirectory() || directoryStats.isSymbolicLink()) {
+ throw new Error('Warpkeep public directory contains a prohibited local artifact.');
+ }
+ let entries;
+ try {
+ entries = readdirSync(directory, { withFileTypes: true });
+ } catch {
+ throw new Error('Warpkeep could not attest the public directory boundary.');
+ }
+ for (const entry of entries) {
+ const entryPath = join(directory, entry.name);
+ const entryStats = readPublicEntryStats(entryPath, false);
+ if (sensitivePublicEntryName(entry.name) || unsafePublicEntry(entryStats)) {
+ throw new Error('Warpkeep public directory contains a prohibited local artifact.');
+ }
+ if (entryStats.isDirectory()) assertSafePublicTree(entryPath, false);
+ }
+}
+
+function requestPublicSegments(requestUrl, base) {
+ try {
+ let pathname = decodeURIComponent(new URL(requestUrl, 'http://warpkeep.local').pathname)
+ .replaceAll('\\', '/');
+ const decodedBase = decodeURIComponent(new URL(base, 'http://warpkeep.local').pathname);
+ if (decodedBase !== '/' && pathname.startsWith(decodedBase)) {
+ pathname = `/${pathname.slice(decodedBase.length)}`;
+ }
+ const segments = pathname.split('/').filter(Boolean);
+ if (segments.some((segment) => segment === '.' || segment === '..' || segment.includes('\0'))) {
+ return undefined;
+ }
+ return segments;
+ } catch {
+ return undefined;
+ }
+}
+
+function requestTargetsUnsafePublicEntry(publicDirectory, requestUrl, base) {
+ try {
+ const segments = requestPublicSegments(requestUrl, base);
+ if (segments === undefined || segments.some(sensitivePublicEntryName)) return true;
+ const rootStats = readPublicEntryStats(publicDirectory, true);
+ if (rootStats === undefined) return false;
+ if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) return true;
+
+ let current = publicDirectory;
+ for (const [index, segment] of segments.entries()) {
+ current = join(current, segment);
+ const stats = readPublicEntryStats(current, true);
+ if (stats === undefined) return false;
+ if (unsafePublicEntry(stats)) return true;
+ if (index < segments.length - 1 && !stats.isDirectory()) return false;
+ }
+ return false;
+ } catch {
+ return true;
+ }
+}
+
+/**
+ * Vite's public middleware bypasses `server.fs.deny`. Refuse startup/build if
+ * that copy-through surface contains a credential, private capture, local
+ * database, recovery archive, special file, or symlink.
+ */
+export function warpkeepLocalPublicBoundaryPlugin() {
+ let publicDirectory;
+ let base = '/';
+ return {
+ name: 'warpkeep-local-public-boundary',
+ enforce: 'pre',
+ configResolved(config) {
+ publicDirectory = config.publicDir || undefined;
+ base = config.base;
+ if (publicDirectory) assertSafePublicTree(publicDirectory);
+ },
+ configureServer(server) {
+ server.middlewares.use((request, response, next) => {
+ if (
+ request.url
+ && (!publicDirectory || !requestTargetsUnsafePublicEntry(publicDirectory, request.url, base))
+ ) {
+ next();
+ return;
+ }
+ response.statusCode = 404;
+ response.setHeader('cache-control', 'no-store');
+ response.setHeader('content-type', 'text/plain; charset=utf-8');
+ response.end('Not Found\n');
+ });
+ },
+ };
+}
diff --git a/scripts/qa-observer/rendered-webgl-browser-probe.d.mts b/scripts/qa-observer/rendered-webgl-browser-probe.d.mts
index 742aea21..fcb5c6e5 100644
--- a/scripts/qa-observer/rendered-webgl-browser-probe.d.mts
+++ b/scripts/qa-observer/rendered-webgl-browser-probe.d.mts
@@ -7,6 +7,8 @@ export const RENDERED_WEBGL_QA_CHROME_TEAM_ID: 'EQHXZ8M8AV';
export const RENDERED_WEBGL_QA_CASE_COUNT: 14;
/** Exact authoritative terrain count for the Genesis generation-v3 render target. */
export const RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_CELL_COUNT: 10000;
+/** Exact visible terrain-kind count after canonical no-lake revision activation. */
+export const RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_KIND_COUNT: 6;
export const RENDERED_WEBGL_QA_LABEL_MAX_ANCHOR_DISPLACEMENT_PIXELS: 0;
export const RENDERED_WEBGL_QA_LABEL_COORDINATE_SERIALIZATION_EPSILON_PIXELS: 0.015;
/** Vite 8 default deny patterns plus the local asset-cache boundary. */
diff --git a/scripts/qa-observer/rendered-webgl-browser-probe.mjs b/scripts/qa-observer/rendered-webgl-browser-probe.mjs
index a676c50c..8bc5a192 100644
--- a/scripts/qa-observer/rendered-webgl-browser-probe.mjs
+++ b/scripts/qa-observer/rendered-webgl-browser-probe.mjs
@@ -14,6 +14,10 @@ import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { analyzeRenderedWebglPngScreenshot } from './png-visual-aggregate.mjs';
+import {
+ WARPKEEP_LOCAL_VITE_FS_DENY,
+ warpkeepLocalPublicBoundaryPlugin,
+} from './local-vite-fs-deny.mjs';
import {
parseRenderedWebglQaObservation,
RENDERED_WEBGL_QA_MAX_READY_MILLISECONDS,
@@ -57,22 +61,17 @@ export const RENDERED_WEBGL_QA_CASE_COUNT = 14;
// count prevents a complete generation-v2 surface (1,261 cells), a partial
// expansion, or a mixed snapshot from being accepted as current render proof.
export const RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_CELL_COUNT = 10_000;
+// The synthetic observer activates canonical Water revision v1. Its 409
+// former lake rows are presented as lowland, leaving exactly six live terrain
+// kinds while the immutable authority metadata remains seven-kind.
+export const RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_KIND_COUNT = 6;
// Every projection-visible keeper name is locked to its castle foundation.
// Dense overviews may overlap, but camera motion cannot aggregate, displace,
// or hide founded identities.
export const RENDERED_WEBGL_QA_LABEL_MAX_ANCHOR_DISPLACEMENT_PIXELS = 0;
const RENDERED_WEBGL_QA_CLUSTER_MAX_ANCHOR_DISPLACEMENT_PIXELS = 112;
export const RENDERED_WEBGL_QA_LABEL_COORDINATE_SERIALIZATION_EPSILON_PIXELS = 0.015;
-// Supplying `server.fs.deny` replaces Vite's defaults instead of appending to
-// them. Keep Vite 8's four reviewed defaults explicitly, then add the source
-// cache boundary required by the local LOD evidence route.
-export const RENDERED_WEBGL_QA_VITE_FS_DENY = Object.freeze([
- '.env',
- '.env.*',
- '*.{crt,pem}',
- '**/.git/**',
- '**/.cache/**',
-]);
+export const RENDERED_WEBGL_QA_VITE_FS_DENY = WARPKEEP_LOCAL_VITE_FS_DENY;
const RENDERED_WEBGL_QA_LABEL_ANGLE_TOLERANCE_RADIANS = 0.002;
const RENDERED_WEBGL_QA_CASTLE_POINTER_ACTIVATION_CASE_ID = 'desktop-balanced';
const RENDERED_WEBGL_QA_MAP_GESTURE_CASE_ID = 'desktop-balanced-player';
@@ -103,22 +102,34 @@ const RENDERED_WEBGL_QA_MAP_DRAG_OFFSETS = Object.freeze([
Object.freeze({ x: 52, y: 14 }),
]);
const RENDERED_WEBGL_QA_MAX_POINTER_COORDINATE_PIXELS = 10_000;
-// The canonical shared forest is a separate static world layer, not part of
-// the quality-scaled procedural feature budget. Presentation telemetry includes
-// it, so the browser ceiling must include its exact 210 instances as well.
+// Shared forest and the reviewed outer-Realm infill are separate real-tree
+// layers, not part of the quality-scaled procedural feature budget. Telemetry
+// includes both; mirror their exact source budgets rather than widening the
+// browser gate with an arbitrary allowance.
const RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT = 210;
+const RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS = Object.freeze({
+ high: 240,
+ balanced: 90,
+ reduced: 0,
+});
const TERRAIN_PRESENTATION_BUDGETS = Object.freeze({
high: Object.freeze({
- semanticFeatureCount: 1_100 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT,
- totalDetailInstanceCount: 7_000 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT,
+ semanticFeatureCount: 1_100 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT
+ + RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS.high,
+ totalDetailInstanceCount: 7_000 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT
+ + RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS.high,
}),
balanced: Object.freeze({
- semanticFeatureCount: 800 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT,
- totalDetailInstanceCount: 5_500 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT,
+ semanticFeatureCount: 800 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT
+ + RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS.balanced,
+ totalDetailInstanceCount: 5_500 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT
+ + RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS.balanced,
}),
reduced: Object.freeze({
- semanticFeatureCount: 400 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT,
- totalDetailInstanceCount: 3_000 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT,
+ semanticFeatureCount: 400 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT
+ + RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS.reduced,
+ totalDetailInstanceCount: 3_000 + RENDERED_WEBGL_QA_SHARED_FOREST_INSTANCE_COUNT
+ + RENDERED_WEBGL_QA_FOREST_INFILL_INSTANCE_BUDGETS.reduced,
}),
});
const LABEL_CULL_REASONS = new Set([
@@ -876,7 +887,8 @@ export function parseRenderedWebglBrowserDom(value, expected) {
candidate.environmentLighting !== 'procedural' ? 'environment-lighting' : '',
candidate.semanticTerrainCellCount !== RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_CELL_COUNT
? 'semantic-terrain-cell-count' : '',
- candidate.semanticTerrainKindCount !== 7 ? 'semantic-terrain-kind-count' : '',
+ candidate.semanticTerrainKindCount !== RENDERED_WEBGL_QA_SEMANTIC_TERRAIN_KIND_COUNT
+ ? 'semantic-terrain-kind-count' : '',
!terrainBudgets
|| !Number.isSafeInteger(candidate.semanticTerrainFeatureCount)
|| candidate.semanticTerrainFeatureCount < 1
@@ -1778,7 +1790,7 @@ async function createLoopbackViteServer(runtimeDirectory, localQaPlugins = []) {
cacheDir: join(privateRuntime, 'vite-cache'),
configFile: false,
envFile: false,
- plugins: [reactPlugin(), ...localQaPlugins],
+ plugins: [warpkeepLocalPublicBoundaryPlugin(), reactPlugin(), ...localQaPlugins],
define: {
__WARPKEEP_LOCAL_QA__: 'true',
__WARPKEEP_PRODUCT_VERSION__: JSON.stringify(packageJson.version),
@@ -2149,8 +2161,11 @@ const READ_DOM_EXPRESSION = `(() => {
const inspectorProfileImage = inspector?.querySelector(
'canvas[data-profile-image-state]'
);
+ // Water endpoint controls intentionally share some list styling. Count only
+ // the semantically named castle list so source/mouth buttons cannot inflate
+ // or invalidate the exact 100-castle accessibility gate.
const exploreCastleButtons = [...document.querySelectorAll(
- '.realm-cell-navigator__castles button'
+ '.realm-cell-navigator__castles[aria-label="Founded castles"] > li > button'
)].filter(visible);
const exploreAccessibleCastleButtons = exploreCastleButtons.filter((button) => (
button instanceof HTMLButtonElement
@@ -2362,7 +2377,11 @@ async function waitForAcceptedRenderedDom(session, expected, state) {
`overflow=${String(value.labelClusterOverflowCount)}`,
`portrait=${String(value.inspectorProfileImageState)}`,
`models=${String(value.presentedModelCount)}`,
- `bases=${String(value.presentedLandscapeBaseCount)}`
+ `bases=${String(value.presentedLandscapeBaseCount)}`,
+ `terrainKinds=${String(value.semanticTerrainKindCount)}`,
+ `terrainFeatures=${String(value.semanticTerrainFeatureCount)}`,
+ `exploreCastles=${String(value.exploreCastleCount)}`,
+ `exploreAccessible=${String(value.exploreAccessibleCastleCount)}`
].join(',');
try {
parseRenderedWebglBrowserDom(value, expected);
diff --git a/scripts/spacetime-additive-migration-proof.mjs b/scripts/spacetime-additive-migration-proof.mjs
index dffdb1e6..30041c02 100644
--- a/scripts/spacetime-additive-migration-proof.mjs
+++ b/scripts/spacetime-additive-migration-proof.mjs
@@ -1,9 +1,16 @@
const SHA256_DIGEST = /^[0-9a-f]{64}$/;
-const RECEIPT_FIELD = 'artifact_sha256';
+const V11_TABLE_SCHEMA_RECEIPT_FIELD = 'v11_table_schema_sha256';
+const V12_TABLE_SCHEMA_RECEIPT_FIELD = 'v12_table_schema_sha256';
+const ARTIFACT_RECEIPT_FIELD = 'artifact_sha256';
+const RECEIPT_FIELDS = Object.freeze([
+ V11_TABLE_SCHEMA_RECEIPT_FIELD,
+ V12_TABLE_SCHEMA_RECEIPT_FIELD,
+ ARTIFACT_RECEIPT_FIELD,
+]);
const INVALID_RECEIPT_MESSAGE =
'The current additive migration proof did not produce its exact success receipt.';
-export const ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 11;
+export const ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 12;
export const ADDITIVE_MIGRATION_PROOF_SPACETIME_CLI_VERSION = '2.6.1';
// The compiled lifecycle lane includes a nine-minute route and one complete
// gathering minute. Keep a bounded margin for server startup and cleanup.
@@ -16,19 +23,31 @@ function rejectReceipt() {
throw new Error(INVALID_RECEIPT_MESSAGE);
}
-export function formatAdditiveMigrationProofReceipt({ summary, artifactDigest }) {
+export function formatAdditiveMigrationProofReceipt({
+ summary,
+ v11TableSchemaDigest,
+ v12TableSchemaDigest,
+ artifactDigest,
+}) {
if (
typeof summary !== 'string'
|| summary.length === 0
|| summary.trim() !== summary
|| /[\r\n]/.test(summary)
- || summary.includes(`${RECEIPT_FIELD}=`)
+ || RECEIPT_FIELDS.some(field => summary.includes(`${field}=`))
+ || typeof v11TableSchemaDigest !== 'string'
+ || !SHA256_DIGEST.test(v11TableSchemaDigest)
+ || typeof v12TableSchemaDigest !== 'string'
+ || !SHA256_DIGEST.test(v12TableSchemaDigest)
|| typeof artifactDigest !== 'string'
|| !SHA256_DIGEST.test(artifactDigest)
) {
rejectReceipt();
}
- return `${SUCCESS_PREFIX} ${summary} ${RECEIPT_FIELD}=${artifactDigest}`;
+ return `${SUCCESS_PREFIX} ${summary} `
+ + `${V11_TABLE_SCHEMA_RECEIPT_FIELD}=${v11TableSchemaDigest} `
+ + `${V12_TABLE_SCHEMA_RECEIPT_FIELD}=${v12TableSchemaDigest} `
+ + `${ARTIFACT_RECEIPT_FIELD}=${artifactDigest}`;
}
export function parseAdditiveMigrationProofReceipt(output) {
@@ -37,14 +56,26 @@ export function parseAdditiveMigrationProofReceipt(output) {
const proofLines = output.split(/\r?\n/).filter(line => (
/^Additive protocol-v\d+ migration proof passed with SpacetimeDB /.test(line)
));
- const digestFields = [...output.matchAll(/\bartifact_sha256=([^\s]*)/g)];
- if (proofLines.length !== 1 || digestFields.length !== 1) rejectReceipt();
+ const digestFields = Object.fromEntries(RECEIPT_FIELDS.map(field => [
+ field,
+ [...output.matchAll(new RegExp(`\\b${field}=([^\\s]*)`, 'g'))],
+ ]));
+ if (
+ proofLines.length !== 1
+ || RECEIPT_FIELDS.some(field => digestFields[field].length !== 1)
+ ) rejectReceipt();
const proofLine = proofLines[0];
- const artifactDigest = digestFields[0][1];
- const receiptSuffix = ` ${RECEIPT_FIELD}=${artifactDigest}`;
+ const v11TableSchemaDigest = digestFields[V11_TABLE_SCHEMA_RECEIPT_FIELD][0][1];
+ const v12TableSchemaDigest = digestFields[V12_TABLE_SCHEMA_RECEIPT_FIELD][0][1];
+ const artifactDigest = digestFields[ARTIFACT_RECEIPT_FIELD][0][1];
+ const receiptSuffix = ` ${V11_TABLE_SCHEMA_RECEIPT_FIELD}=${v11TableSchemaDigest}`
+ + ` ${V12_TABLE_SCHEMA_RECEIPT_FIELD}=${v12TableSchemaDigest}`
+ + ` ${ARTIFACT_RECEIPT_FIELD}=${artifactDigest}`;
if (
!proofLine.startsWith(`${SUCCESS_PREFIX} `)
+ || !SHA256_DIGEST.test(v11TableSchemaDigest)
+ || !SHA256_DIGEST.test(v12TableSchemaDigest)
|| !SHA256_DIGEST.test(artifactDigest)
|| !proofLine.endsWith(receiptSuffix)
|| proofLine.slice(SUCCESS_PREFIX.length + 1, -receiptSuffix.length).length === 0
@@ -52,5 +83,9 @@ export function parseAdditiveMigrationProofReceipt(output) {
rejectReceipt();
}
- return Object.freeze({ artifactDigest });
+ return Object.freeze({
+ v11TableSchemaDigest,
+ v12TableSchemaDigest,
+ artifactDigest,
+ });
}
diff --git a/scripts/spacetime-table-schema-attestation.mjs b/scripts/spacetime-table-schema-attestation.mjs
new file mode 100644
index 00000000..bca95b2b
--- /dev/null
+++ b/scripts/spacetime-table-schema-attestation.mjs
@@ -0,0 +1,179 @@
+import { createHash } from 'node:crypto';
+
+const MAX_CANONICAL_DEPTH = 128;
+
+export class TableSchemaAttestationError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = 'TableSchemaAttestationError';
+ }
+}
+
+function fail(message) {
+ throw new TableSchemaAttestationError(message);
+}
+
+function record(value) {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+function canonicalJson(value, depth = 0, ancestors = new Set()) {
+ if (depth > MAX_CANONICAL_DEPTH) fail('The table schema boundary exceeded its canonical depth limit.');
+ if (value === null || typeof value === 'boolean' || typeof value === 'string') {
+ return JSON.stringify(value);
+ }
+ if (typeof value === 'number') {
+ if (!Number.isFinite(value)) fail('The table schema boundary contained a non-finite number.');
+ return JSON.stringify(value);
+ }
+ if (typeof value !== 'object') {
+ fail('The table schema boundary contained a non-JSON value.');
+ }
+ if (ancestors.has(value)) fail('The table schema boundary contained a cycle.');
+ ancestors.add(value);
+ try {
+ if (Array.isArray(value)) {
+ return `[${value.map(entry => canonicalJson(entry, depth + 1, ancestors)).join(',')}]`;
+ }
+ const ownKeys = Reflect.ownKeys(value);
+ if (ownKeys.some(key => typeof key !== 'string')) {
+ fail('The table schema boundary contained a non-JSON object key.');
+ }
+ const keys = ownKeys.sort();
+ return `{${keys.map(key => {
+ const entry = value[key];
+ if (entry === undefined) fail('The table schema boundary contained an undefined field.');
+ return `${JSON.stringify(key)}:${canonicalJson(entry, depth + 1, ancestors)}`;
+ }).join(',')}}`;
+ } finally {
+ ancestors.delete(value);
+ }
+}
+
+function collectReferencedTypeRefs(value, refs, depth = 0, ancestors = new Set()) {
+ if (depth > MAX_CANONICAL_DEPTH) fail('The table schema type graph exceeded its depth limit.');
+ if (value === null || typeof value !== 'object') return;
+ if (ancestors.has(value)) fail('The table schema type graph contained an object cycle.');
+ ancestors.add(value);
+ try {
+ if (Array.isArray(value)) {
+ for (const entry of value) collectReferencedTypeRefs(entry, refs, depth + 1, ancestors);
+ return;
+ }
+ const keys = Object.keys(value);
+ if (Object.hasOwn(value, 'Ref')) {
+ if (keys.length !== 1 || !Number.isSafeInteger(value.Ref) || value.Ref < 0) {
+ fail('The table schema type graph contained an invalid type reference.');
+ }
+ refs.add(value.Ref);
+ return;
+ }
+ // The CLI JSON uses externally tagged `{ Ref: n }` values. Supporting the
+ // SDK's equivalent tagged shape as well keeps this helper usable in strict
+ // local fixtures without weakening the accepted reference envelope.
+ if (value.tag === 'Ref') {
+ if (
+ keys.length !== 2
+ || !keys.includes('tag')
+ || !keys.includes('value')
+ || !Number.isSafeInteger(value.value)
+ || value.value < 0
+ ) fail('The table schema type graph contained an invalid type reference.');
+ refs.add(value.value);
+ return;
+ }
+ for (const key of keys) {
+ collectReferencedTypeRefs(value[key], refs, depth + 1, ancestors);
+ }
+ } finally {
+ ancestors.delete(value);
+ }
+}
+
+function exactTableNames(value) {
+ if (!Array.isArray(value) || value.length === 0) {
+ fail('The table schema boundary requires one exact non-empty table set.');
+ }
+ const names = value.map(name => {
+ if (typeof name !== 'string' || !/^[a-z][a-z0-9_]*$/.test(name)) {
+ fail('The table schema boundary contained an invalid expected table name.');
+ }
+ return name;
+ });
+ if (new Set(names).size !== names.length) {
+ fail('The table schema boundary contained duplicate expected table names.');
+ }
+ return Object.freeze([...names].sort());
+}
+
+/**
+ * Select the complete table schema and only the typespace closure reachable
+ * from those table row roots. Reducer/procedure-only types are deliberately
+ * excluded so an operational code change cannot perturb the data boundary.
+ */
+export function canonicalTableSchemaBoundary(description, expectedTableNames) {
+ const names = exactTableNames(expectedTableNames);
+ if (
+ !record(description)
+ || !Array.isArray(description.tables)
+ || !record(description.typespace)
+ || !Array.isArray(description.typespace.types)
+ ) fail('The table schema description was invalid.');
+
+ const tablesByName = new Map();
+ for (const table of description.tables) {
+ if (
+ !record(table)
+ || typeof table.name !== 'string'
+ || tablesByName.has(table.name)
+ ) fail('The table schema description contained an invalid table descriptor.');
+ tablesByName.set(table.name, table);
+ }
+ const actualNames = [...tablesByName.keys()].sort();
+ if (
+ actualNames.length !== names.length
+ || actualNames.some((name, index) => name !== names[index])
+ ) fail('The table schema description did not match the exact table set.');
+
+ const tableDescriptors = [];
+ const pendingRefs = [];
+ for (const name of names) {
+ const table = tablesByName.get(name);
+ if (!Number.isSafeInteger(table.product_type_ref) || table.product_type_ref < 0) {
+ fail('The table schema description contained an invalid row-type reference.');
+ }
+ tableDescriptors.push(table);
+ pendingRefs.push(table.product_type_ref);
+ }
+
+ const reachableRefs = new Set();
+ while (pendingRefs.length > 0) {
+ const ref = pendingRefs.pop();
+ if (reachableRefs.has(ref)) continue;
+ const type = description.typespace.types[ref];
+ if (!record(type)) fail('The table schema description omitted a reachable row type.');
+ reachableRefs.add(ref);
+ const discovered = new Set();
+ collectReferencedTypeRefs(type, discovered);
+ for (const referencedRef of discovered) {
+ if (referencedRef >= description.typespace.types.length) {
+ fail('The table schema type graph referenced an absent type.');
+ }
+ pendingRefs.push(referencedRef);
+ }
+ }
+
+ const reachableTypes = [...reachableRefs]
+ .sort((left, right) => left - right)
+ .map(ref => Object.freeze({ ref, type: description.typespace.types[ref] }));
+ return Object.freeze({
+ protocol: 'warpkeep-table-schema-boundary-v1',
+ tables: Object.freeze(tableDescriptors),
+ reachableTypes: Object.freeze(reachableTypes),
+ });
+}
+
+export function canonicalTableSchemaBoundaryDigest(description, expectedTableNames) {
+ const boundary = canonicalTableSchemaBoundary(description, expectedTableNames);
+ return createHash('sha256').update(canonicalJson(boundary)).digest('hex');
+}
diff --git a/scripts/verify-castle-worker-additive-migration.mjs b/scripts/verify-castle-worker-additive-migration.mjs
new file mode 100644
index 00000000..8a03fca7
--- /dev/null
+++ b/scripts/verify-castle-worker-additive-migration.mjs
@@ -0,0 +1,61 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+const schemaPath = resolve(root, 'spacetimedb/src/schema.ts');
+const previousFixturePath = resolve(root, 'spacetimedb/migration-fixtures/additive-v11-schema/src/index.ts');
+const fixturePath = resolve(root, 'spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts');
+
+function registrations(source, marker) {
+ const start = source.indexOf(marker);
+ const end = source.indexOf('\n});', start);
+ assert.ok(start >= 0 && end > start, `missing schema marker: ${marker}`);
+ return source.slice(start + marker.length, end)
+ .split(/[,\n]/)
+ .map(value => value.trim())
+ .filter(value => /^[A-Za-z][A-Za-z0-9]*$/.test(value));
+}
+
+function table(source, name) {
+ const start = source.indexOf(`const ${name} = table(`);
+ const end = [source.indexOf('\n);', start), source.indexOf('\n});', start)]
+ .filter(candidate => candidate > start)
+ .sort((left, right) => left - right)[0] ?? -1;
+ assert.ok(start >= 0 && end > start, `missing table: ${name}`);
+ return source.slice(start, end);
+}
+
+const [schema, previousFixture, fixture] = await Promise.all([
+ readFile(schemaPath, 'utf8'),
+ readFile(previousFixturePath, 'utf8'),
+ readFile(fixturePath, 'utf8'),
+]);
+const current = registrations(schema, 'const warpkeep = schema({');
+const previous = registrations(previousFixture, 'const db = schema({');
+const candidate = registrations(fixture, 'const db = schema({');
+assert.equal(previous.length, 47, 'v11 fixture must end at ref 46');
+assert.deepEqual(current.slice(0, 47), previous, 'current schema changed before the v12 suffix');
+assert.deepEqual(candidate.slice(0, 47), previous, 'v12 fixture changed the deployed prefix');
+assert.deepEqual(candidate.slice(47), [
+ 'realmWorkerSystemV1',
+ 'castleWorkerV1',
+ 'workerAssignmentV1',
+ 'workerNodeOccupationV1',
+ 'workerCommandIdempotencyV1',
+ 'workerAssignmentScheduleV1',
+]);
+assert.deepEqual(current.slice(47), candidate.slice(47), 'module and fixture suffix differ');
+for (const name of ['realmWorkerSystemV1', 'castleWorkerV1', 'workerNodeOccupationV1']) {
+ const definition = table(schema, name);
+ assert.match(definition, /public: true/);
+ assert.doesNotMatch(definition, /\bfid\b|assignmentId|accruedAmount|materializedAmount|balance|requestKey|auth/i);
+}
+assert.doesNotMatch(table(schema, 'castleWorkerV1'), /createdAt|updatedAt/);
+assert.doesNotMatch(table(fixture, 'castleWorkerV1'), /createdAt|updatedAt/);
+for (const name of ['workerAssignmentV1', 'workerCommandIdempotencyV1', 'workerAssignmentScheduleV1']) {
+ assert.doesNotMatch(table(schema, name), /public: true/);
+}
+assert.match(fixture, /fixture_seed_generic_worker_sentinel_v12/);
+console.log('generic worker additive migration proof passed: refs 0–46 preserved, refs 47–52 append-only, populated fixture present, assignment correlation remains private');
diff --git a/scripts/verify-runtime-assets.mjs b/scripts/verify-runtime-assets.mjs
index 4370c9a6..99395f72 100644
--- a/scripts/verify-runtime-assets.mjs
+++ b/scripts/verify-runtime-assets.mjs
@@ -42,7 +42,25 @@ const assets = Object.freeze([
['public/images/realm/hegemony-gold-mine-record.webp', 218_736, 'a2c52a5e1536860ce3ad778c1719e354637fe473495c45ee927c99f468c60fa3', false],
['public/images/realm/hegemony-wheat-farm-record.webp', 224_806, '466c80380a8d23de043731a7c386e78c9b36a2d2e69fa175db4b87efc3f43eb0', false],
['public/images/realm/hegemony-logging-camp-record.webp', 177_622, 'fb9d171e423a7bd4bfcce1e68cd3faecb38b4904bc528f720e4283522fca1293', false],
- ['public/images/realm/hegemony-stone-quarry-record.webp', 186_736, '86b13c14a0eda7403c3583d886be3242e04d7ef9e442fcfdbcc054642421a70a', false]
+ ['public/images/realm/hegemony-stone-quarry-record.webp', 186_736, '86b13c14a0eda7403c3583d886be3242e04d7ef9e442fcfdbcc054642421a70a', false],
+ ['public/images/realm/hegemony-worker-record.webp', 86_984, 'ff758ecbf520b05ccf0a2fa490bcafa6c564514de5ee56ef5a720fd6da24193e', false]
+]);
+
+const decorativeInspectionImageAssets = Object.freeze([
+ Object.freeze({
+ path: 'public/images/realm/hegemony-worker-record.webp',
+ width: 1_024,
+ height: 1_024,
+ format: 'webp',
+ bytes: 86_984,
+ sha256: 'ff758ecbf520b05ccf0a2fa490bcafa6c564514de5ee56ef5a720fd6da24193e',
+ decodedRgbaSha256: '2e77492f76801576adcc9cfe660fa15494123bc43fcd767e005cd5ad95d8b047',
+ alpha: Object.freeze({
+ transparentPixels: 858_605,
+ partiallyTransparentPixels: 39_551,
+ opaquePixels: 150_420
+ })
+ })
]);
const referenceImageAssets = Object.freeze([
@@ -601,6 +619,38 @@ if (resourcePixels.size !== 4) {
throw new Error('The reviewed resource runtime family must contain exactly four pixel-equivalent PNG/WebP pairs.');
}
+for (const asset of decorativeInspectionImageAssets) {
+ const bytes = readContainedRegularFile({
+ root,
+ relativePath: asset.path,
+ label: `${asset.path} decorative inspection image`,
+ expectedBytes: asset.bytes
+ });
+ const hash = createHash('sha256').update(bytes).digest('hex');
+ if (hash !== asset.sha256) throw new Error(`${asset.path} hash changed: ${hash}.`);
+ const image = sharp(bytes, {
+ failOn: 'warning',
+ limitInputPixels: asset.width * asset.height
+ });
+ const metadata = await image.metadata();
+ if (
+ metadata.format !== asset.format
+ || metadata.width !== asset.width
+ || metadata.height !== asset.height
+ || metadata.channels !== 4
+ || metadata.depth !== 'uchar'
+ || metadata.hasAlpha !== true
+ ) throw new Error(`${asset.path} decoder metadata changed: ${JSON.stringify(metadata)}.`);
+ const raw = await image.ensureAlpha().raw().toBuffer();
+ const decodedHash = createHash('sha256').update(raw).digest('hex');
+ if (decodedHash !== asset.decodedRgbaSha256) {
+ throw new Error(`${asset.path} decoded RGBA hash changed: ${decodedHash}.`);
+ }
+ if (!exactRecord(alphaProfile(raw), asset.alpha)) {
+ throw new Error(`${asset.path} alpha profile changed.`);
+ }
+}
+
console.log(
`Verified ${assets.length + imageAssets.length + resourceImageAssets.length} exact runtime assets and `
+ `${referenceImageAssets.length} exact provenance reference masters.`
diff --git a/scripts/verify-spacetime-additive-migration.mjs b/scripts/verify-spacetime-additive-migration.mjs
index 29286a25..6a840cad 100644
--- a/scripts/verify-spacetime-additive-migration.mjs
+++ b/scripts/verify-spacetime-additive-migration.mjs
@@ -6,7 +6,8 @@ import {
randomBytes,
sign as signBytes,
} from 'node:crypto';
-import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { readFile, rm, stat, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { dirname, join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
@@ -17,6 +18,9 @@ import {
ADDITIVE_MIGRATION_PROOF_SPACETIME_CLI_VERSION,
formatAdditiveMigrationProofReceipt,
} from './spacetime-additive-migration-proof.mjs';
+import {
+ canonicalTableSchemaBoundaryDigest,
+} from './spacetime-table-schema-attestation.mjs';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const fixtureModule = resolve(
@@ -63,6 +67,10 @@ const additiveV11SchemaFixture = resolve(
repositoryRoot,
'spacetimedb/migration-fixtures/additive-v11-schema',
);
+const additiveV12SchemaFixture = resolve(
+ repositoryRoot,
+ 'spacetimedb/migration-fixtures/additive-v12-schema',
+);
const additiveModule = resolve(repositoryRoot, 'spacetimedb');
const command = process.env.SPACETIME_BIN || 'spacetime';
const expectedCliVersion = ADDITIVE_MIGRATION_PROOF_SPACETIME_CLI_VERSION;
@@ -243,6 +251,14 @@ const additiveV10Tables = Object.freeze([
const additiveV11Tables = Object.freeze([
'realm_water_revision_v1',
]);
+const additiveV12Tables = Object.freeze([
+ 'realm_worker_system_v1',
+ 'castle_worker_v1',
+ 'worker_assignment_v1',
+ 'worker_node_occupation_v1',
+ 'worker_command_idempotency_v1',
+ 'worker_assignment_schedule_v_1',
+]);
const deployedV3Tables = Object.freeze([
...existingTables,
...additiveV3Tables,
@@ -279,6 +295,10 @@ const deployedV11Tables = Object.freeze([
...deployedV10Tables,
...additiveV11Tables,
]);
+const deployedV12Tables = Object.freeze([
+ ...deployedV11Tables,
+ ...additiveV12Tables,
+]);
const expectedProductTypeRefs = Object.freeze({
allowed_fid: 0,
world_tile: 1,
@@ -327,6 +347,12 @@ const expectedProductTypeRefs = Object.freeze({
stone_expedition_idempotency_v1: 44,
stone_expedition_schedule_v_1: 45,
realm_water_revision_v1: 46,
+ realm_worker_system_v1: 47,
+ castle_worker_v1: 48,
+ worker_assignment_v1: 49,
+ worker_node_occupation_v1: 50,
+ worker_command_idempotency_v1: 51,
+ worker_assignment_schedule_v_1: 52,
});
const childEnvironmentKeys = Object.freeze([
'PATH', 'HOME', 'USER', 'LOGNAME', 'TMPDIR', 'TMP', 'TEMP',
@@ -1194,6 +1220,87 @@ function assertAdditiveV11Schema(before, after) {
);
}
+function assertDeployedV11TablesUnchanged(before, after) {
+ for (const name of deployedV11Tables) {
+ assert.deepEqual(tableSignature(after, name), tableSignature(before, name));
+ assert.equal(
+ tableSignature(after, name).product_type_ref,
+ expectedProductTypeRefs[name],
+ );
+ }
+}
+
+function assertAdditiveV12Schema(before, after) {
+ assertDeployedV11TablesUnchanged(before, after);
+ const beforeNames = new Set(before.tables.map(table => table.name));
+ const added = after.tables
+ .map(table => table.name)
+ .filter(name => !beforeNames.has(name))
+ .sort();
+ assert.deepEqual(added, [...additiveV12Tables].sort());
+ const contracts = {
+ realm_worker_system_v1: {
+ access: 'Public',
+ fields: [
+ 'realm_id', 'policy_version', 'workers_per_castle', 'expected_castle_count',
+ 'expected_worker_count', 'roster_digest', 'mode', 'legacy_drain_required',
+ 'created_at', 'activated_at',
+ ],
+ },
+ castle_worker_v1: {
+ access: 'Public',
+ fields: [
+ 'worker_id', 'origin_castle_id', 'ordinal', 'status', 'resource_kind',
+ 'site_id', 'started_at_micros', 'arrives_at_micros',
+ 'gathering_ends_at_micros', 'return_started_at_micros',
+ 'returns_at_micros', 'route_steps', 'return_start_progress_basis_points',
+ 'timeline_revision', 'revision',
+ ],
+ },
+ worker_assignment_v1: {
+ access: 'Private',
+ fields: [
+ 'assignment_id', 'worker_id', 'fid', 'origin_castle_id', 'resource_kind',
+ 'site_id', 'phase', 'started_at_micros', 'arrives_at_micros',
+ 'gathering_ends_at_micros', 'return_started_at_micros',
+ 'returns_at_micros', 'route_steps', 'return_start_progress_basis_points',
+ 'settled_through_micros', 'accrued_amount', 'materialized_amount',
+ 'timeline_revision', 'policy_version', 'created_at', 'updated_at',
+ ],
+ },
+ worker_node_occupation_v1: {
+ access: 'Public',
+ fields: [
+ 'node_key', 'resource_kind', 'site_id', 'worker_id', 'worker_ordinal',
+ 'origin_castle_id', 'phase', 'started_at_micros', 'arrives_at_micros',
+ 'gathering_ends_at_micros', 'timeline_revision',
+ ],
+ },
+ worker_command_idempotency_v1: {
+ access: 'Private',
+ fields: [
+ 'request_key', 'fid', 'worker_id', 'command_kind', 'resource_kind',
+ 'site_id', 'assignment_id', 'result_revision', 'created_at',
+ ],
+ },
+ worker_assignment_schedule_v_1: {
+ access: 'Private',
+ fields: [
+ 'schedule_id', 'scheduled_at', 'assignment_id', 'worker_id',
+ 'timeline_revision', 'stage',
+ ],
+ },
+ };
+ for (const [name, contract] of Object.entries(contracts)) {
+ assert.deepEqual(fieldNames(after, name), contract.fields);
+ assert.equal(access(after, name), contract.access);
+ assert.equal(
+ tableSignature(after, name).product_type_ref,
+ expectedProductTypeRefs[name],
+ );
+ }
+}
+
async function freeLoopbackPort() {
return new Promise((resolvePromise, rejectPromise) => {
const server = createServer();
@@ -2029,9 +2136,9 @@ async function verifyActualModuleResourceLifecycle(server, database, privateKey,
let stage = 'seed';
let activeModule = 'actual';
const actualArtifactPath = join(additiveModule, 'dist', 'bundle.js');
- // Keep inspection on the complete v11 candidate schema. Reverting to a
+ // Keep inspection on the complete v12 candidate schema. Reverting to a
// predecessor fixture after Stone is appended would be destructive.
- const inspectionArtifactPath = join(additiveV11SchemaFixture, 'dist', 'bundle.js');
+ const inspectionArtifactPath = join(additiveV12SchemaFixture, 'dist', 'bundle.js');
const useActualModule = async () => {
if (activeModule === 'actual') return;
await publishBuiltArtifact(server, ownerToken, actualArtifactPath, database);
@@ -2846,9 +2953,9 @@ async function verifyActualModuleExpeditionLifecycles(
let stage = 'seed-world';
let activeModule = 'actual';
const actualArtifactPath = join(additiveModule, 'dist', 'bundle.js');
- // Reusing the candidate fixture preserves the complete v11 suffix during
+ // Reusing the candidate fixture preserves the complete v12 suffix during
// SQL inspection; publishing any predecessor would request a downgrade.
- const inspectionArtifactPath = join(additiveV11SchemaFixture, 'dist', 'bundle.js');
+ const inspectionArtifactPath = join(additiveV12SchemaFixture, 'dist', 'bundle.js');
const useActualModule = async () => {
if (activeModule === 'actual') return;
await publishBuiltArtifact(server, ownerToken, actualArtifactPath, database);
@@ -3286,7 +3393,7 @@ async function verifyActualModuleWaterLifecycle(server, database, privateKey, ow
let stage = 'publish';
let activeModule = 'actual';
const actualArtifactPath = join(additiveModule, 'dist', 'bundle.js');
- const inspectionArtifactPath = join(additiveV11SchemaFixture, 'dist', 'bundle.js');
+ const inspectionArtifactPath = join(additiveV12SchemaFixture, 'dist', 'bundle.js');
const adminCredential = () => createEphemeralJwt(privateKey, adminServiceClaims());
const useActualModule = async () => {
if (activeModule === 'actual') return;
@@ -3757,7 +3864,7 @@ async function verifyGenesisWorldExpansionLifecycle(
// Wood append. Reverting to an earlier protocol after publishing the
// candidate would correctly be rejected as a destructive schema downgrade.
const fixtureArtifactPath = join(additiveV8SchemaFixture, 'dist', 'bundle.js');
- const inspectionArtifactPath = join(additiveV11SchemaFixture, 'dist', 'bundle.js');
+ const inspectionArtifactPath = join(additiveV12SchemaFixture, 'dist', 'bundle.js');
const adminCredential = () => createEphemeralJwt(privateKey, adminServiceClaims());
await publishBuiltArtifact(server, ownerToken, fixtureArtifactPath, database);
@@ -3944,6 +4051,44 @@ export async function cleanupMigrationProofResources(
if (stopFailure !== undefined) throw stopFailure;
}
+export function installMigrationProofSignalCleanup(
+ cleanup,
+ processTarget = process,
+) {
+ if (
+ typeof cleanup !== 'function'
+ || typeof processTarget?.on !== 'function'
+ || typeof processTarget?.removeListener !== 'function'
+ || typeof processTarget?.exit !== 'function'
+ ) fail('Migration proof signal cleanup setup was invalid.');
+
+ let handled = false;
+ const handlers = {
+ SIGINT: () => handleSignal('SIGINT'),
+ SIGTERM: () => handleSignal('SIGTERM'),
+ };
+ const remove = () => {
+ processTarget.removeListener('SIGINT', handlers.SIGINT);
+ processTarget.removeListener('SIGTERM', handlers.SIGTERM);
+ };
+ const handleSignal = signal => {
+ if (handled) return;
+ handled = true;
+ let exitCode = signal === 'SIGINT' ? 130 : 143;
+ try {
+ cleanup();
+ } catch {
+ exitCode = 1;
+ }
+ remove();
+ processTarget.exit(exitCode);
+ };
+
+ processTarget.on('SIGINT', handlers.SIGINT);
+ processTarget.on('SIGTERM', handlers.SIGTERM);
+ return remove;
+}
+
async function verifyCliVersion() {
const result = await runCommand(['--version'], { timeout: 10_000 });
if (
@@ -3958,7 +4103,40 @@ async function main() {
const port = await freeLoopbackPort();
const server = `http://127.0.0.1:${port}`;
if (!/^http:\/\/127\.0\.0\.1:\d+$/.test(server)) fail('Migration proof was not loopback-only.');
- const dataDirectory = await mkdtemp(join(tmpdir(), 'warpkeep-stdb-migration-'));
+ let dataDirectory;
+ let serverProcess;
+ const removeSignalCleanup = installMigrationProofSignalCleanup(() => {
+ disposableCliCredential = null;
+ let stopFailed = false;
+ if (
+ serverProcess !== undefined
+ && serverProcess.exitCode === null
+ && serverProcess.signalCode === null
+ ) {
+ try {
+ if (!serverProcess.kill('SIGKILL')) stopFailed = true;
+ } catch {
+ stopFailed = true;
+ }
+ }
+ let removalFailed = false;
+ try {
+ if (typeof dataDirectory === 'string') {
+ rmSync(dataDirectory, { recursive: true, force: true });
+ }
+ } catch {
+ removalFailed = true;
+ }
+ if (stopFailed || removalFailed) {
+ fail('Interrupted migration proof cleanup failed.');
+ }
+ });
+ try {
+ dataDirectory = mkdtempSync(join(tmpdir(), 'warpkeep-stdb-migration-'));
+ } catch {
+ removeSignalCleanup();
+ fail('Private migration proof directory setup failed.');
+ }
const publicKeyPath = join(dataDirectory, 'jwt-public.pem');
const privateKeyPath = join(dataDirectory, 'jwt-private.pem');
let privateKey;
@@ -3979,15 +4157,18 @@ async function main() {
}
} catch (error) {
try {
- await rm(dataDirectory, { recursive: true, force: true });
- } catch {
- fail('Ephemeral loopback signing-key cleanup failed.');
+ try {
+ await rm(dataDirectory, { recursive: true, force: true });
+ } catch {
+ fail('Ephemeral loopback signing-key cleanup failed.');
+ }
+ } finally {
+ removeSignalCleanup();
}
if (error instanceof MigrationProofError) throw error;
fail('Ephemeral loopback signing-key setup failed.');
}
- let serverProcess;
try {
serverProcess = containServerProcessErrors(spawn(command, [
'start',
@@ -4004,9 +4185,13 @@ async function main() {
}));
} catch {
try {
- await rm(dataDirectory, { recursive: true, force: true });
- } catch {
- fail('Loopback server startup cleanup failed.');
+ try {
+ await rm(dataDirectory, { recursive: true, force: true });
+ } catch {
+ fail('Loopback server startup cleanup failed.');
+ }
+ } finally {
+ removeSignalCleanup();
}
fail('Loopback server could not start.');
}
@@ -4488,6 +4673,20 @@ async function main() {
populatedWaterStoneV10,
populatedWaterStoneV11,
);
+ const provenV11TableSchemaDigest = canonicalTableSchemaBoundaryDigest(
+ emptyV11,
+ deployedV11Tables,
+ );
+ for (const description of [
+ nonemptyV11,
+ actualModuleV11,
+ populatedWaterStoneV11,
+ ]) {
+ assert.equal(
+ canonicalTableSchemaBoundaryDigest(description, deployedV11Tables),
+ provenV11TableSchemaDigest,
+ );
+ }
for (const name of deployedV11Tables) {
assert.deepEqual(
tableSignature(actualModuleV11, name),
@@ -4548,28 +4747,144 @@ async function main() {
populatedWaterStoneV11Rows,
);
- // Advance every database to the real v11 candidate so the implementation
- // is exercised against the exact v11 table contract without production.
+ // Freeze v12 independently, then prove a populated canonical v11 -> v12
+ // migration before exercising the real candidate artifact.
+ await publish(server, owner.token, additiveV12SchemaFixture, emptyDatabase);
+ await publish(server, owner.token, additiveV12SchemaFixture, nonemptyDatabase);
+ await publish(server, owner.token, additiveV12SchemaFixture, actualModuleDatabase);
+ await publish(server, owner.token, additiveV12SchemaFixture, resourceLifecycleDatabase);
+ await publish(
+ server,
+ owner.token,
+ additiveV12SchemaFixture,
+ populatedWaterStoneMigrationDatabase,
+ );
+ const emptyV12 = await describe(server, owner.token, emptyDatabase);
+ const nonemptyV12 = await describe(server, owner.token, nonemptyDatabase);
+ const actualModuleV12 = await describe(server, owner.token, actualModuleDatabase);
+ const populatedWaterStoneV12 = await describe(
+ server,
+ owner.token,
+ populatedWaterStoneMigrationDatabase,
+ );
+ assertAdditiveV12Schema(emptyV11, emptyV12);
+ assertAdditiveV12Schema(nonemptyV11, nonemptyV12);
+ assertAdditiveV12Schema(actualModuleV11, actualModuleV12);
+ assertAdditiveV12Schema(populatedWaterStoneV11, populatedWaterStoneV12);
+ const fixtureV12TableSchemaDigest = canonicalTableSchemaBoundaryDigest(
+ emptyV12,
+ deployedV12Tables,
+ );
+ for (const description of [
+ nonemptyV12,
+ actualModuleV12,
+ populatedWaterStoneV12,
+ ]) {
+ assert.equal(
+ canonicalTableSchemaBoundaryDigest(description, deployedV12Tables),
+ fixtureV12TableSchemaDigest,
+ );
+ }
+ for (const name of deployedV12Tables) {
+ assert.deepEqual(
+ tableSignature(actualModuleV12, name),
+ tableSignature(emptyV12, name),
+ );
+ }
+ assert.deepEqual(
+ await tableRowDigests(
+ server,
+ owner.token,
+ populatedWaterStoneMigrationDatabase,
+ deployedV11Tables,
+ ),
+ populatedWaterStoneV11Rows,
+ );
+ for (const table of additiveV12Tables) {
+ assert.equal(await count(
+ server,
+ owner.token,
+ populatedWaterStoneMigrationDatabase,
+ table,
+ ), 0n);
+ }
+ await callLoopbackReducer(
+ server,
+ populatedWaterStoneMigrationDatabase,
+ 'fixture_seed_generic_worker_sentinel_v12',
+ owner.token,
+ '[]',
+ 200,
+ );
+ const expectedPopulatedV12Counts = new Map([
+ ['realm_worker_system_v1', 1n],
+ ['castle_worker_v1', 4n],
+ ['worker_assignment_v1', 1n],
+ ['worker_node_occupation_v1', 1n],
+ ['worker_command_idempotency_v1', 1n],
+ ['worker_assignment_schedule_v_1', 1n],
+ ]);
+ for (const [table, expectedCount] of expectedPopulatedV12Counts) {
+ assert.equal(await count(
+ server,
+ owner.token,
+ populatedWaterStoneMigrationDatabase,
+ table,
+ ), expectedCount);
+ }
+ const populatedWaterStoneV12Rows = await tableRowDigests(
+ server,
+ owner.token,
+ populatedWaterStoneMigrationDatabase,
+ deployedV12Tables,
+ );
+ const populatedWaterStoneV12SchemaDigest = schemaDigest(
+ await describe(server, owner.token, populatedWaterStoneMigrationDatabase),
+ );
+ await publish(
+ server,
+ owner.token,
+ additiveV11SchemaFixture,
+ populatedWaterStoneMigrationDatabase,
+ false,
+ /break|delete|remove|migration|incompatible|data loss|table/i,
+ );
+ assert.equal(
+ schemaDigest(await describe(server, owner.token, populatedWaterStoneMigrationDatabase)),
+ populatedWaterStoneV12SchemaDigest,
+ );
+ assert.deepEqual(
+ await tableRowDigests(
+ server,
+ owner.token,
+ populatedWaterStoneMigrationDatabase,
+ deployedV12Tables,
+ ),
+ populatedWaterStoneV12Rows,
+ );
+
+ // Advance every database to the real v12 candidate so the implementation
+ // is exercised against the exact v12 table contract without production.
await publish(server, owner.token, additiveModule, emptyDatabase);
await publish(server, owner.token, additiveModule, nonemptyDatabase);
await publish(server, owner.token, additiveModule, actualModuleDatabase);
await publish(server, owner.token, additiveModule, resourceLifecycleDatabase);
await publish(server, owner.token, additiveModule, populatedWaterStoneMigrationDatabase);
- const populatedWaterStoneCandidateV11 = await describe(
+ const populatedWaterStoneCandidateV12 = await describe(
server,
owner.token,
populatedWaterStoneMigrationDatabase,
);
- for (const name of deployedV11Tables) {
+ for (const name of deployedV12Tables) {
assert.deepEqual(
- tableSignature(populatedWaterStoneCandidateV11, name),
- tableSignature(emptyV11, name),
+ tableSignature(populatedWaterStoneCandidateV12, name),
+ tableSignature(emptyV12, name),
);
}
await publish(
server,
owner.token,
- additiveV11SchemaFixture,
+ additiveV12SchemaFixture,
populatedWaterStoneMigrationDatabase,
);
assert.deepEqual(
@@ -4577,9 +4892,9 @@ async function main() {
server,
owner.token,
populatedWaterStoneMigrationDatabase,
- deployedV11Tables,
+ deployedV12Tables,
),
- populatedWaterStoneV11Rows,
+ populatedWaterStoneV12Rows,
);
await verifyResolverHttpLifecycle(server, actualModuleDatabase, privateKey);
const worldExpansionDurationMilliseconds = await verifyGenesisWorldExpansionLifecycle(
@@ -4616,30 +4931,41 @@ async function main() {
const builtArtifactDigest = createHash('sha256')
.update(await readFile(builtArtifactPath))
.digest('hex');
- const emptyCandidateV11 = await describe(server, owner.token, emptyDatabase);
- const nonemptyCandidateV11 = await describe(server, owner.token, nonemptyDatabase);
- const actualCandidateV11 = await describe(server, owner.token, actualModuleDatabase);
- for (const name of deployedV11Tables) {
+ const emptyCandidateV12 = await describe(server, owner.token, emptyDatabase);
+ const nonemptyCandidateV12 = await describe(server, owner.token, nonemptyDatabase);
+ const actualCandidateV12 = await describe(server, owner.token, actualModuleDatabase);
+ const provenV12TableSchemaDigest = canonicalTableSchemaBoundaryDigest(
+ emptyCandidateV12,
+ deployedV12Tables,
+ );
+ assert.equal(provenV12TableSchemaDigest, fixtureV12TableSchemaDigest);
+ for (const description of [nonemptyCandidateV12, actualCandidateV12]) {
+ assert.equal(
+ canonicalTableSchemaBoundaryDigest(description, deployedV12Tables),
+ provenV12TableSchemaDigest,
+ );
+ }
+ for (const name of deployedV12Tables) {
assert.deepEqual(
- tableSignature(actualCandidateV11, name),
- tableSignature(emptyV11, name),
+ tableSignature(actualCandidateV12, name),
+ tableSignature(emptyV12, name),
);
assert.deepEqual(
- tableSignature(nonemptyCandidateV11, name),
- tableSignature(nonemptyV11, name),
+ tableSignature(nonemptyCandidateV12, name),
+ tableSignature(nonemptyV12, name),
);
assert.deepEqual(
- tableSignature(actualCandidateV11, name),
- tableSignature(actualModuleV11, name),
+ tableSignature(actualCandidateV12, name),
+ tableSignature(actualModuleV12, name),
);
}
// The candidate's on-connect policy intentionally rejects the disposable
- // owner identity. Reuse the table-identical, auth-neutral v11 fixture before
+ // owner identity. Reuse the table-identical, auth-neutral v12 fixture before
// owner SQL reads and never downgrade the schema suffix.
- await publish(server, owner.token, additiveV11SchemaFixture, emptyDatabase);
- await publish(server, owner.token, additiveV11SchemaFixture, nonemptyDatabase);
- await publish(server, owner.token, additiveV11SchemaFixture, actualModuleDatabase);
- // SQL preservation reads remain on the complete v11 candidate. No reducer
+ await publish(server, owner.token, additiveV12SchemaFixture, emptyDatabase);
+ await publish(server, owner.token, additiveV12SchemaFixture, nonemptyDatabase);
+ await publish(server, owner.token, additiveV12SchemaFixture, actualModuleDatabase);
+ // SQL preservation reads remain on the complete v12 candidate. No reducer
// is invoked by these owner-only queries.
for (const [database, beforeRows] of [
[emptyDatabase, emptyV7Rows],
@@ -4652,7 +4978,7 @@ async function main() {
);
}
- const idempotentSchemaBefore = schemaDigest(nonemptyCandidateV11);
+ const idempotentSchemaBefore = schemaDigest(nonemptyCandidateV12);
await publishBuiltArtifact(
server,
owner.token,
@@ -4663,10 +4989,10 @@ async function main() {
schemaDigest(await describe(server, owner.token, nonemptyDatabase)),
idempotentSchemaBefore,
);
- await publish(server, owner.token, additiveV11SchemaFixture, nonemptyDatabase);
+ await publish(server, owner.token, additiveV12SchemaFixture, nonemptyDatabase);
// The actual module correctly rejects the disposable local identity at its
- // on-connect boundary; owner SQL still reads the unchanged v11 rows.
+ // on-connect boundary; owner SQL still reads the unchanged v12 rows.
assert.equal(await count(server, owner.token, emptyDatabase, 'player'), 0n);
assert.equal(await count(server, owner.token, emptyDatabase, 'player_v2'), 0n);
await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 0);
@@ -4691,6 +5017,7 @@ async function main() {
...additiveV9Tables,
...additiveV10Tables,
...additiveV11Tables,
+ ...additiveV12Tables,
]) {
assert.equal(await count(server, owner.token, database, table), 0n);
}
@@ -4721,7 +5048,7 @@ async function main() {
)), actualModuleWorldBefore);
// Identity columns reject arbitrary SQL literals after the candidate's
- // issuer boundary is active. The auth-neutral v11 fixture inserts the
+ // issuer boundary is active. The auth-neutral v12 fixture inserts the
// caller's verified sender identity through a disposable reducer instead.
await callLoopbackReducer(
server,
@@ -4743,10 +5070,11 @@ async function main() {
...additiveV9Tables,
...additiveV10Tables,
...additiveV11Tables,
+ ...additiveV12Tables,
]) {
assert.equal(await count(server, owner.token, emptyDatabase, table), 0n);
}
- const populatedV11SchemaDigest = schemaDigest(await describe(server, owner.token, emptyDatabase));
+ const populatedV12SchemaDigest = schemaDigest(await describe(server, owner.token, emptyDatabase));
await callLoopbackReducer(
server,
@@ -4768,7 +5096,7 @@ async function main() {
);
assert.equal(
schemaDigest(await describe(server, owner.token, emptyDatabase)),
- populatedV11SchemaDigest,
+ populatedV12SchemaDigest,
);
await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 1);
assert.equal(await count(server, owner.token, emptyDatabase, 'castle_slot_v1'), 1n);
@@ -4781,6 +5109,7 @@ async function main() {
...additiveV9Tables,
...additiveV10Tables,
...additiveV11Tables,
+ ...additiveV12Tables,
]) {
assert.equal(await count(server, owner.token, emptyDatabase, table), 0n);
}
@@ -4794,7 +5123,7 @@ async function main() {
);
assert.equal(
schemaDigest(await describe(server, owner.token, emptyDatabase)),
- populatedV11SchemaDigest,
+ populatedV12SchemaDigest,
);
await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 1);
assert.equal(await count(server, owner.token, emptyDatabase, 'castle_slot_v1'), 1n);
@@ -4807,6 +5136,7 @@ async function main() {
...additiveV9Tables,
...additiveV10Tables,
...additiveV11Tables,
+ ...additiveV12Tables,
]) {
assert.equal(await count(server, owner.token, emptyDatabase, table), 0n);
}
@@ -4820,7 +5150,7 @@ async function main() {
);
assert.equal(
schemaDigest(await describe(server, owner.token, emptyDatabase)),
- populatedV11SchemaDigest,
+ populatedV12SchemaDigest,
);
await publish(
server,
@@ -4832,7 +5162,7 @@ async function main() {
);
assert.equal(
schemaDigest(await describe(server, owner.token, emptyDatabase)),
- populatedV11SchemaDigest,
+ populatedV12SchemaDigest,
);
await publish(
server,
@@ -4844,10 +5174,10 @@ async function main() {
);
assert.equal(
schemaDigest(await describe(server, owner.token, emptyDatabase)),
- populatedV11SchemaDigest,
+ populatedV12SchemaDigest,
);
- // The immediate v11 -> v10 rollback must be refused before it can remove
- // the Water revision. Older boundaries continue protecting Stone and Water.
+ // The v12 boundary must refuse every predecessor before any generic-worker
+ // or Water table can be removed.
await publish(
server,
owner.token,
@@ -4858,7 +5188,7 @@ async function main() {
);
assert.equal(
schemaDigest(await describe(server, owner.token, emptyDatabase)),
- populatedV11SchemaDigest,
+ populatedV12SchemaDigest,
);
await publish(
server,
@@ -4870,7 +5200,7 @@ async function main() {
);
assert.equal(
schemaDigest(await describe(server, owner.token, emptyDatabase)),
- populatedV11SchemaDigest,
+ populatedV12SchemaDigest,
);
await publish(
server,
@@ -4882,7 +5212,7 @@ async function main() {
);
assert.equal(
schemaDigest(await describe(server, owner.token, emptyDatabase)),
- populatedV11SchemaDigest,
+ populatedV12SchemaDigest,
);
// Older fixture rollbacks remain refused as well.
await publish(
@@ -4895,17 +5225,17 @@ async function main() {
);
assert.equal(
schemaDigest(await describe(server, owner.token, emptyDatabase)),
- populatedV11SchemaDigest,
+ populatedV12SchemaDigest,
);
await publish(server, owner.token, additiveModule, emptyDatabase);
- assertAdditiveV11Schema(
- emptyV10,
+ assertAdditiveV12Schema(
+ emptyV11,
await describe(server, owner.token, emptyDatabase),
);
// Reuse the table-identical auth-neutral fixture for the final bounded
// identity assertion; the candidate itself deliberately rejects the
// disposable owner issuer before any private identity SQL can run.
- await publish(server, owner.token, additiveV11SchemaFixture, emptyDatabase);
+ await publish(server, owner.token, additiveV12SchemaFixture, emptyDatabase);
await assertFixtureOwnershipCount(server, owner.token, emptyDatabase, 999999, 1);
assert.equal(await count(server, owner.token, emptyDatabase, 'castle_slot_v1'), 1n);
for (const table of [
@@ -4917,6 +5247,7 @@ async function main() {
...additiveV9Tables,
...additiveV10Tables,
...additiveV11Tables,
+ ...additiveV12Tables,
]) {
assert.equal(await count(server, owner.token, emptyDatabase, table), 0n);
}
@@ -4939,7 +5270,9 @@ async function main() {
+ 'public Tier-I Stone sites, identity-minimized occupations, and public-safe lifecycle schedule projection plus private Stone expedition and idempotency '
+ 'tables appended at exact refs 41-45, '
+ 'public ocean-and-river Water revision policy appended at exact ref 46, '
- + '61-tile empty, synthetic nonempty, and populated v10 Water/Stone fixtures remained preserved through v11, '
+ + 'identity-safe generic worker readiness, roster, assignment, occupation, bounded receipt, and private schedule tables appended at exact refs 47-52, '
+ + '61-tile empty, synthetic nonempty, and populated Water/Stone/Water-revision fixtures remained preserved through v12, '
+ + 'every v12 table was populated, retained through the real candidate, and protected from a v12-to-v11 downgrade, '
+ 'exact resolver HTTP lifecycle enforced without mutation, '
+ `atomic 1,261-to-10,000 world expansion proved in ${worldExpansionDurationMilliseconds}ms with an idempotent retry, `
+ `actual Water administration exercised with ${waterLifecycleProof}, `
@@ -4951,13 +5284,19 @@ async function main() {
+ 'presentation-independent founder monitoring and bootstrap, '
+ 'legacy first-time admission rejection and complete-graph re-enable preservation, '
+ 'and guarded backfill rejection/idempotence held, '
- + 'prebuilt-artifact republish idempotent, populated v3-prefix state retained through v11, '
- + 'and guarded v10/v9/v8/v7/v6/v5/v4/v3/v2 rollbacks refused before schema change.',
+ + 'prebuilt-artifact republish idempotent, populated v3-prefix state retained through v12, '
+ + 'and guarded v11/v10/v9/v8/v7/v6/v5/v4/v3/v2 rollbacks refused before schema change.',
+ v11TableSchemaDigest: provenV11TableSchemaDigest,
+ v12TableSchemaDigest: provenV12TableSchemaDigest,
artifactDigest: builtArtifactDigest,
}));
} finally {
disposableCliCredential = null;
- await cleanupMigrationProofResources(serverProcess, dataDirectory);
+ try {
+ await cleanupMigrationProofResources(serverProcess, dataDirectory);
+ } finally {
+ removeSignalCleanup();
+ }
}
}
diff --git a/services/auth-bridge/.dev.vars.example b/services/auth-bridge/.dev.vars.example
index da073e47..41c273d0 100644
--- a/services/auth-bridge/.dev.vars.example
+++ b/services/auth-bridge/.dev.vars.example
@@ -1,11 +1,15 @@
# Development only. Copy to .dev.vars; never commit the copy.
# Generate SIGNING_KEY_JWK separately and use a different key from production.
ENVIRONMENT=development
-ISSUER=http://localhost:8787
+# The bridge itself rejects plaintext requests; use a local TLS endpoint/proxy.
+ISSUER=https://localhost:8787
ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
FARCASTER_DOMAIN=localhost:5173
FARCASTER_SIWE_URI=http://localhost:5173/
-FARCASTER_RPC_URL=https://YOUR-OPTIMISM-RPC.example
+# A single endpoint is accepted only for explicit development on loopback.
+FARCASTER_RPC_URL=http://127.0.0.1:8545
+# Production requires a second HTTPS endpoint on an independent public origin.
+# FARCASTER_RPC_URL_SECONDARY=https://YOUR-SECOND-OPTIMISM-RPC.example
OIDC_AUDIENCE=warpkeep-spacetimedb
OIDC_KEY_ID=warpkeep-dev-2026-01
SPACETIMEDB_URI=http://127.0.0.1:3000
diff --git a/services/auth-bridge/README.md b/services/auth-bridge/README.md
index 5c7a42c2..b49976de 100644
--- a/services/auth-bridge/README.md
+++ b/services/auth-bridge/README.md
@@ -318,11 +318,15 @@ proof, cookie, or profile payload.
`wrangler.toml` declares `workers_dev = false`, the `auth.warpkeep.com`
custom-domain route, `PUBLIC_AUTH_ENABLED = "false"`, and the non-secret
-issuer/origin/database contract. `FARCASTER_RPC_URL`, `SIGNING_KEY_JWK`,
-`ADMIN_TOKEN_SECRET`, and the independent `SESSION_COOKIE_KEY` are managed
-Worker secrets. Both symmetric secrets require at least 32 random bytes and
-all three secret materials must be pairwise distinct, including the private
-`d` scalar inside `SIGNING_KEY_JWK`. `CHALLENGE_REPLAY_GUARD`,
+issuer/origin/database contract. `FARCASTER_RPC_URL`,
+`FARCASTER_RPC_URL_SECONDARY`, `SIGNING_KEY_JWK`, `ADMIN_TOKEN_SECRET`, and the
+independent `SESSION_COOKIE_KEY` are managed Worker secrets. Production accepts
+only two distinct public HTTPS RPC origins. Both official verifier instances
+must independently validate the proof and return the same canonical FID; an
+outage, partial result, or disagreement returns no token. Explicit development
+may instead use one loopback RPC endpoint. Both symmetric secrets require at
+least 32 random bytes and all three secret materials must be pairwise distinct,
+including the private `d` scalar inside `SIGNING_KEY_JWK`. `CHALLENGE_REPLAY_GUARD`,
`AUTH_RATE_LIMITER`, and `SESSION_FAMILIES` are separate SQLite Durable Object
bindings. `QA_CHALLENGE_REPLAY_GUARD` is a fourth isolated SQLite binding; its
additive `QaChallengeReplayGuard` migration requires explicit operator approval
@@ -344,14 +348,17 @@ same Maincloud/database pair. Development remains explicitly configurable and is
not accepted as a production activation profile.
The server-only `POST /v1/admin/config-attestation` route additionally returns
-the independent QA gate, observer URI/database/audience tuple, registered
-public-key fingerprint, canonical registration/expiry timestamps, and maximum
-registration lifetime after admin-secret authentication. The SHA-256 digest
-covers issuer, origins, SIWF coordinates, gameplay audience/key/Maincloud
-coordinates, observer URI/database/audience coordinates, environment, S256
-binding, player/resolver lifetimes, QA scope/procedure/lifetimes, both gates,
-the registered QA fingerprint/registration/expiry/lifetime, the 30-day family
-ceiling, and exact cookie attributes. Operators must compare it with the
+the sorted fingerprints of the exact RPC endpoints, the active signing public
+key's RFC 7638 thumbprint, the independent QA gate, observer
+URI/database/audience tuple, registered public-key fingerprint, canonical
+registration/expiry timestamps, and maximum registration lifetime after
+admin-secret authentication. Each endpoint fingerprint is lowercase hex
+`SHA-256("warpkeep-farcaster-rpc-endpoint-v1\0" + normalizedExactUrl)`, where
+`\0` is one NUL separator; the endpoint URLs themselves are never returned. The
+digest covers those values
+along with issuer, origins, SIWF coordinates, gameplay audience/key/Maincloud
+coordinates, observer coordinates, environment, binding and bounded lifetimes,
+both gates, and exact cookie attributes. Operators must compare it with the
reviewed expected configuration; it is not a deployment action and reveals no
secret material.
diff --git a/services/auth-bridge/src/app.ts b/services/auth-bridge/src/app.ts
index dec53ba5..17f5c63c 100644
--- a/services/auth-bridge/src/app.ts
+++ b/services/auth-bridge/src/app.ts
@@ -676,10 +676,41 @@ async function timingSafeSecretMatch(provided: string, expected: string): Promis
return difference === 0
}
+async function sha256Hex(value: string): Promise {
+ const bytes = new TextEncoder().encode(value)
+ try {
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
+ return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('')
+ } finally {
+ bytes.fill(0)
+ }
+}
+
+/**
+ * Privacy-safe stable identity for an exact configured RPC URL. The URL itself
+ * can contain provider credentials and must never enter an attestation response.
+ */
+export function farcasterRpcEndpointFingerprint(rpcUrl: string): Promise {
+ return sha256Hex(`warpkeep-farcaster-rpc-endpoint-v1\0${rpcUrl}`)
+}
+
async function configurationAttestation(
config: BridgeConfig,
qaObserverKeyFingerprint: string | null,
-): Promise {
+): Promise> {
+ const farcasterRpcEndpointFingerprints = Object.freeze((await Promise.all(
+ config.farcasterRpcUrls.map(farcasterRpcEndpointFingerprint),
+ )).sort())
+ const signingPublicKeyThumbprint = await qaObserverKeyThumbprint({
+ kty: 'EC',
+ crv: 'P-256',
+ x: config.privateJwk.x,
+ y: config.privateJwk.y,
+ })
const canonical = JSON.stringify({
profile: 'warpkeep-auth-v2',
issuer: config.issuer,
@@ -688,6 +719,8 @@ async function configurationAttestation(
siweUri: config.siweUri,
audience: config.audience,
keyId: config.keyId,
+ farcasterRpcEndpointFingerprints,
+ signingPublicKeyThumbprint,
spacetimeDbUri: config.spacetimeDbUri,
spacetimeDbDatabase: config.spacetimeDbDatabase,
publicAuthEnabled: config.publicAuthEnabled,
@@ -717,13 +750,11 @@ async function configurationAttestation(
sessionFamilyTtlSeconds: SESSION_FAMILY_TTL_SECONDS,
sessionCookie: '__Host-warpkeep_session; Secure; HttpOnly; SameSite=Strict; Path=/',
})
- const bytes = new TextEncoder().encode(canonical)
- try {
- const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
- return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('')
- } finally {
- bytes.fill(0)
- }
+ return Object.freeze({
+ digest: await sha256Hex(canonical),
+ farcasterRpcEndpointFingerprints,
+ signingPublicKeyThumbprint,
+ })
}
function adminCredential(request: Request): string | null {
@@ -1308,7 +1339,7 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri
logger.event('exchange_rejected')
throw new HttpError(401, 'challenge_replayed', 'This sign-in challenge is invalid or already used.')
}
- const verifier = dependencies.verifier ?? createOfficialFarcasterVerifier(config.farcasterRpcUrl)
+ const verifier = dependencies.verifier ?? createOfficialFarcasterVerifier(config.farcasterRpcUrls)
let verifiedFid: string
try {
verifiedFid = canonicalFid((await verifyFarcasterWithDeadline(verifier, {
@@ -1585,18 +1616,21 @@ export function createAuthBridge(dependencies: AuthBridgeDependencies = {}): Bri
throw new HttpError(400, 'admin_query_not_allowed', 'This endpoint does not accept query parameters.')
}
let qaObserverKeyFingerprint: string | null = null
- if (config.qaObserverPublicJwk) {
- try {
+ let attestation: Awaited>
+ try {
+ if (config.qaObserverPublicJwk) {
qaObserverKeyFingerprint = await qaObserverKeyThumbprint(config.qaObserverPublicJwk)
- } catch {
- throw new ConfigurationError()
}
+ attestation = await configurationAttestation(config, qaObserverKeyFingerprint)
+ } catch {
+ throw new ConfigurationError()
}
- const digest = await configurationAttestation(config, qaObserverKeyFingerprint)
logger.event('config_attestation_issued')
return json({
profile: 'warpkeep-auth-v2',
- digest,
+ digest: attestation.digest,
+ farcasterRpcEndpointFingerprints: attestation.farcasterRpcEndpointFingerprints,
+ signingPublicKeyThumbprint: attestation.signingPublicKeyThumbprint,
publicAuthEnabled: config.publicAuthEnabled,
qaObserverEnabled: config.qaObserverEnabled,
qaObserverSpacetimeDbUri: config.qaObserverSpacetimeDb?.uri ?? null,
diff --git a/services/auth-bridge/src/config.ts b/services/auth-bridge/src/config.ts
index 0299f18e..d01ca6fb 100644
--- a/services/auth-bridge/src/config.ts
+++ b/services/auth-bridge/src/config.ts
@@ -29,13 +29,15 @@ export type QaObserverSpacetimeDbConfig = Readonly<{
audience: string
}>
+export type FarcasterRpcUrls = readonly [string] | readonly [string, string]
+
export interface BridgeConfig {
issuer: string
issuerUrl: URL
allowedOrigins: ReadonlySet
domain: string
siweUri: string
- farcasterRpcUrl: string
+ farcasterRpcUrls: FarcasterRpcUrls
audience: string
keyId: string
privateJwk: PrivateEcJwk
@@ -140,13 +142,12 @@ function parsePrivateJwk(value: string): PrivateEcJwk {
} catch {
throw new ConfigurationError()
}
- const coordinate = /^[A-Za-z0-9_-]{43}$/
if (
jwk.kty !== 'EC'
|| jwk.crv !== 'P-256'
- || !jwk.x || !coordinate.test(jwk.x)
- || !jwk.y || !coordinate.test(jwk.y)
- || !jwk.d || !coordinate.test(jwk.d)
+ || !jwk.x || !isCanonicalBase64UrlCoordinate(jwk.x)
+ || !jwk.y || !isCanonicalBase64UrlCoordinate(jwk.y)
+ || !jwk.d || !isCanonicalBase64UrlCoordinate(jwk.d)
) {
throw new ConfigurationError()
}
@@ -205,6 +206,72 @@ function parseAudience(value: string): string {
return value
}
+function isLoopbackHostname(hostname: string): boolean {
+ const normalized = hostname.toLowerCase()
+ return normalized === 'localhost'
+ || normalized.endsWith('.localhost')
+ || normalized === '127.0.0.1'
+ || normalized === '[::1]'
+}
+
+function isPublicDnsHostname(hostname: string): boolean {
+ const normalized = hostname.toLowerCase()
+ if (
+ isLoopbackHostname(normalized)
+ || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(normalized)
+ || normalized.includes(':')
+ || normalized.endsWith('.local')
+ || normalized.endsWith('.internal')
+ || normalized.endsWith('.invalid')
+ || normalized.endsWith('.localhost')
+ || normalized.endsWith('.test')
+ || normalized.endsWith('.example')
+ ) {
+ return false
+ }
+ const label = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?'
+ return new RegExp(`^${label}(?:\\.${label})+$`).test(normalized)
+}
+
+function parseFarcasterRpcUrls(env: WorkerEnv, production: boolean): FarcasterRpcUrls {
+ const primary = parseAbsoluteUrl(required(env, 'FARCASTER_RPC_URL'))
+ const secondaryValue = env.FARCASTER_RPC_URL_SECONDARY?.trim()
+ const urls = secondaryValue
+ ? [primary, parseAbsoluteUrl(secondaryValue)]
+ : [primary]
+
+ for (const url of urls) {
+ if (
+ url.username
+ || url.password
+ || url.hash
+ || (url.protocol === 'http:' && !isLoopbackHostname(url.hostname))
+ || (production && (
+ url.protocol !== 'https:'
+ || Boolean(url.port)
+ || !isPublicDnsHostname(url.hostname)
+ ))
+ ) {
+ throw new ConfigurationError()
+ }
+ }
+
+ if (production && urls.length !== 2) {
+ throw new ConfigurationError()
+ }
+ if (!production && urls.length === 1 && !isLoopbackHostname(primary.hostname)) {
+ throw new ConfigurationError()
+ }
+ if (urls.length === 2 && urls[0].origin === urls[1].origin) {
+ throw new ConfigurationError()
+ }
+
+ const normalized = urls.map(url => url.toString())
+ return normalized.length === 2
+ ? Object.freeze([normalized[0], normalized[1]])
+ : Object.freeze([normalized[0]])
+}
+
function parsePublicAuthEnabled(value: string): boolean {
if (value !== 'true' && value !== 'false') {
throw new ConfigurationError()
@@ -321,9 +388,7 @@ export function readBridgeConfig(env: WorkerEnv): BridgeConfig {
throw new ConfigurationError()
}
- const farcasterRpcUrl = required(env, 'FARCASTER_RPC_URL')
- const rpcUrl = parseAbsoluteUrl(farcasterRpcUrl)
- if (production && rpcUrl.protocol !== 'https:') throw new ConfigurationError()
+ const farcasterRpcUrls = parseFarcasterRpcUrls(env, production)
const spacetimeDbUri = parseSpacetimeDbUri(required(env, 'SPACETIMEDB_URI'), production)
const spacetimeDbDatabase = parseSpacetimeDbDatabase(required(env, 'SPACETIMEDB_DATABASE'))
@@ -402,7 +467,7 @@ export function readBridgeConfig(env: WorkerEnv): BridgeConfig {
allowedOrigins,
domain,
siweUri,
- farcasterRpcUrl,
+ farcasterRpcUrls,
audience,
keyId: parseKeyId(configuredKid),
privateJwk,
diff --git a/services/auth-bridge/src/farcaster.ts b/services/auth-bridge/src/farcaster.ts
index 7515a8f0..bcd2268e 100644
--- a/services/auth-bridge/src/farcaster.ts
+++ b/services/auth-bridge/src/farcaster.ts
@@ -14,11 +14,7 @@ function canonicalFid(value: number | bigint | string): string {
return fid.toString(10)
}
-/**
- * Uses Farcaster's official auth client. The client checks the SIWF signature,
- * custody/auth address ownership, and FID resource with `acceptAuthAddress`.
- */
-export function createOfficialFarcasterVerifier(rpcUrl: string): FarcasterVerifier {
+function createSingleOfficialFarcasterVerifier(rpcUrl: string): FarcasterVerifier {
const client = createAppClient({
ethereum: viemConnector({ rpcUrl }),
})
@@ -48,3 +44,53 @@ export function createOfficialFarcasterVerifier(rpcUrl: string): FarcasterVerifi
},
}
}
+
+/**
+ * Requires every configured verifier to independently return the same FID.
+ * One verifier is supported only for the explicitly local development profile.
+ */
+export function createConsensusFarcasterVerifier(
+ verifiers: readonly FarcasterVerifier[],
+): FarcasterVerifier {
+ if (verifiers.length < 1 || verifiers.length > 2) {
+ throw new Error('Farcaster verifier configuration is invalid.')
+ }
+
+ return {
+ async verify(input: FarcasterProofInput): Promise {
+ const results = await Promise.allSettled(verifiers.map(async (verifier) => ({
+ fid: canonicalFid((await verifier.verify(input)).fid),
+ })))
+
+ if (results.length === 1) {
+ const [result] = results
+ if (result.status === 'fulfilled') return result.value
+ throw result.reason
+ }
+
+ const [first, second] = results
+ if (first.status === 'fulfilled' && second.status === 'fulfilled') {
+ if (first.value.fid === second.value.fid) return first.value
+ throw new FarcasterVerifierUnavailableError()
+ }
+ if (first.status === 'fulfilled' || second.status === 'fulfilled') {
+ throw new FarcasterVerifierUnavailableError()
+ }
+ if (
+ first.reason instanceof FarcasterVerifierUnavailableError
+ || second.reason instanceof FarcasterVerifierUnavailableError
+ ) {
+ throw new FarcasterVerifierUnavailableError()
+ }
+ throw new Error('Farcaster verification failed.')
+ },
+ }
+}
+
+/**
+ * Uses Farcaster's official auth client. Each client checks the SIWF signature,
+ * custody/auth address ownership, and FID resource with `acceptAuthAddress`.
+ */
+export function createOfficialFarcasterVerifier(rpcUrls: readonly string[]): FarcasterVerifier {
+ return createConsensusFarcasterVerifier(rpcUrls.map(createSingleOfficialFarcasterVerifier))
+}
diff --git a/services/auth-bridge/src/types.ts b/services/auth-bridge/src/types.ts
index 27ea2ac5..513e943d 100644
--- a/services/auth-bridge/src/types.ts
+++ b/services/auth-bridge/src/types.ts
@@ -41,8 +41,10 @@ export interface WorkerEnv {
FARCASTER_DOMAIN?: string
/** Exact SIWF URI expected in every signed message. */
FARCASTER_SIWE_URI?: string
- /** A private Optimism RPC URL used by the official Farcaster verifier. */
+ /** Primary private Optimism RPC URL used by the official Farcaster verifier. */
FARCASTER_RPC_URL?: string
+ /** Independent secondary Optimism RPC URL. Required in production. */
+ FARCASTER_RPC_URL_SECONDARY?: string
OIDC_AUDIENCE?: string
/** Stable public JWK key id. May also be supplied inside SIGNING_KEY_JWK. */
OIDC_KEY_ID?: string
diff --git a/services/auth-bridge/test-workerd/authBridge.workerd.test.ts b/services/auth-bridge/test-workerd/authBridge.workerd.test.ts
index a7932c80..9787dc21 100644
--- a/services/auth-bridge/test-workerd/authBridge.workerd.test.ts
+++ b/services/auth-bridge/test-workerd/authBridge.workerd.test.ts
@@ -25,7 +25,10 @@ const CONFIG: BridgeConfig = {
allowedOrigins: new Set([ORIGIN]),
domain: DOMAIN,
siweUri: SIWE_URI,
- farcasterRpcUrl: 'https://optimism-rpc.warpkeep.test',
+ farcasterRpcUrls: Object.freeze([
+ 'https://optimism-rpc-one.example.com/',
+ 'https://optimism-rpc-two.example.net/',
+ ]),
audience: 'warpkeep-spacetimedb',
keyId: 'workerd-test-key',
privateJwk: {
diff --git a/services/auth-bridge/test/app.test.ts b/services/auth-bridge/test/app.test.ts
index 3ee94efd..07863dff 100644
--- a/services/auth-bridge/test/app.test.ts
+++ b/services/auth-bridge/test/app.test.ts
@@ -4,11 +4,13 @@ import {
FARCASTER_VERIFICATION_TIMEOUT_MILLISECONDS,
REQUEST_BODY_TIMEOUT_MILLISECONDS,
createAuthBridge,
+ farcasterRpcEndpointFingerprint,
type AuthBridgeDependencies,
} from '../src/app'
import { MemoryChallengeStore } from '../src/challengeStore'
import { PRODUCTION_SPACETIMEDB_DATABASE } from '../src/config'
import { FarcasterVerifierUnavailableError } from '../src/farcaster'
+import { qaObserverKeyThumbprint } from '../src/qaObserver'
import { MemorySessionFamilyStore } from '../src/sessionFamily'
import {
AuthEpochResolverFailure,
@@ -52,7 +54,8 @@ function env(overrides: Partial = {}): WorkerEnv {
ALLOWED_ORIGINS: ORIGIN,
FARCASTER_DOMAIN: DOMAIN,
FARCASTER_SIWE_URI: SIWE_URI,
- FARCASTER_RPC_URL: 'https://optimism-rpc.internal.example',
+ FARCASTER_RPC_URL: 'https://optimism-rpc-one.example.com',
+ FARCASTER_RPC_URL_SECONDARY: 'https://optimism-rpc-two.example.net',
OIDC_AUDIENCE: 'warpkeep-spacetimedb',
OIDC_KEY_ID: 'test-es256-2026',
SPACETIMEDB_URI: 'https://maincloud.spacetimedb.com',
@@ -1127,8 +1130,20 @@ describe('Warpkeep auth bridge', () => {
const firstBody = await json(first)
const secondBody = await json(second)
expect(firstBody).toEqual(secondBody)
+ const farcasterRpcEndpointFingerprints = (await Promise.all([
+ farcasterRpcEndpointFingerprint('https://optimism-rpc-one.example.com/'),
+ farcasterRpcEndpointFingerprint('https://optimism-rpc-two.example.net/'),
+ ])).sort()
+ const signingPublicKeyThumbprint = await qaObserverKeyThumbprint({
+ kty: 'EC',
+ crv: 'P-256',
+ x: String(privateJwk.x),
+ y: String(privateJwk.y),
+ })
expect(firstBody).toMatchObject({
profile: 'warpkeep-auth-v2',
+ farcasterRpcEndpointFingerprints,
+ signingPublicKeyThumbprint,
publicAuthEnabled: true,
qaObserverEnabled: false,
qaObserverSpacetimeDbUri: null,
@@ -1147,6 +1162,8 @@ describe('Warpkeep auth bridge', () => {
siweUri: 'https://warpkeep.example/Warpkeep/',
audience: 'warpkeep-spacetimedb',
keyId: 'test-es256-2026',
+ farcasterRpcEndpointFingerprints,
+ signingPublicKeyThumbprint,
spacetimeDbUri: 'https://maincloud.spacetimedb.com',
spacetimeDbDatabase: PRODUCTION_SPACETIMEDB_DATABASE,
publicAuthEnabled: true,
@@ -1180,9 +1197,27 @@ describe('Warpkeep auth bridge', () => {
expect(serialized).not.toContain(ADMIN_SECRET)
expect(serialized).not.toContain(SESSION_COOKIE_KEY)
expect(serialized).not.toContain(privateJwk.d ?? '')
+ expect(serialized).not.toContain('https://optimism-rpc-one.example.com')
+ expect(serialized).not.toContain('https://optimism-rpc-two.example.net')
const paused = await json(await call({ PUBLIC_AUTH_ENABLED: 'false' }))
expect(paused.digest).not.toBe(reviewedDigest)
expect(paused.publicAuthEnabled).toBe(false)
+ const rpcDrift = await json(await call({
+ FARCASTER_RPC_URL_SECONDARY: 'https://optimism-rpc-three.example.org',
+ }))
+ expect(rpcDrift.digest).not.toBe(reviewedDigest)
+ expect(rpcDrift.farcasterRpcEndpointFingerprints).not.toEqual(farcasterRpcEndpointFingerprints)
+ const replacementPair = await crypto.subtle.generateKey(
+ { name: 'ECDSA', namedCurve: 'P-256' },
+ true,
+ ['sign', 'verify'],
+ )
+ const replacementJwk = await crypto.subtle.exportKey('jwk', replacementPair.privateKey)
+ const signingKeyDrift = await json(await call({
+ SIGNING_KEY_JWK: JSON.stringify(replacementJwk),
+ }))
+ expect(signingKeyDrift.digest).not.toBe(reviewedDigest)
+ expect(signingKeyDrift.signingPublicKeyThumbprint).not.toBe(signingPublicKeyThumbprint)
expect(first.headers.has('access-control-allow-origin')).toBe(false)
expect(h.events).toContain('config_attestation_issued')
})
@@ -1550,6 +1585,46 @@ describe('Warpkeep auth bridge', () => {
await expect(response.json()).resolves.toMatchObject({ error: { code: 'service_misconfigured' } })
})
+ it('requires two independent public HTTPS Farcaster RPC origins in production', async () => {
+ const h = harness()
+ const invalidProductionOverrides: readonly Partial[] = [
+ { FARCASTER_RPC_URL_SECONDARY: undefined },
+ { FARCASTER_RPC_URL: 'http://optimism-rpc-one.example.com' },
+ { FARCASTER_RPC_URL_SECONDARY: 'https://optimism-rpc-one.example.com/secondary' },
+ { FARCASTER_RPC_URL_SECONDARY: 'https://127.0.0.1' },
+ { FARCASTER_RPC_URL_SECONDARY: 'https://10.0.0.1' },
+ { FARCASTER_RPC_URL_SECONDARY: 'https://[2001:db8::1]' },
+ { FARCASTER_RPC_URL_SECONDARY: 'https://optimism-rpc.internal' },
+ { FARCASTER_RPC_URL_SECONDARY: 'https://optimism-rpc-two.example.net/#fragment' },
+ ]
+ for (const overrides of invalidProductionOverrides) {
+ const response = await h.app.fetch(request('/healthz'), env(overrides))
+ expect(response.status).toBe(503)
+ await expect(response.json()).resolves.toMatchObject({ error: { code: 'service_misconfigured' } })
+ }
+
+ const localDevelopment = {
+ ENVIRONMENT: 'development',
+ ISSUER: 'https://localhost:8787',
+ ALLOWED_ORIGINS: 'http://localhost:5173',
+ FARCASTER_DOMAIN: 'localhost:5173',
+ FARCASTER_SIWE_URI: 'http://localhost:5173/',
+ FARCASTER_RPC_URL: 'http://127.0.0.1:8545',
+ FARCASTER_RPC_URL_SECONDARY: undefined,
+ SPACETIMEDB_URI: 'http://127.0.0.1:3000',
+ SPACETIMEDB_DATABASE: 'warpkeep-dev',
+ } satisfies Partial
+ const developmentRequest = () => new Request('https://localhost:8787/healthz')
+ const accepted = await h.app.fetch(developmentRequest(), env(localDevelopment))
+ expect(accepted.status).toBe(200)
+
+ const remoteSingle = await h.app.fetch(developmentRequest(), env({
+ ...localDevelopment,
+ FARCASTER_RPC_URL: 'https://optimism-rpc-one.example.com',
+ }))
+ expect(remoteSingle.status).toBe(503)
+ })
+
it('fails closed without a public issuer and writes only static safe log events', async () => {
const h = harness()
const response = await h.app.fetch(request('/healthz'), env({ ISSUER: undefined }))
diff --git a/services/auth-bridge/test/farcaster.test.ts b/services/auth-bridge/test/farcaster.test.ts
new file mode 100644
index 00000000..2144260c
--- /dev/null
+++ b/services/auth-bridge/test/farcaster.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+ FarcasterVerifierUnavailableError,
+ createConsensusFarcasterVerifier,
+} from '../src/farcaster'
+import type { FarcasterProofInput, FarcasterVerifier } from '../src/types'
+
+const PROOF = Object.freeze({
+ nonce: '12345678',
+ domain: 'warpkeep.example',
+ message: 'test-only SIWF message',
+ signature: `0x${'00'.repeat(65)}` as `0x${string}`,
+ acceptAuthAddress: true,
+}) satisfies FarcasterProofInput
+
+function succeeds(fid: string): FarcasterVerifier & { verify: ReturnType } {
+ return { verify: vi.fn(async () => ({ fid })) }
+}
+
+function fails(error: Error): FarcasterVerifier & { verify: ReturnType } {
+ return { verify: vi.fn(async () => { throw error }) }
+}
+
+describe('Farcaster verifier consensus', () => {
+ it('returns only a matching canonical FID from both independent verifiers', async () => {
+ const first = succeeds('12345')
+ const second = succeeds('00012345')
+ const verifier = createConsensusFarcasterVerifier([first, second])
+
+ await expect(verifier.verify(PROOF)).resolves.toEqual({ fid: '12345' })
+ expect(first.verify).toHaveBeenCalledOnce()
+ expect(second.verify).toHaveBeenCalledOnce()
+ expect(first.verify).toHaveBeenCalledWith(PROOF)
+ expect(second.verify).toHaveBeenCalledWith(PROOF)
+ })
+
+ it('fails unavailable when successful verifiers disagree', async () => {
+ const verifier = createConsensusFarcasterVerifier([
+ succeeds('12345'),
+ succeeds('54321'),
+ ])
+
+ await expect(verifier.verify(PROOF)).rejects.toBeInstanceOf(FarcasterVerifierUnavailableError)
+ })
+
+ it('fails unavailable when only one verifier succeeds', async () => {
+ for (const failure of [
+ new FarcasterVerifierUnavailableError(),
+ new Error('invalid proof'),
+ ]) {
+ const verifier = createConsensusFarcasterVerifier([
+ succeeds('12345'),
+ fails(failure),
+ ])
+ await expect(verifier.verify(PROOF)).rejects.toBeInstanceOf(FarcasterVerifierUnavailableError)
+ }
+ })
+
+ it('preserves provider outage and definitive rejection classifications', async () => {
+ const unavailable = createConsensusFarcasterVerifier([
+ fails(new FarcasterVerifierUnavailableError()),
+ fails(new Error('invalid proof')),
+ ])
+ await expect(unavailable.verify(PROOF)).rejects.toBeInstanceOf(FarcasterVerifierUnavailableError)
+
+ const invalid = createConsensusFarcasterVerifier([
+ fails(new Error('invalid proof one')),
+ fails(new Error('invalid proof two')),
+ ])
+ await expect(invalid.verify(PROOF)).rejects.toMatchObject({
+ name: 'Error',
+ message: 'Farcaster verification failed.',
+ })
+ })
+
+ it('supports exactly one verifier for the explicitly local development profile', async () => {
+ const only = succeeds('12345')
+ await expect(createConsensusFarcasterVerifier([only]).verify(PROOF)).resolves.toEqual({ fid: '12345' })
+ expect(() => createConsensusFarcasterVerifier([])).toThrow('Farcaster verifier configuration is invalid.')
+ expect(() => createConsensusFarcasterVerifier([only, only, only])).toThrow(
+ 'Farcaster verifier configuration is invalid.',
+ )
+ })
+})
diff --git a/services/auth-bridge/test/qaObserver.test.ts b/services/auth-bridge/test/qaObserver.test.ts
index 9f74fd33..962a1be9 100644
--- a/services/auth-bridge/test/qaObserver.test.ts
+++ b/services/auth-bridge/test/qaObserver.test.ts
@@ -111,7 +111,8 @@ function environment(overrides: Partial = {}): WorkerEnv {
ALLOWED_ORIGINS: ORIGIN,
FARCASTER_DOMAIN: 'warpkeep.example',
FARCASTER_SIWE_URI: `${ORIGIN}/`,
- FARCASTER_RPC_URL: 'https://optimism-rpc.internal.example',
+ FARCASTER_RPC_URL: 'https://optimism-rpc-one.example.com',
+ FARCASTER_RPC_URL_SECONDARY: 'https://optimism-rpc-two.example.net',
OIDC_AUDIENCE: 'warpkeep-spacetimedb',
OIDC_KEY_ID: 'test-key',
SIGNING_KEY_JWK: JSON.stringify(signingPrivateJwk),
diff --git a/spacetimedb/README.md b/spacetimedb/README.md
index 0735acd3..d4da369c 100644
--- a/spacetimedb/README.md
+++ b/spacetimedb/README.md
@@ -12,8 +12,9 @@ balance, advance a timer, or decide an expedition outcome.
| Browser/backend wire protocol | 3 |
| Player authentication contract | 2 |
| Genesis world generation | 3 |
-| Append-only schema generation | 10 |
+| Append-only schema generation | 12 (staged suffix) |
| Alpha 0.3.12 suffix | Water refs 37–40; Stone refs 41–45 |
+| Generic worker suffix | refs 47–52; staged, not activated |
Deployed tables retain their original declaration order and shape. Later
features append new tables; they do not rename or delete existing data. The
@@ -55,6 +56,8 @@ Public subscriptions contain only shared-world presentation:
- activated Water layout, body/cell topology, and shared environment data;
- identity-minimized site occupations containing a site, phase, public
timeline, and origin castle;
+- staged four-worker roster and generic node-lease projections; the public
+ rows contain no FID, cargo, accrual, balance, request, or auth data;
- public Community Marks projection only when its policy permits it.
Private tables contain admission, ownership, unclaimed-slot decisions, resource
@@ -94,6 +97,21 @@ Gold, Food, Wood, and Stone each have an independent expedition:
- private reservations prevent passive collection or another lifecycle from
truncating a valid Food, Wood, or Stone award.
+The additive generic-worker suffix defines four stable workers per founded
+castle. Any idle worker can gather Gold, Food, Wood, or Stone, and multiple
+workers may gather the same resource at different nodes. Worker assignments
+use the same canonical site catalogs, route authority, 60-second quantum, and
+30-day cap as the legacy expeditions. The caller's private read projects exact
+server-time availability without a write; scheduled expiry and explicit
+dispatch/recall commands materialize complete quanta. There is no per-minute
+write loop and no `collect` command for generic workers.
+
+The suffix is intentionally staged. The module does not seed, activate,
+backfill, or migrate production workers in this PR. Activation requires an
+admin-reviewed singleton, an exact four-worker roster digest, and zero legacy
+expedition, occupation, and schedule rows. The browser wire protocol remains 3
+until a later client-capability release opts into the generic worker methods.
+
## Entry agreement and Marks
Entry and gameplay require the exact current Alpha Terms and Hegemony Social
diff --git a/spacetimedb/migration-fixtures/additive-v12-schema/package.json b/spacetimedb/migration-fixtures/additive-v12-schema/package.json
new file mode 100644
index 00000000..0caa87d6
--- /dev/null
+++ b/spacetimedb/migration-fixtures/additive-v12-schema/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "warpkeep-additive-v12-schema-migration-fixture",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "spacetimedb": "2.6.1"
+ },
+ "devDependencies": {
+ "typescript": "5.6.3"
+ }
+}
diff --git a/spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts b/spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts
new file mode 100644
index 00000000..ae3a4bc1
--- /dev/null
+++ b/spacetimedb/migration-fixtures/additive-v12-schema/src/index.ts
@@ -0,0 +1,606 @@
+import { schema, table, t } from 'spacetimedb/server';
+import { ScheduleAt, Timestamp } from 'spacetimedb';
+import { SenderError } from 'spacetimedb/server';
+import {
+ goldExpeditionErrorCode,
+ runGoldExpeditionSchedule,
+} from '../../../src/goldExpeditionAuthority';
+import {
+ foodExpeditionErrorCode,
+ runFoodExpeditionSchedule,
+} from '../../../src/foodExpeditionAuthority';
+import {
+ woodExpeditionErrorCode,
+ runWoodExpeditionSchedule,
+} from '../../../src/woodExpeditionAuthority';
+import {
+ stoneExpeditionErrorCode,
+ runStoneExpeditionSchedule,
+} from '../../../src/stoneExpeditionAuthority';
+
+const allowedFid = table({ name: 'allowed_fid' }, {
+ fid: t.u64().primaryKey(), enabled: t.bool(), authEpoch: t.u32(),
+ invitedAt: t.timestamp(), invitedBy: t.string(), note: t.string(),
+});
+const worldTile = table({ name: 'world_tile', public: true }, {
+ key: t.string().primaryKey(), q: t.i32(), r: t.i32(), biome: t.string(),
+ terrainSeed: t.u32(), occupantCastleId: t.option(t.u64()),
+});
+const player = table({ name: 'player', public: true }, {
+ fid: t.u64().primaryKey(), identity: t.identity().unique(), username: t.option(t.string()),
+ displayName: t.option(t.string()), pfpUrl: t.option(t.string()), joinedAt: t.timestamp(), status: t.string(),
+});
+const castle = table({ name: 'castle', public: true }, {
+ castleId: t.u64().primaryKey().autoInc(), ownerFid: t.u64().unique(), tileKey: t.string().unique(),
+ q: t.i32(), r: t.i32(), level: t.i32(), name: t.string(), createdAt: t.timestamp(),
+});
+const adminAudit = table({ name: 'admin_audit' }, {
+ id: t.u64().primaryKey().autoInc(), action: t.string(), targetFid: t.option(t.u64()),
+ actorSubject: t.string(), createdAt: t.timestamp(), note: t.string(),
+});
+const playerV2 = table({ name: 'player_v2', public: true }, {
+ fid: t.u64().primaryKey(), username: t.option(t.string()), displayName: t.option(t.string()),
+ pfpUrl: t.option(t.string()), joinedAt: t.timestamp(), status: t.string(),
+});
+const playerOwnershipV2 = table({ name: 'player_ownership_v2' }, {
+ fid: t.u64().primaryKey(), identity: t.identity().unique(),
+});
+const realmV1 = table({ name: 'realm_v1', public: true }, {
+ realmId: t.string().primaryKey(), publicName: t.string(), seedName: t.string(), numericSeed: t.u32(),
+ generationVersion: t.u32(), authoritativeRadius: t.u32(), renderRadius: t.u32(), playerCapacity: t.u32(),
+ active: t.bool(), createdAt: t.timestamp(),
+});
+const worldTileMetaV1 = table({
+ name: 'world_tile_meta_v1', public: true,
+ indexes: [{ accessor: 'byRealmAndRing', algorithm: 'btree', columns: ['realmId', 'ring'] as const }] as const,
+}, {
+ tileKey: t.string().primaryKey(), realmId: t.string().index(), s: t.i32(), ring: t.u32(), sector: t.u32(),
+ terrainKind: t.string(), passable: t.bool(), movementCost: t.u32(), staticContentKind: t.string(), generationVersion: t.u32(),
+});
+const castleSlotV1 = table({ name: 'castle_slot_v1', public: true }, {
+ slotId: t.u32().primaryKey(), realmId: t.string().index(), tileKey: t.string().unique(), q: t.i32(), r: t.i32(), generationVersion: t.u32(),
+});
+const castleSlotClaimV1 = table({ name: 'castle_slot_claim_v1' }, {
+ slotId: t.u32().primaryKey(), ownerFid: t.u64().unique(), castleId: t.u64().unique(), claimedAt: t.timestamp(), generationVersion: t.u32(),
+});
+const realmProfileV1 = table({ name: 'realm_profile_v1', public: true }, {
+ fid: t.u64().primaryKey(), canonicalUsername: t.option(t.string()), displayName: t.option(t.string()), pfpUrl: t.option(t.string()), publicBio: t.option(t.string()),
+ admittedAt: t.timestamp(), firstAuthenticatedAt: t.option(t.timestamp()), profileUpdatedAt: t.timestamp(), publicStatus: t.string(), communityStatsVisible: t.bool(),
+ totalSnapBurnedMicros: t.option(t.u128()), marksEarnedMicros: t.option(t.u128()), marksSpentMicros: t.option(t.u128()), marksBalanceMicros: t.option(t.u128()), marksPolicyVersion: t.option(t.string()),
+});
+const markAccountV1 = table({ name: 'mark_account_v1' }, {
+ fid: t.u64().primaryKey(), totalSnapBurnedMicros: t.u128(), earnedMicros: t.u128(), spentMicros: t.u128(), balanceMicros: t.u128(), policyVersion: t.string(), updatedAt: t.timestamp(),
+});
+const snapBurnCreditV1 = table({ name: 'snap_burn_credit_v1' }, {
+ eventKey: t.string().primaryKey(), batchId: t.string().index(), chainId: t.u32(), tokenContract: t.string(), transactionHash: t.string(), logIndex: t.u32(), burnReference: t.string().unique(), burnMethod: t.string(), senderAddress: t.string(), blockNumber: t.u64(), blockHash: t.string(), amountMicros: t.u128(), attributedFid: t.u64().index(), attributionPolicyVersion: t.string(), contractCodeHash: t.string(), creditedAt: t.timestamp(),
+});
+const fidWalletAttributionV1 = table({
+ name: 'fid_wallet_attribution_v1', indexes: [{ accessor: 'bySnapshotAndAddress', algorithm: 'btree', columns: ['snapshotGeneration', 'address'] as const }] as const,
+}, {
+ snapshotAttributionKey: t.string().primaryKey(), attributionKey: t.string(), snapshotGeneration: t.u64(), fid: t.u64().index(), address: t.string(), addressType: t.string(), source: t.string(), snapshotAt: t.timestamp(), attributionPolicyVersion: t.string(), active: t.bool(),
+});
+const walletAttributionSnapshotV1 = table({ name: 'wallet_attribution_snapshot_v1' }, {
+ snapshotKey: t.string().primaryKey(), generation: t.u64(), snapshotId: t.string(), policyVersion: t.string(), attributionCount: t.u32(), snapshotAt: t.timestamp(),
+});
+const snapScanCursorV1 = table({ name: 'snap_scan_cursor_v1' }, {
+ cursorKey: t.string().primaryKey(), chainId: t.u32(), tokenContract: t.string(), policyVersion: t.string(), deploymentStartBlock: t.u64(), lastFinalizedBlock: t.u64(), lastFinalizedBlockHash: t.string(), proxyCodeHash: t.string(), implementationAddress: t.string(), implementationCodeHash: t.string(), walletSnapshotGeneration: t.u64(), walletSnapshotId: t.string(), scannedAt: t.timestamp(),
+});
+const snapScanBatchV1 = table({
+ name: 'snap_scan_batch_v1', indexes: [{ accessor: 'byCursorAndStatus', algorithm: 'btree', columns: ['cursorKey', 'status'] as const }] as const,
+}, {
+ batchId: t.string().primaryKey(), cursorKey: t.string(), status: t.string(), previousFinalizedBlock: t.u64(), previousFinalizedBlockHash: t.string(), throughFinalizedBlock: t.u64(), throughFinalizedBlockHash: t.string(), walletSnapshotGeneration: t.u64(), walletSnapshotId: t.string(), walletAttributionCount: t.u32(), expectedCredits: t.u32(), expectedMicros: t.u128(), appliedCredits: t.u32(), appliedMicros: t.u128(), proxyCodeHash: t.string(), implementationAddress: t.string(), implementationCodeHash: t.string(), startedAt: t.timestamp(), finalizedAt: t.option(t.timestamp()),
+});
+const alphaTermsAcceptanceV1 = table({ name: 'alpha_terms_acceptance_v1' }, {
+ acceptanceKey: t.string().primaryKey(), fid: t.u64().index(), termsVersion: t.string(), acceptedAt: t.timestamp(),
+});
+const resourceAccountV1 = table({ name: 'resource_account_v1' }, {
+ fid: t.u64().primaryKey(), castleId: t.u64().unique(), realmId: t.string().index(), food: t.u64(), wood: t.u64(), stone: t.u64(), gold: t.u64(), settledThroughMicros: t.u64(), revision: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp(),
+});
+
+const goldSiteV1 = table({ name: 'gold_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() });
+const goldNodeOccupationV1 = table({ name: 'gold_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() });
+const goldExpeditionV1 = table({ name: 'gold_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedGold: t.u64(), creditedGold: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() });
+const goldExpeditionIdempotencyV1 = table({ name: 'gold_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() });
+const goldExpeditionScheduleV1 = table({ name: 'gold_expedition_schedule_v_1', public: true, scheduled: (): any => runGoldExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() });
+
+const realmForestLayoutV1 = table({ name: 'realm_forest_layout_v1', public: true }, { realmId: t.string().primaryKey(), layoutVersion: t.u32(), policyVersion: t.string(), layoutDigest: t.string(), assetCatalogDigest: t.string(), instanceCount: t.u32(), seededAt: t.timestamp() });
+const realmForestInstanceV1 = table({ name: 'realm_forest_instance_v1', public: true }, { treeId: t.string().primaryKey(), realmId: t.string().index(), tileKey: t.string(), q: t.i32(), r: t.i32(), localXMicrounits: t.i64(), localZMicrounits: t.i64(), worldXMicrounits: t.i64(), worldZMicrounits: t.i64(), rotationMilliDegrees: t.u32(), scaleBasisPoints: t.u32(), speciesId: t.string(), habitat: t.string(), layoutVersion: t.u32() });
+
+const foodSiteV1 = table({ name: 'food_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() });
+const foodNodeOccupationV1 = table({ name: 'food_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() });
+const foodExpeditionV1 = table({ name: 'food_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedFood: t.u64(), creditedFood: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() });
+const foodExpeditionIdempotencyV1 = table({ name: 'food_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() });
+const foodExpeditionScheduleV1 = table({ name: 'food_expedition_schedule_v_1', public: true, scheduled: (): any => runFoodExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() });
+
+const woodSiteV1 = table({ name: 'wood_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() });
+const woodNodeOccupationV1 = table({ name: 'wood_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() });
+const woodExpeditionV1 = table({ name: 'wood_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedWood: t.u64(), creditedWood: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() });
+const woodExpeditionIdempotencyV1 = table({ name: 'wood_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() });
+const woodExpeditionScheduleV1 = table({ name: 'wood_expedition_schedule_v_1', public: true, scheduled: (): any => runWoodExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() });
+
+const realmWaterLayoutV1 = table({ name: 'realm_water_layout_v1', public: true }, { realmId: t.string().primaryKey(), layoutVersion: t.u32(), policyVersion: t.string(), generationVersion: t.u32(), canonicalLandCellCount: t.u32(), oceanCellCount: t.u32(), lakeCellCount: t.u32(), lakeBodyCount: t.u32(), riverCount: t.u32(), riverCellCount: t.u32(), seaLevelMilli: t.i32(), seaLevelPolicyVersion: t.string(), fogStartDepthCells: t.u32(), fogFullDepthCells: t.u32(), hiddenBufferCells: t.u32(), layoutDigest: t.string(), sourceCommit: t.string(), activated: t.bool(), seededAt: t.timestamp(), activatedAt: t.option(t.timestamp()) });
+const realmWaterBodyV1 = table({ name: 'realm_water_body_v1', public: true, indexes: [{ accessor: 'byRealmAndRegime', algorithm: 'btree', columns: ['realmId', 'regime'] as const }] as const }, { bodyId: t.string().primaryKey(), realmId: t.string().index(), regime: t.string(), cellCount: t.u32(), sourceCellKey: t.string(), mouthCellKey: t.string(), surfaceLevelMilli: t.i32(), flowDirectionXQ15: t.i32(), flowDirectionZQ15: t.i32(), wavePreset: t.string(), ordinal: t.u32(), seed: t.u32(), generationVersion: t.u32(), layoutVersion: t.u32() });
+const realmWaterCellV1 = table({ name: 'realm_water_cell_v1', public: true, indexes: [{ accessor: 'byRealmAndRegime', algorithm: 'btree', columns: ['realmId', 'regime'] as const }, { accessor: 'byBody', algorithm: 'btree', columns: ['bodyId'] as const }] as const }, { cellKey: t.string().primaryKey(), realmId: t.string().index(), q: t.i32(), r: t.i32(), regime: t.string(), bodyId: t.string(), depthCells: t.u32(), elevationMilli: t.i32(), surfaceLevelMilli: t.i32(), ring: t.u32(), s: t.i32(), underlyingTileKey: t.option(t.string()), riverOrdinal: t.option(t.u32()), riverOrder: t.option(t.u32()), downstreamWaterCellKey: t.option(t.string()), flowAccumulation: t.u32(), depthClass: t.u32(), oceanDepth: t.u32(), bankSeed: t.u32(), generationVersion: t.u32(), fogBand: t.string(), layoutVersion: t.u32() });
+const realmEnvironmentV1 = table({ name: 'realm_environment_v1', public: true }, { realmId: t.string().primaryKey(), environmentEpoch: t.u64(), waterLayoutVersion: t.u32(), seaLevelMilli: t.i32(), sunDirectionXMicro: t.i32(), sunDirectionYMicro: t.i32(), sunDirectionZMicro: t.i32(), updatedAt: t.timestamp() });
+
+const stoneSiteV1 = table({ name: 'stone_site_v1', public: true }, { siteId: t.string().primaryKey(), q: t.i32(), r: t.i32(), tier: t.u32(), active: t.bool() });
+const stoneNodeOccupationV1 = table({ name: 'stone_node_occupation_v1', public: true, indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const }, { siteId: t.string().primaryKey(), originCastleId: t.u64(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64() });
+const stoneExpeditionV1 = table({ name: 'stone_expedition_v1', indexes: [{ accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const }] as const }, { expeditionId: t.string().primaryKey(), fid: t.u64().unique(), originCastleId: t.u64().unique(), siteId: t.string().index(), phase: t.string(), startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), returnsAtMicros: t.u64(), settledThroughMicros: t.u64(), accruedStone: t.u64(), creditedStone: t.u64(), policyVersion: t.string(), createdAt: t.timestamp(), updatedAt: t.timestamp() });
+const stoneExpeditionIdempotencyV1 = table({ name: 'stone_expedition_idempotency_v1' }, { requestKey: t.string().primaryKey(), fid: t.u64().index(), siteId: t.string(), expeditionId: t.string().unique(), createdAt: t.timestamp() });
+const stoneExpeditionScheduleV1 = table({ name: 'stone_expedition_schedule_v_1', public: true, scheduled: (): any => runStoneExpeditionScheduleV1 }, { scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), originCastleId: t.u64().index(), siteId: t.string().index(), stage: t.string() });
+
+const realmWaterRevisionV1 = table({ name: 'realm_water_revision_v1', public: true }, {
+ realmId: t.string().primaryKey(), revisionVersion: t.u32(), policyVersion: t.string(),
+ baseLayoutVersion: t.u32(), baseLayoutDigest: t.string(), oceanBodyCount: t.u32(),
+ riverBodyCount: t.u32(), enabledBodyCount: t.u32(), oceanCellCount: t.u32(),
+ riverCellCount: t.u32(), enabledCellCount: t.u32(), lakeBodyCount: t.u32(),
+ lakeCellCount: t.u32(), riverWidthCells: t.u32(), navigationFogBoundaryDepthCells: t.u32(),
+ hiddenBufferCells: t.u32(), revisionDigest: t.string(), sourceCommit: t.string(),
+ activated: t.bool(), seededAt: t.timestamp(), activatedAt: t.option(t.timestamp()),
+});
+
+/** v12 generic-worker suffix. Public rows contain only identity/lifecycle data. */
+const realmWorkerSystemV1 = table({ name: 'realm_worker_system_v1', public: true }, {
+ realmId: t.string().primaryKey(), policyVersion: t.string(), workersPerCastle: t.u32(),
+ expectedCastleCount: t.u32(), expectedWorkerCount: t.u32(), rosterDigest: t.string(),
+ mode: t.string(), legacyDrainRequired: t.bool(), createdAt: t.timestamp(),
+ activatedAt: t.option(t.timestamp()),
+});
+const castleWorkerV1 = table({
+ name: 'castle_worker_v1', public: true,
+ indexes: [{ accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const }] as const,
+}, {
+ workerId: t.string().primaryKey(), originCastleId: t.u64(), ordinal: t.u32(), status: t.string(),
+ resourceKind: t.option(t.string()), siteId: t.option(t.string()),
+ startedAtMicros: t.option(t.u64()), arrivesAtMicros: t.option(t.u64()), gatheringEndsAtMicros: t.option(t.u64()),
+ returnStartedAtMicros: t.option(t.u64()), returnsAtMicros: t.option(t.u64()), routeSteps: t.option(t.u32()),
+ returnStartProgressBasisPoints: t.option(t.u32()), timelineRevision: t.u32(), revision: t.u64(),
+});
+const workerAssignmentV1 = table({
+ name: 'worker_assignment_v1',
+ indexes: [
+ { accessor: 'byFid', algorithm: 'btree', columns: ['fid'] as const },
+ { accessor: 'byFidAndPhase', algorithm: 'btree', columns: ['fid', 'phase'] as const },
+ ] as const,
+}, {
+ assignmentId: t.string().primaryKey(), workerId: t.string().unique(), fid: t.u64(),
+ originCastleId: t.u64(), resourceKind: t.string(), siteId: t.string().index(), phase: t.string(),
+ startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(),
+ returnStartedAtMicros: t.option(t.u64()), returnsAtMicros: t.u64(), routeSteps: t.u32(),
+ returnStartProgressBasisPoints: t.u32(), settledThroughMicros: t.u64(), accruedAmount: t.u64(),
+ materializedAmount: t.u64(), timelineRevision: t.u32(), policyVersion: t.string(),
+ createdAt: t.timestamp(), updatedAt: t.timestamp(),
+});
+const workerNodeOccupationV1 = table({
+ name: 'worker_node_occupation_v1', public: true,
+ indexes: [
+ { accessor: 'byOriginCastle', algorithm: 'btree', columns: ['originCastleId'] as const },
+ { accessor: 'byWorker', algorithm: 'btree', columns: ['workerId'] as const },
+ ] as const,
+}, {
+ nodeKey: t.string().primaryKey(), resourceKind: t.string(), siteId: t.string(), workerId: t.string(),
+ workerOrdinal: t.u32(), originCastleId: t.u64(), phase: t.string(),
+ startedAtMicros: t.u64(), arrivesAtMicros: t.u64(), gatheringEndsAtMicros: t.u64(), timelineRevision: t.u32(),
+});
+const workerCommandIdempotencyV1 = table({
+ name: 'worker_command_idempotency_v1',
+ indexes: [{ accessor: 'byFid', algorithm: 'btree', columns: ['fid'] as const }] as const,
+}, {
+ requestKey: t.string().primaryKey(), fid: t.u64(), workerId: t.option(t.string()), commandKind: t.string(),
+ resourceKind: t.option(t.string()), siteId: t.option(t.string()), assignmentId: t.option(t.string()),
+ resultRevision: t.u64(), createdAt: t.timestamp(),
+});
+const workerAssignmentScheduleV1 = table({
+ name: 'worker_assignment_schedule_v_1',
+ indexes: [
+ { accessor: 'byAssignment', algorithm: 'btree', columns: ['assignmentId'] as const },
+ { accessor: 'byWorker', algorithm: 'btree', columns: ['workerId'] as const },
+ ] as const,
+ scheduled: (): any => runWorkerAssignmentScheduleV1,
+}, {
+ scheduleId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), assignmentId: t.string(),
+ workerId: t.string(), timelineRevision: t.u32(), stage: t.string(),
+});
+
+const db = schema({
+ allowedFid, worldTile, player, castle, adminAudit, playerV2, playerOwnershipV2,
+ realmV1, worldTileMetaV1, castleSlotV1, castleSlotClaimV1, realmProfileV1, markAccountV1,
+ snapBurnCreditV1, fidWalletAttributionV1, walletAttributionSnapshotV1, snapScanCursorV1,
+ snapScanBatchV1, alphaTermsAcceptanceV1, resourceAccountV1, goldSiteV1, goldNodeOccupationV1,
+ goldExpeditionV1, goldExpeditionIdempotencyV1, goldExpeditionScheduleV1, realmForestLayoutV1,
+ realmForestInstanceV1, foodSiteV1, foodNodeOccupationV1, foodExpeditionV1,
+ foodExpeditionIdempotencyV1, foodExpeditionScheduleV1, woodSiteV1, woodNodeOccupationV1,
+ woodExpeditionV1, woodExpeditionIdempotencyV1, woodExpeditionScheduleV1, realmWaterLayoutV1,
+ realmWaterBodyV1, realmWaterCellV1, realmEnvironmentV1, stoneSiteV1,
+ stoneNodeOccupationV1, stoneExpeditionV1, stoneExpeditionIdempotencyV1,
+ stoneExpeditionScheduleV1, realmWaterRevisionV1, realmWorkerSystemV1, castleWorkerV1,
+ workerAssignmentV1, workerNodeOccupationV1, workerCommandIdempotencyV1, workerAssignmentScheduleV1,
+});
+
+export const runWorkerAssignmentScheduleV1 = db.reducer(
+ { name: 'run_worker_assignment_schedule_v_1' },
+ { arg: workerAssignmentScheduleV1.rowType },
+ () => {},
+);
+
+export const runGoldExpeditionScheduleV1 = db.reducer(
+ { name: 'run_gold_expedition_schedule_v_1' },
+ { arg: goldExpeditionScheduleV1.rowType },
+ (ctx, { arg }) => {
+ try { runGoldExpeditionSchedule(ctx as any, arg as any); }
+ catch (error) { const code = goldExpeditionErrorCode(error); throw new SenderError(code ?? 'GOLD_SCHEDULE_ERROR'); }
+ },
+);
+export const runFoodExpeditionScheduleV1 = db.reducer(
+ { name: 'run_food_expedition_schedule_v_1' },
+ { arg: foodExpeditionScheduleV1.rowType },
+ (ctx, { arg }) => {
+ try { runFoodExpeditionSchedule(ctx as any, arg as any); }
+ catch (error) { const code = foodExpeditionErrorCode(error); throw new SenderError(code ?? 'FOOD_SCHEDULE_ERROR'); }
+ },
+);
+export const runWoodExpeditionScheduleV1 = db.reducer(
+ { name: 'run_wood_expedition_schedule_v_1' },
+ { arg: woodExpeditionScheduleV1.rowType },
+ (ctx, { arg }) => {
+ try { runWoodExpeditionSchedule(ctx as any, arg as any); }
+ catch (error) { const code = woodExpeditionErrorCode(error); throw new SenderError(code ?? 'WOOD_SCHEDULE_ERROR'); }
+ },
+);
+export const runStoneExpeditionScheduleV1 = db.reducer(
+ { name: 'run_stone_expedition_schedule_v_1' },
+ { arg: stoneExpeditionScheduleV1.rowType },
+ (ctx, { arg }) => {
+ try { runStoneExpeditionSchedule(ctx as any, arg as any); }
+ catch (error) { const code = stoneExpeditionErrorCode(error); throw new SenderError(code ?? 'STONE_SCHEDULE_ERROR'); }
+ },
+);
+
+/** Auth-neutral identity fixture; SQL identity literals are issuer-bound. */
+export const fixtureInsertPlayerOwnershipV9 = db.reducer(
+ { name: 'fixture_insert_player_ownership_v9' },
+ { fid: t.u64() },
+ (ctx, { fid }) => {
+ if (ctx.db.playerOwnershipV2.fid.find(fid) !== null) throw new Error('FIXTURE_OWNERSHIP_EXISTS');
+ ctx.db.playerOwnershipV2.insert({ fid, identity: ctx.sender });
+ },
+);
+
+/** Bounded identity-row assertion; SQL cannot read identity columns across issuers. */
+export const fixtureAssertPlayerOwnershipV9 = db.reducer(
+ { name: 'fixture_assert_player_ownership_v9' },
+ { fid: t.u64(), expectedCount: t.u64() },
+ (ctx, { fid, expectedCount }) => {
+ if (ctx.db.playerOwnershipV2.count() !== expectedCount) throw new Error('FIXTURE_OWNERSHIP_COUNT_INVALID');
+ if (expectedCount === 0n) {
+ if (ctx.db.playerOwnershipV2.fid.find(fid) !== null) throw new Error('FIXTURE_OWNERSHIP_UNEXPECTED');
+ return;
+ }
+ if (expectedCount !== 1n || ctx.db.playerOwnershipV2.fid.find(fid) === null) {
+ throw new Error('FIXTURE_OWNERSHIP_ROW_INVALID');
+ }
+ },
+);
+
+/** Preserve the v9 Water sentinel wire unchanged in the v10 fixture. */
+export const fixtureSeedWaterSentinelV9 = db.reducer(
+ { name: 'fixture_seed_water_sentinel_v9' },
+ ctx => {
+ if (
+ ctx.db.realmWaterLayoutV1.count() !== 0n
+ || ctx.db.realmWaterBodyV1.count() !== 0n
+ || ctx.db.realmWaterCellV1.count() !== 0n
+ || ctx.db.realmEnvironmentV1.count() !== 0n
+ ) throw new Error('FIXTURE_WATER_NOT_EMPTY');
+ const realmId = 'MIGRATION_WATER_SENTINEL';
+ const bodyId = 'migration-water-body';
+ ctx.db.realmWaterLayoutV1.insert({
+ realmId,
+ layoutVersion: 1,
+ policyVersion: 'migration-water-sentinel-v1',
+ generationVersion: 3,
+ canonicalLandCellCount: 10_000,
+ oceanCellCount: 1,
+ lakeCellCount: 0,
+ lakeBodyCount: 0,
+ riverCount: 0,
+ riverCellCount: 0,
+ seaLevelMilli: 0,
+ seaLevelPolicyVersion: 'migration-water-sentinel-v1',
+ fogStartDepthCells: 1,
+ fogFullDepthCells: 2,
+ hiddenBufferCells: 1,
+ layoutDigest: '0'.repeat(64),
+ sourceCommit: '0'.repeat(40),
+ activated: false,
+ seededAt: ctx.timestamp,
+ activatedAt: undefined,
+ });
+ ctx.db.realmWaterBodyV1.insert({
+ bodyId,
+ realmId,
+ regime: 'ocean',
+ cellCount: 1,
+ sourceCellKey: 'migration-water-cell',
+ mouthCellKey: 'migration-water-cell',
+ surfaceLevelMilli: 0,
+ flowDirectionXQ15: 0,
+ flowDirectionZQ15: 0,
+ wavePreset: 'migration',
+ ordinal: 0,
+ seed: 0,
+ generationVersion: 3,
+ layoutVersion: 1,
+ });
+ ctx.db.realmWaterCellV1.insert({
+ cellKey: 'migration-water-cell',
+ realmId,
+ q: 0,
+ r: 0,
+ regime: 'ocean',
+ bodyId,
+ depthCells: 1,
+ elevationMilli: 0,
+ surfaceLevelMilli: 0,
+ ring: 0,
+ s: 0,
+ underlyingTileKey: undefined,
+ riverOrdinal: undefined,
+ riverOrder: undefined,
+ downstreamWaterCellKey: undefined,
+ flowAccumulation: 0,
+ depthClass: 1,
+ oceanDepth: 1,
+ bankSeed: 0,
+ generationVersion: 3,
+ fogBand: 'clear',
+ layoutVersion: 1,
+ });
+ ctx.db.realmEnvironmentV1.insert({
+ realmId,
+ environmentEpoch: 1n,
+ waterLayoutVersion: 1,
+ seaLevelMilli: 0,
+ sunDirectionXMicro: 0,
+ sunDirectionYMicro: 1_000_000,
+ sunDirectionZMicro: 0,
+ updatedAt: ctx.timestamp,
+ });
+ },
+);
+
+/** One typed row per v10 Stone table for the next additive migration. */
+export const fixtureSeedStoneSentinelV10 = db.reducer(
+ { name: 'fixture_seed_stone_sentinel_v10' },
+ ctx => {
+ if (
+ ctx.db.stoneSiteV1.count() !== 0n
+ || ctx.db.stoneNodeOccupationV1.count() !== 0n
+ || ctx.db.stoneExpeditionV1.count() !== 0n
+ || ctx.db.stoneExpeditionIdempotencyV1.count() !== 0n
+ || ctx.db.stoneExpeditionScheduleV1.count() !== 0n
+ ) throw new Error('FIXTURE_STONE_NOT_EMPTY');
+ const startedAtMicros = ctx.timestamp.microsSinceUnixEpoch;
+ const arrivesAtMicros = startedAtMicros + 7n * 24n * 60n * 60n * 1_000_000n;
+ const gatheringEndsAtMicros = arrivesAtMicros + 24n * 60n * 60n * 1_000_000n;
+ const returnsAtMicros = gatheringEndsAtMicros + 24n * 60n * 60n * 1_000_000n;
+ const siteId = 'migration-stone-site';
+ const expeditionId = 'migration-stone-expedition';
+ const originCastleId = 991_001n;
+ const fid = 991_002n;
+ ctx.db.stoneSiteV1.insert({ siteId, q: 1, r: -1, tier: 1, active: true });
+ ctx.db.stoneNodeOccupationV1.insert({
+ siteId,
+ originCastleId,
+ phase: 'outbound',
+ startedAtMicros,
+ arrivesAtMicros,
+ gatheringEndsAtMicros,
+ returnsAtMicros,
+ });
+ ctx.db.stoneExpeditionV1.insert({
+ expeditionId,
+ fid,
+ originCastleId,
+ siteId,
+ phase: 'outbound',
+ startedAtMicros,
+ arrivesAtMicros,
+ gatheringEndsAtMicros,
+ returnsAtMicros,
+ settledThroughMicros: startedAtMicros,
+ accruedStone: 0n,
+ creditedStone: 0n,
+ policyVersion: 'migration-stone-sentinel-v1',
+ createdAt: ctx.timestamp,
+ updatedAt: ctx.timestamp,
+ });
+ ctx.db.stoneExpeditionIdempotencyV1.insert({
+ requestKey: 'migration-stone-sentinel-request-0001',
+ fid,
+ siteId,
+ expeditionId,
+ createdAt: ctx.timestamp,
+ });
+ ctx.db.stoneExpeditionScheduleV1.insert({
+ scheduleId: 0n,
+ scheduledAt: ScheduleAt.time(arrivesAtMicros),
+ originCastleId,
+ siteId,
+ stage: 'arrival',
+ });
+ },
+);
+
+/** Typed v11 sentinel used only to prove rollback refusal and row survival. */
+export const fixtureSeedWaterRevisionSentinelV11 = db.reducer(
+ { name: 'fixture_seed_water_revision_sentinel_v11' },
+ ctx => {
+ if (ctx.db.realmWaterRevisionV1.count() !== 0n) {
+ throw new Error('FIXTURE_WATER_REVISION_NOT_EMPTY');
+ }
+ ctx.db.realmWaterRevisionV1.insert({
+ realmId: 'MIGRATION_WATER_SENTINEL',
+ revisionVersion: 2,
+ policyVersion: 'migration-water-revision-sentinel-v1',
+ baseLayoutVersion: 1,
+ baseLayoutDigest: '0'.repeat(64),
+ oceanBodyCount: 1,
+ riverBodyCount: 0,
+ enabledBodyCount: 1,
+ oceanCellCount: 1,
+ riverCellCount: 0,
+ enabledCellCount: 1,
+ lakeBodyCount: 0,
+ lakeCellCount: 0,
+ riverWidthCells: 1,
+ navigationFogBoundaryDepthCells: 2,
+ hiddenBufferCells: 1,
+ revisionDigest: '1'.repeat(64),
+ sourceCommit: '1'.repeat(40),
+ activated: false,
+ seededAt: ctx.timestamp,
+ activatedAt: undefined,
+ });
+ },
+);
+
+const FIXTURE_RESOURCE_QUANTUM_MICROS = 600_000_000n;
+const FIXTURE_RESOURCE_POLICY_VERSION = 'genesis-resource-yield-v1';
+
+export const fixtureRewindResourceOneQuantum = db.reducer(
+ { name: 'fixture_rewind_resource_one_quantum' },
+ { fid: t.u64() },
+ (ctx, { fid }) => {
+ const row = ctx.db.resourceAccountV1.fid.find(fid);
+ if (
+ row === null
+ || row.policyVersion !== FIXTURE_RESOURCE_POLICY_VERSION
+ || row.revision !== 0n
+ || row.food !== 0n
+ || row.wood !== 0n
+ || row.stone !== 0n
+ || row.gold !== 0n
+ || row.settledThroughMicros < FIXTURE_RESOURCE_QUANTUM_MICROS
+ ) throw new Error('FIXTURE_RESOURCE_STATE_INVALID');
+ const rewoundMicros = row.settledThroughMicros - FIXTURE_RESOURCE_QUANTUM_MICROS;
+ ctx.db.resourceAccountV1.fid.update({
+ ...row,
+ settledThroughMicros: rewoundMicros,
+ createdAt: new Timestamp(rewoundMicros),
+ updatedAt: ctx.timestamp,
+ });
+ },
+);
+
+/** Populates every v12 table with bounded, auth-neutral rows for migration proof. */
+export const fixtureSeedGenericWorkerSentinelV12 = db.reducer(
+ { name: 'fixture_seed_generic_worker_sentinel_v12' },
+ ctx => {
+ if (
+ ctx.db.realmWorkerSystemV1.count() !== 0n
+ || ctx.db.castleWorkerV1.count() !== 0n
+ || ctx.db.workerAssignmentV1.count() !== 0n
+ || ctx.db.workerNodeOccupationV1.count() !== 0n
+ || ctx.db.workerCommandIdempotencyV1.count() !== 0n
+ || ctx.db.workerAssignmentScheduleV1.count() !== 0n
+ ) throw new Error('FIXTURE_WORKER_NOT_EMPTY');
+ const castleId = 991_101n;
+ const fid = 991_102n;
+ const startedAtMicros = ctx.timestamp.microsSinceUnixEpoch;
+ const arrivesAtMicros = startedAtMicros + 30_000_000n;
+ const gatheringEndsAtMicros = arrivesAtMicros + 86_400_000_000n;
+ const returnsAtMicros = gatheringEndsAtMicros + 30_000_000n;
+ const assignmentId = 'migration-worker-assignment-0001';
+ const workerId = 'genesis-001-castle-991101-worker-01';
+ const siteId = 'migration-worker-site';
+ ctx.db.realmWorkerSystemV1.insert({
+ realmId: 'GENESIS_001',
+ policyVersion: 'genesis-001-castle-workers-v1',
+ workersPerCastle: 4,
+ expectedCastleCount: 1,
+ expectedWorkerCount: 4,
+ rosterDigest: 'migration-worker-roster-digest',
+ mode: 'staged',
+ legacyDrainRequired: true,
+ createdAt: ctx.timestamp,
+ activatedAt: undefined,
+ });
+ for (let ordinal = 1; ordinal <= 4; ordinal += 1) {
+ ctx.db.castleWorkerV1.insert({
+ workerId: `genesis-001-castle-991101-worker-0${ordinal}`,
+ originCastleId: castleId,
+ ordinal,
+ status: ordinal === 1 ? 'gathering' : 'idle',
+ resourceKind: ordinal === 1 ? 'stone' : undefined,
+ siteId: ordinal === 1 ? siteId : undefined,
+ startedAtMicros: ordinal === 1 ? startedAtMicros : undefined,
+ arrivesAtMicros: ordinal === 1 ? arrivesAtMicros : undefined,
+ gatheringEndsAtMicros: ordinal === 1 ? gatheringEndsAtMicros : undefined,
+ returnStartedAtMicros: undefined,
+ returnsAtMicros: ordinal === 1 ? returnsAtMicros : undefined,
+ routeSteps: ordinal === 1 ? 1 : undefined,
+ returnStartProgressBasisPoints: undefined,
+ timelineRevision: 0,
+ revision: 0n,
+ });
+ }
+ ctx.db.workerAssignmentV1.insert({
+ assignmentId,
+ workerId,
+ fid,
+ originCastleId: castleId,
+ resourceKind: 'stone',
+ siteId,
+ phase: 'gathering',
+ startedAtMicros,
+ arrivesAtMicros,
+ gatheringEndsAtMicros,
+ returnStartedAtMicros: undefined,
+ returnsAtMicros,
+ routeSteps: 1,
+ returnStartProgressBasisPoints: 0,
+ settledThroughMicros: arrivesAtMicros,
+ accruedAmount: 0n,
+ materializedAmount: 0n,
+ timelineRevision: 0,
+ policyVersion: 'genesis-001-castle-workers-v1',
+ createdAt: ctx.timestamp,
+ updatedAt: ctx.timestamp,
+ });
+ ctx.db.workerNodeOccupationV1.insert({
+ nodeKey: 'stone:migration-worker-site',
+ resourceKind: 'stone',
+ siteId,
+ workerId,
+ workerOrdinal: 1,
+ originCastleId: castleId,
+ phase: 'gathering',
+ startedAtMicros,
+ arrivesAtMicros,
+ gatheringEndsAtMicros,
+ timelineRevision: 0,
+ });
+ ctx.db.workerCommandIdempotencyV1.insert({
+ requestKey: '991102:migration-worker-request-0001',
+ fid,
+ workerId,
+ commandKind: 'dispatch',
+ resourceKind: 'stone',
+ siteId,
+ assignmentId,
+ resultRevision: 0n,
+ createdAt: ctx.timestamp,
+ });
+ ctx.db.workerAssignmentScheduleV1.insert({
+ scheduleId: 0n,
+ scheduledAt: ScheduleAt.time(gatheringEndsAtMicros),
+ assignmentId,
+ workerId,
+ timelineRevision: 0,
+ stage: 'gathering-expiry',
+ });
+ },
+);
+
+export default db;
diff --git a/spacetimedb/migration-fixtures/additive-v12-schema/tsconfig.json b/spacetimedb/migration-fixtures/additive-v12-schema/tsconfig.json
new file mode 100644
index 00000000..ff6a5945
--- /dev/null
+++ b/spacetimedb/migration-fixtures/additive-v12-schema/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "strict": true,
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "target": "ESNext",
+ "lib": ["ES2021", "dom"],
+ "module": "ESNext",
+ "isolatedModules": true,
+ "noEmit": true
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/spacetimedb/pnpm-lock.yaml b/spacetimedb/pnpm-lock.yaml
index e3e7575d..f39761b7 100644
--- a/spacetimedb/pnpm-lock.yaml
+++ b/spacetimedb/pnpm-lock.yaml
@@ -39,6 +39,16 @@ importers:
specifier: 5.6.3
version: 5.6.3
+ migration-fixtures/additive-v12-schema:
+ dependencies:
+ spacetimedb:
+ specifier: 2.6.1
+ version: 2.6.1
+ devDependencies:
+ typescript:
+ specifier: 5.6.3
+ version: 5.6.3
+
migration-fixtures/additive-v2-schema:
dependencies:
spacetimedb:
diff --git a/spacetimedb/src/castleWorkerAuthority.ts b/spacetimedb/src/castleWorkerAuthority.ts
new file mode 100644
index 00000000..62c2ca8a
--- /dev/null
+++ b/spacetimedb/src/castleWorkerAuthority.ts
@@ -0,0 +1,1172 @@
+import type { InferSchema, ReducerCtx } from 'spacetimedb/server';
+import { ScheduleAt } from 'spacetimedb';
+
+import { assertGenesisResourceForFid } from './resourceAuthority';
+import { RESOURCE_BALANCE_CAP } from './resourceAuthorityPolicy';
+import {
+ activeExpeditionResourceReservations,
+ planResourceSettlementForActiveExpeditionReservations,
+} from './resourceExpeditionReservationAuthority';
+import type warpkeep from './schema';
+import {
+ CASTLE_WORKER_POLICY_VERSION,
+ CASTLE_WORKER_TRAVEL_MICROS_PER_STEP,
+ CASTLE_WORKERS_PER_CASTLE,
+ CastleWorkerPolicyError,
+ type CastleWorkerPhase,
+ type CastleWorkerSiteShape,
+ planCastleWorkerAccrual,
+ planCastleWorkerTimeline,
+ rosterDigestForCastleIds,
+ workerAssignmentStateIsConsistent,
+ workerIdForCastle,
+ workerResourcePolicy,
+ assertCastleWorkerId,
+ assertWorkerCommandKey,
+ canonicalWorkerRouteSteps,
+} from './castleWorkerPolicy';
+import {
+ recallAllReplayMatches,
+ recallAllWorkersReceipt,
+ recallReplayMatches,
+ recallWorkerReceipt,
+ takeBoundedRows,
+ workerCastleOwnershipMatches,
+ workerCommandReceiptShapeIsValid,
+ workerScheduleMatchesAssignment,
+} from './castleWorkerCommandPolicy';
+import {
+ assertCastleWorkerRoster,
+ castleWorkerPublicStateIsConsistent,
+ workerSystemRowIsStagedOrActive,
+} from './castleWorkerRoster';
+import {
+ CANONICAL_REALM,
+ canonicalMetaForKey,
+ canonicalTileForKey,
+ matchesCanonicalTerrain,
+ matchesCanonicalWorldMeta,
+} from './world';
+
+type WarpkeepReducerContext = ReducerCtx>;
+type CastleRow = NonNullable>;
+type WorkerRow = NonNullable>;
+type AssignmentRow = NonNullable>;
+type ScheduleRow = NonNullable>;
+type WorkerReceiptRow = NonNullable>;
+type ResourceAccountRow = NonNullable>;
+
+export const WORKER_SCHEDULE_STAGE_ARRIVAL = 'arrival';
+export const WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY = 'gathering-expiry';
+export const WORKER_SCHEDULE_STAGE_RETURN_COMPLETE = 'return-complete';
+const WORKER_SYSTEM_REALM_ID = CANONICAL_REALM.realmId;
+const WORKER_TIMELINE_MAX = 0xffff_ffff;
+export const WORKER_IDEMPOTENCY_RECEIPTS_PER_FID = 64;
+const BOUNDED_WORKER_ERROR_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
+
+export class CastleWorkerAuthorityError extends Error {
+ constructor(readonly code: string) {
+ super(code);
+ this.name = 'CastleWorkerAuthorityError';
+ }
+}
+
+function fail(code: string): never {
+ throw new CastleWorkerAuthorityError(code);
+}
+
+function safeNextU32(value: number, code: string): number {
+ if (!Number.isSafeInteger(value) || value < 0 || value >= WORKER_TIMELINE_MAX) fail(code);
+ return value + 1;
+}
+
+function safeNextU64(value: bigint, code: string): bigint {
+ if (value < 0n || value >= (1n << 64n) - 1n) fail(code);
+ return value + 1n;
+}
+
+function boundedRows(rows: Iterable, maximum: number, code: string): readonly Row[] {
+ const bounded = takeBoundedRows(rows, maximum);
+ if (bounded.overflow) fail(code);
+ return bounded.rows;
+}
+
+function assignmentRequestKey(fid: bigint, idempotencyKey: string): string {
+ assertWorkerCommandKey(idempotencyKey);
+ return `${fid.toString()}:${idempotencyKey}`;
+}
+
+function resourceField(kind: string): 'food' | 'wood' | 'stone' | 'gold' {
+ if (kind === 'food' || kind === 'wood' || kind === 'stone' || kind === 'gold') return kind;
+ fail('WORKER_RESOURCE_UNSUPPORTED');
+}
+
+function assignmentPhase(value: string): CastleWorkerPhase {
+ if (value === 'outbound' || value === 'gathering' || value === 'returning') return value;
+ fail('WORKER_PHASE_INVALID');
+}
+
+function systemRow(ctx: WarpkeepReducerContext) {
+ if (ctx.db.realmWorkerSystemV1.count() !== 1n) fail('WORKER_SYSTEM_NOT_READY');
+ const row = ctx.db.realmWorkerSystemV1.realmId.find(WORKER_SYSTEM_REALM_ID);
+ if (row === null || !workerSystemRowIsStagedOrActive(row)) fail('WORKER_SYSTEM_NOT_READY');
+ return row;
+}
+
+function workerSettlementActive(ctx: WarpkeepReducerContext) {
+ const row = systemRow(ctx);
+ if (row.mode !== 'active') fail('WORKER_SYSTEM_STAGED');
+ if (row.legacyDrainRequired) fail('WORKER_LEGACY_DRAIN_REQUIRED');
+ const legacy = legacyActiveCounts(ctx);
+ if (legacy.expeditions !== 0n || legacy.occupations !== 0n || legacy.schedules !== 0n) {
+ fail('WORKER_LEGACY_DRAIN_REQUIRED');
+ }
+ return row;
+}
+
+function workerSystemActive(ctx: WarpkeepReducerContext) {
+ const row = workerSettlementActive(ctx);
+ const expectedCastleCount = BigInt(row.expectedCastleCount);
+ const expectedWorkerCount = BigInt(row.expectedWorkerCount);
+ if (
+ expectedWorkerCount !== expectedCastleCount * BigInt(CASTLE_WORKERS_PER_CASTLE)
+ || ctx.db.castle.count() !== expectedCastleCount
+ || ctx.db.castleWorkerV1.count() !== expectedWorkerCount
+ || !/^[0-9a-f]{16}$/.test(row.rosterDigest)
+ ) fail('WORKER_ROSTER_NOT_READY');
+ return row;
+}
+
+function canonicalSiteFor(
+ ctx: WarpkeepReducerContext,
+ resourceKind: string,
+ siteId: string,
+): CastleWorkerSiteShape {
+ const policy = workerResourcePolicy(resourceKind);
+ const canonical = policy.canonicalSiteForId(siteId);
+ if (canonical === undefined || !canonical.active || !policy.matchesCanonicalSite(canonical)) {
+ fail('WORKER_SITE_UNAVAILABLE');
+ }
+ const stored = resourceKind === 'gold'
+ ? ctx.db.goldSiteV1.siteId.find(siteId)
+ : resourceKind === 'food'
+ ? ctx.db.foodSiteV1.siteId.find(siteId)
+ : resourceKind === 'wood'
+ ? ctx.db.woodSiteV1.siteId.find(siteId)
+ : ctx.db.stoneSiteV1.siteId.find(siteId);
+ if (stored === null || !policy.matchesCanonicalSite(stored)) fail('WORKER_SITE_INTEGRITY');
+ const tileKey = `${canonical.q},${canonical.r}`;
+ const tile = ctx.db.worldTile.key.find(tileKey);
+ const meta = ctx.db.worldTileMetaV1.tileKey.find(tileKey);
+ const expectedTile = canonicalTileForKey(tileKey);
+ const expectedMeta = canonicalMetaForKey(tileKey);
+ if (
+ tile === null
+ || meta === null
+ || expectedTile === undefined
+ || expectedMeta === undefined
+ || !matchesCanonicalTerrain(tile)
+ || !matchesCanonicalWorldMeta(meta)
+ || !matchesCanonicalTerrain(expectedTile)
+ || !matchesCanonicalWorldMeta(expectedMeta)
+ || !meta.passable
+ || meta.staticContentKind !== 'resource-capable'
+ || tile.q !== canonical.q
+ || tile.r !== canonical.r
+ ) fail('WORKER_SITE_WORLD_INTEGRITY');
+ return canonical;
+}
+
+function legacyOccupationAt(ctx: WarpkeepReducerContext, resourceKind: string, siteId: string): boolean {
+ return resourceKind === 'gold'
+ ? ctx.db.goldNodeOccupationV1.siteId.find(siteId) !== null
+ : resourceKind === 'food'
+ ? ctx.db.foodNodeOccupationV1.siteId.find(siteId) !== null
+ : resourceKind === 'wood'
+ ? ctx.db.woodNodeOccupationV1.siteId.find(siteId) !== null
+ : ctx.db.stoneNodeOccupationV1.siteId.find(siteId) !== null;
+}
+
+function publicWorkerMatchesAssignment(worker: WorkerRow, assignment: AssignmentRow): boolean {
+ const expectedReturnProgress = assignment.phase === 'returning'
+ ? assignment.returnStartProgressBasisPoints
+ : undefined;
+ return worker.workerId === assignment.workerId
+ && worker.ordinal >= 1
+ && worker.ordinal <= CASTLE_WORKERS_PER_CASTLE
+ && worker.workerId === workerIdForCastle(assignment.originCastleId, worker.ordinal)
+ && worker.originCastleId === assignment.originCastleId
+ && worker.status === assignment.phase
+ && worker.resourceKind === assignment.resourceKind
+ && worker.siteId === assignment.siteId
+ && worker.startedAtMicros === assignment.startedAtMicros
+ && worker.arrivesAtMicros === assignment.arrivesAtMicros
+ && worker.gatheringEndsAtMicros === assignment.gatheringEndsAtMicros
+ && worker.returnStartedAtMicros === assignment.returnStartedAtMicros
+ && worker.returnsAtMicros === assignment.returnsAtMicros
+ && worker.routeSteps === assignment.routeSteps
+ && worker.returnStartProgressBasisPoints === expectedReturnProgress
+ && worker.timelineRevision === assignment.timelineRevision;
+}
+
+function occupationMatchesAssignment(
+ occupation: NonNullable>,
+ assignment: AssignmentRow,
+): boolean {
+ // The occupation is only the outbound/gathering site lease and is deleted
+ // before return starts. Return chronology therefore belongs to the worker
+ // projection; every field that the occupation does expose is matched here.
+ return occupation.nodeKey === `${assignment.resourceKind}:${assignment.siteId}`
+ && occupation.resourceKind === assignment.resourceKind
+ && occupation.siteId === assignment.siteId
+ && occupation.workerId === assignment.workerId
+ && occupation.workerOrdinal >= 1
+ && occupation.workerOrdinal <= CASTLE_WORKERS_PER_CASTLE
+ && assignment.workerId === workerIdForCastle(assignment.originCastleId, occupation.workerOrdinal)
+ && occupation.originCastleId === assignment.originCastleId
+ && assignment.phase !== 'returning'
+ && occupation.phase === assignment.phase
+ && occupation.startedAtMicros === assignment.startedAtMicros
+ && occupation.arrivesAtMicros === assignment.arrivesAtMicros
+ && occupation.gatheringEndsAtMicros === assignment.gatheringEndsAtMicros
+ && occupation.timelineRevision === assignment.timelineRevision;
+}
+
+function canonicalCastleOwnershipMatches(
+ ctx: WarpkeepReducerContext,
+ fid: bigint,
+ castleId: bigint,
+): boolean {
+ const castle = ctx.db.castle.castleId.find(castleId);
+ const account = ctx.db.resourceAccountV1.fid.find(fid);
+ return workerCastleOwnershipMatches({
+ fid,
+ castleId,
+ castleOwnerFid: castle?.ownerFid,
+ accountFid: account?.fid,
+ accountCastleId: account?.castleId,
+ });
+}
+
+function assignmentOwnerIsCanonical(ctx: WarpkeepReducerContext, assignment: AssignmentRow): boolean {
+ return canonicalCastleOwnershipMatches(ctx, assignment.fid, assignment.originCastleId);
+}
+
+function receiptOwnerIsCanonical(
+ ctx: WarpkeepReducerContext,
+ receipt: WorkerReceiptRow,
+ expectedCastleId?: bigint,
+): boolean {
+ if (receipt.workerId !== undefined) {
+ const worker = ctx.db.castleWorkerV1.workerId.find(receipt.workerId);
+ return worker !== null
+ && worker.ordinal >= 1
+ && worker.ordinal <= CASTLE_WORKERS_PER_CASTLE
+ && worker.workerId === workerIdForCastle(worker.originCastleId, worker.ordinal)
+ && (expectedCastleId === undefined || worker.originCastleId === expectedCastleId)
+ && canonicalCastleOwnershipMatches(ctx, receipt.fid, worker.originCastleId);
+ }
+ const castle = ctx.db.castle.ownerFid.find(receipt.fid);
+ return castle !== null
+ && (expectedCastleId === undefined || castle.castleId === expectedCastleId)
+ && canonicalCastleOwnershipMatches(ctx, receipt.fid, castle.castleId);
+}
+
+function workerReceiptShapeIsValid(receipt: WorkerReceiptRow): boolean {
+ return workerCommandReceiptShapeIsValid(receipt);
+}
+
+function assertAssignmentState(assignment: AssignmentRow): void {
+ assignmentPhase(assignment.phase);
+ assertCastleWorkerId(assignment.workerId);
+ if (
+ assignment.assignmentId.length === 0
+ || assignment.fid <= 0n
+ || assignment.originCastleId < 0n
+ || assignment.siteId.length === 0
+ || assignment.policyVersion !== CASTLE_WORKER_POLICY_VERSION
+ || !workerAssignmentStateIsConsistent(assignment)
+ || assignment.returnStartProgressBasisPoints > 10_000
+ || !Number.isSafeInteger(assignment.timelineRevision)
+ || assignment.timelineRevision < 0
+ ) fail('WORKER_ASSIGNMENT_STATE_INVALID');
+}
+
+function insertSchedule(
+ ctx: WarpkeepReducerContext,
+ assignment: AssignmentRow,
+ stage: string,
+ atMicros: bigint,
+): void {
+ ctx.db.workerAssignmentScheduleV1.insert({
+ scheduleId: 0n,
+ scheduledAt: ScheduleAt.time(atMicros),
+ assignmentId: assignment.assignmentId,
+ workerId: assignment.workerId,
+ timelineRevision: assignment.timelineRevision,
+ stage,
+ });
+}
+
+function deleteSchedulesForAssignment(ctx: WarpkeepReducerContext, assignmentId: string): void {
+ for (const schedule of [...ctx.db.workerAssignmentScheduleV1.byAssignment.filter(assignmentId)]) {
+ ctx.db.workerAssignmentScheduleV1.scheduleId.delete(schedule.scheduleId);
+ }
+}
+
+function scheduleMatchesAssignment(schedule: ScheduleRow, assignment: AssignmentRow): boolean {
+ return workerScheduleMatchesAssignment(schedule, assignment);
+}
+
+type CallerWorkerGraph = Readonly<{
+ roster: readonly WorkerRow[];
+ assignments: readonly AssignmentRow[];
+}>;
+
+/**
+ * Validate only the caller's fixed-size worker graph. Whole-realm inspection
+ * remains available through the admin procedures, but gameplay paths never
+ * iterate realm-wide tables.
+ */
+function assertCallerWorkerGraph(
+ ctx: WarpkeepReducerContext,
+ fid: bigint,
+ castleId: bigint,
+): CallerWorkerGraph {
+ const roster = assertCastleWorkerRoster(ctx, castleId);
+ const rosterByWorker = new Map(roster.map(worker => [worker.workerId, worker]));
+ const assignments = boundedRows(
+ ctx.db.workerAssignmentV1.byFid.filter(fid),
+ CASTLE_WORKERS_PER_CASTLE,
+ 'WORKER_ASSIGNMENT_LIMIT',
+ );
+ const assignmentByWorker = new Map();
+ for (const assignment of assignments) {
+ assertAssignmentState(assignment);
+ const worker = rosterByWorker.get(assignment.workerId);
+ if (
+ assignment.fid !== fid
+ || assignment.originCastleId !== castleId
+ || !assignmentOwnerIsCanonical(ctx, assignment)
+ || worker === undefined
+ || assignmentByWorker.has(assignment.workerId)
+ || !publicWorkerMatchesAssignment(worker, assignment)
+ ) fail('WORKER_ASSIGNMENT_INTEGRITY');
+ assignmentByWorker.set(assignment.workerId, assignment);
+ }
+ for (const worker of roster) {
+ const directAssignment = ctx.db.workerAssignmentV1.workerId.find(worker.workerId);
+ const assignment = assignmentByWorker.get(worker.workerId);
+ if (
+ (directAssignment === null) !== (assignment === undefined)
+ || (directAssignment !== null && directAssignment.assignmentId !== assignment?.assignmentId)
+ ) fail('WORKER_ASSIGNMENT_INTEGRITY');
+ const occupations = boundedRows(
+ ctx.db.workerNodeOccupationV1.byWorker.filter(worker.workerId),
+ 1,
+ 'WORKER_OCCUPATION_LIMIT',
+ );
+ const schedules = boundedRows(
+ ctx.db.workerAssignmentScheduleV1.byWorker.filter(worker.workerId),
+ 1,
+ 'WORKER_SCHEDULE_LIMIT',
+ );
+ if (assignment === undefined) {
+ if (worker.status !== 'idle' || occupations.length !== 0 || schedules.length !== 0) {
+ fail('WORKER_ASSIGNMENT_INTEGRITY');
+ }
+ continue;
+ }
+ const expectedOccupationCount = assignment.phase === 'returning' ? 0 : 1;
+ if (
+ occupations.length !== expectedOccupationCount
+ || (occupations[0] !== undefined && !occupationMatchesAssignment(occupations[0], assignment))
+ || schedules.length !== 1
+ || !scheduleMatchesAssignment(schedules[0]!, assignment)
+ ) fail('WORKER_ASSIGNMENT_INTEGRITY');
+ }
+ const receipts = boundedRows(
+ ctx.db.workerCommandIdempotencyV1.byFid.filter(fid),
+ WORKER_IDEMPOTENCY_RECEIPTS_PER_FID,
+ 'WORKER_IDEMPOTENCY_LIMIT',
+ );
+ const highestRosterRevision = roster.reduce(
+ (highest, worker) => worker.revision > highest ? worker.revision : highest,
+ 0n,
+ );
+ for (const receipt of receipts) {
+ const receiptWorker = receipt.workerId === undefined
+ ? undefined
+ : rosterByWorker.get(receipt.workerId);
+ if (
+ !workerReceiptShapeIsValid(receipt)
+ || !receiptOwnerIsCanonical(ctx, receipt, castleId)
+ || (receiptWorker !== undefined && receipt.resultRevision > receiptWorker.revision)
+ || (receipt.workerId === undefined && receipt.resultRevision > highestRosterRevision)
+ ) fail('WORKER_IDEMPOTENCY_OWNER_INVALID');
+ }
+ return Object.freeze({ roster, assignments });
+}
+
+function pruneWorkerIdempotencyReceipts(ctx: WarpkeepReducerContext, fid: bigint): void {
+ const receipts = [...boundedRows(
+ ctx.db.workerCommandIdempotencyV1.byFid.filter(fid),
+ WORKER_IDEMPOTENCY_RECEIPTS_PER_FID,
+ 'WORKER_IDEMPOTENCY_LIMIT',
+ )]
+ .sort((left, right) => {
+ const timeOrder = left.createdAt.microsSinceUnixEpoch < right.createdAt.microsSinceUnixEpoch
+ ? -1
+ : left.createdAt.microsSinceUnixEpoch > right.createdAt.microsSinceUnixEpoch ? 1 : 0;
+ return timeOrder || left.requestKey.localeCompare(right.requestKey);
+ });
+ const deleteCount = Math.max(0, receipts.length - WORKER_IDEMPOTENCY_RECEIPTS_PER_FID + 1);
+ for (const receipt of receipts.slice(0, deleteCount)) {
+ ctx.db.workerCommandIdempotencyV1.requestKey.delete(receipt.requestKey);
+ }
+}
+
+function updateResourceAccount(
+ ctx: WarpkeepReducerContext,
+ resource: ResourceAccountRow,
+ balances: ResourceAccountRow,
+ passiveSettledThroughMicros: bigint,
+ revision: bigint,
+): void {
+ ctx.db.resourceAccountV1.fid.update({
+ ...resource,
+ food: balances.food,
+ wood: balances.wood,
+ stone: balances.stone,
+ gold: balances.gold,
+ settledThroughMicros: passiveSettledThroughMicros,
+ revision,
+ updatedAt: ctx.timestamp,
+ });
+}
+
+/**
+ * Materialize every complete worker quantum for one caller in one transaction.
+ * Reads use the sibling projection below and never call this writer. No
+ * per-minute writes occur: schedules and caller reads settle exact quanta.
+ */
+export function settleAllWorkerAssignmentsForFid(
+ ctx: WarpkeepReducerContext,
+ fid: bigint,
+ observedAtMicros = ctx.timestamp.microsSinceUnixEpoch,
+): void {
+ const resource = assertGenesisResourceForFid(ctx, fid);
+ const assignments = boundedRows(
+ ctx.db.workerAssignmentV1.byFid.filter(fid),
+ CASTLE_WORKERS_PER_CASTLE,
+ 'WORKER_ASSIGNMENT_LIMIT',
+ );
+ if (assignments.length > 0) workerSettlementActive(ctx);
+ const passive = planResourceSettlementForActiveExpeditionReservations(
+ ctx,
+ fid,
+ resource.account,
+ resource.terrainKind,
+ observedAtMicros,
+ );
+ const balances = {
+ ...resource.account,
+ food: passive.balances.food,
+ wood: passive.balances.wood,
+ stone: passive.balances.stone,
+ gold: passive.balances.gold,
+ };
+ let changed = passive.completedQuanta > 0n;
+ for (const assignment of assignments) {
+ assertAssignmentState(assignment);
+ if (assignment.fid !== fid || assignment.originCastleId !== resource.castle.castleId) fail('WORKER_OWNER_INTEGRITY');
+ const plan = planCastleWorkerAccrual(assignment, observedAtMicros);
+ const credit = plan.accruedAmount - assignment.materializedAmount;
+ if (credit < 0n) fail('WORKER_MATERIALIZATION_INVALID');
+ if (plan.completedQuanta === 0n && credit === 0n) continue;
+ const field = resourceField(assignment.resourceKind);
+ if (credit > RESOURCE_BALANCE_CAP - balances[field]) fail('WORKER_ACCOUNT_CAPACITY');
+ balances[field] += credit;
+ ctx.db.workerAssignmentV1.assignmentId.update({
+ ...assignment,
+ settledThroughMicros: plan.settledThroughMicros,
+ accruedAmount: plan.accruedAmount,
+ materializedAmount: plan.accruedAmount,
+ updatedAt: ctx.timestamp,
+ });
+ changed = true;
+ }
+ if (changed) {
+ updateResourceAccount(
+ ctx,
+ resource.account,
+ balances,
+ passive.settledThroughMicros,
+ safeNextU64(resource.account.revision, 'WORKER_RESOURCE_REVISION'),
+ );
+ }
+}
+
+export type WorkerPrivateProjection = Readonly<{
+ workerId: string;
+ ordinal: number;
+ status: string;
+ resourceKind: string | undefined;
+ siteId: string | undefined;
+ accruedAmount: bigint;
+ materializedAmount: bigint;
+ availableAmount: bigint;
+ observedAtMicros: bigint;
+ revision: bigint;
+}>;
+
+export function projectMyWorkerState(
+ ctx: WarpkeepReducerContext,
+ fid: bigint,
+ observedAtMicros = ctx.timestamp.microsSinceUnixEpoch,
+): Readonly<{ resource: ResourceAccountRow; balances: Readonly>; workers: readonly WorkerPrivateProjection[] }> {
+ workerSystemActive(ctx);
+ const resource = assertGenesisResourceForFid(ctx, fid);
+ const callerGraph = assertCallerWorkerGraph(ctx, fid, resource.castle.castleId);
+ const passive = planResourceSettlementForActiveExpeditionReservations(
+ ctx,
+ fid,
+ resource.account,
+ resource.terrainKind,
+ observedAtMicros,
+ );
+ const balances = {
+ food: passive.balances.food,
+ wood: passive.balances.wood,
+ stone: passive.balances.stone,
+ gold: passive.balances.gold,
+ };
+ const workers = [...callerGraph.roster]
+ .sort((left, right) => left.ordinal - right.ordinal)
+ .map(worker => {
+ const assignment = ctx.db.workerAssignmentV1.workerId.find(worker.workerId);
+ if (assignment === null) {
+ if (worker.status !== 'idle') fail('WORKER_ASSIGNMENT_MISSING');
+ return Object.freeze({
+ workerId: worker.workerId,
+ ordinal: worker.ordinal,
+ status: worker.status,
+ resourceKind: worker.resourceKind,
+ siteId: worker.siteId,
+ accruedAmount: 0n,
+ materializedAmount: 0n,
+ availableAmount: 0n,
+ observedAtMicros,
+ revision: worker.revision,
+ });
+ }
+ assertAssignmentState(assignment);
+ if (!publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH');
+ const plan = planCastleWorkerAccrual(assignment, observedAtMicros);
+ const availableAmount = plan.accruedAmount - assignment.materializedAmount;
+ if (availableAmount < 0n) fail('WORKER_MATERIALIZATION_INVALID');
+ const field = resourceField(assignment.resourceKind);
+ if (availableAmount > RESOURCE_BALANCE_CAP - balances[field]) fail('WORKER_ACCOUNT_CAPACITY');
+ balances[field] += availableAmount;
+ return Object.freeze({
+ workerId: worker.workerId,
+ ordinal: worker.ordinal,
+ status: worker.status,
+ resourceKind: worker.resourceKind,
+ siteId: worker.siteId,
+ accruedAmount: plan.accruedAmount,
+ materializedAmount: assignment.materializedAmount,
+ availableAmount,
+ observedAtMicros,
+ revision: worker.revision,
+ });
+ });
+ return Object.freeze({ resource: resource.account, balances: Object.freeze(balances), workers: Object.freeze(workers) });
+}
+
+function assertDispatchReservations(
+ ctx: WarpkeepReducerContext,
+ fid: bigint,
+ account: ResourceAccountRow,
+ resourceKind: string,
+): void {
+ const policy = workerResourcePolicy(resourceKind);
+ const reservations = activeExpeditionResourceReservations(ctx, fid);
+ const field = resourceField(resourceKind);
+ const existingReservation = reservations[field];
+ if (account[field] > RESOURCE_BALANCE_CAP || existingReservation > RESOURCE_BALANCE_CAP) fail('WORKER_ACCOUNT_STATE_INVALID');
+ if (policy.gatheringTotal > RESOURCE_BALANCE_CAP - account[field] - existingReservation) {
+ fail('WORKER_ACCOUNT_CAPACITY');
+ }
+}
+
+export type WorkerDispatchResult = Readonly<{ assignment: AssignmentRow; idempotent: boolean }>;
+
+export function dispatchCastleWorker(
+ ctx: WarpkeepReducerContext,
+ input: Readonly<{ fid: bigint; castle: CastleRow; workerId: string; resourceKind: string; siteId: string; idempotencyKey: string }>,
+): WorkerDispatchResult {
+ const requestKey = assignmentRequestKey(input.fid, input.idempotencyKey);
+ const prior = ctx.db.workerCommandIdempotencyV1.requestKey.find(requestKey);
+ if (prior !== null) {
+ if (prior.fid !== input.fid || prior.commandKind !== 'dispatch' || prior.workerId !== input.workerId || prior.resourceKind !== input.resourceKind || prior.siteId !== input.siteId || prior.assignmentId === undefined) fail('WORKER_IDEMPOTENCY_CONFLICT');
+ if (
+ !workerReceiptShapeIsValid(prior)
+ || !canonicalCastleOwnershipMatches(ctx, input.fid, input.castle.castleId)
+ || !receiptOwnerIsCanonical(ctx, prior, input.castle.castleId)
+ ) fail('WORKER_IDEMPOTENCY_OWNER_INVALID');
+ const assignment = ctx.db.workerAssignmentV1.assignmentId.find(prior.assignmentId);
+ if (
+ assignment === null
+ || assignment.fid !== input.fid
+ || assignment.workerId !== input.workerId
+ || assignment.resourceKind !== input.resourceKind
+ || assignment.siteId !== input.siteId
+ || assignment.originCastleId !== input.castle.castleId
+ || !assignmentOwnerIsCanonical(ctx, assignment)
+ ) fail('WORKER_IDEMPOTENCY_STALE');
+ assertAssignmentState(assignment);
+ const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId);
+ if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) {
+ fail('WORKER_IDEMPOTENCY_STALE');
+ }
+ return Object.freeze({ assignment, idempotent: true });
+ }
+ workerSystemActive(ctx);
+ if (!canonicalCastleOwnershipMatches(ctx, input.fid, input.castle.castleId)) fail('WORKER_NOT_OWNED');
+ const callerGraph = assertCallerWorkerGraph(ctx, input.fid, input.castle.castleId);
+ settleAllWorkerAssignmentsForFid(ctx, input.fid);
+ const roster = callerGraph.roster;
+ const worker = ctx.db.castleWorkerV1.workerId.find(input.workerId);
+ if (worker === null || worker.originCastleId !== input.castle.castleId || !roster.some(row => row.workerId === worker.workerId)) fail('WORKER_NOT_OWNED');
+ assertCastleWorkerId(worker.workerId);
+ if (worker.status !== 'idle' || ctx.db.workerAssignmentV1.workerId.find(worker.workerId) !== null) fail('WORKER_NOT_IDLE');
+ const site = canonicalSiteFor(ctx, input.resourceKind, input.siteId);
+ if (legacyOccupationAt(ctx, input.resourceKind, input.siteId)) fail('WORKER_LEGACY_SITE_OCCUPIED');
+ const nodeKey = `${input.resourceKind}:${input.siteId}`;
+ if (ctx.db.workerNodeOccupationV1.nodeKey.find(nodeKey) !== null) fail('WORKER_SITE_OCCUPIED');
+ const routeSteps = canonicalWorkerRouteSteps(input.castle, site);
+ if (routeSteps === undefined || routeSteps <= 0) fail('WORKER_ROUTE_INVALID');
+ const resource = assertGenesisResourceForFid(ctx, input.fid);
+ assertDispatchReservations(ctx, input.fid, resource.account, input.resourceKind);
+ const timeline = planCastleWorkerTimeline(ctx.timestamp.microsSinceUnixEpoch, routeSteps);
+ const timelineRevision = safeNextU32(worker.timelineRevision, 'WORKER_TIMELINE_REVISION');
+ const assignment = ctx.db.workerAssignmentV1.insert({
+ assignmentId: ctx.newUuidV7().toString(),
+ workerId: worker.workerId,
+ fid: input.fid,
+ originCastleId: input.castle.castleId,
+ resourceKind: input.resourceKind,
+ siteId: input.siteId,
+ phase: 'outbound',
+ ...timeline,
+ returnStartedAtMicros: undefined,
+ routeSteps,
+ returnStartProgressBasisPoints: 0,
+ settledThroughMicros: timeline.arrivesAtMicros,
+ accruedAmount: 0n,
+ materializedAmount: 0n,
+ timelineRevision,
+ policyVersion: CASTLE_WORKER_POLICY_VERSION,
+ createdAt: ctx.timestamp,
+ updatedAt: ctx.timestamp,
+ });
+ ctx.db.castleWorkerV1.workerId.update({
+ ...worker,
+ status: 'outbound',
+ resourceKind: input.resourceKind,
+ siteId: input.siteId,
+ startedAtMicros: assignment.startedAtMicros,
+ arrivesAtMicros: assignment.arrivesAtMicros,
+ gatheringEndsAtMicros: assignment.gatheringEndsAtMicros,
+ returnStartedAtMicros: undefined,
+ returnsAtMicros: assignment.returnsAtMicros,
+ routeSteps: assignment.routeSteps,
+ returnStartProgressBasisPoints: undefined,
+ timelineRevision,
+ revision: safeNextU64(worker.revision, 'WORKER_REVISION'),
+ });
+ ctx.db.workerNodeOccupationV1.insert({
+ nodeKey,
+ resourceKind: input.resourceKind,
+ siteId: input.siteId,
+ workerId: worker.workerId,
+ workerOrdinal: worker.ordinal,
+ originCastleId: input.castle.castleId,
+ phase: 'outbound',
+ startedAtMicros: assignment.startedAtMicros,
+ arrivesAtMicros: assignment.arrivesAtMicros,
+ gatheringEndsAtMicros: assignment.gatheringEndsAtMicros,
+ timelineRevision: assignment.timelineRevision,
+ });
+ insertSchedule(ctx, assignment, WORKER_SCHEDULE_STAGE_ARRIVAL, assignment.arrivesAtMicros);
+ const updatedWorker = ctx.db.castleWorkerV1.workerId.find(worker.workerId);
+ if (updatedWorker === null || !publicWorkerMatchesAssignment(updatedWorker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH');
+ pruneWorkerIdempotencyReceipts(ctx, input.fid);
+ ctx.db.workerCommandIdempotencyV1.insert({
+ requestKey,
+ fid: input.fid,
+ workerId: worker.workerId,
+ commandKind: 'dispatch',
+ resourceKind: input.resourceKind,
+ siteId: input.siteId,
+ assignmentId: assignment.assignmentId,
+ resultRevision: updatedWorker.revision,
+ createdAt: ctx.timestamp,
+ });
+ return Object.freeze({ assignment, idempotent: false });
+}
+
+function progressBasisPoints(assignment: AssignmentRow, now: bigint): number {
+ if (now <= assignment.startedAtMicros) return 0;
+ if (now >= assignment.arrivesAtMicros) return 10_000;
+ const elapsed = now - assignment.startedAtMicros;
+ const duration = assignment.arrivesAtMicros - assignment.startedAtMicros;
+ return Number((elapsed * 10_000n) / duration);
+}
+
+function remainingTravelMicros(assignment: AssignmentRow, progress: number): bigint {
+ const travel = BigInt(assignment.routeSteps) * CASTLE_WORKER_TRAVEL_MICROS_PER_STEP;
+ // The return path starts at the worker's current outbound position: zero
+ // progress is still at the castle, while 10,000 is at the node.
+ return (travel * BigInt(progress)) / 10_000n;
+}
+
+function beginWorkerReturn(
+ ctx: WarpkeepReducerContext,
+ assignment: AssignmentRow,
+ progress: number,
+ now: bigint,
+): AssignmentRow {
+ assertAssignmentState(assignment);
+ if (assignment.phase !== 'outbound' && assignment.phase !== 'gathering') return assignment;
+ const occupation = ctx.db.workerNodeOccupationV1.nodeKey.find(`${assignment.resourceKind}:${assignment.siteId}`);
+ if (occupation === null || !occupationMatchesAssignment(occupation, assignment)) fail('WORKER_OCCUPATION_INTEGRITY');
+ const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId);
+ if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH');
+ const returningAtMicros = now + remainingTravelMicros(assignment, progress);
+ const timelineRevision = safeNextU32(assignment.timelineRevision, 'WORKER_TIMELINE_REVISION');
+ const returning = {
+ ...assignment,
+ phase: 'returning',
+ returnStartedAtMicros: now,
+ returnsAtMicros: returningAtMicros,
+ returnStartProgressBasisPoints: progress,
+ timelineRevision,
+ updatedAt: ctx.timestamp,
+ };
+ deleteSchedulesForAssignment(ctx, assignment.assignmentId);
+ ctx.db.workerNodeOccupationV1.nodeKey.delete(occupation.nodeKey);
+ ctx.db.workerAssignmentV1.assignmentId.update(returning);
+ ctx.db.castleWorkerV1.workerId.update({
+ ...worker,
+ status: 'returning',
+ returnStartedAtMicros: now,
+ returnsAtMicros: returningAtMicros,
+ returnStartProgressBasisPoints: progress,
+ timelineRevision,
+ revision: safeNextU64(worker.revision, 'WORKER_REVISION'),
+ });
+ insertSchedule(ctx, returning, WORKER_SCHEDULE_STAGE_RETURN_COMPLETE, returningAtMicros);
+ return returning;
+}
+
+function completeWorkerReturn(ctx: WarpkeepReducerContext, assignment: AssignmentRow, now: bigint): void {
+ if (now < assignment.returnsAtMicros) return;
+ if (assignment.phase !== 'returning') fail('WORKER_RETURN_STATE');
+ const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId);
+ if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH');
+ if (ctx.db.workerNodeOccupationV1.nodeKey.find(`${assignment.resourceKind}:${assignment.siteId}`) !== null) {
+ fail('WORKER_OCCUPATION_INTEGRITY');
+ }
+ deleteSchedulesForAssignment(ctx, assignment.assignmentId);
+ ctx.db.workerAssignmentV1.assignmentId.delete(assignment.assignmentId);
+ ctx.db.castleWorkerV1.workerId.update({
+ ...worker,
+ status: 'idle',
+ resourceKind: undefined,
+ siteId: undefined,
+ startedAtMicros: undefined,
+ arrivesAtMicros: undefined,
+ gatheringEndsAtMicros: undefined,
+ returnStartedAtMicros: undefined,
+ returnsAtMicros: undefined,
+ routeSteps: undefined,
+ returnStartProgressBasisPoints: undefined,
+ timelineRevision: safeNextU32(worker.timelineRevision, 'WORKER_TIMELINE_REVISION'),
+ revision: safeNextU64(worker.revision, 'WORKER_REVISION'),
+ });
+}
+
+function transitionWorkerArrival(ctx: WarpkeepReducerContext, assignment: AssignmentRow, now: bigint): AssignmentRow {
+ if (now < assignment.arrivesAtMicros) return assignment;
+ if (assignment.phase !== 'outbound') return assignment;
+ const occupation = ctx.db.workerNodeOccupationV1.nodeKey.find(`${assignment.resourceKind}:${assignment.siteId}`);
+ if (occupation === null || !occupationMatchesAssignment(occupation, assignment)) fail('WORKER_OCCUPATION_MISSING');
+ const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId);
+ if (worker === null || !publicWorkerMatchesAssignment(worker, assignment)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH');
+ const timelineRevision = safeNextU32(assignment.timelineRevision, 'WORKER_TIMELINE_REVISION');
+ const gathering = { ...assignment, phase: 'gathering', timelineRevision, updatedAt: ctx.timestamp };
+ deleteSchedulesForAssignment(ctx, assignment.assignmentId);
+ ctx.db.workerAssignmentV1.assignmentId.update(gathering);
+ ctx.db.workerNodeOccupationV1.nodeKey.update({ ...occupation, phase: 'gathering', timelineRevision });
+ ctx.db.castleWorkerV1.workerId.update({
+ ...worker,
+ status: 'gathering',
+ timelineRevision,
+ revision: safeNextU64(worker.revision, 'WORKER_REVISION'),
+ });
+ insertSchedule(ctx, gathering, WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY, gathering.gatheringEndsAtMicros);
+ return gathering;
+}
+
+function settleAndBeginReturnAt(
+ ctx: WarpkeepReducerContext,
+ assignment: AssignmentRow,
+ now: bigint,
+ progress: number,
+): AssignmentRow {
+ settleAllWorkerAssignmentsForFid(ctx, assignment.fid, now);
+ const fresh = ctx.db.workerAssignmentV1.assignmentId.find(assignment.assignmentId);
+ if (fresh === null) fail('WORKER_ASSIGNMENT_MISSING');
+ return beginWorkerReturn(ctx, fresh, progress, now);
+}
+
+export function runCastleWorkerSchedule(ctx: WarpkeepReducerContext, schedule: ScheduleRow): void {
+ const assignment = ctx.db.workerAssignmentV1.assignmentId.find(schedule.assignmentId);
+ if (assignment === null || !scheduleMatchesAssignment(schedule, assignment)) {
+ ctx.db.workerAssignmentScheduleV1.scheduleId.delete(schedule.scheduleId);
+ return;
+ }
+ assertAssignmentState(assignment);
+ const now = ctx.timestamp.microsSinceUnixEpoch;
+ if (schedule.stage === WORKER_SCHEDULE_STAGE_ARRIVAL) {
+ transitionWorkerArrival(ctx, assignment, now);
+ return;
+ }
+ if (schedule.stage === WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY) {
+ const gathering = transitionWorkerArrival(ctx, assignment, now);
+ if (now < gathering.gatheringEndsAtMicros || gathering.phase === 'returning') return;
+ settleAndBeginReturnAt(ctx, gathering, gathering.gatheringEndsAtMicros, 10_000);
+ return;
+ }
+ if (schedule.stage === WORKER_SCHEDULE_STAGE_RETURN_COMPLETE) {
+ completeWorkerReturn(ctx, assignment, now);
+ return;
+ }
+ ctx.db.workerAssignmentScheduleV1.scheduleId.delete(schedule.scheduleId);
+}
+
+export function recallCastleWorker(
+ ctx: WarpkeepReducerContext,
+ input: Readonly<{ fid: bigint; castle: CastleRow; workerId: string; idempotencyKey: string }>,
+): void {
+ const requestKey = assignmentRequestKey(input.fid, input.idempotencyKey);
+ const prior = ctx.db.workerCommandIdempotencyV1.requestKey.find(requestKey);
+ if (prior !== null) {
+ if (prior.fid !== input.fid || prior.commandKind !== 'recall' || prior.workerId !== input.workerId) fail('WORKER_IDEMPOTENCY_CONFLICT');
+ if (
+ !recallReplayMatches(prior, input.fid, input.workerId)
+ || !canonicalCastleOwnershipMatches(ctx, input.fid, input.castle.castleId)
+ || !receiptOwnerIsCanonical(ctx, prior, input.castle.castleId)
+ ) fail('WORKER_IDEMPOTENCY_OWNER_INVALID');
+ return;
+ }
+ workerSystemActive(ctx);
+ if (!canonicalCastleOwnershipMatches(ctx, input.fid, input.castle.castleId)) fail('WORKER_NOT_OWNED');
+ const callerGraph = assertCallerWorkerGraph(ctx, input.fid, input.castle.castleId);
+ const worker = callerGraph.roster.find(row => row.workerId === input.workerId);
+ if (worker === undefined) fail('WORKER_NOT_OWNED');
+ let assignment = ctx.db.workerAssignmentV1.workerId.find(worker.workerId);
+ if (assignment === null) {
+ if (worker.status !== 'idle') fail('WORKER_ASSIGNMENT_MISSING');
+ } else if (assignment.phase !== 'returning') {
+ settleAllWorkerAssignmentsForFid(ctx, input.fid);
+ assignment = ctx.db.workerAssignmentV1.workerId.find(worker.workerId);
+ if (assignment === null || assignment.fid !== input.fid) fail('WORKER_ASSIGNMENT_MISSING');
+ const now = ctx.timestamp.microsSinceUnixEpoch;
+ const returnStartedAtMicros = now < assignment.gatheringEndsAtMicros
+ ? now
+ : assignment.gatheringEndsAtMicros;
+ const progress = returnStartedAtMicros < assignment.arrivesAtMicros
+ ? progressBasisPoints(assignment, returnStartedAtMicros)
+ : 10_000;
+ assignment = beginWorkerReturn(ctx, assignment, progress, returnStartedAtMicros);
+ }
+ const updatedWorker = ctx.db.castleWorkerV1.workerId.find(worker.workerId);
+ if (
+ updatedWorker === null
+ || (assignment !== null && !publicWorkerMatchesAssignment(updatedWorker, assignment))
+ ) fail('WORKER_PUBLIC_PRIVATE_MISMATCH');
+ pruneWorkerIdempotencyReceipts(ctx, input.fid);
+ const receipt = recallWorkerReceipt(
+ requestKey,
+ input.fid,
+ worker.workerId,
+ updatedWorker.revision,
+ assignment === null ? undefined : {
+ resourceKind: assignment.resourceKind,
+ siteId: assignment.siteId,
+ assignmentId: assignment.assignmentId,
+ },
+ );
+ ctx.db.workerCommandIdempotencyV1.insert({
+ ...receipt,
+ createdAt: ctx.timestamp,
+ });
+}
+
+export function recallAllCastleWorkers(
+ ctx: WarpkeepReducerContext,
+ input: Readonly<{ fid: bigint; castle: CastleRow; idempotencyKey: string }>,
+): void {
+ const requestKey = assignmentRequestKey(input.fid, input.idempotencyKey);
+ const prior = ctx.db.workerCommandIdempotencyV1.requestKey.find(requestKey);
+ if (prior !== null) {
+ if (prior.fid !== input.fid || prior.commandKind !== 'recall-all' || prior.workerId !== undefined) fail('WORKER_IDEMPOTENCY_CONFLICT');
+ if (
+ !recallAllReplayMatches(prior, input.fid)
+ || !canonicalCastleOwnershipMatches(ctx, input.fid, input.castle.castleId)
+ || !receiptOwnerIsCanonical(ctx, prior, input.castle.castleId)
+ ) fail('WORKER_IDEMPOTENCY_OWNER_INVALID');
+ return;
+ }
+ workerSystemActive(ctx);
+ if (!canonicalCastleOwnershipMatches(ctx, input.fid, input.castle.castleId)) fail('WORKER_NOT_OWNED');
+ const callerGraph = assertCallerWorkerGraph(ctx, input.fid, input.castle.castleId);
+ const roster = [...callerGraph.roster].sort((left, right) => left.ordinal - right.ordinal);
+ if (callerGraph.assignments.some(assignment => assignment.phase !== 'returning')) {
+ settleAllWorkerAssignmentsForFid(ctx, input.fid);
+ }
+ const now = ctx.timestamp.microsSinceUnixEpoch;
+ let lastAssignmentId: string | undefined;
+ let resultRevision = roster.reduce(
+ (highest, worker) => worker.revision > highest ? worker.revision : highest,
+ 0n,
+ );
+ for (const worker of roster) {
+ const fresh = ctx.db.castleWorkerV1.workerId.find(worker.workerId);
+ if (fresh === null || fresh.originCastleId !== input.castle.castleId) fail('WORKER_ROSTER_INTEGRITY');
+ const assignment = ctx.db.workerAssignmentV1.workerId.find(fresh.workerId);
+ if (assignment === null) {
+ if (fresh.status !== 'idle') fail('WORKER_ASSIGNMENT_INTEGRITY');
+ continue;
+ }
+ if (assignment.fid !== input.fid || !publicWorkerMatchesAssignment(fresh, assignment)) fail('WORKER_ASSIGNMENT_INTEGRITY');
+ if (assignment.phase === 'returning') continue;
+ const returnStartedAtMicros = now < assignment.gatheringEndsAtMicros
+ ? now
+ : assignment.gatheringEndsAtMicros;
+ const progress = returnStartedAtMicros < assignment.arrivesAtMicros
+ ? progressBasisPoints(assignment, returnStartedAtMicros)
+ : 10_000;
+ const returning = beginWorkerReturn(ctx, assignment, progress, returnStartedAtMicros);
+ const updatedWorker = ctx.db.castleWorkerV1.workerId.find(fresh.workerId);
+ if (updatedWorker === null || !publicWorkerMatchesAssignment(updatedWorker, returning)) fail('WORKER_PUBLIC_PRIVATE_MISMATCH');
+ lastAssignmentId = returning.assignmentId;
+ if (updatedWorker.revision > resultRevision) resultRevision = updatedWorker.revision;
+ }
+ pruneWorkerIdempotencyReceipts(ctx, input.fid);
+ const receipt = recallAllWorkersReceipt(
+ requestKey,
+ input.fid,
+ resultRevision,
+ lastAssignmentId,
+ );
+ ctx.db.workerCommandIdempotencyV1.insert({
+ ...receipt,
+ createdAt: ctx.timestamp,
+ });
+}
+
+export type WorkerGraphAggregate = Readonly<{
+ systemRows: bigint;
+ mode: string;
+ systemConfigValid: boolean;
+ legacyDrainRequired: boolean;
+ expectedCastleCount: bigint;
+ expectedWorkerCount: bigint;
+ actualWorkerCount: bigint;
+ expectedCountsMatch: boolean;
+ rosterDigestMatches: boolean;
+ castlesMissingWorkers: bigint;
+ castlesWithExtraWorkers: bigint;
+ duplicateOrdinals: bigint;
+ malformedWorkerIds: bigint;
+ invalidWorkerStates: bigint;
+ idleWorkers: bigint;
+ outboundWorkers: bigint;
+ gatheringWorkers: bigint;
+ returningWorkers: bigint;
+ assignments: bigint;
+ occupations: bigint;
+ schedules: bigint;
+ orphanWorkers: bigint;
+ orphanAssignments: bigint;
+ assignmentsMissingOccupation: bigint;
+ assignmentsWithoutSingleSchedule: bigint;
+ orphanOccupations: bigint;
+ orphanSchedules: bigint;
+ invalidSchedules: bigint;
+ assignmentPublicMismatches: bigint;
+ occupationSiteMismatches: bigint;
+ invalidAssignments: bigint;
+ idempotencyReceipts: bigint;
+ invalidIdempotencyReceipts: bigint;
+ idempotencyOverflowFids: bigint;
+ legacyExpeditions: bigint;
+ legacyOccupations: bigint;
+ legacySchedules: bigint;
+ rosterDigest: string;
+ rosterDigestExpected: string;
+}>;
+
+function legacyActiveCounts(ctx: WarpkeepReducerContext): Readonly<{ expeditions: bigint; occupations: bigint; schedules: bigint }> {
+ return Object.freeze({
+ expeditions: ctx.db.goldExpeditionV1.count() + ctx.db.foodExpeditionV1.count() + ctx.db.woodExpeditionV1.count() + ctx.db.stoneExpeditionV1.count(),
+ occupations: ctx.db.goldNodeOccupationV1.count() + ctx.db.foodNodeOccupationV1.count() + ctx.db.woodNodeOccupationV1.count() + ctx.db.stoneNodeOccupationV1.count(),
+ schedules: ctx.db.goldExpeditionScheduleV1.count() + ctx.db.foodExpeditionScheduleV1.count() + ctx.db.woodExpeditionScheduleV1.count() + ctx.db.stoneExpeditionScheduleV1.count(),
+ });
+}
+
+export function inspectCastleWorkerGraph(ctx: WarpkeepReducerContext): WorkerGraphAggregate {
+ const system = ctx.db.realmWorkerSystemV1.realmId.find(WORKER_SYSTEM_REALM_ID);
+ const castles = [...ctx.db.castle.iter()].sort((left, right) => left.castleId < right.castleId ? -1 : left.castleId > right.castleId ? 1 : 0);
+ let castlesMissingWorkers = 0n;
+ let castlesWithExtraWorkers = 0n;
+ let duplicateOrdinals = 0n;
+ let malformedWorkerIds = 0n;
+ let invalidWorkerStates = 0n;
+ let orphanWorkers = 0n;
+ let idleWorkers = 0n;
+ let outboundWorkers = 0n;
+ let gatheringWorkers = 0n;
+ let returningWorkers = 0n;
+ for (const castle of castles) {
+ const rows = [...ctx.db.castleWorkerV1.byOriginCastle.filter(castle.castleId)];
+ if (rows.length < CASTLE_WORKERS_PER_CASTLE) castlesMissingWorkers += 1n;
+ if (rows.length > CASTLE_WORKERS_PER_CASTLE) castlesWithExtraWorkers += 1n;
+ const ordinals = new Set();
+ for (const row of rows) {
+ try { assertCastleWorkerId(row.workerId); } catch { malformedWorkerIds += 1n; }
+ if (!castleWorkerPublicStateIsConsistent(row)) invalidWorkerStates += 1n;
+ if (ordinals.has(row.ordinal)) duplicateOrdinals += 1n;
+ ordinals.add(row.ordinal);
+ if (row.status === 'idle') idleWorkers += 1n;
+ if (row.status === 'outbound') outboundWorkers += 1n;
+ if (row.status === 'gathering') gatheringWorkers += 1n;
+ if (row.status === 'returning') returningWorkers += 1n;
+ }
+ }
+ for (const row of ctx.db.castleWorkerV1.iter()) {
+ if (ctx.db.castle.castleId.find(row.originCastleId) === null) orphanWorkers += 1n;
+ }
+ let orphanAssignments = 0n;
+ let assignmentPublicMismatches = 0n;
+ let invalidAssignments = 0n;
+ let assignmentsMissingOccupation = 0n;
+ let assignmentsWithoutSingleSchedule = 0n;
+ let occupationSiteMismatches = 0n;
+ for (const assignment of ctx.db.workerAssignmentV1.iter()) {
+ const worker = ctx.db.castleWorkerV1.workerId.find(assignment.workerId);
+ if (worker === null) orphanAssignments += 1n;
+ else if (!publicWorkerMatchesAssignment(worker, assignment)) assignmentPublicMismatches += 1n;
+ try {
+ assertAssignmentState(assignment);
+ if (!assignmentOwnerIsCanonical(ctx, assignment)) fail('WORKER_OWNER_INTEGRITY');
+ } catch {
+ invalidAssignments += 1n;
+ }
+ const occupation = ctx.db.workerNodeOccupationV1.nodeKey.find(`${assignment.resourceKind}:${assignment.siteId}`);
+ if (assignment.phase === 'returning') {
+ if (occupation !== null) occupationSiteMismatches += 1n;
+ } else if (occupation === null || !occupationMatchesAssignment(occupation, assignment)) {
+ assignmentsMissingOccupation += 1n;
+ }
+ const schedules = [...ctx.db.workerAssignmentScheduleV1.byAssignment.filter(assignment.assignmentId)];
+ if (schedules.length !== 1 || !schedules.every(schedule => scheduleMatchesAssignment(schedule, assignment))) {
+ assignmentsWithoutSingleSchedule += 1n;
+ }
+ }
+ let orphanOccupations = 0n;
+ for (const occupation of ctx.db.workerNodeOccupationV1.iter()) {
+ const assignment = ctx.db.workerAssignmentV1.workerId.find(occupation.workerId);
+ if (assignment === null) orphanOccupations += 1n;
+ if (occupation.nodeKey !== `${occupation.resourceKind}:${occupation.siteId}`) occupationSiteMismatches += 1n;
+ if (assignment !== null && !occupationMatchesAssignment(occupation, assignment)) occupationSiteMismatches += 1n;
+ }
+ let orphanSchedules = 0n;
+ let invalidSchedules = 0n;
+ for (const schedule of ctx.db.workerAssignmentScheduleV1.iter()) {
+ const assignment = ctx.db.workerAssignmentV1.assignmentId.find(schedule.assignmentId);
+ if (assignment === null) orphanSchedules += 1n;
+ else if (!scheduleMatchesAssignment(schedule, assignment)) invalidSchedules += 1n;
+ }
+ const receiptsPerFid = new Map();
+ let invalidIdempotencyReceipts = 0n;
+ for (const receipt of ctx.db.workerCommandIdempotencyV1.iter()) {
+ receiptsPerFid.set(receipt.fid, (receiptsPerFid.get(receipt.fid) ?? 0) + 1);
+ if (
+ !workerReceiptShapeIsValid(receipt)
+ || !receiptOwnerIsCanonical(ctx, receipt)
+ ) invalidIdempotencyReceipts += 1n;
+ }
+ const idempotencyOverflowFids = BigInt([...receiptsPerFid.values()]
+ .filter(count => count > WORKER_IDEMPOTENCY_RECEIPTS_PER_FID).length);
+ const legacy = legacyActiveCounts(ctx);
+ const castleIds = castles.map(castle => castle.castleId);
+ const expectedWorkerCount = BigInt(castleIds.length * CASTLE_WORKERS_PER_CASTLE);
+ const expectedRosterDigest = rosterDigestForCastleIds(castleIds);
+ return Object.freeze({
+ systemRows: ctx.db.realmWorkerSystemV1.count(),
+ mode: system?.mode ?? 'absent',
+ systemConfigValid: system !== null && workerSystemRowIsStagedOrActive(system),
+ legacyDrainRequired: system?.legacyDrainRequired ?? true,
+ expectedCastleCount: BigInt(system?.expectedCastleCount ?? 0),
+ expectedWorkerCount: BigInt(system?.expectedWorkerCount ?? 0),
+ actualWorkerCount: ctx.db.castleWorkerV1.count(),
+ expectedCountsMatch: system !== null
+ && BigInt(system.expectedCastleCount) === BigInt(castleIds.length)
+ && BigInt(system.expectedWorkerCount) === expectedWorkerCount
+ && ctx.db.castleWorkerV1.count() === expectedWorkerCount,
+ rosterDigestMatches: system !== null && system.rosterDigest === expectedRosterDigest,
+ castlesMissingWorkers,
+ castlesWithExtraWorkers,
+ duplicateOrdinals,
+ malformedWorkerIds,
+ invalidWorkerStates,
+ idleWorkers,
+ outboundWorkers,
+ gatheringWorkers,
+ returningWorkers,
+ assignments: ctx.db.workerAssignmentV1.count(),
+ occupations: ctx.db.workerNodeOccupationV1.count(),
+ schedules: ctx.db.workerAssignmentScheduleV1.count(),
+ orphanWorkers,
+ orphanAssignments,
+ assignmentsMissingOccupation,
+ assignmentsWithoutSingleSchedule,
+ orphanOccupations,
+ orphanSchedules,
+ invalidSchedules,
+ assignmentPublicMismatches,
+ occupationSiteMismatches,
+ invalidAssignments,
+ idempotencyReceipts: ctx.db.workerCommandIdempotencyV1.count(),
+ invalidIdempotencyReceipts,
+ idempotencyOverflowFids,
+ legacyExpeditions: legacy.expeditions,
+ legacyOccupations: legacy.occupations,
+ legacySchedules: legacy.schedules,
+ rosterDigest: system?.rosterDigest ?? '',
+ rosterDigestExpected: expectedRosterDigest,
+ });
+}
+
+export function castleWorkerErrorCode(error: unknown): string | undefined {
+ if (
+ (error instanceof CastleWorkerAuthorityError || error instanceof CastleWorkerPolicyError)
+ && BOUNDED_WORKER_ERROR_CODE.test(error.code)
+ ) return error.code;
+ return undefined;
+}
diff --git a/spacetimedb/src/castleWorkerCommandPolicy.ts b/spacetimedb/src/castleWorkerCommandPolicy.ts
new file mode 100644
index 00000000..33949050
--- /dev/null
+++ b/spacetimedb/src/castleWorkerCommandPolicy.ts
@@ -0,0 +1,186 @@
+import { assertWorkerCommandKey } from './castleWorkerPolicy';
+
+export type WorkerCommandReceiptView = Readonly<{
+ requestKey: string;
+ fid: bigint;
+ workerId: string | undefined;
+ commandKind: string;
+ resourceKind: string | undefined;
+ siteId: string | undefined;
+ assignmentId: string | undefined;
+ resultRevision: bigint;
+}>;
+
+export type WorkerRecallCorrelation = Readonly<{
+ resourceKind: string;
+ siteId: string;
+ assignmentId: string;
+}>;
+
+export function workerCastleOwnershipMatches(input: Readonly<{
+ fid: bigint;
+ castleId: bigint;
+ castleOwnerFid: bigint | undefined;
+ accountFid: bigint | undefined;
+ accountCastleId: bigint | undefined;
+}>): boolean {
+ return input.castleOwnerFid === input.fid
+ && input.accountFid === input.fid
+ && input.accountCastleId === input.castleId;
+}
+
+/** Consume no more than maximum plus the one row needed to prove overflow. */
+export function takeBoundedRows(
+ rows: Iterable,
+ maximum: number,
+): Readonly<{ rows: readonly Row[]; overflow: boolean }> {
+ if (!Number.isSafeInteger(maximum) || maximum < 0) {
+ throw new Error('WORKER_BOUND_INVALID');
+ }
+ const bounded: Row[] = [];
+ for (const row of rows) {
+ if (bounded.length >= maximum) {
+ return Object.freeze({ rows: Object.freeze(bounded), overflow: true });
+ }
+ bounded.push(row);
+ }
+ return Object.freeze({ rows: Object.freeze(bounded), overflow: false });
+}
+
+export function workerCommandReceiptShapeIsValid(receipt: WorkerCommandReceiptView): boolean {
+ const separator = receipt.requestKey.indexOf(':');
+ if (
+ separator <= 0
+ || receipt.requestKey.slice(0, separator) !== receipt.fid.toString()
+ || receipt.resultRevision < 0n
+ ) return false;
+ try {
+ assertWorkerCommandKey(receipt.requestKey.slice(separator + 1));
+ } catch {
+ return false;
+ }
+ const correlated = receipt.resourceKind !== undefined
+ && ['gold', 'food', 'wood', 'stone'].includes(receipt.resourceKind)
+ && receipt.siteId !== undefined
+ && receipt.siteId.length > 0
+ && receipt.assignmentId !== undefined
+ && receipt.assignmentId.length > 0;
+ if (receipt.commandKind === 'dispatch') {
+ return receipt.workerId !== undefined && correlated;
+ }
+ if (receipt.commandKind === 'recall') {
+ const noOp = receipt.resourceKind === undefined
+ && receipt.siteId === undefined
+ && receipt.assignmentId === undefined;
+ return receipt.workerId !== undefined && (correlated || noOp);
+ }
+ return receipt.commandKind === 'recall-all'
+ && receipt.workerId === undefined
+ && receipt.resourceKind === undefined
+ && receipt.siteId === undefined
+ && (receipt.assignmentId === undefined || receipt.assignmentId.length > 0);
+}
+
+export function recallWorkerReceipt(
+ requestKey: string,
+ fid: bigint,
+ workerId: string,
+ resultRevision: bigint,
+ correlation?: WorkerRecallCorrelation,
+): WorkerCommandReceiptView {
+ return Object.freeze({
+ requestKey,
+ fid,
+ workerId,
+ commandKind: 'recall',
+ resourceKind: correlation?.resourceKind,
+ siteId: correlation?.siteId,
+ assignmentId: correlation?.assignmentId,
+ resultRevision,
+ });
+}
+
+export function recallAllWorkersReceipt(
+ requestKey: string,
+ fid: bigint,
+ resultRevision: bigint,
+ assignmentId?: string,
+): WorkerCommandReceiptView {
+ return Object.freeze({
+ requestKey,
+ fid,
+ workerId: undefined,
+ commandKind: 'recall-all',
+ resourceKind: undefined,
+ siteId: undefined,
+ assignmentId,
+ resultRevision,
+ });
+}
+
+export function recallReplayMatches(
+ receipt: WorkerCommandReceiptView,
+ fid: bigint,
+ workerId: string,
+): boolean {
+ return receipt.fid === fid
+ && receipt.commandKind === 'recall'
+ && receipt.workerId === workerId
+ && workerCommandReceiptShapeIsValid(receipt);
+}
+
+export function recallAllReplayMatches(
+ receipt: WorkerCommandReceiptView,
+ fid: bigint,
+): boolean {
+ return receipt.fid === fid
+ && receipt.commandKind === 'recall-all'
+ && receipt.workerId === undefined
+ && workerCommandReceiptShapeIsValid(receipt);
+}
+
+export function workerScheduleMatchesAssignment(
+ schedule: Readonly<{
+ stage: string;
+ workerId: string;
+ timelineRevision: number;
+ scheduledAt: Readonly<{
+ tag: string;
+ value?: unknown;
+ }>;
+ }>,
+ assignment: Readonly<{
+ phase: string;
+ workerId: string;
+ timelineRevision: number;
+ arrivesAtMicros: bigint;
+ gatheringEndsAtMicros: bigint;
+ returnsAtMicros: bigint;
+ }>,
+): boolean {
+ const expectedStage = assignment.phase === 'outbound'
+ ? 'arrival'
+ : assignment.phase === 'gathering'
+ ? 'gathering-expiry'
+ : assignment.phase === 'returning'
+ ? 'return-complete'
+ : undefined;
+ const expectedAtMicros = expectedStage === 'arrival'
+ ? assignment.arrivesAtMicros
+ : expectedStage === 'gathering-expiry'
+ ? assignment.gatheringEndsAtMicros
+ : assignment.returnsAtMicros;
+ const scheduledValue = schedule.scheduledAt.value;
+ const scheduledAtMicros = typeof scheduledValue === 'object'
+ && scheduledValue !== null
+ && 'microsSinceUnixEpoch' in scheduledValue
+ && typeof scheduledValue.microsSinceUnixEpoch === 'bigint'
+ ? scheduledValue.microsSinceUnixEpoch
+ : undefined;
+ return expectedStage !== undefined
+ && schedule.stage === expectedStage
+ && schedule.workerId === assignment.workerId
+ && schedule.timelineRevision === assignment.timelineRevision
+ && schedule.scheduledAt.tag === 'Time'
+ && scheduledAtMicros === expectedAtMicros;
+}
diff --git a/spacetimedb/src/castleWorkerPolicy.ts b/spacetimedb/src/castleWorkerPolicy.ts
new file mode 100644
index 00000000..d0155bc1
--- /dev/null
+++ b/spacetimedb/src/castleWorkerPolicy.ts
@@ -0,0 +1,400 @@
+import {
+ FOOD_EXPEDITION_POLICY_VERSION,
+ FOOD_GATHERING_DURATION_MICROS,
+ FOOD_GATHER_QUANTUM_MICROS,
+ FOOD_GATHER_RATE_PER_QUANTUM,
+} from './foodExpeditionPolicy';
+import {
+ GENESIS_TIER_I_FOOD_SITE_COUNT,
+ GENESIS_TIER_I_FOOD_SITE_DIGEST,
+ FOOD_SITE_POLICY_VERSION,
+ canonicalFoodSiteV1ForId,
+ matchesCanonicalTierIFoodSiteV1,
+} from './foodSitePolicy';
+import {
+ GOLD_EXPEDITION_POLICY_VERSION,
+ GOLD_GATHERING_DURATION_MICROS,
+ GOLD_GATHER_QUANTUM_MICROS,
+ GOLD_GATHER_RATE_PER_QUANTUM,
+} from './goldExpeditionPolicy';
+import {
+ GENESIS_TIER_I_GOLD_SITE_COUNT,
+ GENESIS_TIER_I_GOLD_SITE_DIGEST,
+ GOLD_SITE_POLICY_VERSION,
+ canonicalGoldSiteV1ForId,
+ matchesCanonicalTierIGoldSiteV1,
+ canonicalPassableRouteSteps,
+} from './goldSitePolicy';
+import {
+ STONE_EXPEDITION_POLICY_VERSION,
+ STONE_GATHERING_DURATION_MICROS,
+ STONE_GATHER_QUANTUM_MICROS,
+ STONE_GATHER_RATE_PER_QUANTUM,
+} from './stoneExpeditionPolicy';
+import {
+ GENESIS_TIER_I_STONE_SITE_COUNT,
+ GENESIS_TIER_I_STONE_SITE_DIGEST,
+ STONE_SITE_POLICY_VERSION,
+ canonicalStoneSiteV1ForId,
+ matchesCanonicalTierIStoneSiteV1,
+} from './stoneSitePolicy';
+import {
+ WOOD_EXPEDITION_POLICY_VERSION,
+ WOOD_GATHERING_DURATION_MICROS,
+ WOOD_GATHER_QUANTUM_MICROS,
+ WOOD_GATHER_RATE_PER_QUANTUM,
+} from './woodExpeditionPolicy';
+import {
+ GENESIS_TIER_I_WOOD_SITE_COUNT,
+ GENESIS_TIER_I_WOOD_SITE_DIGEST,
+ WOOD_SITE_POLICY_VERSION,
+ canonicalWoodSiteV1ForId,
+ matchesCanonicalTierIWoodSiteV1,
+} from './woodSitePolicy';
+
+export const CASTLE_WORKERS_PER_CASTLE = 4;
+export const CASTLE_WORKER_POLICY_VERSION = 'genesis-001-castle-workers-v1';
+export const CASTLE_WORKER_GATHER_QUANTUM_MICROS = 60_000_000n;
+export const CASTLE_WORKER_TRAVEL_MICROS_PER_STEP = 30_000_000n;
+export const CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS = 30n * 24n * 60n * 60n * 1_000_000n;
+export const CASTLE_WORKER_U64_MAX = (1n << 64n) - 1n;
+export const CASTLE_WORKER_PROTOCOL_CAPABILITY = 'generic-castle-workers-v1';
+
+export type WorkerResourceKind = 'gold' | 'food' | 'wood' | 'stone';
+export type CastleWorkerPhase = 'outbound' | 'gathering' | 'returning';
+export type CastleWorkerStatus = 'idle' | CastleWorkerPhase;
+
+export type CastleWorkerSiteShape = Readonly<{
+ siteId: string;
+ q: number;
+ r: number;
+ tier: number;
+ active: boolean;
+}>;
+
+export type CastleWorkerResourcePolicy = Readonly<{
+ kind: WorkerResourceKind;
+ siteTable: string;
+ sitePolicyVersion: string;
+ siteCatalogDigest: string;
+ canonicalSiteCount: number;
+ expeditionPolicyVersion: string;
+ quantumMicros: bigint;
+ ratePerQuantum: bigint;
+ gatheringDurationMicros: bigint;
+ gatheringTotal: bigint;
+ canonicalSiteForId: (siteId: string) => CastleWorkerSiteShape | undefined;
+ matchesCanonicalSite: (site: CastleWorkerSiteShape) => boolean;
+}>;
+
+const RESOURCE_POLICIES: Readonly> = Object.freeze({
+ gold: Object.freeze({
+ kind: 'gold',
+ siteTable: 'goldSiteV1',
+ sitePolicyVersion: GOLD_SITE_POLICY_VERSION,
+ siteCatalogDigest: GENESIS_TIER_I_GOLD_SITE_DIGEST,
+ canonicalSiteCount: GENESIS_TIER_I_GOLD_SITE_COUNT,
+ expeditionPolicyVersion: GOLD_EXPEDITION_POLICY_VERSION,
+ quantumMicros: GOLD_GATHER_QUANTUM_MICROS,
+ ratePerQuantum: GOLD_GATHER_RATE_PER_QUANTUM,
+ gatheringDurationMicros: GOLD_GATHERING_DURATION_MICROS,
+ gatheringTotal: (GOLD_GATHERING_DURATION_MICROS / GOLD_GATHER_QUANTUM_MICROS) * GOLD_GATHER_RATE_PER_QUANTUM,
+ canonicalSiteForId: canonicalGoldSiteV1ForId,
+ matchesCanonicalSite: matchesCanonicalTierIGoldSiteV1,
+ }),
+ food: Object.freeze({
+ kind: 'food',
+ siteTable: 'foodSiteV1',
+ sitePolicyVersion: FOOD_SITE_POLICY_VERSION,
+ siteCatalogDigest: GENESIS_TIER_I_FOOD_SITE_DIGEST,
+ canonicalSiteCount: GENESIS_TIER_I_FOOD_SITE_COUNT,
+ expeditionPolicyVersion: FOOD_EXPEDITION_POLICY_VERSION,
+ quantumMicros: FOOD_GATHER_QUANTUM_MICROS,
+ ratePerQuantum: FOOD_GATHER_RATE_PER_QUANTUM,
+ gatheringDurationMicros: FOOD_GATHERING_DURATION_MICROS,
+ gatheringTotal: (FOOD_GATHERING_DURATION_MICROS / FOOD_GATHER_QUANTUM_MICROS) * FOOD_GATHER_RATE_PER_QUANTUM,
+ canonicalSiteForId: canonicalFoodSiteV1ForId,
+ matchesCanonicalSite: matchesCanonicalTierIFoodSiteV1,
+ }),
+ wood: Object.freeze({
+ kind: 'wood',
+ siteTable: 'woodSiteV1',
+ sitePolicyVersion: WOOD_SITE_POLICY_VERSION,
+ siteCatalogDigest: GENESIS_TIER_I_WOOD_SITE_DIGEST,
+ canonicalSiteCount: GENESIS_TIER_I_WOOD_SITE_COUNT,
+ expeditionPolicyVersion: WOOD_EXPEDITION_POLICY_VERSION,
+ quantumMicros: WOOD_GATHER_QUANTUM_MICROS,
+ ratePerQuantum: WOOD_GATHER_RATE_PER_QUANTUM,
+ gatheringDurationMicros: WOOD_GATHERING_DURATION_MICROS,
+ gatheringTotal: (WOOD_GATHERING_DURATION_MICROS / WOOD_GATHER_QUANTUM_MICROS) * WOOD_GATHER_RATE_PER_QUANTUM,
+ canonicalSiteForId: canonicalWoodSiteV1ForId,
+ matchesCanonicalSite: matchesCanonicalTierIWoodSiteV1,
+ }),
+ stone: Object.freeze({
+ kind: 'stone',
+ siteTable: 'stoneSiteV1',
+ sitePolicyVersion: STONE_SITE_POLICY_VERSION,
+ siteCatalogDigest: GENESIS_TIER_I_STONE_SITE_DIGEST,
+ canonicalSiteCount: GENESIS_TIER_I_STONE_SITE_COUNT,
+ expeditionPolicyVersion: STONE_EXPEDITION_POLICY_VERSION,
+ quantumMicros: STONE_GATHER_QUANTUM_MICROS,
+ ratePerQuantum: STONE_GATHER_RATE_PER_QUANTUM,
+ gatheringDurationMicros: STONE_GATHERING_DURATION_MICROS,
+ gatheringTotal: (STONE_GATHERING_DURATION_MICROS / STONE_GATHER_QUANTUM_MICROS) * STONE_GATHER_RATE_PER_QUANTUM,
+ canonicalSiteForId: canonicalStoneSiteV1ForId,
+ matchesCanonicalSite: matchesCanonicalTierIStoneSiteV1,
+ }),
+});
+
+export class CastleWorkerPolicyError extends Error {
+ constructor(readonly code: string) {
+ super(code);
+ this.name = 'CastleWorkerPolicyError';
+ }
+}
+
+function fail(code: string): never {
+ throw new CastleWorkerPolicyError(code);
+}
+
+function assertU64(value: unknown, code: string): asserts value is bigint {
+ if (typeof value !== 'bigint' || value < 0n || value > CASTLE_WORKER_U64_MAX) fail(code);
+}
+
+function checkedSum(left: bigint, right: bigint, code: string): bigint {
+ assertU64(left, code);
+ assertU64(right, code);
+ if (right > CASTLE_WORKER_U64_MAX - left) fail(code);
+ return left + right;
+}
+
+function checkedProduct(left: bigint, right: bigint, code: string): bigint {
+ assertU64(left, code);
+ assertU64(right, code);
+ if (left !== 0n && right > CASTLE_WORKER_U64_MAX / left) fail(code);
+ return left * right;
+}
+
+export function workerResourcePolicy(kind: string): CastleWorkerResourcePolicy {
+ if (kind !== 'gold' && kind !== 'food' && kind !== 'wood' && kind !== 'stone') {
+ fail('WORKER_RESOURCE_UNSUPPORTED');
+ }
+ return RESOURCE_POLICIES[kind];
+}
+
+export function workerResourceKinds(): readonly WorkerResourceKind[] {
+ return Object.freeze(['gold', 'food', 'wood', 'stone']);
+}
+
+export function workerIdForCastle(castleId: bigint, ordinal: number): string {
+ if (castleId < 0n || !Number.isSafeInteger(ordinal) || ordinal < 1 || ordinal > CASTLE_WORKERS_PER_CASTLE) {
+ fail('WORKER_ROSTER_ORDINAL_INVALID');
+ }
+ return `genesis-001-castle-${castleId.toString()}-worker-${String(ordinal).padStart(2, '0')}`;
+}
+
+export function assertCastleWorkerId(workerId: string): void {
+ if (!/^genesis-001-castle-[0-9]+-worker-0[1-4]$/.test(workerId)) {
+ fail('WORKER_ID_INVALID');
+ }
+}
+
+export function assertWorkerCommandKey(value: string): void {
+ if (!/^[a-z0-9][a-z0-9-]{15,79}$/.test(value)) fail('WORKER_COMMAND_KEY_INVALID');
+}
+
+export type CastleWorkerTimeline = Readonly<{
+ startedAtMicros: bigint;
+ arrivesAtMicros: bigint;
+ gatheringEndsAtMicros: bigint;
+ returnsAtMicros: bigint;
+}>;
+
+export function planCastleWorkerTimeline(startedAtMicros: bigint, routeSteps: number): CastleWorkerTimeline {
+ assertU64(startedAtMicros, 'WORKER_START_TIME_INVALID');
+ if (!Number.isSafeInteger(routeSteps) || routeSteps <= 0) fail('WORKER_ROUTE_INVALID');
+ const travelMicros = checkedProduct(BigInt(routeSteps), CASTLE_WORKER_TRAVEL_MICROS_PER_STEP, 'WORKER_TIME_OVERFLOW');
+ const arrivesAtMicros = checkedSum(startedAtMicros, travelMicros, 'WORKER_TIME_OVERFLOW');
+ const gatheringEndsAtMicros = checkedSum(arrivesAtMicros, CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS, 'WORKER_TIME_OVERFLOW');
+ const returnsAtMicros = checkedSum(gatheringEndsAtMicros, travelMicros, 'WORKER_TIME_OVERFLOW');
+ return Object.freeze({ startedAtMicros, arrivesAtMicros, gatheringEndsAtMicros, returnsAtMicros });
+}
+
+export type CastleWorkerAccrualState = Readonly<{
+ phase: string;
+ startedAtMicros: bigint;
+ arrivesAtMicros: bigint;
+ gatheringEndsAtMicros: bigint;
+ returnStartedAtMicros: bigint | undefined;
+ returnsAtMicros: bigint;
+ routeSteps: number;
+ returnStartProgressBasisPoints: number;
+ settledThroughMicros: bigint;
+ accruedAmount: bigint;
+ materializedAmount: bigint;
+ resourceKind: string;
+ policyVersion: string;
+}>;
+
+export type CastleWorkerAccrualPlan = Readonly<{
+ accruedAmount: bigint;
+ newlyAccruedAmount: bigint;
+ completedQuanta: bigint;
+ settledThroughMicros: bigint;
+}>;
+
+export function workerAssignmentStateIsConsistent(state: CastleWorkerAccrualState): boolean {
+ try {
+ const policy = workerResourcePolicy(state.resourceKind);
+ assertU64(state.startedAtMicros, 'WORKER_TIME_INVALID');
+ assertU64(state.arrivesAtMicros, 'WORKER_TIME_INVALID');
+ assertU64(state.gatheringEndsAtMicros, 'WORKER_TIME_INVALID');
+ assertU64(state.returnsAtMicros, 'WORKER_TIME_INVALID');
+ if (state.returnStartedAtMicros !== undefined) {
+ assertU64(state.returnStartedAtMicros, 'WORKER_TIME_INVALID');
+ }
+ assertU64(state.settledThroughMicros, 'WORKER_CURSOR_INVALID');
+ assertU64(state.accruedAmount, 'WORKER_ACCRUAL_INVALID');
+ assertU64(state.materializedAmount, 'WORKER_MATERIALIZED_INVALID');
+ if (
+ !Number.isSafeInteger(state.routeSteps)
+ || state.routeSteps <= 0
+ || !Number.isSafeInteger(state.returnStartProgressBasisPoints)
+ || state.returnStartProgressBasisPoints < 0
+ || state.returnStartProgressBasisPoints > 10_000
+ ) return false;
+ const travelMicros = checkedProduct(
+ BigInt(state.routeSteps),
+ CASTLE_WORKER_TRAVEL_MICROS_PER_STEP,
+ 'WORKER_TIME_OVERFLOW',
+ );
+ const canonicalArrivesAtMicros = checkedSum(
+ state.startedAtMicros,
+ travelMicros,
+ 'WORKER_TIME_OVERFLOW',
+ );
+ const canonicalGatheringEndsAtMicros = checkedSum(
+ canonicalArrivesAtMicros,
+ CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS,
+ 'WORKER_TIME_OVERFLOW',
+ );
+ if (
+ state.policyVersion !== CASTLE_WORKER_POLICY_VERSION
+ || (state.phase !== 'outbound' && state.phase !== 'gathering' && state.phase !== 'returning')
+ || !(state.startedAtMicros < state.arrivesAtMicros
+ && state.arrivesAtMicros < state.gatheringEndsAtMicros)
+ || state.arrivesAtMicros > state.settledThroughMicros
+ || state.settledThroughMicros > state.gatheringEndsAtMicros
+ || state.materializedAmount > state.accruedAmount
+ || state.accruedAmount > policy.gatheringTotal
+ || state.arrivesAtMicros !== canonicalArrivesAtMicros
+ || state.gatheringEndsAtMicros !== canonicalGatheringEndsAtMicros
+ ) return false;
+ if (state.phase !== 'returning') {
+ return state.returnStartedAtMicros === undefined
+ && state.returnStartProgressBasisPoints === 0
+ && state.returnsAtMicros === checkedSum(
+ state.gatheringEndsAtMicros,
+ travelMicros,
+ 'WORKER_TIME_OVERFLOW',
+ );
+ }
+ if (
+ state.returnStartedAtMicros === undefined
+ || state.returnStartedAtMicros < state.startedAtMicros
+ || state.returnStartedAtMicros > state.gatheringEndsAtMicros
+ || state.returnsAtMicros < state.returnStartedAtMicros
+ ) return false;
+ const expectedProgress = state.returnStartedAtMicros >= state.arrivesAtMicros
+ ? 10_000
+ : Number(
+ ((state.returnStartedAtMicros - state.startedAtMicros) * 10_000n)
+ / travelMicros,
+ );
+ if (state.returnStartProgressBasisPoints !== expectedProgress) return false;
+ const expectedReturnsAtMicros = checkedSum(
+ state.returnStartedAtMicros,
+ (travelMicros * BigInt(expectedProgress)) / 10_000n,
+ 'WORKER_TIME_OVERFLOW',
+ );
+ if (state.returnsAtMicros !== expectedReturnsAtMicros) return false;
+ // An outbound recall starts before gathering can begin. Its settlement
+ // cursor remains pinned to arrival and it can never have earned value.
+ if (state.returnStartedAtMicros < state.arrivesAtMicros) {
+ return state.settledThroughMicros === state.arrivesAtMicros
+ && state.accruedAmount === 0n
+ && state.materializedAmount === 0n;
+ }
+ // Gathering recalls may retain only complete quanta observed no later
+ // than the immutable return-start boundary.
+ return state.settledThroughMicros <= state.returnStartedAtMicros;
+ } catch {
+ return false;
+ }
+}
+
+export function planCastleWorkerAccrual(
+ state: CastleWorkerAccrualState,
+ observedAtMicros: bigint,
+): CastleWorkerAccrualPlan {
+ if (!workerAssignmentStateIsConsistent(state)) fail('WORKER_ASSIGNMENT_STATE_INVALID');
+ assertU64(observedAtMicros, 'WORKER_OBSERVED_TIME_INVALID');
+ const policy = workerResourcePolicy(state.resourceKind);
+ const phaseCeiling = state.phase === 'returning'
+ ? state.returnStartedAtMicros
+ : observedAtMicros;
+ if (phaseCeiling === undefined) fail('WORKER_ASSIGNMENT_STATE_INVALID');
+ const ceiling = phaseCeiling < state.gatheringEndsAtMicros
+ ? phaseCeiling
+ : state.gatheringEndsAtMicros;
+ if (ceiling <= state.settledThroughMicros) {
+ return Object.freeze({ accruedAmount: state.accruedAmount, newlyAccruedAmount: 0n, completedQuanta: 0n, settledThroughMicros: state.settledThroughMicros });
+ }
+ const completedQuanta = (ceiling - state.settledThroughMicros) / policy.quantumMicros;
+ const elapsed = checkedProduct(completedQuanta, policy.quantumMicros, 'WORKER_ACCRUAL_OVERFLOW');
+ const settledThroughMicros = checkedSum(state.settledThroughMicros, elapsed, 'WORKER_ACCRUAL_OVERFLOW');
+ const newlyAccruedAmount = checkedProduct(completedQuanta, policy.ratePerQuantum, 'WORKER_ACCRUAL_OVERFLOW');
+ const accruedAmount = checkedSum(state.accruedAmount, newlyAccruedAmount, 'WORKER_ACCRUAL_OVERFLOW');
+ if (accruedAmount > policy.gatheringTotal) fail('WORKER_ACCRUAL_CAP');
+ return Object.freeze({ accruedAmount, newlyAccruedAmount, completedQuanta, settledThroughMicros });
+}
+
+/** Route authority is shared across all four canonical site catalogs. */
+export function canonicalWorkerRouteSteps(
+ origin: Readonly<{ q: number; r: number }>,
+ destination: Readonly<{ q: number; r: number }>,
+): number | undefined {
+ return canonicalPassableRouteSteps(origin, destination);
+}
+
+/** Stable roster digest; order and worker identity are part of the boundary. */
+export function rosterDigestForCastleIds(castleIds: readonly bigint[]): string {
+ const ids = [...castleIds].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
+ let hash = 0xcbf29ce484222325n;
+ for (const castleId of ids) {
+ hash = appendCastleWorkerRosterHash(hash, castleId);
+ }
+ return hash.toString(16).padStart(16, '0');
+}
+
+function appendCastleWorkerRosterHash(hash: bigint, castleId: bigint): bigint {
+ let next = hash;
+ for (const workerId of Array.from(
+ { length: CASTLE_WORKERS_PER_CASTLE },
+ (_, index) => workerIdForCastle(castleId, index + 1),
+ )) {
+ for (const byte of new TextEncoder().encode(workerId)) {
+ next ^= BigInt(byte);
+ next = (next * 0x100000001b3n) & CASTLE_WORKER_U64_MAX;
+ }
+ }
+ return next;
+}
+
+/** Extend the attested digest when an auto-incremented castle is appended. */
+export function appendCastleWorkerRosterDigest(digest: string, castleId: bigint): string {
+ if (!/^[0-9a-f]{16}$/.test(digest)) fail('WORKER_ROSTER_DIGEST_INVALID');
+ return appendCastleWorkerRosterHash(BigInt(`0x${digest}`), castleId)
+ .toString(16)
+ .padStart(16, '0');
+}
diff --git a/spacetimedb/src/castleWorkerRoster.ts b/spacetimedb/src/castleWorkerRoster.ts
new file mode 100644
index 00000000..56b87366
--- /dev/null
+++ b/spacetimedb/src/castleWorkerRoster.ts
@@ -0,0 +1,213 @@
+import type { InferSchema, ReducerCtx } from 'spacetimedb/server';
+
+import {
+ CASTLE_WORKER_POLICY_VERSION,
+ CASTLE_WORKERS_PER_CASTLE,
+ appendCastleWorkerRosterDigest,
+ workerIdForCastle,
+ assertCastleWorkerId,
+} from './castleWorkerPolicy';
+import type warpkeep from './schema';
+
+type WarpkeepReducerContext = ReducerCtx>;
+type CastleRow = NonNullable>;
+type CastleWorkerRow = NonNullable>;
+const MAX_U32 = 0xffff_ffff;
+
+function fail(code = 'WORKER_ROSTER_INTEGRITY'): never {
+ throw new Error(code);
+}
+
+export function expectedWorkerRowsForCastle(
+ castle: Pick,
+): readonly CastleWorkerRow[] {
+ return Object.freeze(Array.from({ length: CASTLE_WORKERS_PER_CASTLE }, (_, index) => {
+ const ordinal = index + 1;
+ return Object.freeze({
+ workerId: workerIdForCastle(castle.castleId, ordinal),
+ originCastleId: castle.castleId,
+ ordinal,
+ status: 'idle',
+ resourceKind: undefined,
+ siteId: undefined,
+ startedAtMicros: undefined,
+ arrivesAtMicros: undefined,
+ gatheringEndsAtMicros: undefined,
+ returnStartedAtMicros: undefined,
+ returnsAtMicros: undefined,
+ routeSteps: undefined,
+ returnStartProgressBasisPoints: undefined,
+ timelineRevision: 0,
+ revision: 0n,
+ });
+ }));
+}
+
+function boundedRows(rows: Iterable, maximum: number, code: string): readonly Row[] {
+ const bounded: Row[] = [];
+ for (const row of rows) {
+ if (bounded.length >= maximum) fail(code);
+ bounded.push(row);
+ }
+ return bounded;
+}
+
+export function workerSystemRowIsStagedOrActive(
+ row: NonNullable>,
+): boolean {
+ return row.realmId === 'GENESIS_001'
+ && row.policyVersion === CASTLE_WORKER_POLICY_VERSION
+ && row.workersPerCastle === CASTLE_WORKERS_PER_CASTLE
+ && (row.mode === 'staged' || row.mode === 'active')
+ && row.expectedCastleCount >= 0
+ && row.expectedWorkerCount === row.expectedCastleCount * CASTLE_WORKERS_PER_CASTLE;
+}
+
+export function assertCastleWorkerRoster(
+ ctx: WarpkeepReducerContext,
+ castleId: bigint,
+): readonly CastleWorkerRow[] {
+ const castle = ctx.db.castle.castleId.find(castleId);
+ if (castle === null) fail('WORKER_CASTLE_MISSING');
+ const rows = [...boundedRows(
+ ctx.db.castleWorkerV1.byOriginCastle.filter(castleId),
+ CASTLE_WORKERS_PER_CASTLE + 1,
+ 'WORKER_ROSTER_OVERSIZED',
+ )]
+ .sort((left, right) => left.ordinal - right.ordinal || left.workerId.localeCompare(right.workerId));
+ if (rows.length !== CASTLE_WORKERS_PER_CASTLE) fail('WORKER_ROSTER_INCOMPLETE');
+ const expectedIds = new Set();
+ for (const row of rows) {
+ assertCastleWorkerId(row.workerId);
+ if (
+ row.originCastleId !== castleId
+ || row.ordinal < 1
+ || row.ordinal > CASTLE_WORKERS_PER_CASTLE
+ || expectedIds.has(row.workerId)
+ || row.workerId !== workerIdForCastle(castleId, row.ordinal)
+ || row.revision < 0n
+ || row.timelineRevision < 0
+ || !castleWorkerPublicStateIsConsistent(row)
+ ) fail('WORKER_ROSTER_INTEGRITY');
+ expectedIds.add(row.workerId);
+ }
+ for (let ordinal = 1; ordinal <= CASTLE_WORKERS_PER_CASTLE; ordinal += 1) {
+ if (!expectedIds.has(workerIdForCastle(castleId, ordinal))) fail('WORKER_ROSTER_INTEGRITY');
+ }
+ return Object.freeze(rows);
+}
+
+export function castleWorkerPublicStateIsConsistent(row: CastleWorkerRow): boolean {
+ const optionalTimeline = [
+ row.resourceKind,
+ row.siteId,
+ row.startedAtMicros,
+ row.arrivesAtMicros,
+ row.gatheringEndsAtMicros,
+ row.returnStartedAtMicros,
+ row.returnsAtMicros,
+ row.routeSteps,
+ row.returnStartProgressBasisPoints,
+ ];
+ if (row.status === 'idle') return optionalTimeline.every(value => value === undefined);
+ if (row.status !== 'outbound' && row.status !== 'gathering' && row.status !== 'returning') return false;
+ if (
+ row.resourceKind === undefined
+ || !['gold', 'food', 'wood', 'stone'].includes(row.resourceKind)
+ || row.siteId === undefined
+ || row.startedAtMicros === undefined
+ || row.arrivesAtMicros === undefined
+ || row.gatheringEndsAtMicros === undefined
+ || row.returnsAtMicros === undefined
+ || row.routeSteps === undefined
+ || row.routeSteps <= 0
+ || !(row.startedAtMicros < row.arrivesAtMicros && row.arrivesAtMicros < row.gatheringEndsAtMicros)
+ ) return false;
+ if (row.status !== 'returning') {
+ return row.returnStartedAtMicros === undefined
+ && row.returnStartProgressBasisPoints === undefined
+ && row.gatheringEndsAtMicros < row.returnsAtMicros;
+ }
+ return row.returnStartedAtMicros !== undefined
+ && row.returnStartProgressBasisPoints !== undefined
+ && row.returnStartProgressBasisPoints <= 10_000
+ && row.returnStartedAtMicros >= row.startedAtMicros
+ && row.returnStartedAtMicros <= row.gatheringEndsAtMicros
+ && row.returnsAtMicros >= row.returnStartedAtMicros;
+}
+
+/**
+ * Founding calls this only when generic mode is active. In staged mode the
+ * function is a no-op, so this PR cannot seed production workers accidentally.
+ */
+export function ensureCastleWorkerRoster(
+ ctx: WarpkeepReducerContext,
+ castle: CastleRow,
+): void {
+ const system = ctx.db.realmWorkerSystemV1.realmId.find('GENESIS_001');
+ if (system === null) return;
+ if (ctx.db.realmWorkerSystemV1.count() !== 1n || !workerSystemRowIsStagedOrActive(system)) {
+ fail('WORKER_SYSTEM_INTEGRITY');
+ }
+ if (system.mode !== 'active') return;
+ if (system.legacyDrainRequired) fail('WORKER_LEGACY_DRAIN_REQUIRED');
+ if (
+ ctx.db.goldExpeditionV1.count() + ctx.db.foodExpeditionV1.count()
+ + ctx.db.woodExpeditionV1.count() + ctx.db.stoneExpeditionV1.count() !== 0n
+ || ctx.db.goldNodeOccupationV1.count() + ctx.db.foodNodeOccupationV1.count()
+ + ctx.db.woodNodeOccupationV1.count() + ctx.db.stoneNodeOccupationV1.count() !== 0n
+ || ctx.db.goldExpeditionScheduleV1.count() + ctx.db.foodExpeditionScheduleV1.count()
+ + ctx.db.woodExpeditionScheduleV1.count() + ctx.db.stoneExpeditionScheduleV1.count() !== 0n
+ ) fail('WORKER_LEGACY_DRAIN_REQUIRED');
+ const existing = boundedRows(
+ ctx.db.castleWorkerV1.byOriginCastle.filter(castle.castleId),
+ CASTLE_WORKERS_PER_CASTLE + 1,
+ 'WORKER_ROSTER_OVERSIZED',
+ );
+ const castleCount = ctx.db.castle.count();
+ const workerCount = ctx.db.castleWorkerV1.count();
+ if (existing.length > 0) {
+ assertCastleWorkerRoster(ctx, castle.castleId);
+ if (
+ BigInt(system.expectedCastleCount) !== castleCount
+ || BigInt(system.expectedWorkerCount) !== workerCount
+ || system.expectedWorkerCount !== system.expectedCastleCount * CASTLE_WORKERS_PER_CASTLE
+ || !/^[0-9a-f]{16}$/.test(system.rosterDigest)
+ ) fail('WORKER_SYSTEM_INTEGRITY');
+ return;
+ } else {
+ if (
+ castleCount !== BigInt(system.expectedCastleCount) + 1n
+ || workerCount !== BigInt(system.expectedWorkerCount)
+ || system.expectedWorkerCount !== system.expectedCastleCount * CASTLE_WORKERS_PER_CASTLE
+ || !/^[0-9a-f]{16}$/.test(system.rosterDigest)
+ ) fail('WORKER_SYSTEM_INTEGRITY');
+ for (const row of expectedWorkerRowsForCastle(castle)) {
+ ctx.db.castleWorkerV1.insert(row);
+ }
+ assertCastleWorkerRoster(ctx, castle.castleId);
+ }
+ const nextCastleCount = system.expectedCastleCount + 1;
+ if (nextCastleCount > MAX_U32 || nextCastleCount > Math.floor(MAX_U32 / CASTLE_WORKERS_PER_CASTLE)) {
+ fail('WORKER_ROSTER_CAPACITY');
+ }
+ const nextWorkerCount = nextCastleCount * CASTLE_WORKERS_PER_CASTLE;
+ if (ctx.db.castleWorkerV1.count() !== BigInt(nextWorkerCount)) {
+ fail('WORKER_ROSTER_INTEGRITY');
+ }
+ ctx.db.realmWorkerSystemV1.realmId.update({
+ ...system,
+ expectedCastleCount: nextCastleCount,
+ expectedWorkerCount: nextWorkerCount,
+ rosterDigest: appendCastleWorkerRosterDigest(system.rosterDigest, castle.castleId),
+ });
+}
+
+export function workerRosterDigestInput(castleIds: readonly bigint[]): string {
+ return [...castleIds]
+ .sort((left, right) => left < right ? -1 : left > right ? 1 : 0)
+ .flatMap(castleId => Array.from({ length: CASTLE_WORKERS_PER_CASTLE }, (_, index) => (
+ workerIdForCastle(castleId, index + 1)
+ )))
+ .join('|');
+}
diff --git a/spacetimedb/src/foodExpeditionAuthority.ts b/spacetimedb/src/foodExpeditionAuthority.ts
index 8de8bc37..3771ef69 100644
--- a/spacetimedb/src/foodExpeditionAuthority.ts
+++ b/spacetimedb/src/foodExpeditionAuthority.ts
@@ -27,6 +27,7 @@ import {
} from './resourceAuthorityPolicy';
import { assertGenesisResourceForFid } from './resourceAuthority';
import {
+ assertLegacyExpeditionDispatchAllowed,
ResourceExpeditionReservationAuthorityError,
activeExpeditionResourceReservations,
planResourceSettlementForActiveExpeditionReservations,
@@ -397,6 +398,7 @@ export function dispatchGenesisFoodExpedition(
}>,
): FoodExpeditionDispatch {
const resource = assertGenesisResourceForFid(ctx, input.fid);
+ assertLegacyExpeditionDispatchAllowed(ctx);
const requestKey = requestKeyFor(input.fid, input.idempotencyKey);
const prior = ctx.db.foodExpeditionIdempotencyV1.requestKey.find(requestKey);
if (prior !== null) {
diff --git a/spacetimedb/src/foundingAuthority.ts b/spacetimedb/src/foundingAuthority.ts
index bf979100..ab59a3d4 100644
--- a/spacetimedb/src/foundingAuthority.ts
+++ b/spacetimedb/src/foundingAuthority.ts
@@ -14,6 +14,7 @@ import {
GENESIS_RESOURCE_POLICY_VERSION,
GENESIS_STARTING_RESOURCE_BALANCES,
} from './resourceAuthorityPolicy';
+import { ensureCastleWorkerRoster } from './castleWorkerRoster';
import {
admissionProfileIsComplete,
trustedProfilesEqual,
@@ -219,6 +220,7 @@ export function ensureGenesisFounder(
|| !trustedProfilesEqual(existingProfile, admissionProfile)
) fail();
assertGenesisFoundingGraph(ctx);
+ ensureCastleWorkerRoster(ctx, existingCastle);
return 'preserved';
}
if (
@@ -314,6 +316,11 @@ export function ensureGenesisFounder(
updatedAt: ctx.timestamp,
});
+ // Generic workers are created only after a separately authorized active
+ // system row exists. The PR ships staged authority and therefore performs
+ // no production roster backfill or activation.
+ ensureCastleWorkerRoster(ctx, castle);
+
assertGenesisFoundingGraph(ctx);
return 'created';
}
diff --git a/spacetimedb/src/goldExpeditionAuthority.ts b/spacetimedb/src/goldExpeditionAuthority.ts
index 2ab554ea..a8fd969d 100644
--- a/spacetimedb/src/goldExpeditionAuthority.ts
+++ b/spacetimedb/src/goldExpeditionAuthority.ts
@@ -25,6 +25,7 @@ import {
} from './resourceAuthorityPolicy';
import { assertGenesisResourceForFid } from './resourceAuthority';
import {
+ assertLegacyExpeditionDispatchAllowed,
ResourceExpeditionReservationAuthorityError,
planResourceSettlementForActiveExpeditionReservations,
} from './resourceExpeditionReservationAuthority';
@@ -377,6 +378,7 @@ export function dispatchGenesisGoldExpedition(
idempotencyKey: string;
}>,
): GoldExpeditionDispatch {
+ assertLegacyExpeditionDispatchAllowed(ctx);
const requestKey = requestKeyFor(input.fid, input.idempotencyKey);
const prior = ctx.db.goldExpeditionIdempotencyV1.requestKey.find(requestKey);
if (prior !== null) {
diff --git a/spacetimedb/src/index.ts b/spacetimedb/src/index.ts
index 429d630a..b4b3c138 100644
--- a/spacetimedb/src/index.ts
+++ b/spacetimedb/src/index.ts
@@ -41,6 +41,15 @@ export {
adminBackfillResourceAccountsV1,
adminGetAlphaStatusV4,
} from './reducers/resources';
+export {
+ getMyWorkerRosterV1,
+ getMyResourceStateV2,
+ dispatchWorkerV1,
+ recallWorkerV1,
+ recallAllWorkersV1,
+ adminGetWorkerSystemStatusV1,
+ adminPlanWorkerRosterV1,
+} from './reducers/castleWorkers';
export {
getMyGoldExpeditionStateV1,
dispatchGoldExpeditionV1,
@@ -83,4 +92,5 @@ export {
runFoodExpeditionScheduleV1,
runWoodExpeditionScheduleV1,
runStoneExpeditionScheduleV1,
+ runCastleWorkerScheduleV1,
} from './schema';
diff --git a/spacetimedb/src/reducers/castleWorkers.ts b/spacetimedb/src/reducers/castleWorkers.ts
new file mode 100644
index 00000000..d8b8ebde
--- /dev/null
+++ b/spacetimedb/src/reducers/castleWorkers.ts
@@ -0,0 +1,322 @@
+import { SenderError, t } from 'spacetimedb/server';
+
+import { requireAdmin, requireGameplayPlayerV1 } from '../auth';
+import {
+ castleWorkerErrorCode,
+ dispatchCastleWorker,
+ inspectCastleWorkerGraph,
+ projectMyWorkerState,
+ recallAllCastleWorkers,
+ recallCastleWorker,
+} from '../castleWorkerAuthority';
+import warpkeep from '../schema';
+
+const workerPrivate = t.object('WorkerPrivateV1', {
+ workerId: t.string(),
+ ordinal: t.u32(),
+ status: t.string(),
+ resourceKind: t.option(t.string()),
+ siteId: t.option(t.string()),
+ accruedAmount: t.u64(),
+ materializedAmount: t.u64(),
+ availableAmount: t.u64(),
+ observedAtMicros: t.u64(),
+ revision: t.u64(),
+});
+
+const myWorkerRoster = t.object('MyWorkerRosterV1', {
+ fid: t.u64(),
+ castleId: t.u64(),
+ observedAtMicros: t.u64(),
+ workers: t.array(workerPrivate),
+});
+
+const myResourceStateV2 = t.object('MyResourceStateV2', {
+ fid: t.u64(),
+ food: t.u64(),
+ wood: t.u64(),
+ stone: t.u64(),
+ gold: t.u64(),
+ workerPendingFood: t.u64(),
+ workerPendingWood: t.u64(),
+ workerPendingStone: t.u64(),
+ workerPendingGold: t.u64(),
+ observedAtMicros: t.u64(),
+ settledThroughMicros: t.u64(),
+ revision: t.u64(),
+ resourcePolicyVersion: t.string(),
+ workerPolicyVersion: t.string(),
+ workerSystemMode: t.string(),
+});
+
+const adminWorkerSystemStatus = t.object('AdminWorkerSystemStatusV1', {
+ systemRows: t.u64(),
+ mode: t.string(),
+ systemConfigValid: t.bool(),
+ legacyDrainRequired: t.bool(),
+ expectedCastleCount: t.u64(),
+ expectedWorkerCount: t.u64(),
+ actualWorkerCount: t.u64(),
+ expectedCountsMatch: t.bool(),
+ rosterDigestMatches: t.bool(),
+ castlesMissingWorkers: t.u64(),
+ castlesWithExtraWorkers: t.u64(),
+ duplicateOrdinals: t.u64(),
+ malformedWorkerIds: t.u64(),
+ invalidWorkerStates: t.u64(),
+ idleWorkers: t.u64(),
+ outboundWorkers: t.u64(),
+ gatheringWorkers: t.u64(),
+ returningWorkers: t.u64(),
+ assignments: t.u64(),
+ occupations: t.u64(),
+ schedules: t.u64(),
+ orphanWorkers: t.u64(),
+ orphanAssignments: t.u64(),
+ assignmentsMissingOccupation: t.u64(),
+ assignmentsWithoutSingleSchedule: t.u64(),
+ orphanOccupations: t.u64(),
+ orphanSchedules: t.u64(),
+ invalidSchedules: t.u64(),
+ assignmentPublicMismatches: t.u64(),
+ occupationSiteMismatches: t.u64(),
+ invalidAssignments: t.u64(),
+ idempotencyReceipts: t.u64(),
+ invalidIdempotencyReceipts: t.u64(),
+ idempotencyOverflowFids: t.u64(),
+ legacyExpeditions: t.u64(),
+ legacyOccupations: t.u64(),
+ legacySchedules: t.u64(),
+ rosterDigest: t.string(),
+ rosterDigestExpected: t.string(),
+});
+
+const adminWorkerRosterPlan = t.object('AdminWorkerRosterPlanV1', {
+ ready: t.bool(),
+ activationBlockedByLegacyRows: t.bool(),
+ mode: t.string(),
+ systemConfigValid: t.bool(),
+ legacyDrainRequired: t.bool(),
+ expectedCastleCount: t.u64(),
+ expectedWorkerCount: t.u64(),
+ actualWorkerCount: t.u64(),
+ expectedCountsMatch: t.bool(),
+ rosterDigestMatches: t.bool(),
+ castlesMissingWorkers: t.u64(),
+ castlesWithExtraWorkers: t.u64(),
+ orphanWorkers: t.u64(),
+ orphanAssignments: t.u64(),
+ assignmentsMissingOccupation: t.u64(),
+ assignmentsWithoutSingleSchedule: t.u64(),
+ orphanOccupations: t.u64(),
+ orphanSchedules: t.u64(),
+ invalidSchedules: t.u64(),
+ assignmentPublicMismatches: t.u64(),
+ occupationSiteMismatches: t.u64(),
+ invalidWorkerStates: t.u64(),
+ invalidAssignments: t.u64(),
+ invalidIdempotencyReceipts: t.u64(),
+ idempotencyOverflowFids: t.u64(),
+ legacyExpeditions: t.u64(),
+ legacyOccupations: t.u64(),
+ legacySchedules: t.u64(),
+ rosterDigest: t.string(),
+ rosterDigestExpected: t.string(),
+});
+
+function senderPolicyError(error: unknown): never {
+ const code = castleWorkerErrorCode(error);
+ if (code !== undefined) throw new SenderError(code);
+ if (error instanceof SenderError) throw error;
+ throw new SenderError('WORKER_REQUEST_FAILED');
+}
+
+function workerSystemMode(ctx: Parameters[0]): string {
+ return ctx.db.realmWorkerSystemV1.realmId.find('GENESIS_001')?.mode ?? 'absent';
+}
+
+function aggregateResult(aggregate: ReturnType) {
+ return { ...aggregate };
+}
+
+export const getMyWorkerRosterV1 = warpkeep.procedure(
+ { name: 'get_my_worker_roster_v1' },
+ myWorkerRoster,
+ ctx => ctx.withTx(tx => {
+ try {
+ const { claims, castle } = requireGameplayPlayerV1(tx);
+ const observedAtMicros = tx.timestamp.microsSinceUnixEpoch;
+ const projection = projectMyWorkerState(tx, claims.fid, observedAtMicros);
+ return {
+ fid: claims.fid,
+ castleId: castle.castleId,
+ observedAtMicros,
+ workers: projection.workers.map(worker => ({
+ workerId: worker.workerId,
+ ordinal: worker.ordinal,
+ status: worker.status,
+ resourceKind: worker.resourceKind,
+ siteId: worker.siteId,
+ accruedAmount: worker.accruedAmount,
+ materializedAmount: worker.materializedAmount,
+ availableAmount: worker.availableAmount,
+ observedAtMicros: worker.observedAtMicros,
+ revision: worker.revision,
+ })),
+ };
+ } catch (error) {
+ return senderPolicyError(error);
+ }
+ }),
+);
+
+export const getMyResourceStateV2 = warpkeep.procedure(
+ { name: 'get_my_resource_state_v2' },
+ myResourceStateV2,
+ ctx => ctx.withTx(tx => {
+ try {
+ const { claims } = requireGameplayPlayerV1(tx);
+ const observedAtMicros = tx.timestamp.microsSinceUnixEpoch;
+ const projection = projectMyWorkerState(tx, claims.fid, observedAtMicros);
+ const pending = { food: 0n, wood: 0n, stone: 0n, gold: 0n };
+ for (const worker of projection.workers) {
+ if (worker.resourceKind === 'food') pending.food += worker.availableAmount;
+ if (worker.resourceKind === 'wood') pending.wood += worker.availableAmount;
+ if (worker.resourceKind === 'stone') pending.stone += worker.availableAmount;
+ if (worker.resourceKind === 'gold') pending.gold += worker.availableAmount;
+ }
+ return {
+ fid: claims.fid,
+ food: projection.balances.food,
+ wood: projection.balances.wood,
+ stone: projection.balances.stone,
+ gold: projection.balances.gold,
+ workerPendingFood: pending.food,
+ workerPendingWood: pending.wood,
+ workerPendingStone: pending.stone,
+ workerPendingGold: pending.gold,
+ observedAtMicros,
+ settledThroughMicros: projection.resource.settledThroughMicros,
+ revision: projection.resource.revision,
+ resourcePolicyVersion: projection.resource.policyVersion,
+ workerPolicyVersion: 'genesis-001-castle-workers-v1',
+ workerSystemMode: workerSystemMode(tx),
+ };
+ } catch (error) {
+ return senderPolicyError(error);
+ }
+ }),
+);
+
+export const dispatchWorkerV1 = warpkeep.reducer(
+ { name: 'dispatch_worker_v1' },
+ { workerId: t.string(), resourceKind: t.string(), siteId: t.string(), idempotencyKey: t.string() },
+ (ctx, { workerId, resourceKind, siteId, idempotencyKey }) => {
+ try {
+ const { claims, castle } = requireGameplayPlayerV1(ctx);
+ dispatchCastleWorker(ctx, { fid: claims.fid, castle, workerId, resourceKind, siteId, idempotencyKey });
+ } catch (error) {
+ return senderPolicyError(error);
+ }
+ },
+);
+
+export const recallWorkerV1 = warpkeep.reducer(
+ { name: 'recall_worker_v1' },
+ { workerId: t.string(), idempotencyKey: t.string() },
+ (ctx, { workerId, idempotencyKey }) => {
+ try {
+ const { claims, castle } = requireGameplayPlayerV1(ctx);
+ recallCastleWorker(ctx, { fid: claims.fid, castle, workerId, idempotencyKey });
+ } catch (error) {
+ return senderPolicyError(error);
+ }
+ },
+);
+
+export const recallAllWorkersV1 = warpkeep.reducer(
+ { name: 'recall_all_workers_v1' },
+ { idempotencyKey: t.string() },
+ (ctx, { idempotencyKey }) => {
+ try {
+ const { claims, castle } = requireGameplayPlayerV1(ctx);
+ recallAllCastleWorkers(ctx, { fid: claims.fid, castle, idempotencyKey });
+ } catch (error) {
+ return senderPolicyError(error);
+ }
+ },
+);
+
+export const adminGetWorkerSystemStatusV1 = warpkeep.procedure(
+ { name: 'admin_get_worker_system_status_v1' },
+ adminWorkerSystemStatus,
+ ctx => ctx.withTx(tx => {
+ requireAdmin(tx);
+ return aggregateResult(inspectCastleWorkerGraph(tx));
+ }),
+);
+
+export const adminPlanWorkerRosterV1 = warpkeep.procedure(
+ { name: 'admin_plan_worker_roster_v1' },
+ adminWorkerRosterPlan,
+ ctx => ctx.withTx(tx => {
+ requireAdmin(tx);
+ const aggregate = inspectCastleWorkerGraph(tx);
+ const legacyRows = aggregate.legacyExpeditions + aggregate.legacyOccupations + aggregate.legacySchedules;
+ return {
+ ready: aggregate.systemRows === 1n
+ && aggregate.systemConfigValid
+ && (aggregate.mode === 'staged' || aggregate.mode === 'active')
+ && !aggregate.legacyDrainRequired
+ && aggregate.expectedCountsMatch
+ && aggregate.rosterDigestMatches
+ && aggregate.castlesMissingWorkers === 0n
+ && aggregate.castlesWithExtraWorkers === 0n
+ && aggregate.duplicateOrdinals === 0n
+ && aggregate.malformedWorkerIds === 0n
+ && aggregate.invalidWorkerStates === 0n
+ && aggregate.orphanWorkers === 0n
+ && aggregate.orphanAssignments === 0n
+ && aggregate.assignmentsMissingOccupation === 0n
+ && aggregate.assignmentsWithoutSingleSchedule === 0n
+ && aggregate.orphanOccupations === 0n
+ && aggregate.orphanSchedules === 0n
+ && aggregate.invalidSchedules === 0n
+ && aggregate.assignmentPublicMismatches === 0n
+ && aggregate.occupationSiteMismatches === 0n
+ && aggregate.invalidAssignments === 0n
+ && aggregate.invalidIdempotencyReceipts === 0n
+ && aggregate.idempotencyOverflowFids === 0n
+ && legacyRows === 0n,
+ activationBlockedByLegacyRows: aggregate.legacyDrainRequired || legacyRows !== 0n,
+ mode: aggregate.mode,
+ systemConfigValid: aggregate.systemConfigValid,
+ legacyDrainRequired: aggregate.legacyDrainRequired,
+ expectedCastleCount: aggregate.expectedCastleCount,
+ expectedWorkerCount: aggregate.expectedWorkerCount,
+ actualWorkerCount: aggregate.actualWorkerCount,
+ expectedCountsMatch: aggregate.expectedCountsMatch,
+ rosterDigestMatches: aggregate.rosterDigestMatches,
+ castlesMissingWorkers: aggregate.castlesMissingWorkers,
+ castlesWithExtraWorkers: aggregate.castlesWithExtraWorkers,
+ orphanWorkers: aggregate.orphanWorkers,
+ orphanAssignments: aggregate.orphanAssignments,
+ assignmentsMissingOccupation: aggregate.assignmentsMissingOccupation,
+ assignmentsWithoutSingleSchedule: aggregate.assignmentsWithoutSingleSchedule,
+ orphanOccupations: aggregate.orphanOccupations,
+ orphanSchedules: aggregate.orphanSchedules,
+ invalidSchedules: aggregate.invalidSchedules,
+ assignmentPublicMismatches: aggregate.assignmentPublicMismatches,
+ occupationSiteMismatches: aggregate.occupationSiteMismatches,
+ invalidWorkerStates: aggregate.invalidWorkerStates,
+ invalidAssignments: aggregate.invalidAssignments,
+ invalidIdempotencyReceipts: aggregate.invalidIdempotencyReceipts,
+ idempotencyOverflowFids: aggregate.idempotencyOverflowFids,
+ legacyExpeditions: aggregate.legacyExpeditions,
+ legacyOccupations: aggregate.legacyOccupations,
+ legacySchedules: aggregate.legacySchedules,
+ rosterDigest: aggregate.rosterDigest,
+ rosterDigestExpected: aggregate.rosterDigestExpected,
+ };
+ }),
+);
diff --git a/spacetimedb/src/reducers/resources.ts b/spacetimedb/src/reducers/resources.ts
index 5c6393cc..3c4077af 100644
--- a/spacetimedb/src/reducers/resources.ts
+++ b/spacetimedb/src/reducers/resources.ts
@@ -2,6 +2,7 @@ import { SenderError, t } from 'spacetimedb/server';
import { WARPKEEP_BACKEND_PROTOCOL_VERSION } from '../config';
import { requireAdmin, requireGameplayPlayerV1 } from '../auth';
+import { castleWorkerErrorCode, settleAllWorkerAssignmentsForFid } from '../castleWorkerAuthority';
import { markAccountIsConsistent } from '../marksAuthorityPolicy';
import {
ResourceAuthorityError,
@@ -73,6 +74,8 @@ const adminAlphaStatusV4 = t.object('AdminAlphaStatusV4', {
});
function senderPolicyError(error: unknown): never {
+ const workerCode = castleWorkerErrorCode(error);
+ if (workerCode !== undefined) throw new SenderError(workerCode);
const foodExpeditionCode = foodExpeditionErrorCode(error);
if (foodExpeditionCode !== undefined) throw new SenderError(foodExpeditionCode);
const woodExpeditionCode = woodExpeditionErrorCode(error);
@@ -165,6 +168,10 @@ export const collectResourcesV1 = warpkeep.reducer(
collectActiveFoodExpedition(ctx, claims.fid);
collectActiveWoodExpedition(ctx, claims.fid);
collectActiveStoneExpedition(ctx, claims.fid);
+ // Generic workers settle into the same private inventory at this exact
+ // server timestamp. This keeps the legacy collect reducer compatible
+ // while new clients use get_my_resource_state_v2 for no-write reads.
+ settleAllWorkerAssignmentsForFid(ctx, claims.fid);
const resourceAfterExpeditions = assertGenesisResourceForFid(ctx, claims.fid);
const settlement = planResourceSettlementForActiveExpeditionReservations(
ctx,
diff --git a/spacetimedb/src/resourceExpeditionReservationAuthority.ts b/spacetimedb/src/resourceExpeditionReservationAuthority.ts
index d1e7ae2a..9dd266ad 100644
--- a/spacetimedb/src/resourceExpeditionReservationAuthority.ts
+++ b/spacetimedb/src/resourceExpeditionReservationAuthority.ts
@@ -1,9 +1,18 @@
import type { InferSchema, ReducerCtx } from 'spacetimedb/server';
+import {
+ CASTLE_WORKERS_PER_CASTLE,
+ workerResourcePolicy,
+} from './castleWorkerPolicy';
+
import {
FOOD_GATHERING_TOTAL_FOOD,
foodExpeditionStateIsConsistent,
} from './foodExpeditionPolicy';
+import {
+ GOLD_GATHERING_TOTAL_GOLD,
+ goldExpeditionStateIsConsistent,
+} from './goldExpeditionPolicy';
import {
WOOD_GATHERING_TOTAL_WOOD,
woodExpeditionStateIsConsistent,
@@ -41,13 +50,20 @@ export type ActiveExpeditionResourceReservations = Readonly<{
food: bigint;
wood: bigint;
stone: bigint;
+ gold: bigint;
}>;
+/** Once generic workers are active, legacy wagon creation is permanently closed. */
+export function assertLegacyExpeditionDispatchAllowed(ctx: WarpkeepReducerContext): void {
+ const workerSystem = ctx.db.realmWorkerSystemV1.realmId.find('GENESIS_001');
+ if (workerSystem?.mode === 'active') fail('LEGACY_EXPEDITION_DISPATCH_RETIRED');
+}
+
/**
- * Return exact uncredited thirty-day awards for the caller's active Food, Wood,
- * and Stone wagons. A returning row has already credited its whole award and
- * thus reserves zero. Independent tables permit one wagon of each resource
- * type.
+ * Return exact uncredited thirty-day awards for every active legacy wagon and
+ * generic assignment. A returning row has already credited its whole award and
+ * thus reserves zero. Independent tables permit one legacy wagon of each
+ * resource type while generic workers add their own private reservations.
*/
export function activeExpeditionResourceReservations(
ctx: WarpkeepReducerContext,
@@ -65,11 +81,33 @@ export function activeExpeditionResourceReservations(
if (stone !== null && !stoneExpeditionStateIsConsistent(stone)) {
fail('STONE_EXPEDITION_RESERVATION_STATE_INVALID');
}
- return Object.freeze({
- food: food === null ? 0n : FOOD_GATHERING_TOTAL_FOOD - food.creditedFood,
- wood: wood === null ? 0n : WOOD_GATHERING_TOTAL_WOOD - wood.creditedWood,
- stone: stone === null ? 0n : STONE_GATHERING_TOTAL_STONE - stone.creditedStone,
- });
+ const gold = ctx.db.goldExpeditionV1.fid.find(fid);
+ if (gold !== null && !goldExpeditionStateIsConsistent(gold)) {
+ fail('GOLD_EXPEDITION_RESERVATION_STATE_INVALID');
+ }
+ let foodReservation = food === null ? 0n : FOOD_GATHERING_TOTAL_FOOD - food.creditedFood;
+ let woodReservation = wood === null ? 0n : WOOD_GATHERING_TOTAL_WOOD - wood.creditedWood;
+ let stoneReservation = stone === null ? 0n : STONE_GATHERING_TOTAL_STONE - stone.creditedStone;
+ let goldReservation = gold === null ? 0n : GOLD_GATHERING_TOTAL_GOLD - gold.creditedGold;
+ let workerAssignmentCount = 0;
+ for (const assignment of ctx.db.workerAssignmentV1.byFid.filter(fid)) {
+ workerAssignmentCount += 1;
+ if (workerAssignmentCount > CASTLE_WORKERS_PER_CASTLE) {
+ fail('WORKER_ASSIGNMENT_LIMIT');
+ }
+ if (assignment.phase === 'returning') continue;
+ const total = workerResourcePolicy(assignment.resourceKind).gatheringTotal;
+ // Reserve the complete remaining award, not only the currently accrued
+ // amount. This leaves room for lazy server-time settlement to materialize
+ // the exact future output without truncation.
+ const fullRemaining = total - assignment.materializedAmount;
+ if (fullRemaining < 0n) throw new ResourceExpeditionReservationAuthorityError('WORKER_RESERVATION_INVALID');
+ if (assignment.resourceKind === 'food') foodReservation += fullRemaining;
+ if (assignment.resourceKind === 'wood') woodReservation += fullRemaining;
+ if (assignment.resourceKind === 'stone') stoneReservation += fullRemaining;
+ if (assignment.resourceKind === 'gold') goldReservation += fullRemaining;
+ }
+ return Object.freeze({ food: foodReservation, wood: woodReservation, stone: stoneReservation, gold: goldReservation });
}
/**
diff --git a/spacetimedb/src/schema.ts b/spacetimedb/src/schema.ts
index 92f17dde..0de5c39a 100644
--- a/spacetimedb/src/schema.ts
+++ b/spacetimedb/src/schema.ts
@@ -16,6 +16,10 @@ import {
runStoneExpeditionSchedule,
stoneExpeditionErrorCode,
} from './stoneExpeditionAuthority';
+import {
+ castleWorkerErrorCode,
+ runCastleWorkerSchedule,
+} from './castleWorkerAuthority';
/**
* Private closed-alpha admission list. This table is intentionally omitted
@@ -1001,6 +1005,170 @@ export const realmWaterRevisionV1 = table(
activatedAt: t.option(t.timestamp()),
},
);
+
+/** Public singleton for the staged generic-worker readiness boundary. */
+export const realmWorkerSystemV1 = table(
+ { name: 'realm_worker_system_v1', public: true },
+ {
+ realmId: t.string().primaryKey(),
+ policyVersion: t.string(),
+ workersPerCastle: t.u32(),
+ expectedCastleCount: t.u32(),
+ expectedWorkerCount: t.u32(),
+ rosterDigest: t.string(),
+ mode: t.string(),
+ legacyDrainRequired: t.bool(),
+ createdAt: t.timestamp(),
+ activatedAt: t.option(t.timestamp()),
+ },
+);
+
+/** Public identity-safe worker roster and lifecycle presentation. */
+export const castleWorkerV1 = table(
+ {
+ name: 'castle_worker_v1',
+ public: true,
+ indexes: [{
+ accessor: 'byOriginCastle',
+ algorithm: 'btree',
+ columns: ['originCastleId'] as const,
+ }] as const,
+ },
+ {
+ workerId: t.string().primaryKey(),
+ originCastleId: t.u64(),
+ ordinal: t.u32(),
+ status: t.string(),
+ resourceKind: t.option(t.string()),
+ siteId: t.option(t.string()),
+ startedAtMicros: t.option(t.u64()),
+ arrivesAtMicros: t.option(t.u64()),
+ gatheringEndsAtMicros: t.option(t.u64()),
+ returnStartedAtMicros: t.option(t.u64()),
+ returnsAtMicros: t.option(t.u64()),
+ routeSteps: t.option(t.u32()),
+ returnStartProgressBasisPoints: t.option(t.u32()),
+ timelineRevision: t.u32(),
+ revision: t.u64(),
+ },
+);
+
+/** Private generic assignment authority. Never expose through subscriptions. */
+export const workerAssignmentV1 = table(
+ {
+ name: 'worker_assignment_v1',
+ indexes: [{
+ accessor: 'byFid',
+ algorithm: 'btree',
+ columns: ['fid'] as const,
+ }, {
+ accessor: 'byFidAndPhase',
+ algorithm: 'btree',
+ columns: ['fid', 'phase'] as const,
+ }] as const,
+ },
+ {
+ assignmentId: t.string().primaryKey(),
+ workerId: t.string().unique(),
+ fid: t.u64(),
+ originCastleId: t.u64(),
+ resourceKind: t.string(),
+ siteId: t.string().index(),
+ phase: t.string(),
+ startedAtMicros: t.u64(),
+ arrivesAtMicros: t.u64(),
+ gatheringEndsAtMicros: t.u64(),
+ returnStartedAtMicros: t.option(t.u64()),
+ returnsAtMicros: t.u64(),
+ routeSteps: t.u32(),
+ returnStartProgressBasisPoints: t.u32(),
+ settledThroughMicros: t.u64(),
+ accruedAmount: t.u64(),
+ materializedAmount: t.u64(),
+ timelineRevision: t.u32(),
+ policyVersion: t.string(),
+ createdAt: t.timestamp(),
+ updatedAt: t.timestamp(),
+ },
+);
+
+/** Public generic node lease. It is deleted when a worker starts returning. */
+export const workerNodeOccupationV1 = table(
+ {
+ name: 'worker_node_occupation_v1',
+ public: true,
+ indexes: [{
+ accessor: 'byOriginCastle',
+ algorithm: 'btree',
+ columns: ['originCastleId'] as const,
+ }, {
+ accessor: 'byWorker',
+ algorithm: 'btree',
+ columns: ['workerId'] as const,
+ }] as const,
+ },
+ {
+ nodeKey: t.string().primaryKey(),
+ resourceKind: t.string(),
+ siteId: t.string(),
+ workerId: t.string(),
+ workerOrdinal: t.u32(),
+ originCastleId: t.u64(),
+ phase: t.string(),
+ startedAtMicros: t.u64(),
+ arrivesAtMicros: t.u64(),
+ gatheringEndsAtMicros: t.u64(),
+ timelineRevision: t.u32(),
+ },
+);
+
+/** Private exactly-once command receipts for dispatch and recall commands. */
+export const workerCommandIdempotencyV1 = table(
+ {
+ name: 'worker_command_idempotency_v1',
+ indexes: [{
+ accessor: 'byFid',
+ algorithm: 'btree',
+ columns: ['fid'] as const,
+ }] as const,
+ },
+ {
+ requestKey: t.string().primaryKey(),
+ fid: t.u64(),
+ workerId: t.option(t.string()),
+ commandKind: t.string(),
+ resourceKind: t.option(t.string()),
+ siteId: t.option(t.string()),
+ assignmentId: t.option(t.string()),
+ resultRevision: t.u64(),
+ createdAt: t.timestamp(),
+ },
+);
+
+/** Private scheduler authority. Assignment correlation never reaches clients. */
+export const workerAssignmentScheduleV1 = table(
+ {
+ name: 'worker_assignment_schedule_v_1',
+ indexes: [{
+ accessor: 'byAssignment',
+ algorithm: 'btree',
+ columns: ['assignmentId'] as const,
+ }, {
+ accessor: 'byWorker',
+ algorithm: 'btree',
+ columns: ['workerId'] as const,
+ }] as const,
+ scheduled: (): any => runCastleWorkerScheduleV1,
+ },
+ {
+ scheduleId: t.u64().primaryKey().autoInc(),
+ scheduledAt: t.scheduleAt(),
+ assignmentId: t.string(),
+ workerId: t.string(),
+ timelineRevision: t.u32(),
+ stage: t.string(),
+ },
+);
const warpkeep = schema({
// Preserve the original production schema prefix exactly. New tables are
// append-only so SpacetimeDB can apply this migration without rewriting it.
@@ -1051,6 +1219,12 @@ const warpkeep = schema({
stoneExpeditionIdempotencyV1,
stoneExpeditionScheduleV1,
realmWaterRevisionV1,
+ realmWorkerSystemV1,
+ castleWorkerV1,
+ workerAssignmentV1,
+ workerNodeOccupationV1,
+ workerCommandIdempotencyV1,
+ workerAssignmentScheduleV1,
});
/**
@@ -1124,6 +1298,21 @@ export const runStoneExpeditionScheduleV1 = warpkeep.reducer(
},
);
+/** Scheduler-only lifecycle reducer for staged generic castle workers. */
+export const runCastleWorkerScheduleV1 = warpkeep.reducer(
+ { name: 'run_worker_assignment_schedule_v_1' },
+ { arg: workerAssignmentScheduleV1.rowType },
+ (ctx, { arg }) => {
+ try {
+ runCastleWorkerSchedule(ctx, arg);
+ } catch (error) {
+ const code = castleWorkerErrorCode(error);
+ if (code !== undefined) throw new SenderError(code);
+ throw error;
+ }
+ },
+);
+
// SpacetimeDB 2.6's default case converter separates a trailing digit from
// its prefix (`v2` -> `v_2`). Pin every versioned wire spelling explicitly.
for (const name of [
@@ -1168,6 +1357,13 @@ for (const name of [
'admin_seed_genesis_water_layout_v1',
'admin_activate_genesis_water_layout_v1',
'admin_inspect_genesis_water_layout_v1',
+ 'get_my_worker_roster_v1',
+ 'get_my_resource_state_v2',
+ 'dispatch_worker_v1',
+ 'recall_worker_v1',
+ 'recall_all_workers_v1',
+ 'admin_get_worker_system_status_v1',
+ 'admin_plan_worker_roster_v1',
]) {
warpkeep.moduleDef.explicitNames.entries.push({
tag: 'Function',
diff --git a/spacetimedb/src/stoneExpeditionAuthority.ts b/spacetimedb/src/stoneExpeditionAuthority.ts
index 4b96bee9..49bc887d 100644
--- a/spacetimedb/src/stoneExpeditionAuthority.ts
+++ b/spacetimedb/src/stoneExpeditionAuthority.ts
@@ -27,6 +27,7 @@ import {
} from './resourceAuthorityPolicy';
import { assertGenesisResourceForFid } from './resourceAuthority';
import {
+ assertLegacyExpeditionDispatchAllowed,
ResourceExpeditionReservationAuthorityError,
activeExpeditionResourceReservations,
planResourceSettlementForActiveExpeditionReservations,
@@ -397,6 +398,7 @@ export function dispatchGenesisStoneExpedition(
}>,
): StoneExpeditionDispatch {
const resource = assertGenesisResourceForFid(ctx, input.fid);
+ assertLegacyExpeditionDispatchAllowed(ctx);
const requestKey = requestKeyFor(input.fid, input.idempotencyKey);
const prior = ctx.db.stoneExpeditionIdempotencyV1.requestKey.find(requestKey);
if (prior !== null) {
diff --git a/spacetimedb/src/woodExpeditionAuthority.ts b/spacetimedb/src/woodExpeditionAuthority.ts
index 17d7bf73..59767d61 100644
--- a/spacetimedb/src/woodExpeditionAuthority.ts
+++ b/spacetimedb/src/woodExpeditionAuthority.ts
@@ -27,6 +27,7 @@ import {
} from './resourceAuthorityPolicy';
import { assertGenesisResourceForFid } from './resourceAuthority';
import {
+ assertLegacyExpeditionDispatchAllowed,
ResourceExpeditionReservationAuthorityError,
activeExpeditionResourceReservations,
planResourceSettlementForActiveExpeditionReservations,
@@ -397,6 +398,7 @@ export function dispatchGenesisWoodExpedition(
}>,
): WoodExpeditionDispatch {
const resource = assertGenesisResourceForFid(ctx, input.fid);
+ assertLegacyExpeditionDispatchAllowed(ctx);
const requestKey = requestKeyFor(input.fid, input.idempotencyKey);
const prior = ctx.db.woodExpeditionIdempotencyV1.requestKey.find(requestKey);
if (prior !== null) {
diff --git a/spacetimedb/tests/castleWorkerAuthority.test.ts b/spacetimedb/tests/castleWorkerAuthority.test.ts
new file mode 100644
index 00000000..44613420
--- /dev/null
+++ b/spacetimedb/tests/castleWorkerAuthority.test.ts
@@ -0,0 +1,151 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+
+function source(path: string): string {
+ return readFileSync(new URL(path, import.meta.url), 'utf8');
+}
+
+function section(text: string, startNeedle: string, endNeedle: string): string {
+ const start = text.indexOf(startNeedle);
+ const end = text.indexOf(endNeedle, start + startNeedle.length);
+ assert.ok(start >= 0 && end > start, `missing section ${startNeedle}`);
+ return text.slice(start, end);
+}
+
+test('worker lifecycle advances one synchronized revision and one schedule at a time', () => {
+ const authority = source('../src/castleWorkerAuthority.ts');
+ const dispatch = section(authority, 'export function dispatchCastleWorker', 'function progressBasisPoints');
+ const arrival = section(authority, 'function transitionWorkerArrival', 'function settleAndBeginReturnAt');
+ const returning = section(authority, 'function beginWorkerReturn', 'function completeWorkerReturn');
+ const complete = section(authority, 'function completeWorkerReturn', 'function transitionWorkerArrival');
+
+ assert.match(dispatch, /timelineRevision = safeNextU32\(worker\.timelineRevision/);
+ assert.equal(dispatch.match(/insertSchedule\(/g)?.length, 1);
+ assert.match(dispatch, /WORKER_SCHEDULE_STAGE_ARRIVAL/);
+ assert.match(dispatch, /revision: safeNextU64\(worker\.revision/);
+
+ for (const transition of [arrival, returning]) {
+ assert.match(transition, /timelineRevision = safeNextU32\(assignment\.timelineRevision/);
+ assert.match(transition, /deleteSchedulesForAssignment\(ctx, assignment\.assignmentId\)/);
+ assert.match(transition, /revision: safeNextU64\(worker\.revision/);
+ assert.equal(transition.match(/insertSchedule\(/g)?.length, 1);
+ }
+ assert.match(arrival, /WORKER_SCHEDULE_STAGE_GATHERING_EXPIRY/);
+ assert.match(returning, /WORKER_SCHEDULE_STAGE_RETURN_COMPLETE/);
+ assert.match(complete, /deleteSchedulesForAssignment\(ctx, assignment\.assignmentId\)/);
+ assert.match(complete, /workerAssignmentV1\.assignmentId\.delete\(assignment\.assignmentId\)/);
+});
+
+test('recall caps server-time accrual and persists replay-safe no-op receipts', () => {
+ const authority = source('../src/castleWorkerAuthority.ts');
+ const recall = section(authority, 'export function recallCastleWorker', 'export function recallAllCastleWorkers');
+ const recallAll = section(authority, 'export function recallAllCastleWorkers', 'export type WorkerGraphAggregate');
+ const accrual = source('../src/castleWorkerPolicy.ts');
+
+ assert.match(recall, /now < assignment\.gatheringEndsAtMicros[\s\S]*assignment\.gatheringEndsAtMicros/);
+ assert.match(recall, /recallWorkerReceipt\([\s\S]*assignment === null \? undefined/);
+ assert.match(recallAll, /recallAllWorkersReceipt\([\s\S]*lastAssignmentId/);
+ assert.doesNotMatch(recallAll, /if \(lastAssignmentId === undefined\) return;/);
+ assert.match(authority, /WORKER_IDEMPOTENCY_RECEIPTS_PER_FID = 64/);
+ assert.match(authority, /workerCommandIdempotencyV1\.byFid\.filter\(fid\)/);
+ assert.match(accrual, /state\.phase === 'returning'[\s\S]*state\.returnStartedAtMicros/);
+});
+
+test('founding updates active roster readiness atomically and legacy dispatch is retired', () => {
+ const roster = source('../src/castleWorkerRoster.ts');
+ const founding = source('../src/foundingAuthority.ts');
+ const reservations = source('../src/resourceExpeditionReservationAuthority.ts');
+
+ assert.match(roster, /realmWorkerSystemV1\.realmId\.update\(\{[\s\S]*expectedCastleCount: nextCastleCount,[\s\S]*expectedWorkerCount: nextWorkerCount,[\s\S]*rosterDigest: appendCastleWorkerRosterDigest\(system\.rosterDigest, castle\.castleId\)/);
+ assert.doesNotMatch(roster, /ctx\.db\.castle\.iter\(\)|ctx\.db\.castleWorkerV1\.iter\(\)/);
+ assert.match(founding, /ensureCastleWorkerRoster\(ctx, existingCastle\)/);
+ assert.match(founding, /ensureCastleWorkerRoster\(ctx, castle\)/);
+ assert.match(reservations, /if \(workerSystem\?\.mode === 'active'\) fail\('LEGACY_EXPEDITION_DISPATCH_RETIRED'\)/);
+ for (const kind of ['gold', 'food', 'wood', 'stone']) {
+ const legacy = source(`../src/${kind}ExpeditionAuthority.ts`);
+ const dispatch = section(legacy, `export function dispatchGenesis${kind[0].toUpperCase()}${kind.slice(1)}Expedition`, 'const requestKey');
+ assert.match(dispatch, /assertLegacyExpeditionDispatchAllowed\(ctx\)/);
+ }
+});
+
+test('worker reads use bounded indexes and public tables omit assignment correlation', () => {
+ const authority = source('../src/castleWorkerAuthority.ts');
+ const reservations = source('../src/resourceExpeditionReservationAuthority.ts');
+ const schema = source('../src/schema.ts');
+ const settlement = section(authority, 'export function settleAllWorkerAssignmentsForFid', 'export type WorkerPrivateProjection');
+ const active = section(authority, 'function workerSystemActive', 'function canonicalSiteFor');
+ const callerGraph = section(authority, 'function assertCallerWorkerGraph', 'function pruneWorkerIdempotencyReceipts');
+ const publicMatch = section(authority, 'function publicWorkerMatchesAssignment', 'function occupationMatchesAssignment');
+ const occupationMatch = section(authority, 'function occupationMatchesAssignment', 'function canonicalCastleOwnershipMatches');
+
+ assert.match(settlement, /workerAssignmentV1\.byFid\.filter\(fid\)/);
+ assert.doesNotMatch(settlement, /workerAssignmentV1\.iter\(\)/);
+ assert.doesNotMatch(active, /\.iter\(\)|inspectCastleWorkerGraph/);
+ assert.match(callerGraph, /workerAssignmentV1\.byFid\.filter\(fid\)/);
+ assert.match(callerGraph, /workerNodeOccupationV1\.byWorker\.filter\(worker\.workerId\)/);
+ assert.match(callerGraph, /workerAssignmentScheduleV1\.byWorker\.filter\(worker\.workerId\)/);
+ assert.match(callerGraph, /workerCommandIdempotencyV1\.byFid\.filter\(fid\)/);
+ assert.doesNotMatch(callerGraph, /\.iter\(\)/);
+ assert.match(reservations, /workerAssignmentV1\.byFid\.filter\(fid\)/);
+ assert.match(publicMatch, /worker\.workerId === workerIdForCastle\(assignment\.originCastleId, worker\.ordinal\)/);
+ assert.match(publicMatch, /worker\.returnStartedAtMicros === assignment\.returnStartedAtMicros/);
+ assert.match(publicMatch, /worker\.returnStartProgressBasisPoints === expectedReturnProgress/);
+ for (const field of [
+ 'nodeKey', 'resourceKind', 'siteId', 'workerId', 'workerOrdinal',
+ 'originCastleId', 'phase', 'startedAtMicros', 'arrivesAtMicros',
+ 'gatheringEndsAtMicros', 'timelineRevision',
+ ]) assert.match(occupationMatch, new RegExp(`occupation\\.${field}`));
+ assert.match(occupationMatch, /assignment\.phase !== 'returning'/);
+ assert.doesNotMatch(section(schema, 'export const castleWorkerV1', 'export const workerAssignmentV1'), /assignmentId/);
+ assert.doesNotMatch(section(schema, 'export const workerNodeOccupationV1', 'export const workerCommandIdempotencyV1'), /assignmentId/);
+ assert.doesNotMatch(section(schema, 'export const workerAssignmentScheduleV1', 'const warpkeep = schema'), /public: true/);
+});
+
+test('worker assignments and replay receipts remain bound to canonical castle ownership', () => {
+ const authority = source('../src/castleWorkerAuthority.ts');
+ const graph = section(authority, 'export function inspectCastleWorkerGraph', 'export function castleWorkerErrorCode');
+ const dispatch = section(authority, 'export function dispatchCastleWorker', 'function progressBasisPoints');
+ const recall = section(authority, 'export function recallCastleWorker', 'export function recallAllCastleWorkers');
+ const recallAll = section(authority, 'export function recallAllCastleWorkers', 'export type WorkerGraphAggregate');
+
+ assert.match(authority, /function canonicalCastleOwnershipMatches[\s\S]*castle\.castleId\.find\(castleId\)[\s\S]*resourceAccountV1\.fid\.find\(fid\)/);
+ assert.match(graph, /if \(!assignmentOwnerIsCanonical\(ctx, assignment\)\) fail\('WORKER_OWNER_INTEGRITY'\)/);
+ assert.match(graph, /!workerReceiptShapeIsValid\(receipt\)[\s\S]*!receiptOwnerIsCanonical\(ctx, receipt\)/);
+ assert.match(graph, /!receiptOwnerIsCanonical\(ctx, receipt\)/);
+ assert.match(dispatch, /workerReceiptShapeIsValid\(prior\)/);
+ assert.match(recall, /recallReplayMatches\(prior, input\.fid, input\.workerId\)/);
+ assert.match(recallAll, /recallAllReplayMatches\(prior, input\.fid\)/);
+ for (const replay of [dispatch, recall, recallAll]) {
+ assert.match(replay, /receiptOwnerIsCanonical\(ctx, prior, input\.castle\.castleId\)/);
+ assert.match(replay, /canonicalCastleOwnershipMatches\(ctx, input\.fid, input\.castle\.castleId\)/);
+ }
+ assert.match(dispatch, /!assignmentOwnerIsCanonical\(ctx, assignment\)/);
+});
+
+test('admin readiness and sender errors fail closed on every bounded graph signal', () => {
+ const reducers = source('../src/reducers/castleWorkers.ts');
+ const authority = source('../src/castleWorkerAuthority.ts');
+
+ for (const signal of [
+ 'systemConfigValid', 'legacyDrainRequired', 'expectedCountsMatch',
+ 'rosterDigestMatches', 'invalidWorkerStates', 'assignmentsMissingOccupation',
+ 'assignmentsWithoutSingleSchedule', 'orphanSchedules', 'invalidSchedules',
+ 'invalidAssignments', 'invalidIdempotencyReceipts', 'idempotencyOverflowFids',
+ ]) assert.match(reducers, new RegExp(signal));
+ assert.match(section(reducers, 'export const adminPlanWorkerRosterV1', '\n);'), /requireAdmin\(tx\)/);
+ assert.match(reducers, /throw new SenderError\('WORKER_REQUEST_FAILED'\)/);
+ assert.match(authority, /BOUNDED_WORKER_ERROR_CODE = \/\^\[A-Z\]\[A-Z0-9_\]\{0,63\}\$\//);
+});
+
+test('CI runs both static and real populated v11 to v12 migration proofs', () => {
+ const workflow = source('../../.github/workflows/verify.yml');
+ const verifier = source('../../scripts/verify-spacetime-additive-migration.mjs');
+
+ assert.match(workflow, /npm run stdb:verify-worker-migration[\s\S]*npm run stdb:verify-additive-migration/);
+ assert.match(verifier, /function assertAdditiveV12Schema\(before, after\)/);
+ assert.match(verifier, /fixture_seed_generic_worker_sentinel_v12/);
+ assert.match(verifier, /populatedWaterStoneV12Rows/);
+ assert.match(verifier, /additiveV11SchemaFixture,[\s\S]{0,120}populatedWaterStoneMigrationDatabase,[\s\S]{0,40}false/);
+ assert.match(verifier, /every v12 table was populated, retained through the real candidate/);
+});
diff --git a/spacetimedb/tests/castleWorkerAuthorityBehavior.test.ts b/spacetimedb/tests/castleWorkerAuthorityBehavior.test.ts
new file mode 100644
index 00000000..397c2974
--- /dev/null
+++ b/spacetimedb/tests/castleWorkerAuthorityBehavior.test.ts
@@ -0,0 +1,125 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ recallAllReplayMatches,
+ recallAllWorkersReceipt,
+ recallReplayMatches,
+ recallWorkerReceipt,
+ takeBoundedRows,
+ workerCastleOwnershipMatches,
+ workerCommandReceiptShapeIsValid,
+ workerScheduleMatchesAssignment,
+} from '../src/castleWorkerCommandPolicy';
+
+test('canonical worker ownership rejects cross-FID and cross-castle authority', () => {
+ assert.equal(workerCastleOwnershipMatches({
+ fid: 101n,
+ castleId: 7n,
+ castleOwnerFid: 101n,
+ accountFid: 101n,
+ accountCastleId: 7n,
+ }), true);
+ assert.equal(workerCastleOwnershipMatches({
+ fid: 202n,
+ castleId: 7n,
+ castleOwnerFid: 101n,
+ accountFid: 202n,
+ accountCastleId: 9n,
+ }), false);
+ assert.equal(workerCastleOwnershipMatches({
+ fid: 101n,
+ castleId: 7n,
+ castleOwnerFid: 101n,
+ accountFid: 101n,
+ accountCastleId: 8n,
+ }), false);
+});
+
+test('an idle recall receipt remains an exact harmless replay after reassignment', () => {
+ const receipt = recallWorkerReceipt(
+ '101:recall-noop-0001',
+ 101n,
+ 'genesis-001-castle-7-worker-01',
+ 4n,
+ );
+ assert.equal(workerCommandReceiptShapeIsValid(receipt), true);
+ assert.equal(recallReplayMatches(
+ receipt,
+ 101n,
+ 'genesis-001-castle-7-worker-01',
+ ), true);
+
+ const laterAssignment = Object.freeze({
+ assignmentId: 'later-assignment',
+ workerId: 'genesis-001-castle-7-worker-01',
+ phase: 'outbound',
+ });
+ assert.equal(recallReplayMatches(receipt, 101n, laterAssignment.workerId), true);
+ assert.equal(receipt.assignmentId, undefined);
+ assert.equal(receipt.resourceKind, undefined);
+ assert.equal(receipt.siteId, undefined);
+});
+
+test('recall and recall-all receipts distinguish correlation from replay-safe no-op', () => {
+ const correlated = recallWorkerReceipt(
+ '101:recall-active-01',
+ 101n,
+ 'genesis-001-castle-7-worker-02',
+ 9n,
+ { resourceKind: 'wood', siteId: 'logging-camp-001', assignmentId: 'assignment-01' },
+ );
+ const noOpAll = recallAllWorkersReceipt('101:recall-all-noop1', 101n, 9n);
+ assert.equal(workerCommandReceiptShapeIsValid(correlated), true);
+ assert.equal(workerCommandReceiptShapeIsValid(noOpAll), true);
+ assert.equal(recallAllReplayMatches(noOpAll, 101n), true);
+ assert.equal(recallAllReplayMatches(noOpAll, 202n), false);
+ assert.equal(recallReplayMatches(correlated, 101n, correlated.workerId!), true);
+
+ assert.equal(workerCommandReceiptShapeIsValid({
+ ...correlated,
+ assignmentId: undefined,
+ resourceKind: 'wood',
+ }), false);
+});
+
+test('stale schedule generations never match the current worker lifecycle', () => {
+ const assignment = Object.freeze({
+ phase: 'outbound',
+ workerId: 'genesis-001-castle-7-worker-03',
+ timelineRevision: 8,
+ arrivesAtMicros: 2_000_000n,
+ gatheringEndsAtMicros: 3_000_000n,
+ returnsAtMicros: 4_000_000n,
+ });
+ const current = Object.freeze({
+ stage: 'arrival',
+ workerId: assignment.workerId,
+ timelineRevision: 8,
+ scheduledAt: Object.freeze({
+ tag: 'Time',
+ value: Object.freeze({ microsSinceUnixEpoch: assignment.arrivesAtMicros }),
+ }),
+ });
+ assert.equal(workerScheduleMatchesAssignment(current, assignment), true);
+ assert.equal(workerScheduleMatchesAssignment({ ...current, timelineRevision: 7 }, assignment), false);
+ assert.equal(workerScheduleMatchesAssignment({ ...current, stage: 'return-complete' }, assignment), false);
+ assert.equal(workerScheduleMatchesAssignment({
+ ...current,
+ scheduledAt: { tag: 'Time', value: { microsSinceUnixEpoch: 2_000_001n } },
+ }, assignment), false);
+});
+
+test('bounded caller reads consume only the one overflow row needed to fail closed', () => {
+ let reads = 0;
+ const rows = (function* manyRows() {
+ for (let index = 0; index < 100; index += 1) {
+ reads += 1;
+ yield index;
+ }
+ }());
+ const result = takeBoundedRows(rows, 4);
+ assert.deepEqual(result.rows, [0, 1, 2, 3]);
+ assert.equal(result.overflow, true);
+ assert.equal(reads, 5);
+});
diff --git a/spacetimedb/tests/castleWorkerMigrationTooling.test.ts b/spacetimedb/tests/castleWorkerMigrationTooling.test.ts
new file mode 100644
index 00000000..27b7458f
--- /dev/null
+++ b/spacetimedb/tests/castleWorkerMigrationTooling.test.ts
@@ -0,0 +1,85 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+
+function source(path: string): string {
+ return readFileSync(new URL(path, import.meta.url), 'utf8');
+}
+
+function schemaRegistrations(text: string, marker: string): string[] {
+ const start = text.indexOf(marker);
+ const end = text.indexOf('\n);', start);
+ assert.ok(start >= 0 && end > start);
+ return text.slice(start + marker.length, end)
+ .split(/[,\n]/)
+ .map(value => value.trim())
+ .filter(value => /^[A-Za-z][A-Za-z0-9]*$/.test(value));
+}
+
+function tableDefinition(text: string, name: string): string {
+ const start = text.indexOf(`const ${name} = table(`);
+ const end = [text.indexOf('\n);', start), text.indexOf('\n});', start)]
+ .filter(candidate => candidate > start)
+ .sort((left, right) => left - right)[0] ?? -1;
+ assert.ok(start >= 0 && end > start);
+ return text.slice(start, end);
+}
+
+test('v12 fixture appends six generic-worker tables after the exact v11 prefix', () => {
+ const v11 = source('../migration-fixtures/additive-v11-schema/src/index.ts');
+ const v12 = source('../migration-fixtures/additive-v12-schema/src/index.ts');
+ const v11Registrations = schemaRegistrations(v11, 'const db = schema({');
+ const v12Registrations = schemaRegistrations(v12, 'const db = schema({');
+ assert.equal(v11Registrations.length, 47);
+ assert.deepEqual(v12Registrations.slice(0, 47), v11Registrations);
+ assert.deepEqual(v12Registrations.slice(47), [
+ 'realmWorkerSystemV1',
+ 'castleWorkerV1',
+ 'workerAssignmentV1',
+ 'workerNodeOccupationV1',
+ 'workerCommandIdempotencyV1',
+ 'workerAssignmentScheduleV1',
+ ]);
+ assert.match(v12, /fixture_seed_generic_worker_sentinel_v12/);
+ assert.match(source('../migration-fixtures/additive-v12-schema/package.json'), /additive-v12-schema/);
+});
+
+test('public generic-worker rows exclude private ownership and accrual fields', () => {
+ const schema = source('../src/schema.ts');
+ for (const name of ['realmWorkerSystemV1', 'castleWorkerV1', 'workerNodeOccupationV1']) {
+ const definition = tableDefinition(schema, name);
+ assert.match(definition, /public: true/);
+ assert.doesNotMatch(definition, /\bfid\b|assignmentId|accruedAmount|materializedAmount|balance|requestKey|auth/i);
+ }
+ const assignment = tableDefinition(schema, 'workerAssignmentV1');
+ const idempotency = tableDefinition(schema, 'workerCommandIdempotencyV1');
+ const schedule = tableDefinition(schema, 'workerAssignmentScheduleV1');
+ assert.doesNotMatch(assignment, /public: true/);
+ assert.doesNotMatch(idempotency, /public: true/);
+ assert.doesNotMatch(schedule, /public: true/);
+ assert.match(assignment, /fid: t\.u64\(\)/);
+ assert.match(assignment, /accruedAmount: t\.u64\(\)/);
+ assert.match(idempotency, /requestKey: t\.string\(\)\.primaryKey\(\)/);
+ assert.doesNotMatch(tableDefinition(schema, 'castleWorkerV1'), /createdAt|updatedAt/);
+ assert.doesNotMatch(
+ tableDefinition(source('../migration-fixtures/additive-v12-schema/src/index.ts'), 'castleWorkerV1'),
+ /createdAt|updatedAt/,
+ );
+});
+
+test('worker reducers are caller-bound and activation remains explicitly gated', () => {
+ const reducers = source('../src/reducers/castleWorkers.ts');
+ const authority = source('../src/castleWorkerAuthority.ts');
+ assert.match(reducers, /name: 'dispatch_worker_v1'/);
+ assert.match(reducers, /requireGameplayPlayerV1\(ctx\)/);
+ assert.match(reducers, /dispatchCastleWorker\(ctx, \{ fid: claims\.fid, castle/);
+ assert.match(reducers, /name: 'recall_all_workers_v1'/);
+ assert.match(reducers, /name: 'admin_plan_worker_roster_v1'/);
+ assert.match(authority, /if \(row\.mode !== 'active'\) fail\('WORKER_SYSTEM_STAGED'\)/);
+ assert.match(authority, /legacy\.expeditions !== 0n \|\| legacy\.occupations !== 0n \|\| legacy\.schedules !== 0n/);
+ assert.match(authority, /workerNodeOccupationV1\.nodeKey\.delete\(occupation\.nodeKey\)/);
+ assert.match(authority, /deleteSchedulesForAssignment/);
+ assert.match(authority, /WORKER_IDEMPOTENCY_RECEIPTS_PER_FID = 64/);
+ assert.match(authority, /planCastleWorkerAccrual\(assignment, observedAtMicros\)/);
+ assert.match(authority, /No[\s\S]{0,20}per-minute writes/);
+});
diff --git a/spacetimedb/tests/castleWorkerPolicy.test.ts b/spacetimedb/tests/castleWorkerPolicy.test.ts
new file mode 100644
index 00000000..a7358540
--- /dev/null
+++ b/spacetimedb/tests/castleWorkerPolicy.test.ts
@@ -0,0 +1,168 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS,
+ CASTLE_WORKER_POLICY_VERSION,
+ CASTLE_WORKERS_PER_CASTLE,
+ CastleWorkerPolicyError,
+ planCastleWorkerAccrual,
+ planCastleWorkerTimeline,
+ rosterDigestForCastleIds,
+ workerAssignmentStateIsConsistent,
+ workerIdForCastle,
+ workerResourceKinds,
+ workerResourcePolicy,
+} from '../src/castleWorkerPolicy';
+
+test('generic worker roster IDs are stable and exactly four per castle', () => {
+ assert.equal(CASTLE_WORKERS_PER_CASTLE, 4);
+ assert.deepEqual(
+ Array.from({ length: CASTLE_WORKERS_PER_CASTLE }, (_, index) => workerIdForCastle(42n, index + 1)),
+ [
+ 'genesis-001-castle-42-worker-01',
+ 'genesis-001-castle-42-worker-02',
+ 'genesis-001-castle-42-worker-03',
+ 'genesis-001-castle-42-worker-04',
+ ],
+ );
+ assert.notEqual(rosterDigestForCastleIds([42n, 7n]), rosterDigestForCastleIds([42n]));
+ assert.equal(rosterDigestForCastleIds([42n, 7n]), rosterDigestForCastleIds([7n, 42n]));
+});
+
+test('all four resource policies use the shared 60-second quantum and 30-day cap', () => {
+ assert.deepEqual(workerResourceKinds(), ['gold', 'food', 'wood', 'stone']);
+ for (const kind of workerResourceKinds()) {
+ const policy = workerResourcePolicy(kind);
+ assert.equal(policy.quantumMicros, 60_000_000n);
+ assert.equal(policy.gatheringDurationMicros, CASTLE_WORKER_MAX_GATHERING_DURATION_MICROS);
+ assert.equal(policy.gatheringTotal, 43_200n * policy.ratePerQuantum);
+ }
+});
+
+test('timeline and accrual are server-time-only and quantum aligned', () => {
+ const timeline = planCastleWorkerTimeline(1_000_000n, 3);
+ assert.equal(timeline.arrivesAtMicros, 91_000_000n);
+ assert.equal(timeline.gatheringEndsAtMicros, 2_592_091_000_000n);
+ assert.equal(timeline.returnsAtMicros, 2_592_181_000_000n);
+ const policy = workerResourcePolicy('stone');
+ const state = {
+ phase: 'gathering',
+ ...timeline,
+ returnStartedAtMicros: undefined,
+ routeSteps: 3,
+ returnStartProgressBasisPoints: 0,
+ settledThroughMicros: timeline.arrivesAtMicros,
+ accruedAmount: 0n,
+ materializedAmount: 0n,
+ resourceKind: 'stone',
+ policyVersion: CASTLE_WORKER_POLICY_VERSION,
+ } as const;
+ const plan = planCastleWorkerAccrual(state, timeline.arrivesAtMicros + 2n * policy.quantumMicros + 1n);
+ assert.equal(plan.completedQuanta, 2n);
+ assert.equal(plan.newlyAccruedAmount, 2n * policy.ratePerQuantum);
+ assert.equal(plan.settledThroughMicros, timeline.arrivesAtMicros + 2n * policy.quantumMicros);
+});
+
+test('early recall is structurally valid and permanently caps accrual at return start', () => {
+ const timeline = planCastleWorkerTimeline(1_000_000n, 3);
+ const policy = workerResourcePolicy('wood');
+ const outboundRecall = {
+ phase: 'returning',
+ ...timeline,
+ returnStartedAtMicros: timeline.startedAtMicros + 30_000_000n,
+ returnsAtMicros: timeline.startedAtMicros + 59_997_000n,
+ routeSteps: 3,
+ returnStartProgressBasisPoints: 3_333,
+ settledThroughMicros: timeline.arrivesAtMicros,
+ accruedAmount: 0n,
+ materializedAmount: 0n,
+ resourceKind: 'wood',
+ policyVersion: CASTLE_WORKER_POLICY_VERSION,
+ } as const;
+ assert.equal(workerAssignmentStateIsConsistent(outboundRecall), true);
+ assert.deepEqual(planCastleWorkerAccrual(outboundRecall, timeline.gatheringEndsAtMicros), {
+ accruedAmount: 0n,
+ newlyAccruedAmount: 0n,
+ completedQuanta: 0n,
+ settledThroughMicros: timeline.arrivesAtMicros,
+ });
+
+ const returnStartedAtMicros = timeline.arrivesAtMicros + 2n * policy.quantumMicros + 30_000_000n;
+ const gatheringRecall = {
+ ...outboundRecall,
+ returnStartedAtMicros,
+ returnsAtMicros: returnStartedAtMicros + 90_000_000n,
+ returnStartProgressBasisPoints: 10_000,
+ };
+ const capped = planCastleWorkerAccrual(gatheringRecall, timeline.gatheringEndsAtMicros);
+ assert.equal(capped.completedQuanta, 2n);
+ assert.equal(capped.accruedAmount, 2n * policy.ratePerQuantum);
+ assert.equal(capped.settledThroughMicros, timeline.arrivesAtMicros + 2n * policy.quantumMicros);
+ assert.equal(
+ planCastleWorkerAccrual({
+ ...gatheringRecall,
+ settledThroughMicros: capped.settledThroughMicros,
+ accruedAmount: capped.accruedAmount,
+ materializedAmount: capped.accruedAmount,
+ }, timeline.gatheringEndsAtMicros).newlyAccruedAmount,
+ 0n,
+ );
+});
+
+test('returning assignments fail closed without a bounded return-start cursor', () => {
+ const timeline = planCastleWorkerTimeline(1_000_000n, 1);
+ assert.equal(workerAssignmentStateIsConsistent({
+ phase: 'returning',
+ ...timeline,
+ returnStartedAtMicros: undefined,
+ routeSteps: 1,
+ returnStartProgressBasisPoints: 0,
+ settledThroughMicros: timeline.arrivesAtMicros,
+ accruedAmount: 0n,
+ materializedAmount: 0n,
+ resourceKind: 'food',
+ policyVersion: CASTLE_WORKER_POLICY_VERSION,
+ }), false);
+});
+
+test('assignment timing cannot forge a shorter route or return-progress award', () => {
+ const timeline = planCastleWorkerTimeline(1_000_000n, 2);
+ const base = {
+ phase: 'outbound',
+ ...timeline,
+ returnStartedAtMicros: undefined,
+ routeSteps: 2,
+ returnStartProgressBasisPoints: 0,
+ settledThroughMicros: timeline.arrivesAtMicros,
+ accruedAmount: 0n,
+ materializedAmount: 0n,
+ resourceKind: 'stone',
+ policyVersion: CASTLE_WORKER_POLICY_VERSION,
+ } as const;
+ assert.equal(workerAssignmentStateIsConsistent(base), true);
+ assert.equal(workerAssignmentStateIsConsistent({
+ ...base,
+ arrivesAtMicros: base.arrivesAtMicros - 1n,
+ }), false);
+ const returnStartedAtMicros = base.startedAtMicros + 30_000_000n;
+ assert.equal(workerAssignmentStateIsConsistent({
+ ...base,
+ phase: 'returning',
+ returnStartedAtMicros,
+ returnStartProgressBasisPoints: 5_001,
+ returnsAtMicros: returnStartedAtMicros + 30_000_000n,
+ }), false);
+});
+
+test('policy rejects invalid resource kinds, roster ordinals, and routes', () => {
+ assert.throws(() => workerResourcePolicy('mana'), (error: unknown) => (
+ error instanceof CastleWorkerPolicyError && error.code === 'WORKER_RESOURCE_UNSUPPORTED'
+ ));
+ assert.throws(() => workerIdForCastle(1n, 5), (error: unknown) => (
+ error instanceof CastleWorkerPolicyError && error.code === 'WORKER_ROSTER_ORDINAL_INVALID'
+ ));
+ assert.throws(() => planCastleWorkerTimeline(0n, 0), (error: unknown) => (
+ error instanceof CastleWorkerPolicyError && error.code === 'WORKER_ROUTE_INVALID'
+ ));
+});
diff --git a/spacetimedb/tests/foodExpeditionReducers.test.ts b/spacetimedb/tests/foodExpeditionReducers.test.ts
index dc88f138..101d6024 100644
--- a/spacetimedb/tests/foodExpeditionReducers.test.ts
+++ b/spacetimedb/tests/foodExpeditionReducers.test.ts
@@ -40,7 +40,7 @@ test('v7 Food tables remain intact through later additive suffixes', () => {
const registrations = schemaRegistrations(schema);
const v4Registrations = schemaRegistrations(v4.replace('const db = schema({', 'const warpkeep = schema({'));
assert.deepEqual(registrations.slice(0, v4Registrations.length), v4Registrations);
- assert.deepEqual(registrations.slice(-27), [
+ assert.deepEqual(registrations.slice(-33, -6), [
'goldSiteV1',
'goldNodeOccupationV1',
'goldExpeditionV1',
diff --git a/spacetimedb/tests/goldExpeditionReducers.test.ts b/spacetimedb/tests/goldExpeditionReducers.test.ts
index 86218d53..0c9e3de9 100644
--- a/spacetimedb/tests/goldExpeditionReducers.test.ts
+++ b/spacetimedb/tests/goldExpeditionReducers.test.ts
@@ -40,7 +40,7 @@ test('v5 Gold authority prefix remains intact through later additive suffixes',
const registrations = schemaRegistrations(schema);
const v4Registrations = schemaRegistrations(v4.replace('const db = schema({', 'const warpkeep = schema({'));
assert.deepEqual(registrations.slice(0, v4Registrations.length), v4Registrations);
- assert.deepEqual(registrations.slice(-27), [
+ assert.deepEqual(registrations.slice(-33, -6), [
'goldSiteV1',
'goldNodeOccupationV1',
'goldExpeditionV1',
@@ -69,7 +69,7 @@ test('v5 Gold authority prefix remains intact through later additive suffixes',
'stoneExpeditionScheduleV1',
'realmWaterRevisionV1',
]);
- assert.deepEqual(registrations.slice(-27, -22), [
+ assert.deepEqual(registrations.slice(-33, -28), [
'goldSiteV1',
'goldNodeOccupationV1',
'goldExpeditionV1',
diff --git a/spacetimedb/tests/playerIdentityPrivacy.test.ts b/spacetimedb/tests/playerIdentityPrivacy.test.ts
index 464485c1..f6b5d7b1 100644
--- a/spacetimedb/tests/playerIdentityPrivacy.test.ts
+++ b/spacetimedb/tests/playerIdentityPrivacy.test.ts
@@ -128,6 +128,7 @@ test('generated bindings contain the public projections and omit every private e
const publicTableFiles = [
'castle_slot_v_1_table.ts',
'castle_table.ts',
+ 'castle_worker_v_1_table.ts',
'food_expedition_schedule_v_1_table.ts',
'food_node_occupation_v_1_table.ts',
'food_site_v_1_table.ts',
@@ -145,12 +146,14 @@ test('generated bindings contain the public projections and omit every private e
'realm_water_cell_v_1_table.ts',
'realm_water_layout_v_1_table.ts',
'realm_water_revision_v_1_table.ts',
+ 'realm_worker_system_v_1_table.ts',
'stone_expedition_schedule_v_1_table.ts',
'stone_node_occupation_v_1_table.ts',
'stone_site_v_1_table.ts',
'wood_expedition_schedule_v_1_table.ts',
'wood_node_occupation_v_1_table.ts',
'wood_site_v_1_table.ts',
+ 'worker_node_occupation_v_1_table.ts',
'world_tile_meta_v_1_table.ts',
'world_tile_table.ts',
];
@@ -228,6 +231,9 @@ test('generated bindings contain the public projections and omit every private e
'wallet_attribution_snapshot_v_1',
'wood_expedition_idempotency_v_1',
'wood_expedition_v_1',
+ 'worker_assignment_schedule_v_1',
+ 'worker_assignment_v_1',
+ 'worker_command_idempotency_v_1',
];
for (const stem of privateTableStems) {
assert.equal(existsSync(new URL(`${stem}_table.ts`, bindingsRoot)), false);
diff --git a/spacetimedb/tests/resourceReducers.test.ts b/spacetimedb/tests/resourceReducers.test.ts
index 67308336..b5ae9377 100644
--- a/spacetimedb/tests/resourceReducers.test.ts
+++ b/spacetimedb/tests/resourceReducers.test.ts
@@ -78,6 +78,12 @@ test('resource and Gold prefixes remain intact through later additive suffixes',
'stoneExpeditionIdempotencyV1',
'stoneExpeditionScheduleV1',
'realmWaterRevisionV1',
+ 'realmWorkerSystemV1',
+ 'castleWorkerV1',
+ 'workerAssignmentV1',
+ 'workerNodeOccupationV1',
+ 'workerCommandIdempotencyV1',
+ 'workerAssignmentScheduleV1',
]);
const account = tableDefinition(schema, 'resourceAccountV1');
diff --git a/spacetimedb/tests/stoneExpeditionReducers.test.ts b/spacetimedb/tests/stoneExpeditionReducers.test.ts
index 9810701a..ed68b295 100644
--- a/spacetimedb/tests/stoneExpeditionReducers.test.ts
+++ b/spacetimedb/tests/stoneExpeditionReducers.test.ts
@@ -40,7 +40,7 @@ test('v10 Stone tables remain before the additive Water revision suffix', () =>
const registrations = schemaRegistrations(schema);
const v4Registrations = schemaRegistrations(v4.replace('const db = schema({', 'const warpkeep = schema({'));
assert.deepEqual(registrations.slice(0, v4Registrations.length), v4Registrations);
- assert.deepEqual(registrations.slice(-10), [
+ assert.deepEqual(registrations.slice(-16, -6), [
'realmWaterLayoutV1',
'realmWaterBodyV1',
'realmWaterCellV1',
diff --git a/spacetimedb/tests/waterRevisionAuthority.test.ts b/spacetimedb/tests/waterRevisionAuthority.test.ts
index 0a800f33..83a52c0c 100644
--- a/spacetimedb/tests/waterRevisionAuthority.test.ts
+++ b/spacetimedb/tests/waterRevisionAuthority.test.ts
@@ -62,5 +62,5 @@ test('the append-only public revision table stores policy without topology', ()
assert.match(revision, /navigationFogBoundaryDepthCells: t\.u32\(\)/);
assert.match(revision, /activatedAt: t\.option\(t\.timestamp\(\)\)/);
assert.doesNotMatch(revision, /\n\s*q:|\n\s*r:|cellKey:|bodyId:/);
- assert.match(schema, /stoneExpeditionScheduleV1,\n\s*realmWaterRevisionV1,\n\}\);/);
+ assert.match(schema, /stoneExpeditionScheduleV1,\n\s*realmWaterRevisionV1,\n\s*realmWorkerSystemV1,\n\s*castleWorkerV1,\n\s*workerAssignmentV1,\n\s*workerNodeOccupationV1,\n\s*workerCommandIdempotencyV1,\n\s*workerAssignmentScheduleV1,\n\}\);/);
});
diff --git a/spacetimedb/tests/waterRevisionMigrationTooling.test.ts b/spacetimedb/tests/waterRevisionMigrationTooling.test.ts
index 04d2a5d2..6034984d 100644
--- a/spacetimedb/tests/waterRevisionMigrationTooling.test.ts
+++ b/spacetimedb/tests/waterRevisionMigrationTooling.test.ts
@@ -57,11 +57,11 @@ test('the auth-neutral v11 fixture extends the exact v10 table prefix at ref 46'
assert.match(v11, /name: 'fixture_seed_water_revision_sentinel_v11'/);
});
-test('the migration verifier proves v10 to v11 with populated state and no downgrade', () => {
+test('the migration verifier retains the populated v10 to v11 proof inside protocol v12', () => {
const verifier = source('../../scripts/verify-spacetime-additive-migration.mjs');
const receipt = source('../../scripts/spacetime-additive-migration-proof.mjs');
- assert.match(receipt, /ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 11/);
+ assert.match(receipt, /ADDITIVE_MIGRATION_PROOF_PROTOCOL_VERSION = 12/);
assert.match(verifier, /spacetimedb\/migration-fixtures\/additive-v11-schema/);
assert.match(verifier, /const additiveV11Tables = Object\.freeze\(\[\s*'realm_water_revision_v1'/);
assert.match(verifier, /realm_water_revision_v1: 46/);
@@ -72,9 +72,9 @@ test('the migration verifier proves v10 to v11 with populated state and no downg
assert.match(verifier, /populatedWaterStoneV10Rows/);
assert.match(verifier, /populatedWaterStoneV11Rows/);
assert.match(verifier, /additiveV10SchemaFixture,[\s\S]{0,120}populatedWaterStoneMigrationDatabase,[\s\S]{0,40}false/);
- assert.match(verifier, /The immediate v11 -> v10 rollback must be refused/);
+ assert.match(verifier, /v12 boundary must refuse every predecessor/);
assert.match(verifier, /deployedV11Tables/);
- assert.match(verifier, /populated v10 Water\/Stone fixtures remained preserved through v11/);
+ assert.match(verifier, /populated Water\/Stone\/Water-revision fixtures remained preserved through v12/);
assert.match(verifier, /stage = 'revision-base-precondition'/);
assert.match(verifier, /stage = 'revision-inert-base-rejection'/);
assert.match(verifier, /stage = 'revision-admin-denial'/);
@@ -90,6 +90,6 @@ test('the migration verifier proves v10 to v11 with populated state and no downg
assert.ok(inspectionFixtures.length >= 4);
assert.deepEqual(
new Set(inspectionFixtures),
- new Set(['additiveV11SchemaFixture']),
+ new Set(['additiveV12SchemaFixture']),
);
});
diff --git a/spacetimedb/tests/woodExpeditionReducers.test.ts b/spacetimedb/tests/woodExpeditionReducers.test.ts
index 71109bf0..42ed2b47 100644
--- a/spacetimedb/tests/woodExpeditionReducers.test.ts
+++ b/spacetimedb/tests/woodExpeditionReducers.test.ts
@@ -40,7 +40,7 @@ test('v8 Wood tables remain intact through later additive suffixes', () => {
const registrations = schemaRegistrations(schema);
const v4Registrations = schemaRegistrations(v4.replace('const db = schema({', 'const warpkeep = schema({'));
assert.deepEqual(registrations.slice(0, v4Registrations.length), v4Registrations);
- assert.deepEqual(registrations.slice(-27), [
+ assert.deepEqual(registrations.slice(-33, -6), [
'goldSiteV1',
'goldNodeOccupationV1',
'goldExpeditionV1',
diff --git a/src/components/WarpkeepExperience.tsx b/src/components/WarpkeepExperience.tsx
index ffa45db6..7d61fb4a 100644
--- a/src/components/WarpkeepExperience.tsx
+++ b/src/components/WarpkeepExperience.tsx
@@ -1128,6 +1128,18 @@ export function WarpkeepExperience() {
onClaimStoneExpedition={backend.state.stoneExpedition === undefined
? undefined
: backend.claimStoneExpedition}
+ workerProjection={backend.state.workerProjection}
+ workerRoster={backend.state.workerRoster}
+ workerResourceState={backend.state.workerResourceState}
+ onDispatchWorker={backend.state.workerProjection?.mode === 'active'
+ ? backend.dispatchWorker
+ : undefined}
+ onRecallWorker={backend.state.workerProjection?.mode === 'active'
+ ? backend.recallWorker
+ : undefined}
+ onRecallAllWorkers={backend.state.workerProjection?.mode === 'active'
+ ? backend.recallAllWorkers
+ : undefined}
graphicsPreference={graphicsPreference}
resolvedGraphicsQuality={resolvedGraphicsQuality}
audioMuted={audioMuted}
diff --git a/src/components/menu/SettingsPanel.tsx b/src/components/menu/SettingsPanel.tsx
index 707e8e0d..83af3f48 100644
--- a/src/components/menu/SettingsPanel.tsx
+++ b/src/components/menu/SettingsPanel.tsx
@@ -32,6 +32,7 @@ const GRAPHICS_COPY: Readonly;
export function SettingsPanel({
+ id,
audioMuted = false,
closeLabel = 'BACK TO THE MENU',
preference,
@@ -66,6 +68,7 @@ export function SettingsPanel({
aria-labelledby="warpkeep-settings-title"
aria-modal="true"
className="warpkeep-settings__panel"
+ id={id}
ref={dialogRef}
role="dialog"
>
diff --git a/src/components/menu/latestPatchNotes.ts b/src/components/menu/latestPatchNotes.ts
index 4872a27f..3c12f4c8 100644
--- a/src/components/menu/latestPatchNotes.ts
+++ b/src/components/menu/latestPatchNotes.ts
@@ -6,24 +6,24 @@ export type LatestPatchNotes = Readonly<{
alphaNotice: string;
}>;
-const ALPHA_0_3_13_PATCH_NOTES: LatestPatchNotes = Object.freeze({
- releasedOn: '19 JUL 2026',
- title: 'THE LIVING LOWLANDS',
+const ALPHA_0_3_14_PATCH_NOTES: LatestPatchNotes = Object.freeze({
+ releasedOn: '22 JUL 2026',
+ title: 'A STEADIER FRONTIER',
summary:
- 'Genesis 001 breathes more naturally as rivers, forests, grasslands, and the ocean settle into a clearer frontier.',
+ 'Genesis 001 is steadier and easier to read, with resilient Realm rendering, selectable moving water, and a greener Lowlands.',
highlights: Object.freeze([
- 'The old scattered lakes have receded into lowland, leaving twelve persistent one-cell rivers and the ocean around Genesis 001.',
- 'You can pan through the coast and open water until the full fog boundary, including from the strategic overview.',
- 'Grass now follows broad biome patterns and forests gather into natural groves while rivers, roads, keeps, and resource sites stay clear.',
- 'Moving supply wagons can be selected, and the Realm menu now keeps every active expedition within reach.'
+ 'The Realm now recovers from temporary graphics interruptions while preserving your selection and camera intent.',
+ 'Castle rendering can continue at compact detail when optional richer models cannot load.',
+ 'Water surfaces now move gently when motion is enabled, and visible river and ocean cells can be selected for read-only public records.',
+ 'The Lowlands now use a clearer green palette and denser grass coverage without changing authoritative terrain, ownership, or resource rules.'
]),
alphaNotice:
- 'Alpha 0.3.13 is an unfinished, evolving world. Community feedback helps shape what is built next.'
+ 'Alpha 0.3.14 is an unfinished, evolving world. Four-worker gathering is staged for later and is not live yet; community feedback helps shape what is built next.'
});
export const WARPKEEP_PATCH_NOTES_BY_VERSION: Readonly> =
Object.freeze({
- '0.3.13': ALPHA_0_3_13_PATCH_NOTES
+ '0.3.14': ALPHA_0_3_14_PATCH_NOTES
});
export function getLatestPatchNotes(productVersion: string) {
diff --git a/src/components/realm/RealmAccessibilityControls.tsx b/src/components/realm/RealmAccessibilityControls.tsx
index 5520c0e5..d748c7d8 100644
--- a/src/components/realm/RealmAccessibilityControls.tsx
+++ b/src/components/realm/RealmAccessibilityControls.tsx
@@ -25,6 +25,25 @@ export type RealmNavigatorCastle = Readonly<{
r: number;
}>;
+export type RealmNavigatorWaterBody = Readonly<{
+ bodyId: string;
+ label: string;
+ sourceCellKey: string;
+ mouthCellKey: string;
+ sourceCoord: HexCoord;
+ mouthCoord: HexCoord;
+}>;
+
+export type RealmNavigatorWorker = Readonly<{
+ workerId: string;
+ ordinal: number;
+ originCastleId: number;
+ originCastleName: string;
+ status: 'idle' | 'outbound' | 'gathering' | 'returning';
+ coord: HexCoord;
+ ownedByViewer: boolean;
+}>;
+
export type RealmNavigatorCloseReason = 'escape' | 'close-button' | 'camera-preset';
export type RealmNavigatorCoordinateJump = Readonly<{
@@ -43,11 +62,16 @@ export type RealmAccessibilityControlsProps = Readonly<{
id: string;
open: boolean;
castles: readonly RealmNavigatorCastle[];
+ workers?: readonly RealmNavigatorWorker[];
+ waterBodies?: readonly RealmNavigatorWaterBody[];
ownCastleId?: number;
selectedCastleId?: number;
+ selectedWorkerId?: string;
onRequestOpen: () => void;
onRequestClose: (reason: RealmNavigatorCloseReason) => void;
onActivateCastle: (castle: RealmNavigatorCastle) => void;
+ onActivateWorker?: (worker: RealmNavigatorWorker) => void;
+ onActivateWaterCell?: (cellKey: string) => void;
coordinateJump?: RealmNavigatorCoordinateJump;
cameraPresets?: readonly RealmNavigatorCameraPreset[];
/** Player chrome may provide its own PFP launcher while reusing this dialog. */
@@ -75,11 +99,16 @@ export function RealmAccessibilityControls({
id,
open,
castles,
+ workers = [],
+ waterBodies = [],
ownCastleId,
selectedCastleId,
+ selectedWorkerId,
onRequestOpen,
onRequestClose,
onActivateCastle,
+ onActivateWorker,
+ onActivateWaterCell,
coordinateJump,
cameraPresets = [],
triggerVisible = true,
@@ -126,6 +155,26 @@ export function RealmAccessibilityControls({
? castles.filter((castle) => searchCopy(castle).includes(query))
: castles;
}, [castles, search]);
+ const visibleWaterBodies = useMemo(() => {
+ const query = search.trim().toLocaleLowerCase();
+ return query
+ ? waterBodies.filter((body) => (
+ `${body.label} ${body.sourceCoord.q},${body.sourceCoord.r} ${body.mouthCoord.q},${body.mouthCoord.r}`
+ .toLocaleLowerCase()
+ .includes(query)
+ ))
+ : waterBodies;
+ }, [search, waterBodies]);
+ const visibleWorkers = useMemo(() => {
+ const query = search.trim().toLocaleLowerCase();
+ return query
+ ? workers.filter((worker) => (
+ `worker ${worker.ordinal} ${worker.originCastleName} ${worker.status} ${worker.coord.q},${worker.coord.r}`
+ .toLocaleLowerCase()
+ .includes(query)
+ ))
+ : workers;
+ }, [search, workers]);
const handleDialogKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
@@ -164,7 +213,7 @@ export function RealmAccessibilityControls({
)}
+ {visibleWorkers.length > 0 && onActivateWorker ? (
+
+ WORKERS
+
+ {visibleWorkers.map((worker) => {
+ const selected = worker.workerId === selectedWorkerId;
+ return (
+ -
+
+
+ );
+ })}
+
+
+ ) : null}
+
+ {visibleWaterBodies.length > 0 && onActivateWaterCell ? (
+
+ PUBLIC WATER
+
+ {visibleWaterBodies.map((body) => (
+ -
+
+
{body.label}
+
source {body.sourceCoord.q},{body.sourceCoord.r} · mouth {body.mouthCoord.q},{body.mouthCoord.r}
+
+
+
+
+
+
+ ))}
+
+
+ ) : null}
+
{coordinateJump ? (