vehicle.dcc_address, when set, is globally unique |
DB partial UNIQUE index on dcc_address WHERE dcc_address IS NOT NULL – dummies (NULL DCC) freely coexist |
| Dummy vehicles cannot be driven |
VehicleService.RegisterVehicle allows DCCAddress = nil; LocoService rejects throttle ops when Vehicle.DCCAddress is nil with vehicle_is_dummy |
| A user can only register vehicles in their DCC pool |
VehicleService.Register/Update validates DCCAddress != nil → exists DCCAddressRange where addr ∈ [from,to]; dummies skip the pool check |
vehicle.kind is in the closed catalogue |
DB CHECK (kind IN ('loco','emu','driving_wagon','trolley','wagon')) + service-side validation |
| At most one active lease per vehicle/train |
LeaseVehicle transaction + partial unique index on active rows |
| Lessee cannot edit, only drive |
Authorization middleware (§11) |
| Exactly one signalman per interlocking |
Partial unique index UNIQUE(interlocking_id) WHERE ended_at IS NULL |
| Interlocking displacement requires explicit confirmation |
InterlockingService.Join(..., force bool) rejects force:false when a session is active; force:true ends the incumbent session with reason "displaced" before opening a new one (§6.3d) |
| A vehicle appears at most once on a layout roster |
UNIQUE(layout_id, vehicle_id) on layout_vehicles |
| Only the vehicle owner may add/remove it from a layout roster |
LayoutVehicleSecurityContext.CanMutateRoster checks vehicle.OwnerUserID == actor.ID |
| Temporary role expires automatically |
Filtered out in AuthService.Roles(); janitor goroutine reaps |
| Takeover auto-grant after 15 s with no rejection |
TakeoverService timer + AutoGrantAt column |
| API key lifetime ≤ 365 days |
APIKeyService.Create validates expires_at - now() ≤ 365d |
| API key plaintext never stored |
Only KeyHash and KeyPrefix are persisted |
| API key inherits owner's effective roles & DCC pool |
APIKeyService.Verify returns the same auth.Identity as login |
| The system layout always exists and is unique |
Bootstrap migration seeds one row with name='default', is_system=true, locked=false; admins cannot delete it; partial unique index UNIQUE(is_system) WHERE is_system = TRUE guarantees uniqueness |
| The system layout cannot be locked |
DB CHECK NOT (is_system = TRUE AND locked = TRUE); LayoutService.Lock rejects with default_layout_cannot_be_locked |
| Every non-system layout has at least one attached command station |
LayoutService.Create requires non-empty commandStationIds; LayoutService.DetachCommandStation rejects with layout_needs_at_least_one_command_station if it would leave the layout with zero rows; CommandStationService.Delete rejects with 409 for the same reason |
| The system layout's command-station set is virtual (= full catalogue) |
No LayoutCommandStation rows exist for is_system = TRUE; DB CHECK on the join table forbids inserts pointing at the system layout; LayoutService.AttachCommandStation / DetachCommandStation on the system layout return 422 default_layout_command_stations_immutable; the LIST endpoint synthesises the set from command_stations |
A drive session's LayoutID is set at login and immutable |
The JWT carries { userId, layoutId }; the WS upgrade reads layoutId from the token and writes it once to DriveSession.LayoutID; there is no session.setLayout action and no /layouts/{id}/join route |
| Logging in to a locked layout is impossible |
AuthService.Login rejects Layout.Locked == true with 422 layout_locked; GET /api/v1/layouts/login filters out locked rows server-side so the dropdown never offers them |
| Locking a layout never kicks live sessions |
LayoutService.Lock only flips the locked column and writes the audit row; it never enumerates or touches DriveSessions; existing sessions in the now-locked layout finish on their own |
| The picked command station must belong to the session's layout |
LayoutService.CommandStationIsAttached(layoutID, commandStationID) is the gate behind session.setCommandStation; for non-system layouts it checks LayoutCommandStation, for the system layout it checks command_stations directly; mismatch returns ack { error:"command_station_not_attached_to_layout" } |
| Changing the picked command station mid-session runs the emergency plan |
session.setCommandStation with a different value runs EmergencyPlan against the previous CommandStationID before re-pointing the session; identical to the dead-man's switch code path (§4.5) |
| Deleting a command station cascades to live sessions |
CommandStationService.Delete removes every LayoutCommandStation row pointing at the station and detaches every live DriveSession pinned to it (CommandStationID → nil + broadcast session.commandStationChanged); the deletion itself still fails with 409 if any non-system layout would be left without command stations |
| Signalman role is layout-scoped |
AuthService.Effective(user, layoutID) unions LayoutSignalman rows for that layout only |
| Interlocking visibility filtered by layout |
InterlockingService.List(layoutID) returns only LayoutInterlocking whitelisted rows |
| Command station edits are admin-only |
CommandStationSecurityContext.CanEditCommandStation (§7a.3) returns Deny for non-admin |
| Vehicle function number ∈ [0, 31] |
DB CHECK (num BETWEEN 0 AND 31) on dcc_functions + service validation |
Each dcc_functions row owned by template XOR vehicle |
DB CHECK exactly one of vehicle_id, template_id is non-NULL |
(vehicle_id, num) / (template_id, num) are unique per owner |
Partial UNIQUE indexes on dcc_functions; service catches collisions |
| Only the vehicle's owner may edit its function definitions |
FunctionSecurityContext.CanEditFunctions (lessees and signalmen are denied even with active driving authority) |
| Editing a linked vehicle detaches it via copy-on-write |
FunctionService.EnsureDetached runs as the first step of every mutation, in the same transaction |
| Deleting a referenced template requires explicit cascade |
TemplateService.Delete returns 409 unless cascade=true; with cascade, every linked vehicle is detached first |
| A script attachment binds to a Vehicle XOR a Train |
DB CHECK ( (vehicle_id IS NULL) <> (train_id IS NULL) ) + service validation |
| Only the script's owner may edit it |
ScriptSecurityContext.CanEditScript (lessees seeing it via leased vehicle can only run) |
| A script can never escalate its user's driving authority |
Every Goja binding routes through LocoService/TrainService in server, which re-checks LocoSecurityContext.CanDriveLoco against the current session state on every call – the same code path a manual throttle takes |
| Script source size cap |
ScriptService.Save rejects sources larger than 64 KiB with 422 |
| The same script appears at most once per throttle |
UNIQUE(script_id, vehicle_id) WHERE vehicle_id IS NOT NULL + analogous index on train_id |
| One running goroutine per VM |
The executor creates exactly one *goja.Runtime per active run and owns it from one goroutine; never shared (Goja VMs are not goroutine-safe per its FAQ) |
| Every run has a hard wall-clock deadline |
The executor arms time.AfterFunc(Script.DeadlineSec, vm.Interrupt) at run start; default 60 s, hard cap 600 s validated by ScriptService.Save |
At most one active run per ScriptAttachment per user |
ScriptService.Run rejects a second press on the same attachment from the same user with ack { ok:false, error:"already_running" }; different attachments and different users run independently |
| Executor process crash never affects the DCC bus |
The command station lives in server only; on executor crash server fan-outs script.runStopped { reason:"executor_crashed" } to every affected session and respawns the executor (§7.x) |
| Script source is never sent to the executor over the wire... that user does not own |
The RPC connection is local-only (Unix socket, 0600) – source travels server→executor only for the runs server itself authored on the user's behalf |
| A train slider moves every powered, non-excluded member in lock-step |
train.setSpeed fans Station.SetSpeed over every member with a DCC address and excludeFromSpeed == false; timing fields may defer individual writes via TrainSpeedScheduler |
train.setSpeed is best-effort, not atomic |
DCC bus has no multi-address atomic write; the WS ack returns per-member {ok, error?} so the UI can surface a partial failure rather than silently desync the consist |
Every layout has a non-empty AdminPINHash |
LayoutService.Create requires a non-empty adminPin in the request; the bootstrap migration that seeds the system layout sets a one-shot random PIN (logged once on first boot) so the column is NOT NULL by construction. LayoutService.UpdateAdminPIN rejects an empty payload with pin_missing (UI helper – the page doesn't even fire the request when the field is blank, so the "no reset" semantic is enforced client-side too) |
| Layout admin PIN never appears in plaintext at rest |
LayoutService.Create and LayoutService.UpdateAdminPIN argon2id-hash the PIN with a per-row salt before writing; the request struct field is overwritten in memory after hashing; audit Metadata for layout.admin_pin_changed carries only the first 8 chars of the previous hash prefix for forensic correlation, never the plaintext |
| At most one active sudo elevation per (user, layout) |
UNIQUE index UNIQUE(user_id, layout_id) on sudo_elevations; SudoService.Sudo upserts the row, so a second click while a row is still live simply pushes ExpiresAt forward (the "renew the timer" path). The janitor goroutine reaps WHERE expires_at <= now() every cfg.JanitorInterval (default 10 s) |
| Sudo elevation TTL is bounded |
SudoService.Sudo writes ExpiresAt = now() + cfg.SudoTTL, where cfg.SudoTTL is validated at server startup to lie in [1m, 10m]; the default is 2m. Configuration outside that window is a fatal startup error |
| Sudo admin == permanent admin everywhere |
domain.EffectiveRoles is a flat set; the policy layer asks eff.Has(domain.RoleAdmin) and never branches on the source of the membership. A sudo admin can rename the layout, attach/detach stations, manage signalmen, rotate the admin PIN, delete the layout — every operation a permanent admin can do (§7a.7.6). The 2-minute window plus the rate-limiter on the PIN dialog are the only guard rails |
| Logging out clears the user's active sudo elevation |
AuthService.Logout deletes the caller's SudoElevation row for the JWT-pinned layout (if any) in the same transaction as the cookie clearing; auth.elevationChanged is broadcast to every other live session of the user |
| Deleting a layout cascades to its sudo elevations |
LayoutService.Delete removes every SudoElevation row with layout_id = <id> in the same transaction; affected sessions get auth.elevationChanged (best-effort: a layout with active sessions is rejected with 409 layout_in_use first, so this branch is rarely exercised) |
| Sudo PIN attempts are rate-limited per (userId, layoutId) |
SudoService.Sudo and SudoService.GrantSignalman share an in-memory rolling-window failure counter keyed on (user_id, layout_id); after cfg.MaxFailures misses inside cfg.FailWindow the tuple is soft-locked for cfg.LockDuration and auth.sudo_locked is audited |
| Signalman icon writes a permanent grant |
SudoService.GrantSignalman upserts a layout_signalmen row with expires_at = NULL after PIN verification; the membership lasts until the user (or an admin) deletes it. There is no janitor for these rows because they are not time-bounded by design (§7a.7.2) |