Home

03 invariants

3a.3 Invariants enforced by services + DB constraints

Invariant Enforced by
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)