Problem Statement
Three bug clusters undermine trust in dezhban day-to-day:
- Settings don't take effect. Editing config (CLI or GUI) changes nothing
until a full daemon restart; declining the GUI's restart prompt leaves the
saved settings silently inert, and the GUI can show values that don't match
what the daemon is enforcing.
- Posture is slow and opaque. After the VPN redials out of FULL BLOCK, the
guard takes up to poll-interval × hysteresis (~30s at defaults) to restore,
with no indication that recovery is even in progress.
- The GUI occasionally beachballs with no reproducible pattern.
Beyond bugs, the two surfaces have drifted: pause/resume exist only in the CLI,
status wording is written twice and differs, the app calls the same thing "kill
switch", "protection", and "guard" depending on the pane, and the advanced
config block is unreachable from any tool. Finally, the settings surface serves
neither audience well: beginners face raw keys and durations; power users still
can't reach vpn.advanced.* without editing JSON by hand.
Solution
- Live reload: a
reload control-socket op re-reads the root-owned config
from inside the run loop; config set and the GUI trigger it, falling back to
restart only for the few restart-required keys. A keychain-gated token
(biometry-protected, ADR 0003) authorizes a config-write op so GUI settings
changes stop requiring sudo, and the sudo path that remains gets a real Touch
ID retry via SUDO_ASKPASS.
- Fast, visible recovery: a tunnel-up edge while blocked starts accelerated
geo probes until the hysteresis streak resolves (hysteresis itself unchanged),
and the snapshot carries recovery progress ("1/2 good readings") that both
surfaces display.
- Beachball hunt: a main-thread hang watchdog logs stalls to the persistent
log, plus defensive fixes for the plausible suspects (state-file stat moved
off-main).
- One voice: a Go renderer turns a Snapshot into headline + detail
sentences; the CLI prints them, the state file and status --json carry them,
the GUI displays them. Vocabulary standardizes on the Guard family everywhere.
- Parity: GUI gains Pause/Resume (with countdown), an explicit Restart,
a Doctor results pane, and profile visibility/picker.
- Tiered settings, no lost flexibility: Strict/Balanced/Relaxed presets
defined once in the config core with drift-to-Custom detection, exposed as
config preset in the CLI and a picker in the GUI; every vpn.advanced.*
key becomes settable via config set and a collapsed GUI Advanced pane.
Commits
Each commit leaves the tree building, tests green, and behavior coherent.
Phase A — diagnostics and defensive fixes (bugs first)
- Add a main-thread hang watchdog to the GUI. A background heartbeat pings
the main run loop; if a ping goes unanswered past a threshold, log the stall
duration and app state to the persistent log. Display-only; no behavior
change.
- Move the 1-second state-file stat/read off the main thread. The timer
fires a background read; only the decoded result hops to main. Likely-suspect
fix for the beachballs; identical UI behavior.
- Add a table-driven config round-trip test asserting every settable key
travels file → load → normalize → runner options without being dropped.
Pins the "a specific key is inert" class of bug and becomes the harness later
commits extend.
Phase B — live reload (the settings bug)
- Extract the config→runner-options derivation into a pure function the
run loop can call again later. Pure refactor; existing tests keep passing.
- Teach the run loop to re-derive options from a freshly loaded config via
a new channel case, applying live-safe changes and reporting which keys need
a restart. No caller yet; unit-tested directly against the fake backend.
- Add the
reload op to the control protocol and server whitelist, wired
to the new channel. Gate mirrors existing op gating.
config set sends reload after a successful write, printing what
applied live and what needs restart; silent no-op message when no daemon
answers.
- GUI applies settings via
reload instead of batching a restart, keeping
restart as the fallback for restart-required keys or an unresponsive daemon.
Footer reports applied-live vs needs-restart honestly.
- Fix the GUI stale-values case: re-seed the Settings pane from the config
file on window open and when the file's mtime changes externally.
Phase C — auth (rest of ADR 0003)
- Give the sudo path a Touch ID retry: ship a tiny askpass helper, switch
the elevation path to sudo -A, drop the null-stdin defect-to-password
behavior. Lifecycle ops (install/start/stop/panic) benefit immediately.
- Introduce the control token: daemon reads a hash at its state dir;
socket ops can require token auth. No writer yet.
- Add the
config-write socket op, token-gated, letting an authorized
client submit validated config changes the daemon writes and reloads
itself.
- GUI stores the token biometry-gated in the keychain and uses
config-write for settings changes — reading the token IS the Touch ID
prompt, with native retry and password fallback. Sudo remains only for
lifecycle ops.
- Flip ADR 0003 to implemented in the ADR index; docs for the token flow.
Phase D — recovery speed and visibility
- Publish recovery progress in the Snapshot (current streak / needed)
whenever the daemon is counting toward a posture flip; CLI status prints
it. Display-only.
- Fast-probe on tunnel-up while blocked: the up edge schedules accelerated
geo probes until the streak resolves, then cadence returns to
pollInterval. Hysteresis and the hold-on-unknown invariant unchanged;
runner tests pin edge → accelerated probing → GUARD.
Phase E — one renderer, one vocabulary
- Add a Go render package: Snapshot → headline + detail sentences in the
Guard vocabulary, golden-tested for every posture and window state.
- CLI
status adopts the renderer for its human summary (structured
lines stay).
- State file and
status --json carry the rendered strings.
- GUI displays the carried strings, deleting its hand-written sentence
copies; menubar tooltip and Overview headline come from the renderer.
- Vocabulary sweep: Guard-family labels across GUI panes, CLI help, docs,
and the stale modes HTML page; glossary updated as the authority.
Phase F — GUI parity surfaces
- Pause/Resume in the GUI with pauseMax countdown, using the existing
socket ops — copy makes clear it relaxes the guard and auto-reverts.
- Explicit Restart control in the GUI (was only implicit in settings
apply).
doctor gains machine-readable output; then a Doctor pane in the
GUI renders the same checks.
- Profile visibility/picker: show
vpn.profiles and the matched
activeProfile; open a profile-targeted switch window from the GUI.
Phase G — tiered settings
config set learns every vpn.advanced.* key, extending the Phase A
round-trip test to cover them.
- Preset definitions in the config core (Strict / Balanced / Relaxed as
key-value sets with cost/benefit descriptions) plus a drift function
reporting which keys diverge from a preset. Pure logic + tests.
- CLI
config preset list / show / apply / diff, apply routed through
the same write+reload path as config set.
- GUI preset picker showing cost beside benefit, with drift-to-Custom
detection and a diff of divergent keys.
- GUI Advanced disclosure pane exposing
vpn.advanced.* behind the
"touch only if you know why" warning.
Phase H — Swift test target
- Add the first SPM test target covering Snapshot decoding, posture→icon
mapping, and the settings seed/apply batching logic; wire into CI where the
runner allows (macOS job).
Docs and CHANGELOG entries land in the same commit as each behavior change, per
repo convention.
Decision Document
- Reload semantics:
reload re-reads the root-owned config file — it
grants no new write authority, so it rides the existing op-gating model.
config-write DOES grant write authority and is therefore token-gated
(keychain, biometry-protected). Two ops, deliberately different trust levels.
- Live-safe vs restart-required keys: the run loop applies what it can
(intervals, hysteresis, countries, providers, windows, VPN sets); keys wired
before the loop starts (control socket path/group, log destination, service
identity) report restart-required. The reload response enumerates both so no
surface ever claims a change applied when it didn't.
- Recovery acceleration changes cadence only, never the rules: hysteresis
still gates the flip, unknown still holds posture, the accelerated probe uses
the same tunnel+destination-scoped provider pass. No new window trigger, no
invariant change, no new ADR needed for it.
- The renderer is the single voice: Go renders once; every surface displays
the same strings. Swift never composes posture sentences again. Vocabulary is
the Guard family: Guard / Full block / Standby / Paused / Window open.
- Pause in the GUI is trigger 3 unchanged — same socket op, same pauseMax
cap, same auto-revert; only presentation is new.
- Presets are a write-time macro, not runtime state: applying a preset
writes ordinary keys through the ordinary path; the daemon never knows about
presets. Drift detection compares current config to preset definitions at
display time. This keeps vpn.profiles (VPN identities) cleanly distinct
from presets (strictness strategies).
- No new Go dependencies. The askpass helper is trivial; keychain access is
Swift/Security-framework; presets and renderer are stdlib.
- Beachball work is diagnose-then-fix: the watchdog plus off-main file
reads ship now; any further fix waits for watchdog evidence rather than
guessing.
Testing Decisions
- Tests assert external behavior — published snapshots, socket responses,
rendered strings, written config — never internal call sequences.
- Go: runner tests with the fake backend/monitor (prior art: the existing
runner package tests) cover reload application, restart-required reporting,
and the tunnel-up fast-probe path. Config round-trip table pins every key
including advanced. Renderer uses golden-string tests per posture. Control
tests (prior art: the existing control package tests) cover the new ops and
token gating.
- Swift: new SPM test target pins Snapshot decoding, posture→icon mapping,
and settings batching — the seams where GUI bugs actually lived.
- Privileged on-host checks (CI can't run them) get new entries in the
standing checklist: live reload against a running daemon, Touch ID retry
behavior, token enrollment/revocation, pause countdown.
- Behavior-preservation for refactor commits: build the parent commit in a
scratch worktree and diff print-rules output (stdout AND stderr) across
configs × modes.
Out of Scope
- Any change to enforcement invariants: no fourth window trigger, no shared
caps, no relaxation of the tunnel+destination-scoped geo pass, no change to
hold-on-unknown.
- Linux/Windows GUI work (Go-side changes remain cross-platform; GUI work is
the macOS app).
- Trusted-networks/SSID features, notification granularity, config
history/rollback (backlog items not pulled in here).
- The upgrade pipeline and its CanActivate gating.
- Removing any existing setting or capability — this epic only adds reachability
and layers presets on top.
Further Notes
- Phases are ordered so the bug fixes (A–D) land before the larger surface work
(E–G); each phase is independently shippable and PR-able.
- ADR 0003 is the only ADR this epic ships; flip its status in the same PR as
the token work.
- The stale modes HTML page rewrite rides the vocabulary sweep commit.
Addendum — icon truthfulness, first-run, in-app docs, settings UX
Seven follow-up items. Investigation notes that changed their shape:
- Icon state logic already exists.
PostureUI.guardHoldsDownedTunnel already
maps "guard posture, no tunnel up" to the red blocked icon, and
PostureUI.dockState already narrows the Dock tile to blocked-vs-default. What
is wrong is narrower than it looked (below).
- The guard-holds-downed-tunnel state is never published. On a tunnel-down
edge the run loop opens the automatic reconnect window in the same loop
iteration, so the snapshot goes straight from guard to switch-window. The
red state is therefore unreachable on the automatic path — it only appears when
the window is suppressed (reconnect disabled, flap guard, standby, full block).
- A reconnect window does not cut egress. It deliberately relaxes the guard so
the VPN can redial, so the real IP may be exposed. Red there would invert the
icon's meaning at the moment it matters most; amber stays correct.
- Disabling the windows already works (
"0" / config.Disabled on
switchWindow, reconnectWindow, pauseMax). The gap is purely that the GUI
offers no visible "Off", and pauseMax has no GUI control at all.
Phase I — icon truthfulness and brand assets
- Rename
gui/assets/ to gui/artifacts/. Mechanical: update the build
script's asset root, the README banner, docs image paths, and the source
comments that name the directory. Historical CHANGELOG entries keep their old
paths — they describe shipped releases and must not be rewritten. Drop the
stray .DS_Store and add an ignore rule.
- Dock icon shows the brand icon unless egress is cut. Today the
not-blocked Dock tile is staged from the icon-on state tile; it should be
the brand app icon, with icon-blocked only when actually blocked. A
build-script mapping change plus the matching comment; the coarsening logic in
dockState is already right.
- Publish the cut moment before opening the automatic window. The
tunnel-down edge writes a snapshot showing the guard holding a downed tunnel,
then opens the reconnect window. Makes an already-correct state observable
and lets both surfaces report the drop honestly instead of jumping straight to
"window open".
- "Hold the line" — a deliberate-disconnect affordance. dezhban cannot tell
an intentional disconnect from an accidental drop, which is the real cause of
the reported confusion: disconnecting by hand opens a reconnect window the user
never wanted. Add an explicit operator action that suppresses the automatic
window for the next drop, so a deliberate disconnect stays cut and red. This is
strictly more restrictive — it adds no relaxation trigger and needs no ADR.
Exposed on both surfaces alongside pause (pause = "let me use my real IP";
hold the line = "keep me cut").
- Sharpen the open-window copy so a relaxed guard is unmistakable —
the exposure and the remaining time lead the sentence. Rides the Phase E
renderer, so both surfaces change together.
Phase J — first-run setup in the GUI
- Extract the setup wizard's decisions and defaults into a Go core, leaving
the interactive prompt library as CLI-only presentation. Same questions, same
derived config, one place.
- Native first-run wizard in the GUI, driven by that core and writing
through the same validated write-and-reload path as every other settings
change. Runs on first launch and is re-openable later.
Phase K — in-app wiki and contextual help
- Bundle documentation into the app at build time. The build script copies
the selected markdown into app Resources so it ships with the version it
documents and works offline — which is exactly when the kill switch has cut
traffic. A CI check fails the build if a referenced doc is missing or renamed.
- A documentation browser pane rendering that bundled markdown natively,
with search and a tutorial track for first-time users.
- Contextual deep links from individual settings controls into the relevant
documentation section, so help is reachable where the question arises.
Phase L — settings UX and window controls
- Purpose-built duration controls. Replace raw duration text entry with a
preset menu plus a slider bounded by each field's real cap, a custom entry for
exact values, and an explicit Off choice wherever the disabled sentinel
applies — each stating its consequence in plain words. Expose vpn.pauseMax,
which currently has no GUI control.
- Content and layout pass across the GUI: ordering, grouping, headings,
field descriptions, empty and error states, and the shared CLI help text for
the same concepts. Follows the Phase E vocabulary so every surface reads the
same way.
Addendum decisions
- Red means egress is actually cut — no exceptions. An open switch, reconnect,
or pause window stays amber because traffic is flowing and the real IP may be
exposed. The fix for a deliberate disconnect is to suppress the window (item 35)
or disable it outright, not to recolor a relaxed state.
- "Hold the line" suppresses; it never relaxes. It removes an automatic
relaxation for one drop. The three sanctioned relaxation triggers are unchanged
and no fourth is introduced.
- Documentation stays single-source. The GUI renders the repo's markdown; it
never restates it. A doc referenced by the bundle is load-bearing, same rule as
doc paths cited from source.
- The setup core is shared, the presentation is not. The interactive prompt
dependency stays off the GUI and off the enforcement path.
Addendum testing
- Go: the snapshot published on a tunnel-down edge, window suppression after
"hold the line", and the setup core's question-to-config derivation.
- Swift: posture-to-icon and posture-to-Dock-tile mapping in the new test target,
including the guard-holds-downed-tunnel case that has no coverage today.
- Build: the docs bundling check, and a manual on-host pass confirming the Dock
tile swaps only when egress is cut.
Addendum 2 — defaults source of truth, menubar scope, pause presets
Phase M — one source of truth for defaults
Default values are currently restated in at least four places: the Go constants
in the config package, the GUI's placeholder hints, the documentation tables, and
the example config files. They have already drifted. The GUI hints advertise a
30s endpoint refresh and a 5s tunnel watch; the shipped defaults are 1m and 1s.
Anyone reading the settings pane is being told the wrong thing today.
- Promote defaults to declared metadata. Each tunable becomes a described
entry in the config package — key, default, cap, unit, whether the disabled
sentinel applies, and a one-line explanation — with the existing constants as
the values behind it. Pure addition; Normalize keeps its current behavior and
its tests keep passing.
- Every surface reads that metadata instead of restating it. The GUI derives
hints, slider bounds, and Off-availability from it; CLI help and validate
output do the same. The drifted hints disappear because nothing hardcodes them
anymore.
- A test fails the build when documentation or example configs disagree
with the declared defaults, so the config reference and the shipped examples
cannot silently rot again.
Phase N — menubar scope
The menubar becomes a glance-and-go surface: routine actions and read-only
information only. Everything else moves into the main window, which is where
detail belongs.
- Narrow the menubar to routine actions. Start and Stop the kill switch as
the primary pair, opening and cancelling a switch window, and Pause. Manual
block/unblock, profile and doctor detail, and configuration all move to the
window. Note that lifecycle actions require root every time — a daemon cannot
manage its own lifecycle — so the Touch ID retry work in Phase C is a
prerequisite for this to feel routine rather than punishing.
- Keep the read-only information that is actually worth a glance: current
posture in the shared vocabulary, tunnel and exit country, and any live window
countdown. Sourced from the Phase E renderer, so it matches the CLI word for
word.
- Demote Panic behind the Option key — the standard macOS treatment for a
destructive item. It stays reachable from the menubar on purpose: it is the
lockout escape hatch, and the situation where it is needed is exactly the
situation where the main window may not open.
Phase O — pause presets
- Pause offers predefined durations rather than a single fixed length or a
free-text duration: a short list covering the realistic cases, defined once in
the config core and consumed by both the CLI and the GUI so the two offer the
same choices. Each option states when protection returns. Options above the
configured cap are shown as unavailable with the cap as the reason, rather than
silently clamping to something the user did not pick. The existing cap,
disable-gate, and auto-revert behavior are unchanged.
Addendum 2 decisions
- Defaults are data, not prose. A default stated in more than one place is a
default that will drift; the metadata table is the only place a value is
written down, and every other surface derives from it.
- The menubar is for glancing and routine acts; the window is for detail.
The split is deliberate, and Panic is the one exception because its whole
purpose is working when everything else does not.
- Pause presets clamp nothing silently. A duration beyond the cap is refused
and explained rather than quietly shortened.
Problem Statement
Three bug clusters undermine trust in dezhban day-to-day:
until a full daemon restart; declining the GUI's restart prompt leaves the
saved settings silently inert, and the GUI can show values that don't match
what the daemon is enforcing.
guard takes up to poll-interval × hysteresis (~30s at defaults) to restore,
with no indication that recovery is even in progress.
Beyond bugs, the two surfaces have drifted: pause/resume exist only in the CLI,
status wording is written twice and differs, the app calls the same thing "kill
switch", "protection", and "guard" depending on the pane, and the advanced
config block is unreachable from any tool. Finally, the settings surface serves
neither audience well: beginners face raw keys and durations; power users still
can't reach
vpn.advanced.*without editing JSON by hand.Solution
reloadcontrol-socket op re-reads the root-owned configfrom inside the run loop;
config setand the GUI trigger it, falling back torestart only for the few restart-required keys. A keychain-gated token
(biometry-protected, ADR 0003) authorizes a
config-writeop so GUI settingschanges stop requiring sudo, and the sudo path that remains gets a real Touch
ID retry via
SUDO_ASKPASS.geo probes until the hysteresis streak resolves (hysteresis itself unchanged),
and the snapshot carries recovery progress ("1/2 good readings") that both
surfaces display.
log, plus defensive fixes for the plausible suspects (state-file stat moved
off-main).
sentences; the CLI prints them, the state file and
status --jsoncarry them,the GUI displays them. Vocabulary standardizes on the Guard family everywhere.
a Doctor results pane, and profile visibility/picker.
defined once in the config core with drift-to-Custom detection, exposed as
config presetin the CLI and a picker in the GUI; everyvpn.advanced.*key becomes settable via
config setand a collapsed GUI Advanced pane.Commits
Each commit leaves the tree building, tests green, and behavior coherent.
Phase A — diagnostics and defensive fixes (bugs first)
the main run loop; if a ping goes unanswered past a threshold, log the stall
duration and app state to the persistent log. Display-only; no behavior
change.
fires a background read; only the decoded result hops to main. Likely-suspect
fix for the beachballs; identical UI behavior.
travels file → load → normalize → runner options without being dropped.
Pins the "a specific key is inert" class of bug and becomes the harness later
commits extend.
Phase B — live reload (the settings bug)
run loop can call again later. Pure refactor; existing tests keep passing.
a new channel case, applying live-safe changes and reporting which keys need
a restart. No caller yet; unit-tested directly against the fake backend.
reloadop to the control protocol and server whitelist, wiredto the new channel. Gate mirrors existing op gating.
config setsendsreloadafter a successful write, printing whatapplied live and what needs restart; silent no-op message when no daemon
answers.
reloadinstead of batching a restart, keepingrestart as the fallback for restart-required keys or an unresponsive daemon.
Footer reports applied-live vs needs-restart honestly.
file on window open and when the file's mtime changes externally.
Phase C — auth (rest of ADR 0003)
the elevation path to
sudo -A, drop the null-stdin defect-to-passwordbehavior. Lifecycle ops (install/start/stop/panic) benefit immediately.
socket ops can require token auth. No writer yet.
config-writesocket op, token-gated, letting an authorizedclient submit validated config changes the daemon writes and reloads
itself.
config-writefor settings changes — reading the token IS the Touch IDprompt, with native retry and password fallback. Sudo remains only for
lifecycle ops.
Phase D — recovery speed and visibility
whenever the daemon is counting toward a posture flip; CLI
statusprintsit. Display-only.
geo probes until the streak resolves, then cadence returns to
pollInterval. Hysteresis and the hold-on-unknown invariant unchanged;runner tests pin edge → accelerated probing → GUARD.
Phase E — one renderer, one vocabulary
Guard vocabulary, golden-tested for every posture and window state.
statusadopts the renderer for its human summary (structuredlines stay).
status --jsoncarry the rendered strings.copies; menubar tooltip and Overview headline come from the renderer.
and the stale modes HTML page; glossary updated as the authority.
Phase F — GUI parity surfaces
socket ops — copy makes clear it relaxes the guard and auto-reverts.
apply).
doctorgains machine-readable output; then a Doctor pane in theGUI renders the same checks.
vpn.profilesand the matchedactiveProfile; open a profile-targeted switch window from the GUI.Phase G — tiered settings
config setlearns everyvpn.advanced.*key, extending the Phase Around-trip test to cover them.
key-value sets with cost/benefit descriptions) plus a drift function
reporting which keys diverge from a preset. Pure logic + tests.
config preset list / show / apply / diff, apply routed throughthe same write+reload path as
config set.detection and a diff of divergent keys.
vpn.advanced.*behind the"touch only if you know why" warning.
Phase H — Swift test target
mapping, and the settings seed/apply batching logic; wire into CI where the
runner allows (macOS job).
Docs and CHANGELOG entries land in the same commit as each behavior change, per
repo convention.
Decision Document
reloadre-reads the root-owned config file — itgrants no new write authority, so it rides the existing op-gating model.
config-writeDOES grant write authority and is therefore token-gated(keychain, biometry-protected). Two ops, deliberately different trust levels.
(intervals, hysteresis, countries, providers, windows, VPN sets); keys wired
before the loop starts (control socket path/group, log destination, service
identity) report restart-required. The reload response enumerates both so no
surface ever claims a change applied when it didn't.
still gates the flip, unknown still holds posture, the accelerated probe uses
the same tunnel+destination-scoped provider pass. No new window trigger, no
invariant change, no new ADR needed for it.
the same strings. Swift never composes posture sentences again. Vocabulary is
the Guard family: Guard / Full block / Standby / Paused / Window open.
cap, same auto-revert; only presentation is new.
writes ordinary keys through the ordinary path; the daemon never knows about
presets. Drift detection compares current config to preset definitions at
display time. This keeps
vpn.profiles(VPN identities) cleanly distinctfrom presets (strictness strategies).
Swift/Security-framework; presets and renderer are stdlib.
reads ship now; any further fix waits for watchdog evidence rather than
guessing.
Testing Decisions
rendered strings, written config — never internal call sequences.
runner package tests) cover reload application, restart-required reporting,
and the tunnel-up fast-probe path. Config round-trip table pins every key
including advanced. Renderer uses golden-string tests per posture. Control
tests (prior art: the existing control package tests) cover the new ops and
token gating.
and settings batching — the seams where GUI bugs actually lived.
standing checklist: live reload against a running daemon, Touch ID retry
behavior, token enrollment/revocation, pause countdown.
scratch worktree and diff
print-rulesoutput (stdout AND stderr) acrossconfigs × modes.
Out of Scope
caps, no relaxation of the tunnel+destination-scoped geo pass, no change to
hold-on-unknown.
the macOS app).
history/rollback (backlog items not pulled in here).
and layers presets on top.
Further Notes
(E–G); each phase is independently shippable and PR-able.
the token work.
Addendum — icon truthfulness, first-run, in-app docs, settings UX
Seven follow-up items. Investigation notes that changed their shape:
PostureUI.guardHoldsDownedTunnelalreadymaps "guard posture, no tunnel up" to the red blocked icon, and
PostureUI.dockStatealready narrows the Dock tile to blocked-vs-default. Whatis wrong is narrower than it looked (below).
edge the run loop opens the automatic reconnect window in the same loop
iteration, so the snapshot goes straight from
guardtoswitch-window. Thered state is therefore unreachable on the automatic path — it only appears when
the window is suppressed (reconnect disabled, flap guard, standby, full block).
the VPN can redial, so the real IP may be exposed. Red there would invert the
icon's meaning at the moment it matters most; amber stays correct.
"0"/config.DisabledonswitchWindow,reconnectWindow,pauseMax). The gap is purely that the GUIoffers no visible "Off", and
pauseMaxhas no GUI control at all.Phase I — icon truthfulness and brand assets
gui/assets/togui/artifacts/. Mechanical: update the buildscript's asset root, the README banner, docs image paths, and the source
comments that name the directory. Historical CHANGELOG entries keep their old
paths — they describe shipped releases and must not be rewritten. Drop the
stray
.DS_Storeand add an ignore rule.not-blocked Dock tile is staged from the
icon-onstate tile; it should bethe brand app icon, with
icon-blockedonly when actually blocked. Abuild-script mapping change plus the matching comment; the coarsening logic in
dockStateis already right.tunnel-down edge writes a snapshot showing the guard holding a downed tunnel,
then opens the reconnect window. Makes an already-correct state observable
and lets both surfaces report the drop honestly instead of jumping straight to
"window open".
an intentional disconnect from an accidental drop, which is the real cause of
the reported confusion: disconnecting by hand opens a reconnect window the user
never wanted. Add an explicit operator action that suppresses the automatic
window for the next drop, so a deliberate disconnect stays cut and red. This is
strictly more restrictive — it adds no relaxation trigger and needs no ADR.
Exposed on both surfaces alongside pause (pause = "let me use my real IP";
hold the line = "keep me cut").
the exposure and the remaining time lead the sentence. Rides the Phase E
renderer, so both surfaces change together.
Phase J — first-run setup in the GUI
the interactive prompt library as CLI-only presentation. Same questions, same
derived config, one place.
through the same validated write-and-reload path as every other settings
change. Runs on first launch and is re-openable later.
Phase K — in-app wiki and contextual help
the selected markdown into app Resources so it ships with the version it
documents and works offline — which is exactly when the kill switch has cut
traffic. A CI check fails the build if a referenced doc is missing or renamed.
with search and a tutorial track for first-time users.
documentation section, so help is reachable where the question arises.
Phase L — settings UX and window controls
preset menu plus a slider bounded by each field's real cap, a custom entry for
exact values, and an explicit Off choice wherever the disabled sentinel
applies — each stating its consequence in plain words. Expose
vpn.pauseMax,which currently has no GUI control.
field descriptions, empty and error states, and the shared CLI help text for
the same concepts. Follows the Phase E vocabulary so every surface reads the
same way.
Addendum decisions
or pause window stays amber because traffic is flowing and the real IP may be
exposed. The fix for a deliberate disconnect is to suppress the window (item 35)
or disable it outright, not to recolor a relaxed state.
relaxation for one drop. The three sanctioned relaxation triggers are unchanged
and no fourth is introduced.
never restates it. A doc referenced by the bundle is load-bearing, same rule as
doc paths cited from source.
dependency stays off the GUI and off the enforcement path.
Addendum testing
"hold the line", and the setup core's question-to-config derivation.
including the guard-holds-downed-tunnel case that has no coverage today.
tile swaps only when egress is cut.
Addendum 2 — defaults source of truth, menubar scope, pause presets
Phase M — one source of truth for defaults
Default values are currently restated in at least four places: the Go constants
in the config package, the GUI's placeholder hints, the documentation tables, and
the example config files. They have already drifted. The GUI hints advertise a
30s endpoint refresh and a 5s tunnel watch; the shipped defaults are 1m and 1s.
Anyone reading the settings pane is being told the wrong thing today.
entry in the config package — key, default, cap, unit, whether the disabled
sentinel applies, and a one-line explanation — with the existing constants as
the values behind it. Pure addition;
Normalizekeeps its current behavior andits tests keep passing.
hints, slider bounds, and Off-availability from it; CLI help and
validateoutput do the same. The drifted hints disappear because nothing hardcodes them
anymore.
with the declared defaults, so the config reference and the shipped examples
cannot silently rot again.
Phase N — menubar scope
The menubar becomes a glance-and-go surface: routine actions and read-only
information only. Everything else moves into the main window, which is where
detail belongs.
the primary pair, opening and cancelling a switch window, and Pause. Manual
block/unblock, profile and doctor detail, and configuration all move to the
window. Note that lifecycle actions require root every time — a daemon cannot
manage its own lifecycle — so the Touch ID retry work in Phase C is a
prerequisite for this to feel routine rather than punishing.
posture in the shared vocabulary, tunnel and exit country, and any live window
countdown. Sourced from the Phase E renderer, so it matches the CLI word for
word.
destructive item. It stays reachable from the menubar on purpose: it is the
lockout escape hatch, and the situation where it is needed is exactly the
situation where the main window may not open.
Phase O — pause presets
free-text duration: a short list covering the realistic cases, defined once in
the config core and consumed by both the CLI and the GUI so the two offer the
same choices. Each option states when protection returns. Options above the
configured cap are shown as unavailable with the cap as the reason, rather than
silently clamping to something the user did not pick. The existing cap,
disable-gate, and auto-revert behavior are unchanged.
Addendum 2 decisions
default that will drift; the metadata table is the only place a value is
written down, and every other surface derives from it.
The split is deliberate, and Panic is the one exception because its whole
purpose is working when everything else does not.
and explained rather than quietly shortened.