9. Delivery Order
Implemented in milestones; each milestone is independently shippable.
M1 – Real-time throttle (no users, in-process baseline).
- Add the new
pkgs/bigfred/serverpackage withchi+coder/websocketand a single/api/v1/wsendpoint that echoes messages. Build it as a third binary next tolocoandrb. - Expose
LocoServiceas a thin wrapper over the existingapp.LocoApp(reuse, do not rewrite). DCC dispatch stays in-process; §7e will move it into the siblingdcc-busdaemon in M4.5 below. - Bring up the Vite + React + TS frontend with a
useSockethook and a single speed slider, just to validate the full loop: UI → WS →Station.SetSpeed→ poller → broadcast → UI.
M2 – Persistence (REL) + users + auth.
- Wire REL with the SQLite3 adapter, generate the initial migration set
(
users,vehicles,dcc_address_ranges,temporary_roles). - Implement
AuthService(login + PIN, argon2id hashing, rate-limiting in Redis) and the JWT/cookie middleware. - Implement
UserService(CRUD, role changes, temporary roles, DCC pool assignment) behindRequireRole(admin).
M3 – Ownership, leases, trains, functions & templates + audit log.
- Add
vehicles,trains,vehicle_leases,train_leasestables and the corresponding services. PlugRequireVehicleDrive/RequireVehicleEditmiddleware into the existing throttle endpoints. Implement train driving in the Throttle overlay (train.setSpeedon the dcc-bus data plane, per-memberloco.subscribe, function accordions, owner-onlyspeedMultiplieredit — §6.3a). ShipThrottlePage/ThrottleCockpitwith a unified vehicle + train picker reusing the same vertical throttle surface. - Add
vehicle_templatesand unifieddcc_functionstable (vehicle_idXORtemplate_id),FunctionService(withEnsureDetachedcopy-on-write),TemplateService,FunctionSecurityContextandTemplateSecurityContext. Ship the closedFunctionIconcatalogue plus matching SVG assets in the frontend. Wire thevehicle.functionsChangedWS event. - Add the
audit_log_entriestable,AuditService(append-only writer + filterable reader) andAuditSecurityContext. WireAuditService.Loginto every vehicle/train/lease/function/template mutation listed in §3a.5. Janitor goroutine emitsvehicle.lease_expired/train.lease_expired. - Add the lease/train UI screens in React (MUI dialogs + tables),
the vehicle catalogue with Edytuj funkcje (§6.3e),
VehicleFunctionsPage(sortable list, icon picker from §3a.8), the template manager, and an admin-only "Activity" screen readingGET /api/v1/audit-log.
M4 – Command Stations and layouts.
- Add
command_stations,layoutsandlayout_command_stationstables, plusLayoutSignalmanandLayoutInterlockingjoin tables. Seed exactly one system layout row withname='default',is_system=true,locked=false(uniqueness guarded by a partial unique indexUNIQUE(is_system) WHERE is_system = TRUE). Add the DB CHECKNOT (is_system = TRUE AND locked = TRUE)and the CHECK onlayout_command_stationsthat refuses inserts whoselayout_idpoints at the system row (the system layout's set is virtual, §3a.3 invariants). - Implement
CommandStationService(admin CRUD overcentralki) andLayoutService(CRUD, lock / unlock, attach / detach command stations, signalmen list, interlocking whitelist). MakeLayoutService.CreaterequirecommandStationIdswith ≥1 entry, reject the system row fromUpdate/Delete/Lock/AttachCommandStation/DetachCommandStationwith the matching errors, and refuse to leave a non-system layout with zero attached stations. MoveLocoServiceto amap[commandStationID]Stationresolver keyed offsession.CommandStationID(§3a.4 rule 5). Implement thesession.setCommandStationWS action (§4.2 / §4.5): validates the picked id againstLayoutCommandStation(orcommand_stationsfor the system layout), runs the emergency plan on the previousCommandStationIDfirst when picking a different one, broadcastssession.commandStationChanged. Wire thelayout.commandStationsChangedfan-out fromCommandStationService.Deleteand the attach/detach endpoints, so live drive sessions get their dropdowns refreshed and any session pinned to a deleted / detached station is auto-detached (CommandStationID → nil) with reason"deleted"/"detached". - Wire the layout picker into the login flow (§7a.1): add
the unauthenticated
GET /api/v1/layouts/loginendpoint returning non-locked rows, updatePOST /api/v1/auth/loginto take{ login, pin, layoutId }and bakelayoutIdinto the JWT, and update the WS upgrade to readLayoutIDfrom the token (NOT from a/layouts/{id}/joincall – that endpoint does not exist). On the frontend, add the layout dropdown next to login + PIN onLoginPage.tsx; the system layout is the default pre-selection and is rendered via thelayout:system_default_labeli18n key. Add an admin-only/admin/layoutspage inAppShell.tsxfor name / lock / attached-stations management (the system layout's row is read-only there). - Add the command-station-picker dropdown to the vehicle
control view. It is populated from
session.opened.availableCommandStations, firessession.setCommandStationon change, and re-renders on everysession.commandStationChanged/layout.commandStationsChangedevent. The throttle stays gated (slider disabled, every action short-circuited to a UI warning) untilCommandStationIDis non-nil. When the dropdown contains exactly one entry, the UI MAY auto-firesetCommandStationonce; the contract is identical to a manual pick. - Wire
AuditService.Loginto every command-station mutation, every layout mutation (create / update / delete), every lock toggle (layout.locked/layout.unlocked) and every attach / detach (layout.command_station_attached/layout.command_station_detached) – see §3a.5. - Wire sudo elevation (§7a.7). Add the
sudo_elevationstable and theLayout.AdminPINHashcolumn (NOT NULL; the bootstrap migration seeds the system layout with a one-shot random PIN, logged once). ImplementAuthService.Sudo/RevokeSudoand theLayoutService.UpdateAdminPINrotation path (with the "blank field = no change" semantic). Add thePOST/DELETE /api/v1/layouts/{id}/sudoendpoints and theauth.elevationChangedWS fan-out. Wire the existing janitor goroutine to reap expired rows and emitauth.elevationChanged. Plumb the sudo admin source throughAuthService.Effective(a flatdomain.EffectiveRolesset — sudo admin grants the same authority as a permanent admin everywhere). Cascade the cleanup onAuthService.LogoutandLayoutService.Delete. Add the second endpointPOST/DELETE /api/v1/layouts/{id}/signalmandriven bySudoService.GrantSignalman / RevokeSignalman— the engineer's-cap icon writes a permanentLayoutSignalmanrow withexpires_at = NULLafter the same PIN check. Front-end: add the closed-padlock indicator (with live MM:SS countdown badge) and the engineer's-cap indicator (binary toggle, no countdown) toAppShell.tsx, the shared<SudoPinDialog>, and the layout admin PIN field on the/admin/layoutssettings page (with the "leaving blank does NOT reset the PIN" helper, §7a.7.5). Shippl/sudo.jsonen/sudo.jsonand the new error codes (sudo_invalid_pin,sudo_locked,sudo_layout_mismatch,layout_admin_pin_invalid,layout_admin_pin_unset).
M4.5 – dcc-bus daemon split (throttle data plane out of loco-server).
For the full specification see §7e DCC bus daemon.
After M4 the system has multiple command stations attached to multiple
layouts but the DCC bus still lives inside loco-server. M4.5 extracts
the throttle data plane into a per-(layoutId, commandStationId)
sibling daemon supervised by §7d (SupervisordService).
16a. Add pkgs/bigfred/dcc-bus/ (cobra subcommand on the same loco-server
binary) with --layout-id, --command-station-id, --port,
--bind, --station-name, --station-kind, --station-uri,
--speed-steps, --jwt-secret, --redis-addr, --heartbeat-secs,
--deadman-secs flags. loco-server passes station parameters from
its command_stations table when registering supervisord programs.
The daemon dials Redis, loads roster snapshots, opens the command
station via pkgs/loco/commandstation, listens for WebSocket on
--port, and exits non-zero on boot failure (§7e.2). No SQLite
in the daemon process.
16b. Build the WebSocket handlers inside dcc-bus:
loco.subscribe, loco.unsubscribe, loco.setSpeed,
loco.toggleFn, system.estop, ping. JWT auth (?token=)
rejects upgrades whose layoutId does not match --layout-id.
coder/websocket is reused; the policy gate goes through
pkgs/bigfred/server/security byte-for-byte (§7e.5).
16c. Add the per-daemon poller (§7e.3) that ticks
Station.GetSpeed / ListFunctions for subscribed addresses
in the interesting set — vehicles from the layout's
LayoutVehicle roster (or the full catalogue for the system
layout) with a non-NULL DCC address. Writes
loco:state:<csId> in Redis on change and broadcasts to WS
subscribers. Skips addresses with no current subscriber.
16d. Wire the per-daemon dead-man's switch (§7e.5): per-session
LastHeartbeat, gracePeriod snapshot from
domain.EmergencyPlan, on lost handle run SetSpeed(0) on
DriveTargets for the user, publish on
bigfred:layout:<L>:emergency:<userId> (Redis), emit
session.warning and session.emergencyExecuted on the WS.
16e. Add DccBusService to loco-server (§7e.6): desired-state
map keyed by (layoutID, csID), port pool (default
[9200, 9299]), EnsureRunning / Stop / PublishCommand
methods, persistence of the port mapping in Redis
(HSET dcc-bus:ports). Constructed in cli/root.go after
SupervisordService; on boot RestoreFromPersisted re-reads
the mapping so a loco-server restart does not lose track of
already-running daemons.
16f. Move throttle dispatch out of LocoService.SetSpeed /
ToggleFn / EStop into a new LocoServiceDriver that
publishes onto dcc-bus:cmd:<L>:<C> (Redis pub/sub) via
DccBusService.PublishCommand. Update TrainService.SetSpeed,
TakeoverService release and ScriptService (executor RPC
handler) to use the new driver. Keep the legacy in-process
path under a --no-supervisor dev flag for testing.
16g. Extend the WS hub: extend session.opened,
session.commandStationChanged and layout.commandStationsChanged
payloads to include availableCommandStations[i].wsUrl and
status (§7e.6). On session.setCommandStation { commandStationId },
call DccBusService.EnsureRunning(L, C) before acking; on
failure return ack { ok:false, error:"dcc_bus_unavailable" }
or error:"no_dcc_bus_ports_available". Mirror the chosen
wsUrl back to every concurrent session of the user via
session.commandStationChanged.
16h. Add a Redis pub/sub consumer in loco-server
(dcc_bus_consumer.go) that psubscribes to dcc-bus:evt:*,
parses session.emergencyExecuted / takeover-relevant
loco.state events, writes the matching audit rows (§3a.5),
and fans them out to the control-plane WS for non-throttle
listeners (admin dashboards, MCP SSE).
16i. On the frontend: extend the throttle overlay (§6.3b) with the
dual-WebSocket lifecycle (§7e.7). Add <CommandStationPicker>,
<SharedBusChip> and <DeadmanIndicator> components. Open
the data-plane WS on session.commandStationChanged; close it
on overlay close, logout, or cs switch. Add useDataPlane()
and useControlPlane() hooks; rewire <ThrottleSlider> and
<FunctionButtons> to dispatch on the data plane.
Ship pl/throttle.json + en/throttle.json (§7e.7).
16j. Add §7e.8 acceptance criteria to
14-acceptance-criteria/10-dcc-bus.md and verify them
end-to-end on a dev box.
M5 – Interlockings, takeover, radio.
- Add
interlockings,interlocking_sessionsandtakeover_requeststables and services, all filtered through the active layout'sLayoutInterlockingwhitelist. Implement the 15-second takeover state machine whose grant creates a 5-minute self-lease to the signalman (reusingVehicleLease/TrainLease), ends the driver's throttle session for the target, and removes the target from their throttle picker until release (§4.3). Implement the closed-vocabulary radio with Redis-only storage (default 4h TTL, NO SQLiteradio_messagestable): every message carries a target (user XOR interlocking) and a context (vehicle XOR train), is fanned into the addressee's and sender's Redis streams (§4.4.4), and is replayed viaradio.replay/ the read-only REST endpoints (GET …/interlockings/{id}/radio,GET /api/v1/radio/mine). Add the per-target emergency stopsystem.estopTarget(„Zatrzymaj skład"). WireAuditService.Logforsession.emergency_executedemitted by the dead-man's switch handler in the Hub. - Add the layout dashboard (
HomePage.tsx– three live tables, §6.3c) and the three-panel interlocking view (InterlockingPage.tsx– occupy / leave with displacement confirm, navigation guard, left radio chat panel + centre searchable vehicle/train roster with per-row Radio / Stop / Takeover actions, the searchable phrase table popup, and the closable takeover throttle overlay gated on speed 0, §6.3d). Add the driver-side throttle radio and chat overlays (searchable interlocking picker + phrase table, group-chat history, red unread badge, on-screen alert popup, §6.3b) and theuseRadioSounds()hook (radio-sent.ogg/{phrase}.oggunderweb/public/sounds/interlockings/). Addlayout_vehiclestable and presence tracking in the Hub.
M5.1 – Train announcements panel (post-M5 slice, frontend-only).
18a. Ship the third interlocking panel
(<InterlockingTrainAnnouncementsPanel>), a static manifest
(web/src/config/trainAnnouncements.ts), the useTrainAnnouncementSound
hook, i18n labels, and Ogg assets under
web/public/sounds/train-announcements/ (§6.3d). No backend changes;
playback is client-local only.
M6 – API keys + built-in MCP server.
- Add the
api_keystable,APIKeyService(mint / verify / revoke, hard cap 365 days), and the corresponding REST endpoints plus a React screen to mint and revoke keys (showing plaintext exactly once). Each key is bound to the layout that was active when minted. - Add the
pkgs/bigfred/server/mcppackage usinggithub.com/mark3labs/mcp-go. Wire the SSE handler under/mcpbehind the API-key middleware, and add aloco server --mcp-stdiosubcommand for local clients (Claude Desktop / Cursor). Expose the curated tool surface listed in §7b.3.
M7 – Server-side scripts (Goja, sibling scripts-executor process).
- Add the
scriptsandscript_attachmentstables,ScriptService,ScriptSecurityContextand the REST endpoints listed in §4.1. Enforce the 64 KiB source cap, the Vehicle-XOR-Train attachment invariant and theDeadlineSec ∈ [1, 600]range at both the DB and service layers. WireAuditService.Loginto everyScriptandScriptAttachmentmutation (create / update / delete / attach / detach). - Build
pkgs/bigfred/server/scripts/runtime.go: aRuntimestruct that embeds*goja.Runtime, wiresfindFirstLoco,findByDCCAddr,members,sleep,logand theVehiclehelper viavm.Set+UncapFieldNameMapper, and exposesRun(ctx context.Context, src string) errorthat armsvm.Interrupton context cancellation and timeout. Cover withruntime_test.gorunning the canonical scenario (findFirstLoco,setSpeed,findByDCCAddr(815),funcOn,sleep,funcOff) against a stubbedLocoServiceand asserting the exact sequence ofSetSpeed/SetFunctioncalls. - Build
pkgs/bigfred/server/executor/: the length-prefixed JSON codec, theClientused inserver, theServerused inscripts-executor, and theSupervisor(exec the child, exponential backoff, health pings, in-flight run accounting). The supervisor must surfacesystem.status { scriptsExecutor }over WS and the "Scripts unavailable" banner when it gives up. - Add the
pkgs/scripts-executor/package withmain.goand aloco scripts-executorcobra command. The binary is built from the same Go module asloco server; CI builds both and the Makefile gets amake scripts-executortarget. - Ship the Scripts page (
web/src/pages/ScriptsPage.tsx): list, Monaco editor withlanguage="javascript", icon picker, attachment management, deadline slider. AddScriptButtons.tsxandScriptConsole.tsxto the vehicle and train control views so attached scripts render next toF0–F31and per-run logs surface inline. - Wire the new WS events (
script.run,script.stop,script.log,script.runStarted,script.runStopped,script.changed) and the dead-man's switch integration (ScriptService.StopAllForUserinvoked from the Hub beforeSetSpeed(0)fan-out). Integration test: kill the executor mid-run withSIGKILLand assert every in-flightrunIdreceivesscript.runStopped { reason:"executor_crashed" }within the supervisor's first detection cycle, while throttle commands on unrelated vehicles continue to round-trip.
M8 – Polish.
- Redis (cache + Pub/Sub for multi-instance fan-out), background poller upgrades, optimistic UI tweaks, accessibility audit on the MUI screens.