This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Accord is a distributed application for tracking "riding time" (controlled grappling time) in competitive grappling matches using consensus-driven voting by judges. It consists of:
- Android App: Kotlin Compose Multiplatform application (
/app) - supports both Android and JVM desktop - Node.js Server: Express + Socket.IO backend (
/server) with SQLite database
cd server
# Development
npm start # Start dev server with nodemon
# Testing
npm test # Run all tests with coverage
npm test -- <file> # Run specific test file
# Database migrations
npm run db:create_migration # Create new migration
npm run db:migrate # Run pending migrations
npm run db:rollback # Rollback last migrationcd app
# Build
./gradlew build # Build all targets
./gradlew assembleDebug # Build Android debug APK
./gradlew assembleRelease # Build Android release APK (requires signing config)
# Run
./gradlew :shared:run # Run JVM desktop app
# Android deployment
./gradlew installDebug # Install debug APK to connected deviceThe application's defining feature is threshold-based consensus for tracking control time (/server/lib/ridingTime.js):
- Vote Threshold: Single judge: threshold = 1. Multiple judges: threshold = 2 (always, regardless of judge count)
- Control Recognition: Control is only counted when active votes ≥ threshold
- Time Accumulation: Riding time accumulates only during periods when threshold is met
- Winner Determination: Competitor with more riding time wins (or by submission if called)
Example: With 3 judges and threshold of 2, riding time starts accumulating when the 2nd judge presses their button and stops when votes drop below 2.
Users (authenticated via API token)
↓
Mats (training locations with word-based invite codes like "morning.coffee.bicycle")
↓
Matches (orange vs green competitor)
↓
Rounds (sequential sub-matches with RidingTimeVotes and RoundPauses)
↓
RidingTimeVotes (individual judge votes with timestamps)
RoundPauses (pause/resume intervals with paused_at/resumed_at timestamps)
-
Server: Socket.IO with room-based broadcasting (
/server/lib/server/webSocketServer.js)- Each match has its own room:
match:${matchId} - Authentication via
socket.handshake.auth.apiToken
- Each match has its own room:
-
Worker process (
/server/bin/worker <workerToken>): Separate process that connects via WebSocket usingworkerToken. Runs four background jobs:MatchUpdateWorker: Ticks every 250ms. Broadcastsmatch.updatefor every open (not-yet-ended) round and for every match currently in a break, throttled to ~1s. Also tracks active match IDs across ticks — when a match exits the active set (just ended), immediately broadcasts one finalmatch.updateso judges receive the ended state without waiting for their local countdown to expire. The other three workers tick every 1 second:TechFallTrackerWorker: Checks open rounds for tech fall threshold; if reached, ends the round in the DB and broadcastsround.tech-fallBreakTransitionWorker: Checks matches in break state; when break expires, starts the next round and broadcastsbreak.endedRoundTimerWorker: Checks open rounds for timer expiry; if elapsed time (minus pauses) ≥ max duration, ends the round and broadcastsmatch.update- Critical: Controllers never emit WebSocket events directly — all WebSocket emissions go through the worker process exclusively.
- Warning: An unhandled exception in any worker's
performJob()will silently kill that worker — it stops re-queuing with no log output. Always handle errors insideperformJob()or the affected rounds/matches will never auto-advance.
-
Break lifecycle: When a round ends and the match is not over,
Round.end()setsbreak_started_atandbreak_durationon the match.MatchUpdateWorkerpicks this up within 1 second and starts broadcastingmatch.updatewithbreak_remaining(computed seconds).BreakTransitionWorkerauto-starts the next round when elapsed >=break_durationand emitsbreak.ended. -
Server→Client events (all carry the full Match payload):
match.update— periodic score/state updates while a round is active or a break is in progressround.tech-fall— fired once when tech fall threshold is reached and round is endedbreak.ended— fired once when a break expires and the next round has been auto-started
-
Client: Flow-based observation (
/app/shared/src/commonMain/kotlin/dev/jvmname/accord/network/SocketClient.kt)observeMatch(matchId): Flow<Match>auto-joins room on collection and listens tomatch.update,round.tech-fall, andbreak.ended- Auto-leaves room on cancellation
Custom ORM: BaseRecord class (/server/lib/active_record/baseRecord.js) wraps Sequelize
- Models extend BaseRecord:
User,Mat,Match,Round,RidingTimeVote - Database schemas in
/server/config/db/schemas/ - Migrations in
/server/config/db/migrations/
Request Flow:
Express Route → Controller → Authenticate → Authorize → Execute → Render JSON
Authorization Levels (/server/lib/server/authorizer.js):
judge: User must be in Match's judges listmanage: Match creator permissionspause: Judges or managers can pause/resume a roundview: Public access
Key Controllers:
/server/controllers/matsController.js- Mat CRUD, judge/viewer management/server/controllers/matchesController.js- Match lifecycle, round management/server/routes/mat/and/server/routes/match/- Route definitions
UI Framework: Slack Circuit (Presenter pattern)
- Screens in
/app/shared/src/commonMain/kotlin/dev/jvmname/accord/ui/ - Each screen has:
Screen.kt(route),Presenter.kt(logic),Content.kt(UI) - Navigation is stack-based with gesture support
Dependency Injection: Metro (annotation-based)
AppScope: Singleton app-level dependenciesMatchScope: Per-match scoped dependencies- Assisted injection for screen parameters
Domain Layer (/app/shared/src/commonMain/kotlin/dev/jvmname/accord/domain/):
MatchManager: Match lifecycle, API calls, cachingMatManager: Mat operationsUserManager: Authentication and profileRoundTracker: Local round timing for solo modeRoundAudioFeedbackHelper: Plays audible alerts (start/stop/end of round, control changes) based on match state transitions
Network Layer (/app/shared/src/commonMain/kotlin/dev/jvmname/accord/network/):
AccordClient: HTTP client (Ktor) for REST APISocketClient: WebSocket client (Socket.IO) for real-time updatesApiResult<T>: Sealed interface for Success/Error responses
Dual Control Modes:
- Solo Mode (
SoloControlTimePresenter): Local-only practice mode, no network - Consensus Mode (
ConsensusControlTimePresenter): Network-synchronized judging with live riding time - Delegation (
DelegatingControlTimePresenter): Routes to correct presenter based onControlTimeType
Server (JavaScript with Sequelize):
User:id,name,api_tokenMat:id,name,judge_count(optional, no longer required on creation),creator_idMatch:id,mat_id,creator_id,red_competitor_id,blue_competitor_id,red_score,blue_score,started_at,ended_at,break_started_at,break_durationRound:id,match_id,ended_at,declared_winner_id,stoppageRidingTimeVote:id,round_id,judge_id,competitor_id,ended_atRoundPause:id,round_id,paused_at,resumed_at
Client (Kotlin with kotlinx.serialization):
- Value classes for type safety:
UserId,MatId,MatchId,RoundId - Models use nested relationships (e.g.,
Matchincludesjudges: List<User>,rounds: List<Round>) Round.controlTime: Map<UserId, Int>contains riding time in seconds per competitor
-
Consensus as Core: Not voting for a winner, but real-time consensus on "who has control" - prevents single biased judge from affecting result
-
Time-Based Calculations: Riding time calculated from vote timestamps (started_at/ended_at), not increments - resilient to race conditions
-
Invite Code Pattern: Sharable word-based codes (e.g., "morning.coffee.bicycle") instead of UUIDs for user-friendly mat access
-
Stateless Server: Judge state stored in database, not in-memory - enables horizontal scaling
-
Room-Based Broadcasting: WebSocket rooms keep server scalable; all interested clients receive updates atomically
-
Match Extensions in
models_ext.kt: All winner/score derivation fromMatchbelongs in/app/shared/src/commonMain/kotlin/dev/jvmname/accord/network/models_ext.kt, not inlined in presenters. Key extensions:Match.winner(roundIndex),Match.winnerCompetitor,Match.roundScore(),Match.toMatchResult().toMatchResult()always returns a non-nullMatchResultfor any ended match — do NOT add awinner ?: return nullguard; a null winner on an ended match is a valid draw.Match.merge(other)handles cache updates — some HTTP endpoints return partial payloads (omittingjudges,mat, etc.) and merge preserves cached values for those.break_remainingis a worker-computed field that is only present in WebSocketmatch.updatepayloads — HTTP responses always return it asnull.mergeusesif (breakStartedAt != null) breakRemaining ?: other.breakRemaining else nullto preserve the last WebSocket value during an active break and clear it once the break ends. -
MatchResultis shared across screens: Defined inJudgeSessionScreen.ktbut imported byMasterSessionScreen.ktas well. HaswinConditions: StringandroundWinners: List<Competitor>. EmptyroundWinnersmeans all rounds were tied (draw);toText()handles this case. -
Judge vs Master role separation: Judges only vote on control time — they do NOT handle meta-round actions (submission, stoppage, manual score edits are master-only).
JudgeSessionEvent.EndRoundis a simpledata objectthat ends the round with no params. All structured end-round actions (submission name, who submitted/stopped, stoppage vs submission choice) belong exclusively in the master session. -
Master session overlay pattern: The master uses Circuit's
OverlayEffect+BottomSheetOverlayfor dialogs (notAlertDialog). The end-round dialog isSubmissionDialogin/app/.../ui/session/master/overlay.kt, which returns aSubmissionResultsealed class.SubmissionResult.Confirmedcarrieswinnerandstoppage. The content maps the result toMasterSessionEvent.RecordRoundResult(winner, stoppage)— NOTEndRound.EndRoundis adata objectthat firesPOST /rounds/endimmediately on button tap and opens the dialog;RecordRoundResultfiresPATCH /rounds/resultwhen the dialog is confirmed. There is also a separateEndMatchConfirmDialog(triggered byMasterSessionEvent.ShowEndMatchDialog/DismissEndMatchDialog) that shows a confirmation prompt before ending the entire match — distinct from the per-round end flow.MasterSessionStateincludesshowEndMatchDialog,orangeHealthFraction, andgreenHealthFractionfor the health bar UI. -
All rounds always play out: A match always runs all
maxRounds(3) rounds — there is no early exit when a competitor reaches 2 wins. The match winner is determined by best-2-of-3 only after all 3 rounds have completed.Match.getWinner()returnsnullwhile rounds are still in progress. Do not reintroduce early-exit logic inRound.end()orMatch.getWinner(). -
End-round method routing:
RoundController.endRound(winner, submission, stoppage)—winnerdoubles as submitter (submission path) or stopper (stoppage path).MasterSessionroutes to the correctMatchManager.endRoundoverload based on thestoppageflag. The server API uses{ submission, submitter }for submissions and{ stoppage: true, stopper }for stoppages. -
Two-step end-round flow: Ending a round is split into two API calls.
POST /match/:matchId/rounds/end(no body) stops the clock and starts the break immediately.PATCH /match/:matchId/rounds/result{ winner: "red"|"blue"|null, stoppage: boolean }records who won — this is only valid during the break or after the match has ended. On the client:MasterSessionEvent.EndRound(data object) callssession.endRound()and opens the dialog;MasterSessionEvent.RecordRoundResult(winner, stoppage)callssession.recordRoundResult(). Dismissing the dialog without confirming leaves the result null on the server.
Tests use Jest with jest-express and jest-extended:
cd server
npm test # Run all tests with coverage
npm test -- ridingTime.test.js # Run specific testCoverage excludes /helpers/ and /models/ directories (configured in package.json).
Key test areas:
/server/test/ridingTime.test.js- Consensus algorithm tests/server/test/server/- Server framework, WebSocket, authorization/server/test/active_record/- ORM layer tests/server/test/http/- HTTP client tests
- Development/Testing: SQLite (
database.sqlitein/server) - Schema Management: Sequelize migrations in
/server/config/db/migrations/ - ORM: Custom
BaseRecordwrapper around Sequelize models
Migration naming convention: <description>_<timestamp>.js
All endpoints except POST /users require authentication via x-api-token header. API token is returned on user creation.
WebSocket authentication uses socket.handshake.auth.apiToken.
- Create migration:
npm run db:create_migration - Define schema in migration file (
/server/config/db/migrations/) - Run migration:
npm run db:migrate - Create model class extending
BaseRecordin/server/models/ - Add schema definition in
/server/config/db/schemas/default.js
- Create screen directory in
/app/shared/src/commonMain/kotlin/dev/jvmname/accord/ui/<screen-name>/ - Create three files:
<ScreenName>Screen.kt- Screen data class (route)<ScreenName>Presenter.kt- Business logic with@CircuitInjectannotation<ScreenName>Content.kt- Composable UI with@CircuitInjectannotation
- KSP will auto-generate factory classes
- Add screen to navigation stack in presenter
- Add route in
/server/routes/<domain>/index.js - Create controller method in
/server/controllers/<domain>Controller.js - Use
authorizer.authorize(req, <permission>)for auth checks - Return JSON via
res.json({ ... }) - Update
/server/README.mdwith endpoint documentation
Rules for writing socket.io and other JS interop code in wasmJsMain. These are not derivable from the code and were learned the hard way.
File structure:
- External declarations with
@file:JsModule("...")must be in their own file —js()helper functions in the same file are treated as module imports and fail to compile. js()helpers live in theactualimplementation file (e.g.,SocketClient.wasmJs.kt), never in the@file:JsModulefile.- All files using
JsAny,js(), or any Wasm/JS interop API need@file:OptIn(ExperimentalWasmJsInterop::class)at the top.
External class rules:
- All
external classdeclarations must extend: JsAny— this is required for Wasm GC reference typing. - When the Kotlin name differs from the JS export name, use
@JsName("ExactJsName"). Example: socket.io-client exportsSocket, notJsSocket, so@JsName("Socket")is required.
js() intrinsic rules:
- Argument must be a string literal (no variables, no concatenation).
- Single-line preferred — multiline/triple-quoted behavior is unconfirmed.
- Function parameters are accessible by their Kotlin names inside the string.
- Add
@Suppress("UNUSED_PARAMETER")on everyjs()helper — the IDE can't see parameter usage inside the string literal.
Type rules:
- Kotlin
Stringmaps directly to JSStringinexternalsignatures. It is NOT a subtype ofJsAny— don't useJsAnywhereStringis correct and vice versa. - For event callbacks, use three separate payload helpers depending on the event:
- Match objects →
js("JSON.stringify(obj)")thenjson.decodeFromString<Match>(...) - Disconnect reason (plain string) →
js("String(obj)") - connect_error (JS Error) →
js("err.message || String(err)")—JSON.stringifyreturns"{}"for Error objects
- Match objects →
Lambda identity:
- Kotlin/Wasm uses
getCachedJsObjectfor lambda-to-JS-function conversion. The same Kotlinvalreference always produces the same JS wrapper. socket.on(event, listener)+socket.off(event, listener)with the samevalcorrectly de-registers. No dispatch-map escape hatch is needed.
webpack.config.d:
- Files placed in
app/shared/webpack.config.d/are injected into the generated Karma config forwasmJsBrowserTest. Confirmed viabuild/wasm/packages/accord-shared-test/karma.conf.js. socket-io.jssetsconfig.node = falseper socket.io bundler docs to prevent webpack from processing Node.js-style dynamic requires.
Cannot cast null to kotlin.String — diagnosing on Wasm:
- This error means a null value reached a non-nullable
Stringposition at the Wasm GC level. Common causes:- A DB column that is
allowNull: trueon the server but mapped to a non-nullable value class (e.g.UserId,AuthToken) in Kotlin. Fix: add a migration to enforceNOT NULLon the column. - Stale data in
DataStore(serialized with a field that was later made non-nullable). Fix: wrapjson.decodeFromString<T>()inrunCatching { }.getOrNull()inobserveMatInfo()/observeCurrentMatch()so corrupt cache silently returns null. MutablePreferences.remove(key)in DataStore 1.3.0-alpha on Wasm — see below.
- A DB column that is
Known DataStore bug — MutablePreferences.remove(key) on Kotlin/Wasm (as of Apr 2026):
prefs.remove(key)crashes withCannot cast null to kotlin.Stringwhen the key does not yet exist in the store. Internally, DataStore casts the null return ofmap.remove()to non-nullableString— a DataStore alpha Wasm GC codegen bug.- Affected: any
Preferences.Key<String>passed toremove.Intkeys may not be affected. - Fix: never call
removeon the path where you're about toset. Restructure as:This avoidsval valueStr = value?.extractString() datastore.edit { prefs -> if (valueStr != null) prefs[KEY] = valueStr else prefs.remove(KEY) // only reached when genuinely clearing }
removeon first-write (key not yet present), which is when the crash occurs. - Pure-remove callers (
clearPreBoostVolume, null-clearing branches in other methods) still useremoveand will crash if the key was never previously set.
Known Wasm GC bug — Metro + @GraphExtension (as of Apr 2026):
wasmJsBrowserDevelopmentRunfails in both Chrome and Firefox withwasm validation error: type mismatch/call[1] expected type (ref null <ImplType>), found struct.get of type (ref null 11)where type 11 =kotlin.Any.- Root cause: Metro generates
AccordGraph.Implwith athisGraphInstancefield typed askotlin.Any. InImpl.<init>, this field is read viastruct.getand passed to child graph factory constructors that expect the concreteImpltype. The JVM backend emitscheckcast; Kotlin/Wasm does not emitref.cast, so the Wasm GC validator rejects the binary. - Trigger: the
@GraphExtensionchild graph (MatchGraph) is what causes Metro to generatethisGraphInstance. Removing the child graph would fix it, but that's not viable. - This is NOT: a Compose/Skiko issue, a socket.io issue, or fixable by restructuring
@Providesmethods or@ContributesTomodules. All such changes shift the byte offset but do not resolve the error. - Bug report:
.ai/plans/metro-wasm-bug-report.md— file against Metro and Kotlin/Wasm. - Diagnosing Wasm type mismatches: parse the name section of the
.wasmbinary with Node.js to map type indices to Kotlin class names (see conversation history for the script).
For expensive operations, ALWAYS use the run_task MCP tool instead of Bash.
Commands that MUST use run_task:
- gradle, bazel, make, cmake, mvn, cargo build, go build
- docker build, docker-compose, kubectl, helm
- npm/yarn/pnpm build, pytest, jest, mocha
Usage:
- command: The full shell command
- working_directory: Absolute path to project root
- env_vars: Optional like "KEY=value,KEY2=value2"
NEVER run these via Bash. Always use run_task MCP tool.
The project deploys two services to Railway, both triggered by GHA on push to main:
- API server (
.github/workflows/server-deploy.yml): triggers onserver/package.jsonchange (version bump). Usesrailway upfrom repo root withrailway.jsonat root. - Web client (
.github/workflows/android-release.yml,web-deployjob): triggers alongside the Android release onapp/gradle.propertieschange (version bump). Builds:shared:wasmJsBrowserDistributionvia Gradle, copiesapp/shared/railway.web.jsoninto the output asrailway.json, then runsrailway upfromapp/shared/build/dist/wasmJs/productionExecutable/.
Version bumping: VERSION_NAME and VERSION_CODE in app/gradle.properties are the single source of truth for the app version. They flow into androidApp versionName/versionCode, the shared BuildConfig.VERSION_NAME (displayed in the UI on MainContent), and the GHA release tag. To cut a new release, bump both values there. The API server version is separately managed via server/package.json.
Key Railway CLI behavior: railway up uploads from the git repository root, not the current working directory, when run from inside a git repo. This means running it from app/shared/build/dist/wasmJs/productionExecutable/ would upload the entire monorepo. In CI, the web-deploy job has no checkout step, so railway up correctly uploads only the artifact directory. For local web deploys, copy artifacts outside the git tree first: cp -r app/shared/build/dist/wasmJs/productionExecutable/ /tmp/wasm-deploy/ and then railway up --project <id> --environment <id> from /tmp/wasm-deploy/.
Web client static serving: The web client uses Caddy (not nginx). The workflow bundles three files into the wasm build output: Staticfile (triggers Railpack's staticfile provider, which installs Caddy), Caddyfile.override (our custom config with correct CSP headers), and railway.json. The service-level start command is set to caddy run --config /app/Caddyfile.override --adapter caddyfile, which overrides the Railpack-generated /app/Caddyfile. Do not remove Staticfile (Caddy won't be installed) and do not rename Caddyfile.override to Caddyfile (Railpack overwrites it with its own generated config at build time). app/shared/railway.web.json must use "builder": "RAILPACK" — do not switch to "NIXPACKS", as Nixpacks installs Caddy but does not wire it into PATH, causing caddy: command not found at runtime. Do not add buildEnvironment: "V3" to app/shared/railway.web.json — it causes "Railpack could not determine how to build the app" for static sites.
railway up --detach vs --ci: Both workflows use --detach. Do not switch to --ci — --ci respects watchPatterns on the service config and will skip the deploy if it judges no watched files changed. --detach always builds. The CI trigger paths (paths: in the workflow) already handle the "when to deploy" logic, making watch patterns redundant. The Railway service must also have no watchPatterns set in the dashboard — a /app watch pattern will silently skip all web client deploys since the uploaded artifact directory contains no /app path.
CSP for WebAssembly: The Caddyfile must include 'wasm-unsafe-eval' in script-src, or the browser will block .wasm instantiation. This is not a CORS issue — it's a distinct CSP directive. The browser error is CompileError: call to WebAssembly.instantiateStreaming() blocked by CSP.
CORS: The API server allows http://localhost:8080 for local dev and reads WEB_ORIGIN env var for the production web client URL. Set WEB_ORIGIN=https://<web-service-domain> on the Railway API service.
Required GitHub secrets: RAILWAY_TOKEN, RAILWAY_SERVICE_ID (API), RAILWAY_WEB_SERVICE_ID (web client).
All plans will always be written into .ai/plans/<name-of-feature>. Never put any plans in any folder within /app or /server