Status: Living tracker — workstream states are updated as they land; flips to Historical when #159 closes. The locked architecture decision is recorded as ADR-0012.
Breakdown of the remaining work to ship private friend challenge boards (v1) per #159. Much of the design is already built; this plan covers only the gaps.
Built and merged:
crosscue/backend/challenge_boards/, ~1,184 lines): bootstrap, get/patch
player, avatar, list/create boards, board detail, invite preview/join/regenerate, leave,
result submission. Migrations 0001–0003 (players, boards, memberships, board_events,
challenge_results). Weekly + lifetime leaderboards computed live, source_id matching,
invite hashing/expiry/versioning, idempotent results.lib/features/challenge_boards/): full presentation layer, API +
sample repositories, result outbox, submission mapper, identity store. Wired into the 4-tab
app shell. Solve completion → challenge submission hook
(solve_notifier.dart:421). Deep-link join routing (#203), no-backend gating (#198),
iOS associated-domains + Android autoVerify configured.Architecture decision (locked): v1 keeps live-compute lifetime + bounded retention purge.
We do NOT build daily_results / player_board_stats / processed_lifetime_weeks / the Monday
rollover cron for v1. The issue’s acceptance criteria referencing rollover/player_board_stats
are superseded by this decision and should be amended on the issue.
The snapshot above is preserved as the plan of record; these workstreams have since shipped (the per-workstream sections below were written pre-merge):
7 3 * * *) +
src/retention.ts + migration 0005_board_events_retention.sql; a heartbeat
(ops_meta, migration 0007, GET /health/retention) plus a weekly Actions
check landed in #262.AVATARS bucket is provisioned.X-Crosscue-Client header + optional
MIN_SUPPORTED_CLIENT → 426 client_too_old force-upgrade lever.database_ids are in
wrangler.toml; Workers observability is enabled per-env.assetlinks.json are served from Cloudflare
Pages (crosscue.pages.dev), not crosscue.app — the apex was never
registered (#269/#270). Both hosts are wired everywhere, so an apex can take
over later without breaking already-shared pages.dev links. References to
crosscue.app/join/... in the E2/F1 sections below read as crosscue.pages.dev
today.Still open: E2 custom domain (api.crosscue.app, gated on registering the
apex), and the optional D3/D4 activity feed / canonical-source registry.
Survives device restore; an explicit v1 acceptance criterion. Currently bootstrap-only — no recovery secret exists, so identity is lost on reinstall.
POST /players/restore — exchange playerId + recoverySecret for a fresh auth
token. Store only hash(recoverySecret). Structured error for restore-failed.POST /players/recovery/rotate — generate new recovery secret (Web Crypto),
store new hash, invalidate older bundles, record recovery_secret_rotated_at.recovery_secret_hash, recovery_secret_rotated_at to players.
Confirmed absent from 0001 (current columns: id, display_name, auth_token_hash, avatar_*,
created_at, last_seen_at, deleted_at).challenge_identity_store now persists recoverySecret
(in the synced app_settings table) and exposes readRecoveryBundle(); ChallengeBoardApi
captures the secret on bootstrap, adds restore() / rotateRecovery(), and _authOptions()
restores from the bundle before bootstrapping a new player. rotateRecovery is exposed on
ChallengeProfileRepository. Covered by challenge_api_client_test (bootstrap-persists-secret,
restore-before-bootstrap, rotation-stores-new-secret).edit_name_sheet), shown only when a real backend is configured, behind a
showResetRecoveryDialog confirm; calls rotateRecovery(), closes the sheet, and snackbars the
result. Covered by challenge_sheets_test (action shown/hidden + invokes callback). Optional
refinement still open: move the bundle from app_settings to a dedicated challenge-identity sync
blob (today it’s co-located in the synced settings KV, which already survives same-platform
restore but co-mingles with settings sync).DELETE /players/me — DONE (workstream A commit): leaves all boards, deletes
results, anonymizes + soft-deletes the player, revokes token.ChallengeBoardApi.deleteAccount() (explicit token, no
auto-bootstrap, clears local identity via ChallengeIdentityStore.clear() +
AppSettingsDao.removeValue). Wired into Privacy → “Clear all data”: deletes the server player
first (while the token still exists), with an offline/error dialog letting the user clear the
device anyway and retry later. Gated to no-op when the Challenge Boards backend isn’t configured.docs/privacy.md gains an “Optional Challenge Boards” section
(server-stored identity/display name/membership/invite metadata/result metadata/lifetime
stats/recovery-bundle-hash/UTC rules/retention/deletion/backup caveat), qualified summary, and
an updated retention/deletion section. Effective date bumped to 2026-06-09.deleteAccount (deletes + clears, no-op without identity); privacy_screen
integration test asserts clear-all also DELETEs the server account and clears local identity.Scope correction: under live-compute lifetime, challenge_results IS the lifetime source, so
those rows must NOT be purged without a rollover step (which v1 defers). They are compact (one row
per player per daily puzzle), so v1 retains them. Retention applies only to board_events, which
feeds no ranking.
board_events older than 14 UTC
days, in bounded chunks. challenge_results is intentionally retained under live-compute.[triggers] crons for default/staging/production.board_events(created_at) for the purge query.player_board_stats rollover
is added later, or if per-puzzle result volume is observed to grow unexpectedly.RL_IDENTITY (IP-keyed,
15/60s) on bootstrap/restore, RL_WRITE (player-keyed, 60/60s) on join/results/invite-regenerate.
Bindings are optional in Env so local/test runs without them skip limiting; over-limit returns
429 rate_limited. Caps remain transactional.validateDisplayName now normalizes
case/separators/leetspeak and rejects a reserved-handle set + profanity/slur starter blocklist
(400 invalid_display_name). The list lives in src/index.ts and is meant to be maintained.GET /boards/:id/events — events are written but not exposed; include
only if product wants an activity feed in v1.source_id match is acceptable for
v1 (no challenge_puzzles registry / checksum). Document the decision; reject mismatched sources.0003 is now a no-op (ranking_mode lives only in 0001),
so a fresh wrangler d1 migrations apply against new staging/prod databases applies cleanly. The
test harness loads all migrations sequentially with no tolerance, proving the clean apply.database_ids in wrangler.toml.api.crosscue.app; keep invite links on
crosscue.app/join/....DB, PUBLIC_APP_URL, and the
optional rate-limit bindings (SHA-256 hashing, no server secret). Revisit only if HMAC-salted
hashing is added later.@cloudflare/vitest-pool-workers covering bindings, scheduled
handler, rate-limit behavior, error paths; confirm compat flags match Wrangler.apple-app-site-association and assetlinks.json from
crosscue.app; verify entitlements/autoVerify resolve. deeplinks/join.html is the web fallback.docs/qa/) — invite expiry/regeneration/old-link-invalidation, board
full/limit, idempotent rejoin, leave + final-member auto-delete, same-cloud restore (iOS &
Android), UTC weekly reset, retention purge, installed/not-installed deep-link cold-start &
warm-start, source eligibility.E (infra) ─┬─> can deploy backend at all
A (recovery)│
B (privacy) ├─> release gates: must land before store submission
C (retention)
D (hardening) ─> before public beta
F (deeplink/QA) ─> final verification
Recommended order: E1–E3 → A → B → C → D → F. E unblocks real testing; A and B are the two acceptance-criteria gaps that block release; C prevents unbounded growth; D/F harden before beta.
player_board_stats acceptance criteria as superseded by the
live-compute + purge decision.