# Update check
> Part of the edamame contributor deep-dives. Index and project-wide conventions: [`AGENTS.md`](../../AGENTS.md). Sibling docs live in [`docs/dev/`](.).
- **One fetch, one cache, four states, three entry points.** `App::latest_release` is the session cache every path reads; `ReleaseStatus` is `Pending | UpToDate | Available | Failed`, and `UpdateModal` renders all four. 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.
- **Both conclusive states share one body shape and one title:** a verdict line, a blank, then `Installed version:` / `Latest release:` rows — a two-row table with padded values, 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` included. `Available` alone adds the notes and `[ View on GitHub ]`, which opens *that release's* page (`release_url`), not the releases list.
- **The up-to-date / available split 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 consumers must agree. A build *ahead* of the latest release compares `Less` and is not an update.
- **A comparison that can't be made is its own state.** `compare_versions` answers `Option<Ordering>`; `None` (either side unparseable as 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 back in: both are silent on the notice path, but only one is *true*, and the explicit-check modal prints installed and latest versions right 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.
- **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" — that is what keeps it robust against the first-run welcome, the config warning, the capabilities notice, and anything added later.
- **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 config warning and capabilities notice are not consent surfaces for this.
- **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\"")` matches key text inside another field's *value*, and a release `name` is free text somebody types. 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 is remote text reaching 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 the semver alphabet (`is_tag_char`: ASCII alphanumerics plus `.`, `-`, `_`, `+`). It rejects rather than sanitizes, and a rejected tag drops the whole release (`parse_release` → `None`) — notes without a version are not a finding. The excluded characters are the ones that would break the `Latest release:` row's layout (whitespace, controls) or change what the URL resolves to (`/ ? # % &`).
- **`sanitize_notes` strips `Cf` formatting characters as well as controls** — bidi overrides and zero-width joiners are not `char::is_control`, and they are the last thing remote text could do to a surface that never re-parses it as Markdown.
- **`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, and an explicit request is the opposite. It shows a cached *positive* result meanwhile so the modal isn't blank, but never a cached `Failed`.
- **Bookkeeping writes must not flash.** `update_notified_for` and `last_update_check` go through a bare `Config::save`, not `save_config_with_flash` — the user changed no setting. `Config::save` already declines to write under `--no-config`, so neither needs its own gate.
- **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 over already-bounded text, choosing between three fixed styles. Don't grow it into an inline parser — `**bold**` and `[text](url)` stay literal on purpose, and a parser would hand a release body emphasis, links, images and layout inside an app modal. Wrapped lines have no hanging indent; `ModalView` owns the wrapping.
- **Release notes are remote text and are bounded on the worker, before anything else sees them.** `sanitize_notes` cuts at cargo-dist's first `## Install` heading, strips control characters, and caps at 30 lines / 2 000 bytes, so the main thread never holds an unbounded string. They are rendered a line at a time with structural styling only and **never re-parsed as Markdown**.
- **`body` needs a real JSON string decoder; `tag_name` does not.** A tag never contains a quote or backslash, so `parse_tag_name` scans to the next `"`. The body is prose carrying quotes, backslashes, newlines and emoji, so `decode_json_string` walks escapes properly — an embedded `\"` would otherwise terminate the value at the first quotation mark the author typed. Still no `serde_json`: this is a JSON *string-literal* decoder, and every failure degrades to "no notes". (A `"###` sequence terminates an `r#"…"#` literal, so fixtures containing one need different quoting.)
- **The release notes ship from `CHANGELOG.md`.** `dist` reads the section matching the tag into the top of the GitHub release body, which edamame reads back and shows above `## Install` — so a `CHANGELOG.md` entry *is* what users read in the update modal. A release cut without a matching section still notifies, just with no summary.
- **`ui::update_check` takes plain values, not `ReleaseStatus`.** Nothing under `src/ui/` imports `crate::app`, and this is not the module to start: the modal adapter maps the status onto `ui`'s own `UpdateReport`. That translation is the only reason `update_check` can 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 a catch-all opening GitHub, so a newly added button silently opened GitHub instead of doing its own job — exactly what `[ Release notes ]` would have done.
- **The About page reports no release information at all.** It used to fetch on every first open, which made merely opening the page a network request and put a second surface in the business of rendering release state.