diff --git a/docs/auto-update-plan.md b/docs/auto-update-plan.md
index 6ffd73d..51169d3 100644
--- a/docs/auto-update-plan.md
+++ b/docs/auto-update-plan.md
@@ -1,143 +1,181 @@
-# Auto-update for haul — plan
+# Auto-update — plan
-Status: **planned, not started.** Blocked on four decisions (bottom of this
-file). No code has been written.
+Status: **planned, not started.** Blocked on decisions at the bottom of this
+file. No code written.
-The model is the self-updater in
+Ben wants this as a **standalone, reusable Go module**, not something buried in
+haul — so it can be dropped into dial, haul, and whatever comes next. This doc
+lives in haul's repo because that's where the conversation started; the module
+gets its own repo and its own README.
+
+The behaviour to copy is the self-updater in
[ShippingTracker](https://gitea.apointless.space/bsncubed/ShippingTracker)
-(`HelloWorld/Updater.vb`), which haul should behave like.
+(`HelloWorld/Updater.vb`).
## Research already done — don't redo this
-Verified 2026-07-29 against the live gitea instance:
+Verified 2026-07-29/30 against the live gitea instance:
-- `https://gitea.apointless.space/api/v1/repos/bsncubed/ShippingTracker/releases/latest`
- returns **HTTP 200 anonymously**. No token needed.
-- `.../repos/bsncubed/haul` also returns **200 anonymously**, and
- `.../repos/bsncubed/haul/releases` returns `[]`. So haul's repo is public;
- the 404 on `releases/latest` is simply "no releases exist yet", not a
- permissions problem. The updater will work unauthenticated.
-- Release JSON exposes `tag_name`, `draft`, `prerelease`, and an `assets` array
- whose entries carry `name` and `browser_download_url`. ShippingTracker's
- latest (`1.1.0.2`) attaches exactly two assets: `Shipping Tracker.exe` and
- `checksums.txt`.
+- The releases API is **readable anonymously** — both
+ `.../repos/bsncubed/ShippingTracker/releases/latest` and
+ `.../repos/bsncubed/haul` return 200 with no token. The updater needs no
+ credentials.
+- `.../repos/bsncubed/haul/releases` returns `[]`. haul's repo is public; the
+ 404 on `releases/latest` is just "no releases yet", not permissions.
+- **dial already publishes proper releases.** Tag `0.2.0`, `draft: false`,
+ `prerelease: false`, with six assets: `dial-darwin-amd64`,
+ `dial-darwin-arm64`, `dial-linux-amd64`, `dial-linux-arm64`,
+ `dial-windows-amd64.exe`, and `SHA256SUMS`.
+- Release JSON exposes `tag_name`, `draft`, `prerelease`, and `assets[]` with
+ `name` and `browser_download_url`.
+- Gitea serves `go-import` meta tags
+ (``),
+ so `go get gitea.apointless.space/bsncubed/` resolves.
## What ShippingTracker's updater does
1. GET `/api/v1/repos/{owner}/{repo}/releases/latest`; take `tag_name`, match
assets by exact filename.
2. Parse the tag into a version — tolerates a leading `v`, pads missing
- components with `0` (an unset `System.Version` component is `-1`, which
- sorts wrong).
+ components with `0`.
3. **Silent vs. loud.** A startup check swallows every failure (offline, DNS,
timeout, non-200, malformed JSON) with no UI at all. A user-clicked check
reports "you're up to date" and surfaces errors.
4. Prompts with current vs. available before doing anything.
5. **Pre-flights write permission** next to the executable, so a read-only
install directory fails early rather than half-applying an update.
-6. Streams the download to `.new` behind a small progress window.
-7. Verifies SHA256 against a `checksums.txt` asset in `sha256sum` format
- (` `). If that asset is absent the update still installs,
- unverified.
+6. Streams the download to `.new` behind a progress window.
+7. Verifies SHA256 against a checksum asset in `sha256sum` format
+ (` `). Absent checksum asset → installs unverified.
8. **Swaps by rename**: running exe → `.old`, `.new` → exe, rolling back if the
- second rename fails. `.old` is deleted on the next launch.
-9. On any failure, offers to open the releases page in a browser instead.
-
-## Ports over unchanged
-
-The API shape, the silent/loud split, tag parsing, checksum verification, the
-rename-swap with `.old` cleanup, and the manual-download fallback.
+ second rename fails. `.old` deleted on next launch.
+9. On any failure, offers to open the releases page in a browser.
The rename dance exists because Windows won't delete a *running* binary but will
rename one. Linux and macOS also allow renaming a running executable (the inode
-stays live), so a single code path covers all three platforms.
+stays live), so one code path covers all three.
-## Has to change for haul
+## The module
-1. **haul has no version at all.** ShippingTracker compares against
- `AssemblyVersion`; `cmd/haul` has no equivalent, which is why the Makefile
- deliberately omits `-X main.version` (a `-X` against a non-existent symbol is
- silently a no-op). Adding one is a prerequisite — without it every check
- reports "up to date".
-2. **One asset becomes many.** ShippingTracker is Windows-only, so its
- `AssetName` is a single constant. haul must select an asset by
- `runtime.GOOS`/`runtime.GOARCH` (`haul-linux-amd64`, `haul-darwin-arm64`,
- `haul-windows-amd64.exe`).
-3. **The executable bit.** On Unix the downloaded file needs `chmod 0755`.
- Nothing in the VB version corresponds to this.
-4. **Fyne threading.** `internal/ui` only touches widgets from the UI thread and
- returns from I/O via `fyne.Do`. The updater must follow the same shape as
- `session.transfer` and `pane.navigateKeepMarks`.
+Its own repo — something like `gitea.apointless.space/bsncubed/selfupdate`
+(name is an open decision). Separate module, separately versioned, imported by
+each project.
-## Blockers — read before starting
+### The single most important constraint: no UI
-**The release assets can't be built yet.** This gates everything. haul is cgo,
-so `make dist` produces the native binary only; there is currently no way to
-produce macOS or Windows assets from the Linux box. An updater with nothing to
-download is pointless, so **the release pipeline must come first**
-(`fyne-cross` over Docker, which is installed, or real machines).
+ShippingTracker's updater calls `MessageBox.Show` directly. **A reusable module
+cannot do that**, because the three consumers could not be more different:
-**Linux binaries don't travel.** haul links dynamically against `libGL`,
-`libX11`, `libc` and ten others. A binary built on Ubuntu 24.04 may not start on
-a different or older distro — it would download, swap itself in, and then fail
-to launch, which is the worst failure mode an updater has. ShippingTracker never
-hit this (.NET Framework, stable Windows ABI). Options: build against the oldest
-glibc worth supporting, ship Linux without auto-update, or accept it.
+| Consumer | UI |
+| --- | --- |
+| ShippingTracker | WinForms message boxes |
+| haul | Fyne GUI, and widgets may only be touched from the UI thread via `fyne.Do` |
+| dial | Bubble Tea TUI, an Elm-style message loop |
-**`checksums.txt` is not a signature.** It lives on the same server as the
+So the module is **pure logic plus callbacks**: it decides, downloads, verifies
+and swaps; the host application does every prompt, progress bar and error
+dialog. That also makes it trivially testable against `httptest`.
+
+### Sketch
+
+```go
+type Config struct {
+ BaseURL string // https://gitea.apointless.space
+ Owner, Repo string
+ Current Version
+ AssetName func(goos, goarch string) string // default: --[.exe]
+ ChecksumAsset string // "SHA256SUMS" (dial) or "checksums.txt" (ShippingTracker)
+ HTTPClient *http.Client
+ UserAgent string
+ AllowPrerelease bool
+}
+
+func Check(ctx, cfg) (*Release, error) // fetch, parse tag, pick this platform's asset
+func (r *Release) Newer(v Version) bool
+func Download(ctx, r, dst string, onProgress func(done, total int64)) error
+func Verify(path, checksumURL, assetName string) error
+func Apply(newPath string) error // rename dance, rollback, chmod 0755
+func CleanupOld() error // delete .old; never errors
+```
+
+`onProgress` is the seam: haul marshals it through `fyne.Do`, dial turns it into
+a Bubble Tea message, ShippingTracker-style apps call it directly.
+
+### Configurable because it genuinely varies
+
+- **Checksum asset name** — dial uses `SHA256SUMS`, ShippingTracker uses
+ `checksums.txt`. Proof this can't be a constant.
+- **Asset naming** — a `func(goos, goarch)` hook, defaulting to dial's existing
+ `--[.exe]` convention, which haul should adopt too.
+- **Forge** — gitea's release JSON is GitHub-shaped, so the same client covers
+ GitHub for free. Worth keeping the fetch behind a small interface.
+
+### Stays with each project
+
+Version stamping, the release pipeline, asset naming, and all UI.
+
+## Consumers, in order
+
+**dial first — it is not blocked.** Pure Go, cross-compiles trivially, and its
+`make dist` already produces all five platform binaries and `SHA256SUMS`, which
+release `0.2.0` already has attached. dial can ship auto-update as soon as the
+module exists, which makes it the honest test of whether the API is actually
+reusable.
+
+**haul second, and it needs work first:**
+
+1. **haul has no version at all.** `cmd/haul` has no `version` variable, which
+ is why the Makefile omits `-X main.version` (a `-X` against a non-existent
+ symbol is silently a no-op). Without it every check reports "up to date".
+2. **haul cannot build its own release assets.** It is cgo, so `make dist`
+ produces the native binary only — no macOS or Windows assets exist to
+ download. Needs `fyne-cross` over Docker (installed) or real machines.
+3. **Linux binaries don't travel.** haul links dynamically against `libGL`,
+ `libX11`, `libc` and ten others; a binary built on Ubuntu 24.04 may not start
+ on another distro. It would download, swap itself in, then fail to launch —
+ the worst failure mode an updater has. dial, being static, has no such
+ problem. Options: build against the oldest glibc worth supporting, ship Linux
+ without auto-update, or accept it.
+
+## Security note
+
+**A checksum is not a signature.** `SHA256SUMS` sits on the same server as the
binary, so it catches corruption and truncation but not a compromised gitea —
anyone who can replace one can replace the other. ShippingTracker accepts this.
Closing the gap means a minisign/ed25519 signature with the public key compiled
-into haul, roughly 40 extra lines.
+into each consumer, roughly 40 extra lines in the module.
-## Proposed shape
+## Consuming the module
-New package `internal/selfupdate`, UI-free so it can be tested against a fake
-HTTP server:
+Because the host is self-hosted, dependent projects will likely want:
-| Piece | Responsibility |
-| --- | --- |
-| `Check(ctx) (*Release, error)` | Fetch latest, parse tag, pick this platform's asset |
-| `Release.Newer(current)` | Version comparison |
-| `Download(ctx, rel, progress)` | Stream to `.new`, report progress |
-| `Verify(path, checksumURL)` | SHA256 against `checksums.txt` |
-| `Apply(newPath)` | Rename dance, rollback, `chmod` |
-| `CleanupOld()` | Delete `.old`; never errors |
+```sh
+go env -w GOPRIVATE=gitea.apointless.space/*
+```
-Plus a thin `internal/ui/update.go` for dialogs, reusing `confirmDialog` from
-`dialogs.go` and a progress dialog modelled on the existing transfer bar.
-
-Integration points, mirroring ShippingTracker's `Form1_Load` and `Form3`:
-
-- `CleanupOld()` and a silent check when the connect window opens
- (`newConnectWindow`).
-- A manual "Check for updates" button. The connect window is the natural home —
- haul has no settings screen.
-
-Release process: a `make release VERSION=x.y.z` target that builds, writes
-`checksums.txt`, and prints the tag/upload steps. Tag as `1.2.0` or `v1.2.0`,
-and don't mark it pre-release — `/releases/latest` skips drafts and
-pre-releases.
+so the module is fetched straight from gitea rather than through
+`proxy.golang.org` and the checksum database. Worth confirming in practice —
+the repo is public, so the proxy path may work unaided.
## Phasing
| Phase | Work |
| --- | --- |
-| 0 | Version stamping — `var version` in `cmd/haul`, `-X` in the Makefile, `haul --version` |
-| 1 | **Release pipeline** — cross-builds via fyne-cross, checksums, first tagged release |
-| 2 | `internal/selfupdate` with tests against a fake HTTP server |
-| 3 | UI wiring — startup silent check, manual button, progress and error dialogs |
-| 4 | README: how updating works, and how to publish a release |
+| 0 | Create the module repo; `Check`/`Newer` plus version parsing, tested against `httptest` |
+| 1 | `Download`/`Verify`/`Apply`/`CleanupOld`, tested in a temp dir |
+| 2 | **Adopt in dial** — the real proof of reusability, and shippable immediately |
+| 3 | haul phase A: version stamping (`var version`, `-X`, `haul --version`) |
+| 4 | haul phase B: release pipeline via fyne-cross, first tagged release |
+| 5 | haul phase C: wire the module in — silent check on the connect window, a manual "Check for updates" button, progress and error dialogs |
+| 6 | READMEs: the module's own, plus a section in each consumer |
-Phases 0 and 1 are worth doing regardless — there is currently no way to ship
-haul to anyone at all.
+## Open decisions
-## Open decisions — needed before building
-
-1. **Auto-check on startup, or manual only?** ShippingTracker does both. Silent
- startup checks mean haul contacts gitea on every launch.
-2. **Which platforms get auto-update?** Suggestion: exclude Linux at first, for
- the dynamic-linking reason above.
-3. **Checksums only, or add signatures?**
-4. **Do phase 1 first, or write the updater against ShippingTracker's existing
- releases as a test fixture?**
+1. **Module name and path** — `selfupdate`? `gitup`? `bsncubed/selfupdate`?
+2. **Auto-check on startup, or manual only?** ShippingTracker does both. Silent
+ startup checks mean the app contacts gitea on every launch.
+3. **Which platforms get auto-update?** Suggestion: exclude haul's Linux build
+ for the dynamic-linking reason above. dial is fine everywhere.
+4. **Checksums only, or real signatures?**
+5. **Confirm dial goes first** — it's unblocked and validates the API, whereas
+ haul can't produce release assets yet.