# Update check
> Part of the edamame contributor deep-dives. Index and project-wide conventions: [`AGENTS.md`](../../AGENTS.md). Sibling docs live in [`docs/dev/`](.).
The network bounds (hardcoded host, body cap, timeout, no identity) are in [security-invariants.md](security-invariants.md#other-bounded-surfaces).
## States and the modal
- **One fetch, one cache, five states, three entry points.** `App::latest_release` is the session cache every path reads; `ReleaseStatus` is `Pending | UpToDate | Available | Inconclusive | Failed`, and `UpdateModal` renders all of them. The startup check pushes it *only* for `Available`; the About page's `[ Check for updates ]` and `Action::CheckForUpdates` push it in whatever state is known and let the in-flight result replace it. Keeping the up-to-date and failure states on the same modal is what lets an explicit check answer honestly while the startup path stays silent — don't split them.
- **Conclusive states share one body shape and one title:** a verdict line, a blank, then `Installed:` / `Latest:` rows (a two-row table, not numbers embedded in a sentence), so the states differ by verdict rather than layout. The frame is captioned `Check for updates` in every state. `Available` alone adds the notes and `[ View on GitHub ]`, which opens *that release's* page (`release_url`), not the releases list.
- **The verdict is decided once, in `ReleaseStatus::from_fetch`, not at render time** — the modal picks copy from the variant and `policy::notice_due` gates the nag on it, so both must agree. A build *ahead* of the latest release is not an update.
- **A comparison that can't be made is its own state.** `compare_versions` answers `Option<Ordering>`; `None` (either side not a dotted numeric version — `v0.2.0-rc1`, `v0.2.0+build.7`) becomes `Inconclusive`, which `notice_due` keeps as silent as `UpToDate`. Don't fold it into `UpToDate`: only one of them is *true*, and the explicit-check modal prints both versions under its verdict. `Inconclusive` says "Couldn't compare versions." and keeps `[ View on GitHub ]`. `ReleaseStatus::tag()` is the single accessor across the three states that name a tag.
- **`ui::update_check` takes plain values, not `ReleaseStatus`.** Nothing under `src/ui/` imports `crate::app`; the modal adapter maps the status onto `ui`'s own `UpdateReport`. That translation is what lets `update_check` stay under `app` rather than becoming a leaf subsystem.
- **Every About-page button has a named index arm.** `AboutModal::resolve` matches `RELEASE_NOTES_BUTTON` / `CHECK_UPDATES_BUTTON` / `VIEW_ON_GITHUB_BUTTON`, and its final `ButtonPressed(_)` arm does *nothing* — it was once a catch-all opening GitHub, so a newly added button silently did that instead of its own job.
- **The About page reports no release information itself.** Opening it is not a network request; release state has one rendering surface, `UpdateModal`.
## Startup timing and throttling
- **The notice cannot join `App::new`'s startup-modal ordering** — every other startup modal is built synchronously in a deliberate priority order; this one depends on a result arriving much later. A finding parks in `App::pending_update_notice` and `tick_update_notice` pushes it on the first frame `modal_stack.is_empty()`. Gate on *the stack being empty*, never on "no welcome modal", so it stays robust against every other startup modal, current and future.
- **The startup check waits out the first-run welcome modal**, which is where `check_for_updates` is asked — a check fired before it makes the request the setting exists to gate. `spawn_startup_update_check` is therefore a `tick_timers` member, not a one-shot call from `run()`: it parks while a `WelcomeModal` is on the stack and re-reads the setting *after* it closes, so a first-run decline is honored on that same launch. Only the welcome gates it; the other startup modals are not consent surfaces.
- **`last_update_check` is stamped at *spawn*, not on arrival** — a worker that hangs to its timeout, or a process killed before the result lands, would otherwise leave the clock untouched and re-check every launch. The cost is that a transient failure waits out the full interval, which is right for a notification nobody is waiting on.
- **The throttle governs the automatic check only.** `open_update_modal` always re-fetches: the 24 h gate bounds *unattended* chatter. It shows a cached *positive* result meanwhile so the modal isn't blank, but never a cached `Failed`.
- **The bookkeeping fields live on `State`, in `state.toml`, and their writes must not flash.** `last_update_check` and `update_notified_for` are machine-written, so they sit on `crate::config::State`, not `Config` (see [machine-state.md](machine-state.md)); `check_for_updates` stays on `Config` as a real user setting. They are written through `save_state_bookkeeping` → a bare `State::save`, not `save_config_with_flash`, since the user changed no setting. `State::save` already declines to write under `--no-config`, so neither needs its own gate.
## Parsing remote text
- **Both field scans are anchored to real top-level keys.** `parse::top_level_value` tracks object depth and consumes strings whole; a bare `find("\"body\"")` would match key text inside another field's *value* (a release `name` is free text), and depth also excludes the nested `author` / `assets` objects. Still not a JSON parser — it locates one key and hands the rest to the caller.
- **`tag_name` is validated, not just extracted.** It reaches a rendered line *and*, via `fetch::release_url`, a URL handed to the system browser — so `parse_tag_name` refuses a tag over `MAX_TAG_BYTES` (64) or carrying anything outside `is_tag_char` (ASCII alphanumerics plus `.`, `-`, `_`, `+`). It rejects rather than sanitizes, and a rejected tag drops the whole release (`parse_release` → `None`). The excluded characters are the ones that would break the `Latest:` row (whitespace, controls) or change what the URL resolves to (`/ ? # % &`).
- **`body` needs a real JSON string decoder; `tag_name` does not.** A valid tag never contains a quote or backslash, so `parse_tag_name` scans to the next `"`. The body carries quotes, backslashes, newlines and emoji, so `decode_json_string` walks escapes properly — an embedded `\"` would otherwise end the value early. Still no `serde_json`, and every failure degrades to "no notes". (A `"###` sequence terminates an `r#"…"#` literal, so test fixtures containing one need different quoting.)
- **Release notes are bounded on the worker, before anything else sees them.** `sanitize_notes` cuts at cargo-dist's first `## Install` heading, strips control characters *and* `Cf` formatting characters (bidi overrides and zero-width joiners are not `char::is_control`), and applies `MAX_NOTES_LINES` / `MAX_NOTES_BYTES`, so the main thread never holds an unbounded string.
- **Notes get structural styling, never Markdown parsing.** `ui::update_check::note_lines` bolds an ATX heading (dropping its `#` run), swaps a `-`/`*`/`+` marker for a `•`, and drops the blank line Keep a Changelog puts *under* each heading while keeping the one above. That is the whole vocabulary: a *line* classified locally, choosing between three fixed styles. Don't grow it into an inline parser — `**bold**` and `[text](url)` stay literal on purpose, since a parser would hand a release body emphasis, links and images inside an app modal. `ModalView` owns wrapping (no hanging indent).
- **The notes ship from `CHANGELOG.md`.** `dist` puts the section matching the tag at the top of the GitHub release body, so a changelog entry *is* what users read in the update modal (see [releasing.md](releasing.md)). A release without a matching section still notifies, just with no summary. The bundled-changelog path is separate: [post-upgrade.md](post-upgrade.md).