Part 10 of The Operator’s Cockpit, the series on driving one GopherTrunk daemon through one REST + SSE API from browser and terminal alike. Every panel so far has been a renderer over read-only endpoints. This post crosses to the write side — how the same API lets you retune, lock out a talkgroup, or hot-swap a whole config file, and how it refuses to lie to you about what actually took effect.
TL;DR: Write-mode is built on three ideas. A capability gate:
GET /api/v1/mutationsreportscan_mutateplus per-subsystem writability so a UI lights up write actions only when they’d succeed. Honest PATCH semantics: settings and talkgroup edits use pointer fields so JSON-omitted keys mean “leave alone”, and the response splits fields intoapplied(took effect now) vsrestart_required(written but needs a restart). Validate → activate: swapping the whole config file resolves the path against an allow-list, then either hot-reloads in place or re-execs the daemon — with an mtime guard that returns409rather than clobbering an external edit.
Key takeaways
- The daemon tells the UI what it can do.
can_mutateand the*_writableflags mean write buttons appear only when the wiring and auth allow them — no probing for a403. - Pointer fields preserve intent. A PATCH carries only the keys you set;
omitted fields are never zeroed, because the wire types use
*Tand the daemon dispatches only non-nil ones. - “Applied” vs “restart required” is a first-class answer. Every mutating response says which fields hot-applied and which were only written to disk, so the UI can render an honest “restart required” badge.
- Config swaps validate before they act, and won’t clobber. The path is
allow-listed and stat-checked; a
reloadreports what it could hot-apply, arestartre-execs; a stale-mtime write returns409, not a silent overwrite.
Cheat sheet
| Endpoint | What it does | Where it lives |
|---|---|---|
GET /api/v1/mutations |
capability gate: can_mutate + *_writable |
internal/api/handlers_mutations.go (handleMutationStatus) |
PATCH /api/v1/talkgroups/{id} |
pointer-field policy edit | handlers_mutations.go (handleUpdateTalkgroup) |
POST /api/v1/calls/{serial}/end |
force-release a call | handlers_mutations.go (handleEndCall) |
PATCH /api/v1/settings |
live edit → applied / restart_required | internal/api/handlers_settings.go (handleSettingsPatch) |
applyHotReload |
route each field to hot or cold | handlers_settings.go |
POST /api/v1/config/activate |
reload or restart into a config file | internal/api/handlers_config_activate.go |
In this post
- The capability gate — how the UI knows what it may write.
- Pointer-field PATCH — leave-alone semantics on the wire.
- Hot vs cold — the applied / restart-required split.
- The mtime guard — why an external edit gets a
409. - Validate → activate — swapping the running config safely.
The capability gate
Before a UI shows a single write control, it asks the daemon what it’s allowed to
do. GET /api/v1/mutations always answers 200 with a capability snapshot — the
auth mode, whether this request would be accepted, and which subsystems are even
wired for mutation:
// internal/api/handlers_mutations.go (shape)
func (s *Server) handleMutationStatus(w http.ResponseWriter, r *http.Request) {
canMutate := s.auth.canMutate(r)
writeJSON(w, http.StatusOK, map[string]any{
"auth_mode": s.auth.mode.String(), // "auto" | "required" | "disabled"
"can_mutate": canMutate, // would this request pass auth?
"allow_mutations": canMutate, // legacy alias (deprecated)
"engine_writable": s.mutator != nil, // can we end calls / retune?
"retention_writable": s.retention != nil, // can we sweep retention?
"tones_writable": s.tones != nil, // can we reset tone-out?
})
}
That single call is why the browser and TUI can light up write keybindings
instead of probing a real endpoint and catching a 401/403. The *_writable
flags are granular enough that a panel can show the exact reason a button is
inert — “tone-out detector not wired” versus “mutations disabled at daemon” —
which is far kinder than a greyed-out button with no explanation. The TUI ANDs
this with its own --write flag; the browser gates on the same snapshot. The
daemon is the authority on capability, and it says so up front.
Pointer-field PATCH: leave-alone on the wire
The mutating edits — talkgroup policy, settings — are all partial updates, and
GopherTrunk encodes “partial” the same way everywhere: pointer fields. A talkgroup
PATCH body is a struct of *T, so a JSON-omitted field decodes to nil and is
never applied, and only the keys you actually sent are written:
// internal/api/handlers_mutations.go (shape)
type updateTalkgroupRequest struct {
Priority *int `json:"priority"`
Lockout *bool `json:"lockout"`
Scan *bool `json:"scan"`
Stream *bool `json:"stream"`
// …Record, Mute, Icon — all pointers, all optional
}
func (s *Server) handleUpdateTalkgroup(w http.ResponseWriter, r *http.Request) {
// …parse id, decode req; reject an all-nil body as 400
s.talkgroups.UpdateFields(uint32(id), func(tg *trunking.TalkGroup) {
if req.Priority != nil { tg.Priority = *req.Priority }
if req.Lockout != nil { tg.Lockout = *req.Lockout }
if req.Scan != nil { tg.Scan = *req.Scan }
// …only non-nil fields are applied
})
writeJSON(w, http.StatusOK, talkgroupToDTO(s.talkgroups.Lookup(uint32(id))))
}
An all-nil body is a 400 (“supply at least one of…”), a missing talkgroup is a
404, and success returns the full updated record so the UI re-renders from truth
rather than optimistically guessing. The pattern repeats on the simpler mutators:
handleEndCall force-releases the call on a device serial (with an optional
reason, defaulting to manual) and 404s when no call holds that device;
handleRetentionSweep and handleToneReset each 503 when their subsystem isn’t
wired. Every one of them is either a clean success with the new state, or a
specific, actionable status code.
Hot vs cold: the applied / restart-required split
Settings are where “did it take effect?” gets interesting, because some fields the
daemon can change live and others it genuinely can’t. PATCH /api/v1/settings
takes a pointer-field patch (same leave-alone rule), writes it to config.yaml,
then walks the patch and dispatches each field to a live applier when one exists —
returning two lists so the UI can tell the truth:
// internal/api/handlers_settings.go (shape)
func (s *Server) applyHotReload(p config.Patch) (applied, restartRequired []string) {
app := s.settings // may be nil — then every field falls back to cold
if p.AudioVolume != nil {
if app != nil { app.SetAudioVolume(*p.AudioVolume); applied = append(applied, "audio.volume") }
else { restartRequired = append(restartRequired, "audio.volume") }
}
if p.LogLevel != nil {
if app != nil && app.SetLogLevel(*p.LogLevel) == nil { applied = append(applied, "log.level") }
else { restartRequired = append(restartRequired, "log.level") }
}
// …audio.muted/enabled, scanner.scan_mode, recordings.write_raw/enhance — hot when applier present
// …then a table of cold-only keys (api.http_addr, sdr.sample_rate, storage.path, …) → restartRequired
return applied, restartRequired
}
Some fields — the listen address, the SDR sample rate, the storage path — simply
cannot change under a running daemon, so they’re written to config.yaml and
listed in restart_required. The others hot-apply through a SettingsApplier
interface (volume, mute, log level, scan mode, the voice-enhance toggle) and land
in applied. The response carries both lists plus a fresh runtime snapshot, and
the UI renders a “restart required” badge on exactly the cold fields. Nothing is
silently ignored, and nothing pretends to have taken effect when it didn’t.
How that principle shaped the Go code
- The applier is optional.
s.settingscan be nil; when it is, every field falls torestart_required. The code degrades to “written but not applied” rather than crashing or silently dropping. - The writer is an interface.
ConfigWriterandSettingsApplierare seams, so the API package doesn’t pull in the OS file machinery and tests can fake both. Path()empty means read-only. A daemon started without-confighas no file to write; the endpoint503s and the UI renders Settings read-only rather than offering edits that can’t persist.
The mtime guard: a 409, not a clobber
Config edits can race. You might be editing config.yaml in a text editor while
the browser tries to PATCH it. GopherTrunk refuses to be the one that loses your
work: the writer stamps and checks the file’s mtime, and a mismatch surfaces as a
distinct conflict rather than a generic failure:
// internal/api/handlers_settings.go (shape)
if _, err := s.configWriter.WritePatch(patch); err != nil {
if isExternalEditConflict(err) { // "modified externally"
s.writeError(w, http.StatusConflict, err.Error()) // 409 → the UI can say
return // "reload, it changed under you"
}
s.writeError(w, http.StatusBadRequest, err.Error())
return
}
The 409 is deliberate: it lets the UI present a clear “the config changed under
you — reload before saving” toast instead of a scary generic error, and it means
the daemon never overwrites an external edit it didn’t see. Safe write is as much
about not writing at the wrong moment as it is about the write itself.
Validate → activate: swapping the whole config
The heaviest write is repointing the daemon at a different config file. That’s
POST /api/v1/config/activate, and it’s built as two clean phases — validate,
then activate — with the activation itself offering two modes:
// internal/api/handlers_config_activate.go (shape)
func (s *Server) handleConfigActivate(w http.ResponseWriter, r *http.Request) {
if s.configActiv == nil { s.writeError(w, 503, "activation not supported"); return }
// — validate —
path, err := s.configBuilder.resolvePath(req.Path) // allow-list + .yaml/.yml
if err != nil { s.writeError(w, 400, "config: "+err.Error()); return }
if _, err := os.Stat(path); err != nil { s.writeError(w, 404, "config: "+err.Error()); return }
switch req.Mode {
case "", "reload": // hot-apply what it can, report the rest
applied, restartRequired, aerr := s.configActiv.ActivateReload(path)
// …400 on load error, else 200 with { applied, restart_required }
case "restart": // re-exec the daemon so every field takes effect
// …202 Accepted; the process tears down and re-execs — the SPA expects a reconnect
}
}
The resolvePath step is the safety fence: the requested path is checked against
the config builder’s allow-list of directories and must carry a .yaml/.yml
extension before anything touches it (the same resolver we’ll meet again driving
the config builder in Part 13). A reload returns the familiar
applied / restart_required split — it hot-applies what the daemon can and
tells you what still needs a restart. A restart re-execs the process and returns
202, a signal to the SPA that a disconnect-and-reconnect is coming. Both modes
share the same validation front door; they differ only in how completely they take
effect. The finished experience — editing, staging, and activating live — is the
Live edits operator surface.
Where this goes next
Part 11 leaves the API and looks at the frame around it: the mobile shell, the command palette, and installing the console as a PWA on a phone — how one React layout serves a desktop sidebar and a phone’s bottom nav from a single nav registry, and how the whole SPA precaches so it opens instantly against a Raspberry Pi in the field.
FAQ
How does the UI know whether it can write at all?
It reads GET /api/v1/mutations — can_mutate says whether the current request
would pass auth, and the *_writable flags say which subsystems are wired. The
browser and TUI light up write controls from that snapshot instead of probing for
a rejection.
What happens to fields I don’t include in a PATCH?
Nothing — they’re left alone. The wire types use pointer fields, so a JSON-omitted
key decodes to nil and is never applied. Only the keys you send are written.
Why does a settings change sometimes say “restart required”?
Because some fields can’t change under a running daemon (the listen address, the
SDR sample rate, the storage path). Those are written to config.yaml and returned
in restart_required; the hot-reloadable ones (volume, mute, log level, scan mode,
voice-enhance) are applied immediately and returned in applied.
What’s the 409 on a settings save?
The config file’s mtime changed since the daemon last read it — usually because
you edited it externally. Rather than clobber that edit, the daemon returns 409
so the UI can prompt you to reload before saving.
What’s the difference between reload and restart on config activate?
reload hot-applies what it can and reports what still needs a restart; restart
re-execs the daemon so every field takes effect and returns 202, warning the
SPA to expect a reconnect. Both validate the path against an allow-list first.
Series navigation
Part 10 of 14 · ← Part 9: The Map — Plotting Sites & Emitters · Next → Part 11: The Mobile Shell, Command Palette & PWA