Home

13 delivery order

9. Delivery Order

Implemented in milestones; each milestone is independently shippable.

M1 – Real-time throttle (no users, in-process baseline).

  1. Add the new pkgs/bigfred/server package with chi + coder/websocket and a single /api/v1/ws endpoint that echoes messages. Build it as a third binary next to loco and rb.
  2. Expose LocoService as a thin wrapper over the existing app.LocoApp (reuse, do not rewrite). DCC dispatch stays in-process; §7e will move it into the sibling dcc-bus daemon in M4.5 below.
  3. Bring up the Vite + React + TS frontend with a useSocket hook and a single speed slider, just to validate the full loop: UI → WS → Station.SetSpeed → poller → broadcast → UI.

M2 – Persistence (REL) + users + auth.

  1. Wire REL with the SQLite3 adapter, generate the initial migration set (users, vehicles, dcc_address_ranges, temporary_roles).
  2. Implement AuthService (login + PIN, argon2id hashing, rate-limiting in Redis) and the JWT/cookie middleware.
  3. Implement UserService (CRUD, role changes, temporary roles, DCC pool assignment) behind RequireRole(admin).

M3 – Ownership, leases, trains, functions & templates + audit log.

  1. Add vehicles, trains, vehicle_leases, train_leases tables and the corresponding services. Plug RequireVehicleDrive / RequireVehicleEdit middleware into the existing throttle endpoints. Implement train driving in the Throttle overlay (train.setSpeed on the dcc-bus data plane, per-member loco.subscribe, function accordions, owner-only speedMultiplier edit — §6.3a). Ship ThrottlePage / ThrottleCockpit with a unified vehicle + train picker reusing the same vertical throttle surface.
  2. Add vehicle_templates and unified dcc_functions table (vehicle_id XOR template_id), FunctionService (with EnsureDetached copy-on-write), TemplateService, FunctionSecurityContext and TemplateSecurityContext. Ship the closed FunctionIcon catalogue plus matching SVG assets in the frontend. Wire the vehicle.functionsChanged WS event.
  3. Add the audit_log_entries table, AuditService (append-only writer + filterable reader) and AuditSecurityContext. Wire AuditService.Log into every vehicle/train/lease/function/template mutation listed in §3a.5. Janitor goroutine emits vehicle.lease_expired / train.lease_expired.
  4. 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 reading GET /api/v1/audit-log.

