2026 10 22 family sync design - xopherdeep/do-it-for-the-xp GitHub Wiki

Multiplayer, part 3: Family Sync (the hero follows the player) — design

Date: 2026-10-22. Status: approach and design approved in chat by the owner (2026-10-21/22); this spec awaits review. Board: app task #1337 (epic #1333, sprint mp-next); server epic #6080 on the xophz-compass-xp board.

Part 3 of 3 of multiplayer. Part 1, the shared base (2026-10-13-multiplayer-shared-base-design.md), is live: the family on the site, kid phones with hero keys, the event stream, hero cards, server rewards, and two-phone play (src/lib/services/multiplayer/FAMILY_EVENTS.md: the sheet, phone claims, verdicts). Part 2 (A, the party and body double) has its own spec to come and is not needed by this one.

Why

The owner linked a kid's phone and the kid got a card, a sheet of chores and a Done button, with the line "Your quests are on the family device". That is Part 1's stand-in. The owner wants the kids to play the whole game on their own phones and on the family tablet, with the WordPress site carrying the sync, and is fine with that behind XP Pro. The requirement is that it works flawlessly: no lost rewards and never two versions of a hero.

Decisions (owner, 2026-10-21 and 2026-10-22)

  • Approach: the hero follows the player. Every family device holds a copy of the family's data and of every hero. A hero plays on one device at a time: the device that holds the hero's turn is the only writer of that hero's records. Opening the hero elsewhere takes the turn (after a warning when the other device is still playing). Nothing is merged, so nothing conflicts. Chore claims and Game Master verdicts travel as events, never as part of a save, so they are never lost. This was chosen over merge rules for every record type.
  • A Game Master's approval wins (decided on the board for #1337).
  • XP Pro, per family (Part 1's table): Free keeps the whole game on one device plus today's kid-phone board; Pro turns every family device into the full game. Kid phones inherit the family's licence (they adopt the family id when linked).
  • A takeover is always explicit, never automatic after a timeout: the second device is told the hero is still playing elsewhere and asked.
  • Chat is writable from kid phones.
  • A lapsed Pro sends a kid phone back to today's board rather than freezing the game.
  • No new backend: everything on the site lives in xophz-compass-xp under xp-family/v1, beside Part 1.
  • The free kid-phone board gets its bug fixed in the same effort: the family device re-reads its phone list after a phone links, so the sheet arrives (today it waits for an app restart or the Phones modal).

1. Scope

In:

  • The site keeps the family's save as records (section 2) with a per-family change number, a hero's play turn (section 3), the Pro gate on the new routes and the relaxed "home device" rule for entitled families.
  • The app syncs its stores: change capture, an outbox, download and apply, the live mirrors (section 4).
  • The hero's turn on the device: taking, resting, the busy question, the takeover case (section 5).
  • The approval loop under single-writer: who applies a verdict and pays (section 6).
  • Kid phones with Pro become the full portal; without Pro they keep today's board (section 7).
  • Every Game Master device is a family device: a second Game Master's phone, remote approvals (section 8).
  • The free sheet fix (section 9).

Out (later specs or tasks): Fridge Mode (#1206), push notifications (#1166), Game Master roles (#1208), two heroes on one kid phone, kids posting to the Town Hall from their phones, encryption of records at rest, Part 2 (parties).

Needs first: nothing new. Part 1 is live on doit.forthexp.com (the owner linked a phone on 2026-10-21). Rehearsals run on the local Docker WordPress ("My Compass", http://localhost) as before.

2. The site: the family's save as records

The table

{prefix}xp_family_records, created by the schema class on activation and on the stored schema version check, beside Part 1's tables:

Column Type Meaning
seq bigint The family's change number for this write (section 2, "numbering"); per family, so the primary key is (family_id, seq)
family_id char(36)
store varchar(40) The store name as the app's backup uses it (__profiles, __questLog, ...)
record_key varchar(191) The record's key in that store
hero_id varchar(64), nullable The hero a hero-scope record belongs to; null for family records
device_id varchar(16) The device that wrote it
saved_at datetime Server time of the write
deleted tinyint 1 for a tombstone (the record was removed); body is then empty
body mediumtext The record as JSON, at most 512 KB

Primary key (family_id, seq); unique key (family_id, store, record_key) with record_key in a binary collation (keys that differ only by case never collide); index (family_id, hero_id, seq); index (deleted, saved_at) for the tombstone cleanup.

Numbering. xp_families gains records_seq bigint default 0. A batch write runs under the family's named lock (GET_LOCK("xp_family_{id}"), as events do): read the counter, give each record the next number, upsert the rows (an existing key is overwritten and gets the new number), write the counter back. A reader asking for "after N" can therefore never miss a record that was still being written. Numbers only grow; gaps are normal.

Tombstones. A removal is a row with deleted = 1 and an empty body, numbered like any write, so devices learn about it. The cleanup job (Part 1's WP-Cron) deletes tombstones older than 90 days. Live records are never aged out: they are the save.

The stores and their scopes

The sync covers the stores a backup file carries (BACKUP_STORES in BackupService.ts), except __localSettings (device matters: sound, this device's look of the UI). The server knows the list and refuses any other store name.

Scope Stores Hero id of a record
family __achievementCategories, __abilities, __accessories, __shops, __temples, __dosDonts, __family_settings, __packs none
family, any writer __chat, __achievements, __beasts none for a parent's rows; a hero key's rows carry its own hero and it may only create or rewrite those (chat messages, the quests a temple run or a quick task makes, beasts it painted); parents may write any row
hero __profiles the record key (the profile id)
hero __questLog, __purchaseLog, __castLog the record's userId
hero __latePenalties the record's heroId
hero __templeProgress the part of the key before the first : ({userId}:{templeId})
hero __notifications the record key (the hero id)

The app derives the hero id per store (syncScopes.ts, section 4) and sends it; the server checks it against the store's rule where it can (the key-based ones) and against the caller (a hero key may only name its own hero).

Routes

All under xp-family/v1, registered only on the family sites (the Town Hall's list), CORS as Part 1's routes.

Method and route Caller Does
PUT /records {device_id, records: [...]} parent, hero key Writes up to 50 records (at most 1 MB in all); answers per record
GET /records?since={seq}&limit={n} parent, hero key Records after seq that the caller may read, oldest first; limit up to 200, default 100, and a page stops at 1 MB of bodies (at least one row), so a few large records can never wedge a device
POST /heroes/{hero_id}/play {device_id, label, force} parent, the hero's own key Takes the hero's turn (section 3); a hero key's label is its device row's, never the body's
POST /heroes/{hero_id}/rest {device_id} parent, the hero's own key Gives the turn back if this device holds it; otherwise nothing

A record on the wire: { store, key, hero_id, deleted, body }, where body is the record as a JSON string (the app JSON.stringifys it; the site checks that it decodes to an object, measures the 512 KB cap on the string, reads userId or heroId from it where the store's rule says so, and stores the string verbatim; a tombstone carries no body). The same string comes back from GET /records and the app parses it. This keeps PHP's array-or-object ambiguity out of the save. The answer to a put:

{ "accepted": [ { "store": "__questLog", "key": "…", "seq": 1041 } ],
  "refused":  [ { "store": "__profiles", "key": "…", "code": "xp_family_turn", "message": "…" } ] }

Refusal codes per record: xp_family_turn (a hero record from a device that does not hold the hero's turn), xp_family_scope (a hero key writing a family store other than __chat, or another hero's record, or a hero id that does not match the key), xp_family_store_unknown, xp_family_record_invalid (not an object, too big, a bad key). A refused record does not stop the rest of the batch. There is no "stale" answer: hero records have one writer, and family records are last write wins per record (two Game Masters editing the same quest in the same minute is rare and the later edit stands).

Who may read. A parent (the family's owner, as in Part 1) reads every record of the family. A hero key reads the family-scope records and the hero-scope records of its own hero. GET /records filters by that; since works the same for both because the numbering is per family.

Who may write. Family-scope records: a parent. __chat: a parent, or a hero key for its own messages (a hero key's chat rows carry its hero id; it may add a message or rewrite its own, never another member's or a parent's). Hero-scope records: the device that holds that hero's turn, whether a parent device or the hero's own key; the server compares device_id with the hero's turn_device_id and refuses otherwise. The single-writer rule is thereby enforced by the site, not by trust in the app.

The Pro gate

The four routes above require the family to be entitled: Gatekeeper_Xp_Pro_Licenses::get( $family_id ) and ::is_entitled( $record, time() ) (the xophz-compass-gatekeeper plugin). A family that is not entitled gets 403 xp_family_pro_required for the whole request. The check is cached per request. The free tier's routes (Part 1) are unchanged.

The home rule relaxes for entitled families. Part 1 refuses a parent's events from any device but the family's home_device_id (409 xp_family_moved). For an entitled family every parent device may send events and read them, because every parent device is a family device (section 8). POST /family still answers as before; the app no longer asks the home question for an entitled family.

Privacy

The records are the family's own data, written to the family's own account on the owner's own site: the same content as a backup file. PINs travel sealed, the way backups seal them (sealProfileSecrets); claim photos never travel (they are not in a synced store); heroes still have no accounts and no email. Quest names, which Part 1 kept off the public events, are family-scope records and reach only the family's own devices.

3. The site: a hero's turn

xp_family_heroes gains four columns: turn_device_id varchar(16) null, turn_label varchar(60), turn_taken_at datetime null, turn_seen_at datetime null.

POST /heroes/{id}/play with { device_id, label, force } answers:

Situation Answer
No device holds the turn, or this device does { "answer": "granted" }; turn_* set to this device and now
Another device holds it and force is false { "answer": "busy", "device": { "label": "Mia's iPhone", "last_seen_at": "…" } }, HTTP 200
Another device holds it and force is true granted; the turn moves to this device

A hero key may only ask for its own hero (otherwise xp_family_scope, 403). label is what the busy line names: a kid phone sends the label it was linked with ("Mia's iPhone"); a parent device sends "the family {platform}" ("the family tablet", "the family phone", "the family computer", from the platform the app runs on). rest clears the turn when device_id holds it and does nothing otherwise, so a late rest from a device that lost the turn cannot clear the new holder.

turn_seen_at is refreshed by the holder's record writes and by its GET /records calls (the holder downloads every minute while open), so "last seen" in the busy answer is honest to the minute. There is no lease: the site never frees a turn by itself (decision above). Removing a hero or revoking a device clears a turn it held.

4. The app: syncing the stores

New pure modules in src/lib/services/sync/, each with a jest spec, and one setup-style store. The family store (src/lib/store/stores/family.ts, already 900 lines) keeps binding, events, cards, sheets and the phone; the sync is its own store, useSyncStore (src/lib/store/stores/sync.ts), bound to the family store for auth and the device.

Module Holds
syncScopes.ts The store list with scope and hero-id rule (the table in section 2); heroIdOf(store, key, value); which stores a kid phone may write
recordSignals.ts A tiny signal: recordWritten(store, key, value | null); emitted by the storage layer, listened to by the sync store. No imports from stores, so DbStorageApi can import it
recordOutbox.ts The queue of changed keys per store (a key appears once; a later change replaces the earlier), batching to 50 records and 1 MB, what each answer does (accepted drops the key; xp_family_turn drops it and marks the hero taken over; other refusals drop it and log), the reset on start
recordApply.ts Writing downloaded records into the local stores by key (a tombstone removes), skipping records this device wrote (its own device_id), and the list of mirrors to refresh afterwards per store
heroTurn.ts The turn state machine per hero on this device (section 5): resting, asking, held, busy, takenOver, with the transitions and what each site answer does
verdictApply.ts Applying a claim.settled to a quest log entry once, with the pay (section 6)
familySyncClient.ts The four routes through townHallFetch with the parent's auth or the hero key; snake_case to camelCase, as familyServer.ts does for Part 1

Change capture. Every write in the game goes through DbStorageApi.set/remove/clear (src/lib/databases/DbStorageApi.ts); profiles too, since ProfileDb extends it and the user store's persistUser flushes through ProfileDb (StorageService.saveProfiles is only the reinstall mirror in Capacitor Preferences). DbStorageApi calls recordWritten with the store's name (storage.config.name), the key and the value (null for a removal; clear emits one removal per key it held). The sync store listens and, for a synced store, puts the key in the outbox. Writes made while applying downloaded records are not captured (the apply runs under a flag), so a download never echoes. A backup restore writes the raw Ionic stores and bypasses the hook: on a synced device a restore sets firstPushDone back to false, so the whole save is re-sent, and queues a tombstone for every key the restore removed (it holds the before-snapshot of each store for its own rollback).

Uploading. 5 seconds after the last change, on Save & Quit, when the app goes to the background, and on the browser's online event; one request at a time; a call while one runs joins it. Each record is read from its store at send time (so a key changed twice is sent once, with its latest value), tagged with its hero id, and sent with the device id. A hero record is sent only while this device holds that hero's turn (heroTurn says held); records of a hero whose turn this device lost are dropped (section 5 explains why that is safe).

Downloading. Every minute while open (the family store's existing download tick; the two timers are merged into one tick for a Pro family), when the app comes to the front, after each upload, and at once when a hero's turn is granted. GET /records?since=lastSeq paged, up to 5 pages per tick, as the phone sync does. Records are applied in seq order; lastSeq is saved after each page. Applying a record writes the store (not captured), then refreshes the mirror the backup restore refreshes for that store (BACKUP_STORES[...].afterRestore: profiles into the user store; the other stores are read on demand by their screens and need no mirror). The current hero's profile, if it arrived, is reloaded into useUserStore the way a restore reloads users.

The first sync. When Family Sync turns on for a family (Pro and linked) the family device sends every record of every synced store (sync.firstPushDone false): the whole save, in batches, with a clerk line while it runs. A kid phone's first open with Pro downloads from 0 behind a clerk line, then signs the hero in (section 7). A second Game Master device downloads from 0 after its Town Hall link and keeps its own local records too (both sides then upload what the other lacks: records are keyed, so nothing doubles).

State on the device, an Ionic Storage store named __xp_sync (SyncDb, on DbStorageApi), not in BACKUP_STORES (a restored queue would resend, and the turns belong to this device):

interface SyncState {
  outbox: Array<{ store: string; key: string; heroId: string | null; queuedAt: string }>;
  lastSeq: number;
  firstPushDone: boolean;
  firstPullDone: boolean;
  /** heroId -> the turn this device believes it has, for a restart; the site is asked again on the next sign-in */
  turns: Record<string, "held" | "resting">;
  /** Verdicts this Game Master device sent for entries it does not own, for the Approvals screen until the mirror arrives */
  verdictsSent: Record<string, { approved: boolean; at: string }>;
  /** `claim.settled` events for entries that have not arrived yet, applied once they do */
  pendingVerdicts: Record<string, ClaimSettled & { heroId: string; seq: number }>;
}

When the sync runs. multiplayerEnabled() is on, the family is bound (a parent device) or linked (a kid phone), and useProStore().isPro is true. With any of them false the sync store does nothing and the game behaves as today.

5. The hero's turn on the device

heroTurn.ts, one machine per hero id, driven by the sync store:

Event From To Does
A hero signs in (profile picked on a family device; a kid phone opens its hero) resting asking POST play without force
granted asking held Download at once, then play; the outbox may send this hero's records
busy asking busy The clerk asks (copy below); play is blocked behind the prompt
The player says "Play here anyway" busy asking → held POST play with force
The player says no busy resting Back to the profile picker (family device) or the mirror of the hero (kid phone: the board, read-only)
Sign-out, profile switch, Save & Quit, the app goes to the background held resting Upload what waits, then POST rest
The app comes back to the front with the same hero resting asking → held or busy Silent when granted
The site refuses a hero record with xp_family_turn held takenOver The outbox drops this hero's keys; the device downloads from lastSeq and replaces its copy of the hero; the clerk says so (copy below); then resting
Offline when asking asking held (provisional) Local-first: play goes on; the ask is retried on online; a later busy is shown then, a later xp_family_turn is the takeover case

The one loss case. A hero forced onto device B while device A still plays offline. A's later writes are refused; A replaces its copy from the site and tells the player. A's unsaved play since B took over is gone (items bought, rooms cleared, HP). A's claims are not: a claim is also a quest.claimed event, and its quest log entry, if refused as a record, is still in A's events; the Game Master sees the claim in Approvals from B's mirror only if B also made it, so the spec keeps the claim alive this way: a quest log entry refused with xp_family_turn whose status is pending is re-sent as a phone-style claim event (a fresh quest.claimed with requiresApproval and without the synced mark of section 6), and the device holding the turn takes it exactly as Part 1 takes a phone claim (phoneClaimIntake.takePhoneClaim), which writes the entry on the owner's side. Paid honor-system claims lost to a takeover are not re-paid (the pay happened on A, in play that is gone; the quest is open again on B, and the hero can claim it there). This case needs a hero on two devices in the same session with one of them offline, and the busy question makes it a choice.

A mirror yields. A device that does not hold a hero's turn is a mirror of that hero. A local change it makes to that hero's records (a Game Master editing a profile while the kid plays elsewhere) is sent by borrowing the turn when the hero is free (take, send, rest) and otherwise waits; if the holder's record arrives first, the waiting change yields to it and is dropped. A family device's first push sends every hero's records the same way, borrowing each free turn in turn, so a kid phone that links afterwards pulls its hero.

Game Master actions on a hero played elsewhere. A purchase fulfilled or refunded, a Dashboard bonus or penalty, or a profile edit in the Game Master console changes the hero's records, so it runs the same way a verdict does: when the hero is free the device takes the turn, applies it, uploads and rests; when another device holds the hero the clerk says "{nick} is playing on {device}. Try again when they rest." and nothing is written.

The holder's copy is the truth. Nothing is merged record by record. When the player's own offline play is confirmed, or the player answers Play here, that device's copy of the hero's records is the truth: the catch-up read never lands the site's copies of that hero over them, they are uploaded whole, and verdicts or rewards kept meanwhile re-apply on this copy (a verdict applies once, by the entry's status). A plain grant or a borrowed turn never pushes a whole copy, so a stray write on a mirror cannot overwrite the hero. After a takeover or a Not now, the device's next read fetches the family's records from the start once, to repair the keys it dropped; a paid honor-system claim the site never received is removed locally by that repair (the spec's loss case).

Late penalties, deadlines, encounters. A device charges late penalties, runs the chore deadline clock, wall-clock encounters and notifications only for heroes whose turn it holds, so a penalty is charged once and an ambush fires on the phone in the hero's hand. Late penalties, deadline alerts and the recap run only while the hero is held (never charged twice; at worst delayed while the turn is re-asked). Phone reminders and the encounter clock for the hero signed in on a device follow a softer rule, "unless another device is known to hold it": a hero that is resting (the app in the background) or re-asking for its turn keeps its reminders and its ambush-on-return on this device; only a busy or took-over answer cancels them. Other heroes on a family device are watched only while held.

Copy, adventure voice in src/lib/services/copy/catalog/sync.ts (the other voices fall back), spoken by the clerk through the reveal prompt, never a toast or native alert (the in-world UI rules):

Key Line
sync.busy {nick} is still playing on {device} (last seen {ago}). Play here anyway? with Play here / Not now
sync.tookOver {device} took over {nick}'s game while this one was away. This device has caught up.
sync.firstPull Fetching your family's world… (with the count of records so far)
sync.firstPush Sending the family's save to the site…
sync.proRequired Playing the whole game on this phone needs XP Pro. Ask your Game Master.
sync.heroBusyForGm {nick} is playing on {device}. Try again when they rest. (a Game Master change to a hero played elsewhere)
sync.heroUnreachableForGm the line when the turn cannot be had at all (offline, or the site refused), same place
sync.verdictSent Approved · paying on {nick}'s device (Approvals, until the mirror arrives; Not this time · sent for a rejection)
sync.offline No signal. Play on; this device tells the others when it's back.

6. The approval loop under one writer

A claim made on any device writes a pending quest log entry (a hero record, synced) and queues the existing quest.claimed event, so the family feed and Part 1's rules keep working. Every Game Master device sees the pending entries of every hero in Approvals, because hero records are applied into the same __questLog store the screen reads.

Telling synced claims apart. Part 1's intake (isPhoneClaim) takes every quest.claimed from another device as a phone claim and logs it; with several synced devices that would log each claim twice (once as the record, once as the intake). quest.claimed therefore gains one kept field, synced (boolean), in both lists (EVENT_KINDS in familyEvents.ts and Xophz_Compass_Xp_Family_Event_Rules::KINDS): a device that syncs records sets it, and the intake skips a synced claim, since its entry arrives as a record. A free kid phone's claim, and the takeover re-send of section 5, carry no mark and are taken as today. In a Pro family the intake runs on the device holding the hero's turn, or on a Game Master device that takes the turn for the moment, the same as a verdict.

A verdict (approveClaim / rejectClaim in approvalFlow.ts), on a Pro family device:

  1. If this device holds the hero's turn (the kid is playing on this tablet): apply and pay as today, and queue quest.approved/quest.rejected as today.
  2. Otherwise queue claim.settled (Part 1's kind, claimId = the entry id, with the pay or the note) and record the verdict in verdictsSent, so Approvals shows sync.verdictSent at once. Then:
    • nobody holds the turn: this device takes it (play without force answers granted), downloads, applies the verdict through verdictApply, pays, uploads, rests. The hero is paid within the minute, with no kid action.
    • another device holds it: that device applies the verdict on its next download (within the minute), pays, and its upload updates every mirror.
  3. verdictApply is idempotent: it reads the entry, and does nothing when the entry is already settled; it marks rewardsApplied with the pay, as approvalFlow does. Repeats and replays cannot double-pay. A claim.settled for an entry the device does not have yet waits (kept by event id in SyncDb) until the entry arrives.

reward.granted (server rewards, Part 1 section 8) follows the same rule: paid by the device holding the hero's turn, or by a Game Master device that takes it for the moment; the receipt rule from Part 1 keeps it to once.

Phone claims from Part 1 (a free family's kid phone, or a Pro kid phone whose Pro lapsed) are taken as today by the family device, which for a Pro family is any device holding or taking the hero's turn.

7. Kid phones

With Pro: the first open after linking downloads the family and the hero (sync.firstPull), then signs the hero in (loginUser(heroId)), asks for the turn, and opens the portal. The hero-phone guard (src/router/_Guards/heroPhone.guard.ts) lets every route through when the sync store says the phone is a full portal (isPro && firstPullDone); canAccessGameMaster still answers denied in kid mode, so the Game Master console stays shut whatever the phone holds. The profile picker shows only this hero with the line "This is {nick}'s phone"; backup and restore, Family settings, the Town Hall link and the Phones panel are hidden in kid mode; Settings keeps Unlink this phone (Part 1's forget, now also resting the hero and clearing __xp_sync). Save & Quit uploads, rests and returns to the hero-phone screen with the card. Chat posts from the phone are __chat records (any writer).

The first pull is done only when the hero has arrived. Until the hero's profile lands, the phone keeps pulling each round behind the progress line; after five minutes it shows the card with Play disabled and the line "Your quests are on the family device" as its footer, and keeps trying.

Without Pro (never had it, or lapsed past isPro's grace): today's board, unchanged, with sync.proRequired as its footer line when the family device has Pro turned off; the records already on the phone stay until it is relinked. The phone claims the family's licence as it does today (useProStore, adoptFamily at link time).

8. Every Game Master device is a family device

A second phone linked with the same site account (the Town Hall link) is a parent device: it binds (POST /family, any answer but taken/other-family is fine for an entitled family; the home question is not asked), downloads the whole save, and has the Game Master console, Approvals (remote approvals, #1207) and the Phones panel. Both parents' devices may edit family records (last write wins per record) and send events. The hero turn rules are the same for a parent device playing a hero as for a kid phone. Game Master roles (#1208) are a later spec; today every parent device has every power, as the shared device does.

For a free family nothing changes: one home device, the home question, Part 1's rules.

9. The free sheet fix

Two small changes on master before the rest (the complaint that started this):

  • The family store's download tick also refreshes the phone list (loadPhones()) before the sheet pass, so a phone linked since the last bind gets its sheet within two minutes without an app restart.
  • The Phones modal polls the phone list every 10 seconds while a link code is live and runs the sheet pass when a new phone appears, so the kid sees the sheet about as soon as the code is claimed.

10. When things go wrong

Situation What happens
Offline, timeout, 5xx Local-first: play goes on, the outbox waits and retries with Part 1's backoff; the turn ask is retried on online
403 xp_family_pro_required The sync store turns itself off and refreshes the licence (useProStore); a kid phone shows the board with sync.proRequired; a family device plays as a free family
401 on the parent's link The Town Hall's rule (the link is removed, the Game Master registers again); the outbox is kept
401 on a hero key Part 1's unlinked screen with Start fresh; __xp_sync is cleared
xp_family_turn on a record The takeover case (section 5)
xp_family_scope, xp_family_store_unknown, xp_family_record_invalid The record is dropped from the outbox and logged with debug; the batch carries on
429 Waits for Retry-After, as Part 1
A record that cannot be applied (a store refuses the value) Logged, skipped, lastSeq still advances; the next write of that key tries again
The site's clock and the device's disagree Only seq orders records; saved_at is for people

11. Slices

  1. The free sheet fix (section 9), straight to master as a small path-scoped commit.
  2. The server: schema (xp_family_records, records_seq, the turn columns), the four routes, writer and reader rules, the Pro gate, the relaxed home rule, tombstone cleanup, PHP tests, the rehearsal smoke script extended.
  3. The app's sync core: syncScopes, recordSignals wired into DbStorageApi and StorageService.saveProfiles, recordOutbox, recordApply, familySyncClient, SyncDb, the sync store with upload, download, first push and first pull; every GM device a family device (the relaxed home question).
  4. The turn and the verdicts: heroTurn, taking and resting on sign-in, switch, Save & Quit and background, the busy and took-over clerk lines, verdictApply, approvalFlow under one writer, penalties and clocks only for held heroes.
  5. Kid phones as the full portal: the guard, the first pull, the one-hero picker, hidden Game Master bits, Unlink, the Pro fallback to the board.
  6. Rehearsal and release: the local Docker WordPress with two browsers (family device and kid phone) and a third (second Game Master), then the plugin release through the COMPASS updater (owner's go), the web deploy on push, the APK rebuild and upload.

12. Testing

  • Jest for every module in section 4: each store's scope and hero id (including the templeProgress key and __chat's any-writer); the outbox (a key once, latest value at send time, batching limits, each answer, the reset on start, hero records only while held); apply (tombstones, own-device skip, mirrors per store, no echo); the turn machine (every transition in section 5, including offline and the takeover); verdictApply idempotence (repeat, replay, entry not yet arrived); the sync store off without Pro; __xp_sync not in BACKUP_STORES.
  • PHP, through the plugin's tests/run-tests.mjs: every route refuses the wrong caller and an unentitled family; a hero key reads only its hero's records and the family's; a hero record from a non-holder is refused with xp_family_turn while the batch carries on; __chat from a hero key is accepted; play answers granted, busy and forced; rest from a non-holder changes nothing; numbering under the lock; since paging; tombstones and their cleanup; the relaxed home rule only for entitled families.
  • Cypress at 390×844 with cy.intercept (never the live site): the busy question and both answers, the took-over line, the first pull on a kid phone opening the portal, the board with sync.proRequired.
  • The rehearsal network: link two parent devices and one kid phone; play a hero on the tablet, then on the phone (busy, take over); approve a claim from the second parent's phone while the hero plays elsewhere, and while nobody does; force a takeover with one device offline and read the took-over line; lapse Pro and see the board.
  • chemx lint and the PR's Tests check gate each slice; chemx verify runs on GitHub.
⚠️ **GitHub.com Fallback** ⚠️