M4 – Command Stations and layouts.

  1. Add command_stations, layouts and layout_command_stations tables, plus LayoutSignalman and LayoutInterlocking join tables. Seed exactly one system layout row with name='default', is_system=true, locked=false (uniqueness guarded by a partial unique index UNIQUE(is_system) WHERE is_system = TRUE). Add the DB CHECK NOT (is_system = TRUE AND locked = TRUE) and the CHECK on layout_command_stations that refuses inserts whose layout_id points at the system row (the system layout's set is virtual, §3a.3 invariants).
  2. Implement CommandStationService (admin CRUD over centralki) and LayoutService (CRUD, lock / unlock, attach / detach command stations, signalmen list, interlocking whitelist). Make LayoutService.Create require commandStationIds with ≥1 entry, reject the system row from Update / Delete / Lock / AttachCommandStation / DetachCommandStation with the matching errors, and refuse to leave a non-system layout with zero attached stations. Move LocoService to a map[commandStationID]Station resolver keyed off session.CommandStationID (§3a.4 rule 5). Implement the session.setCommandStation WS action (§4.2 / §4.5): validates the picked id against LayoutCommandStation (or command_stations for the system layout), runs the emergency plan on the previous CommandStationID first when picking a different one, broadcasts session.commandStationChanged. Wire the layout.commandStationsChanged fan-out from CommandStationService.Delete and 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".
  3. Wire the layout picker into the login flow (§7a.1): add the unauthenticated GET /api/v1/layouts/login endpoint returning non-locked rows, update POST /api/v1/auth/login to take { login, pin, layoutId } and bake layoutId into the JWT, and update the WS upgrade to read LayoutID from the token (NOT from a /layouts/{id}/join call – that endpoint does not exist). On the frontend, add the layout dropdown next to login + PIN on LoginPage.tsx; the system layout is the default pre-selection and is rendered via the layout:system_default_label i18n key. Add an admin-only /admin/layouts page in AppShell.tsx for name / lock / attached-stations management (the system layout's row is read-only there).
  4. Add the command-station-picker dropdown to the vehicle control view. It is populated from session.opened.availableCommandStations, fires session.setCommandStation on change, and re-renders on every session.commandStationChanged / layout.commandStationsChanged event. The throttle stays gated (slider disabled, every action short-circuited to a UI warning) until CommandStationID is non-nil. When the dropdown contains exactly one entry, the UI MAY auto-fire setCommandStation once; the contract is identical to a manual pick.
  5. Wire AuditService.Log into 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.
  6. Wire sudo elevation (§7a.7). Add the sudo_elevations table and the Layout.AdminPINHash column (NOT NULL; the bootstrap migration seeds the system layout with a one-shot random PIN, logged once). Implement AuthService.Sudo / RevokeSudo and the LayoutService.UpdateAdminPIN rotation path (with the "blank field = no change" semantic). Add the POST/DELETE /api/v1/layouts/{id}/sudo endpoints and the auth.elevationChanged WS fan-out. Wire the existing janitor goroutine to reap expired rows and emit auth.elevationChanged. Plumb the sudo admin source through AuthService.Effective (a flat domain.EffectiveRoles set — sudo admin grants the same authority as a permanent admin everywhere). Cascade the cleanup on AuthService.Logout and LayoutService.Delete. Add the second endpoint POST/DELETE /api/v1/layouts/{id}/signalman driven by SudoService.GrantSignalman / RevokeSignalman — the engineer's-cap icon writes a permanent LayoutSignalman row with expires_at = NULL after 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) to AppShell.tsx, the shared <SudoPinDialog>, and the layout admin PIN field on the /admin/layouts settings page (with the "leaving blank does NOT reset the PIN" helper, §7a.7.5). Ship pl/sudo.json
    • en/sudo.json and 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.

  1. Add interlockings, interlocking_sessions and takeover_requests tables and services, all filtered through the active layout's LayoutInterlocking whitelist. Implement the 15-second takeover state machine whose grant creates a 5-minute self-lease to the signalman (reusing VehicleLease / 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 SQLite radio_messages table): 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 via radio.replay / the read-only REST endpoints (GET …/interlockings/{id}/radio, GET /api/v1/radio/mine). Add the per-target emergency stop system.estopTarget („Zatrzymaj skład"). Wire AuditService.Log for session.emergency_executed emitted by the dead-man's switch handler in the Hub.
  2. 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 the useRadioSounds() hook (radio-sent.ogg / {phrase}.ogg under web/public/sounds/interlockings/). Add layout_vehicles table 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.

  1. Add the api_keys table, 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.
  2. Add the pkgs/bigfred/server/mcp package using github.com/mark3labs/mcp-go. Wire the SSE handler under /mcp behind the API-key middleware, and add a loco server --mcp-stdio subcommand for local clients (Claude Desktop / Cursor). Expose the curated tool surface listed in §7b.3.

M7 – Server-side scripts (Goja, sibling scripts-executor process).

  1. Add the scripts and script_attachments tables, ScriptService, ScriptSecurityContext and the REST endpoints listed in §4.1. Enforce the 64 KiB source cap, the Vehicle-XOR-Train attachment invariant and the DeadlineSec ∈ [1, 600] range at both the DB and service layers. Wire AuditService.Log into every Script and ScriptAttachment mutation (create / update / delete / attach / detach).
  2. Build pkgs/bigfred/server/scripts/runtime.go: a Runtime struct that embeds *goja.Runtime, wires findFirstLoco, findByDCCAddr, members, sleep, log and the Vehicle helper via vm.Set + UncapFieldNameMapper, and exposes Run(ctx context.Context, src string) error that arms vm.Interrupt on context cancellation and timeout. Cover with runtime_test.go running the canonical scenario (findFirstLoco, setSpeed, findByDCCAddr(815), funcOn, sleep, funcOff) against a stubbed LocoService and asserting the exact sequence of SetSpeed / SetFunction calls.
  3. Build pkgs/bigfred/server/executor/: the length-prefixed JSON codec, the Client used in server, the Server used in scripts-executor, and the Supervisor (exec the child, exponential backoff, health pings, in-flight run accounting). The supervisor must surface system.status { scriptsExecutor } over WS and the "Scripts unavailable" banner when it gives up.
  4. Add the pkgs/scripts-executor/ package with main.go and a loco scripts-executor cobra command. The binary is built from the same Go module as loco server; CI builds both and the Makefile gets a make scripts-executor target.
  5. Ship the Scripts page (web/src/pages/ScriptsPage.tsx): list, Monaco editor with language="javascript", icon picker, attachment management, deadline slider. Add ScriptButtons.tsx and ScriptConsole.tsx to the vehicle and train control views so attached scripts render next to F0F31 and per-run logs surface inline.
  6. 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.StopAllForUser invoked from the Hub before SetSpeed(0) fan-out). Integration test: kill the executor mid-run with SIGKILL and assert every in-flight runId receives script.runStopped { reason:"executor_crashed" } within the supervisor's first detection cycle, while throttle commands on unrelated vehicles continue to round-trip.

M8 – Polish.

  1. Redis (cache + Pub/Sub for multi-instance fan-out), background poller upgrades, optimistic UI tweaks, accessibility audit on the MUI screens.