plan.md
c139 plan: Updates
Written: 2026-11-11, at kick-off, from Mark's prompt (initial-prompt.md), c130's platform research (§5 Auto-update), c137's delivery modes and inventory, c62's build-partner records and the /partner-digest skill. Reader: Mark at the review stop (review-1.md); Tyler for the dependencies; the session that pushes the screens into ../qnative after the rulings.
The defaults below are proposals. Nothing is ruled until Mark walks review-1.md.
Why
The native app (c131) is the one thing every build partner installs, and it will change weekly while the program moves. The low-level update already exists: qupdate in qcontrol reads a sidecar JSON that CI publishes beside each binary, downloads the tarball, checks its SHA-256 and replaces the binary in place, elevating only when the target is unwritable. What it lacks is everything a person sees: no states anyone can read, no preference, no channels, no release notes, no restart, and no word for "someone else manages this". Mark's prompt asks for that half: updates launched from Settings, invisible if the person wants it so, the current and new version with release notes, and an early era for build partners that looks different from the structured rollout that comes with maturity.
The shape
What is updated
Two binaries live on a partner's machine: the app (qnative, the window and the tray, per user) and qcontrol (the sensor, a service installed for every user). The person sees one product, Qpoint. The Updates page shows both versions and one Update action; the sensor's row says when it needs an administrator (qupdate's elevation rule). Whether one update carries both is Tyler's call (T2, T4); the page works either way, because each row has its own state.
The app's version is the one in the window's footer (v0.1.0 today). The sensor's version is the one the sensor reports (/api/sensor, served by the bridge until c131 D17's routes land). The mocks read both from there; only the new version, its date and its notes are a marked knob, because no world carries update state.
The feed
qupdate's sidecar, as it is: version (tag or 7-char SHA), git_sha, ref, committed_at, published_at, os, arch, artifact, sha256. This cycle asks for three fields more in the partner era and three later (T3), none of which changes what qupdate does with the rest:
| Field | Era | Meaning |
|---|---|---|
channel | now | partners while there is one; stable · beta later. The page reads it to decide what it shows (D12). |
notes | now | a URL to the notes for this version (JSON beside the sidecar: summary, items[] with a source each, sharp_edges[], trail links, audience: partners | public) |
size | now | bytes, so the page can say "18.4 MB" before downloading |
rollout | later | the share of installs offered this version (0–100); the app draws a stable number per install and compares |
min_version | later | below it, the update is offered even to someone who said "not now" (a security floor) |
pulled | later (or now, as the brake) | a release withdrawn after publishing; installs that have not taken it never will |
The states
One machine for each binary. Every state says what is true in one sentence, and what happens next (R4).
| State | What is true | What the page says (the sentence) | Actions |
|---|---|---|---|
| Up to date | the last check found nothing newer | "Qpoint is up to date." · version, published date, last checked | Check now |
| Checking | a check is in flight (hourly, or Check now) | "Checking for updates…" | — |
| Available | a newer version exists; nothing is downloaded yet (in Manually it waits here; the other two download at once) | "Qpoint 0.2.0 is available." · you have, published, size | Update · Not now |
| Downloading | the download runs, then the checksum, then staging | "Downloading 0.2.0…" · bytes, the three steps download · verify · stage | Cancel (until staging) |
| Ready to restart | the new version is staged; the running one is still the old | "0.2.0 is ready. Restart Qpoint to finish." · "qcontrol keeps watching while the app restarts." | Restart now · When I quit |
| Installed (after a quiet install) | the app restarted into the new version without asking | "Updated to 0.2.0 yesterday at 18:02." · what's new, below | — (the notes lead) |
| Failed | a download, check or replace did not complete; nothing changed | "The update to 0.2.0 didn't install." · the reason in plain words · "You're still on 0.1.0." | Try again · Copy details |
| Offline | the feed is unreachable; not an error of ours | "Can't check for updates right now." · the host, last checked, when it tries again | Try again |
| Managed elsewhere | a package manager or (later) the organisation owns updates | "Updates are managed by Homebrew." · how to update there | Copy the command; later: Contact IT |
installing is not a state the person waits on: the replace happens during staging, and the switch happens at restart, so there is no moment when quitting Qpoint breaks it (Zed's non-interruptible phase, moved to where it costs nothing). Failed after three automatic tries, or for three days, becomes visible beyond the page: one tray line (R3's exception, "visible when needed").
The preference
One control, three values, under "Install updates". Checks always run (hourly) unless updates are managed elsewhere; the preference decides what happens after a check finds something.
| Value | Checks | Downloads | Installs | What the person sees |
|---|---|---|---|---|
| Automatically (default, D1) | hourly | in the background | at quit, at the machine's restart, or when the window is closed to the tray and the machine has been idle ten minutes, under the guard (D3, D13) | the trace, after (D2) |
| Ask me first | hourly | in the background | when the person says Restart now, When I quit or When I'm away | the tray's dot and its "Restart to update" item; the footer's "0.2.0 ready"; how long it has waited (D14) |
| Manually (only when I click Update) | hourly | when the person presses Update | as Ask me first | the Updates page, the tray's "Update available" item, nothing else |
"Invisible if their preference so desires" is the first value. Invisible means nothing interrupts: no dialog, no OS notification, no toast, no restart while the window is open. It does not mean untraceable (D2).
The trace
After a quiet install, the app says what changed where the person already looks, and nowhere else:
- The footer. The window's footer already names the data source and the version (c131 R1). For seven days after an update the version reads
v0.2.0 · updated; a click opens Settings → Updates. In Ask me first, it readsv0.1.0 · 0.2.0 readyuntil the restart. - The Updates page leads with the installed version and its notes, then the history (version, date, how it came: automatically or by you).
- The tray menu has one line while an update is ready ("Restart to update to 0.2.0"); in Automatically that line is there for whoever opens the menu, with no dot on the icon. The version line at the menu's foot says
updatedfor the same seven days. - The history carries a state, so a failed attempt shows beside the installs (Firefox's update history; D16).
No inbox message: c137's inventory dropped app-update as a message type on the same grounds (row 24: "invisible when working: the updater and the Status page's version line handle it"). Reopening that row is the alternative in call 2.
Two eras
The page is one page. The era changes what it shows, not how it is built (R7), and the era comes from the feed, not from a flag in the app (D12).
| Now: build partners | Later: a structured rollout | |
|---|---|---|
| Who has the app | the partner cohort; every install is a partner's | every customer; partners and volunteers on Beta |
| Channels | one, partners (CI's latest) | Stable and Beta; a person or the organisation chooses |
| Cadence | when CI publishes a tag | Stable on a cadence; Beta between |
| Rollout | everyone at once; a pulled release is the brake (T3) | staged by percentage over days, halted on a bad signal; Check now gets the newest the person's channel allows |
| Release notes | internal notes inline: what changed, sharp edges fixed, known issues, each with its source; the trail (cycle records, commits) one link away | public notes: three lines inline and the public changelog; internal notes stay on Beta for partners |
| Version shown | the tag, with the 7-char SHA beside it | the tag; the SHA in Copy version info |
| Who decides | the person, through the preference | the person, inside the organisation's policy from the plane (channel allowed, version pinned, an update window) |
| Managed elsewhere | a package manager | a package manager, MDM, the plane's policy |
| Skip | none; "Not now" defers (D8) | the organisation pins; the person still has no skip |
| Expiry | none: a partner build never expires (against TestFlight's 90 days); the age line does the work (D15) | none; min_version is the floor |
Later, from the prior art (prior-art.md questions 12–16): the channel is a setting in the same install, never a second app; the rollout share is drawn on the machine and never sent, and a staged rollout tells the person nothing, since Check now gets the update anyway; a release may carry a priority set once, mapped onto c137's quiet and normal modes, never priority; the organisation's policy holds the channels allowed, a pin, a minimum, a wait of about 30 days, an update window shown in advance, and who goes first.
Out of scope
- The self-hosted control plane (c134). Updating it is an operator updating a server (a Docker image tag, migrations, a maintenance window), a different story from a person's laptop. Later, its own cycle.
- The organisation's side of the later era: the plane's update policy page, rollout health, the admin's pin. Named here so the native page leaves room for it; designed with c134 when the plane exists.
- First install and the installer. c131's packaging.
- Downgrade. Not offered; a pulled release is replaced by a newer one.
Rules
- R1. Never in the way. No modal, no restart while the window is open, no update during a tour or a developer stream. The app restarts when the person says so, when they quit, or when nobody is looking (D3).
- R2. The sensor keeps watching. Updating the app never stops qcontrol, and the page says so at the moment of restart. Updating qcontrol is the one moment the machine may go briefly unwatched, and the page says that before it happens (T4).
- R3. Silent leaves a trace, never a prompt. After a quiet install, the change is told where the person looks (footer, Updates page, tray menu), never pushed. The one exception: an update that keeps failing becomes one tray line.
- R4. One sentence per state, and what happens next. Versions as the feed names them; the source named (downloads.qpoint.io, Homebrew, HQ); no state without a next step (c131 R1, c108's honest states).
- R5. Nothing installs unverified. The checksum always (qupdate), the signature when Tyler's signing lands (T1). A failed check changes nothing and says so.
- R6. Holding a version back is an organisation's decision. The person can defer ("Not now"), not refuse. In a product that governs AI on a machine, a stale sensor is a gap; skipping belongs to policy, later (D8).
- R7. One page for both eras. The state machine is the protocol; notes, channel and policy are slots that fill when the feed carries them. Nothing built for the partner era is thrown away when the structured rollout arrives.
Decisions (proposed defaults, for review-1)
| # | Question | Default |
|---|---|---|
| D1 | The preference's default | Automatically. The partner cohort gets fixes without acting; R1 and D3 keep it from interrupting; the trace keeps it honest. |
| D2 | What "silent" means | A quiet trace in the app: the footer's updated mark for seven days, the Updates page leading with what's new, the history. No inbox message, OS notification or toast; c137 row 24 stands. |
| D3 | When an automatic update installs | at quit, at the machine's restart, or after ten idle minutes with the window closed to the tray (a relaunch nobody sees: the tray icon is back within seconds), under D13's guard. Never while the window is open. |
| D4 | How Ask me first asks | the tray icon's dot, the tray menu's "Restart to update to 0.2.0", the footer's 0.2.0 ready. No OS notification. |
| D5 | The version string | the tag (0.2.0), with the 7-char SHA beside it in the partner era; every build that reaches partners carries a tag (T3). |
| D6 | Internal notes for build partners | inline, at partner altitude (what changed for you, sharp edges fixed, known issues, each with its source), with the full trail one link away. Later: public notes inline as three lines and a link. |
| D7 | Who owns release notes | the person who cuts the release (engineering) owns the facts and the gate; the draft is generated from the /partner-digest sources at release time; design owns the template and the voice check. Shipped beside the sidecar (notes). |
| D8 | Skip this version | none. "Not now" defers to the next quit or the next day. Holding a version is the organisation's, later. |
| D9 | One update or two | one page and one Update for the app and qcontrol, each with its own row and state; the sensor's row says when it needs an administrator. |
| D10 | Managed elsewhere | the page says who manages updates and how to update there; the preference is shown but disabled with the reason; checks still run, so the page can say what is newest. |
| D11 | Where Settings lives | a Settings page at the foot of the rail (⌘,), with sections General · Updates · Notifications · Data · Developer; the tray menu has Check for updates… and Settings…. |
| D12 | The era | a property of the feed (channel, notes.audience), not of the app build: when the feed grows channels, the same page shows the channel control. |
| D13 | The idle relaunch's guard (from the prior art, question 11) | only when everything comes back as it was: the window closed, no focus taken, and no developer stream (c138), mechanic session (c136), open required message (c137), tour or capture running. The history says "while you were away". VS Code guards its idle restart this way; the Claude desktop app's unguarded one cut live sessions. |
| D14 | Ask me first's clock (question 9) | it never prompts and never escalates; it says how long the update has waited ("Ready since yesterday at 18:40"), and after three days the tray line and the footer say "ready since Tuesday". |
| D15 | An install that stopped updating (question 10) | after 60 days, one tray line at most once a month ("Qpoint 0.1.0 is 2 months old"), under any preference, managed included; the Updates page always shows the age. |
| D16 | What the notes and history carry (TestFlight, Firefox) | partner notes add "What to try" (two lines, the build's own invitation); the history carries a state, so a failure shows. |
Phases
0. Prior art (done 2026-11-11)
prior-art.md: seven desktop apps' update settings (Zed, VS Code, Firefox, 1Password, Slack, the Claude desktop app, Raycast) and five ways of giving some people a build first (VS Code Insiders, Chrome's channels, TestFlight, staged percentages, B2B early access), each in the same rows; what we copy, what we refuse, sixteen questions with defaults. It backed D1, D2, D5–D8 and D10, found the model stricter than every precedent on five counts (no restart while the window is open, the sensor's sentence, a visible trace, no skip, one channel source) and thinner on seven; four of those became D13–D16, the rest are later-era notes above.
1. The model (done 2026-11-11)
This plan's §The shape, drawn as An update and its states with the c130 kit; ## The shape at the end of the CYCLE.md Why.
2. Mocks (done 2026-11-11)
In the native skin (c132 medium A), rows from useNativeWorld(name), the update state as a marked knob (app/data/c139-updates-mock.ts):
/pages/native/settings-updates: Settings → Updates in every state, in both eras, with the preference live; the footer's trace; the managed variants (a package manager; the organisation, later era only)./pages/native/updates-tray: the tray menu in each state that reaches it, the footer's two marks, and what's new after a quiet install.scripts/shot-programs/c139-updates.json(scale: 2,clip: ".native-window"); the sketch inc130-sketches-design.json.
3. Story (done 2026-11-11)
app/data/stories/c139.ts, wired in app/data/cycle-stories-mock.ts.
Review stop 1: review-1.md.
4. Into the app (after the rulings)
The rulings applied to the mocks, then the states pushed into ../qnative through /gpui-story (a story per state, captured per world; c132's loop), with the footer's trace and the tray line. Waits for T2 (which updater) only for the real wiring; the stories can be drawn on a fake updater behind the same states.
5. The feed contract (with Tyler)
The sidecar's new fields and the notes file written up as a proposal for qupdate or the app's own updater; the notes draft wired to the release step.
6. The later era (when maturity calls)
Channels, staged rollout, public notes, the organisation's policy and its page on the plane (c134). Not now.
Tyler's dependencies
| # | Dependency | What the design assumes until then |
|---|---|---|
| T1 | Signing: the Apple Developer ID and notarisation of every update, Windows Authenticode, and whether an update signature (minisign or EdDSA) is checked beyond SHA-256 (c130 shape Q7) | the checksum is the check; the Failed state's words cover a signature failure the same way |
| T2 | The updater: qupdate grows bundle-awareness (the macOS .app replace, the Windows helper for a running exe, a per-user install) or the app gets its own (cargo-packager-updater, self_update); one updater for both binaries or two | one state machine per binary behind one page; the page doesn't know which updater runs |
| T3 | The feed: channel, notes, size now; a tag for every build that reaches partners; a way to pull a release; later rollout and min_version | the mock's fixture has these fields, marked |
| T4 | qcontrol's update: elevation on each OS, whether the sensor's restart drops events and for how long, whether the app's update ever carries the sensor | the sensor row asks for an administrator and says "qcontrol restarts; a few seconds go unwatched" only if T4 says so; the mock says "restarts" without a number |
| T5 | Knowing it is managed: how the app learns a package manager or MDM installed it (a build-time explanation as Zed's ZED_UPDATE_EXPLANATION, the install path, a marker file) | a Homebrew install is the mock's example |
| T6 | Restart mechanics: the relaunch path on three OSes, and whether the idle tray relaunch (D3) is safe on each | D3 as written; T6 can narrow it to quit and restart only |
Clarifying questions
- Is the partner era's notes audience everyone with the app (today: yes, every install is a partner's), or do we need a per-person "insider" flag before the later era? Default: everyone, until the feed says
audience: public. - Should developer mode being allowed (c138 D8) change what the Updates page shows (the SHA, the commit trail, a "build from main" channel)? Default: no; the channel is the feed's, not the build's.
- Does a mechanic-access grant (c136) block an automatic update of qcontrol while a fix is in progress? Default: no, but the grant's Status line shows the version, so the mechanic sees it.
Patterns to consider
- The era as data. The feed carries the channel and the notes' audience, so the structured rollout is the same page reading more fields: modularity as purpose plus protocol (the state machine is the protocol, the slots are free).
- The footer as the trace. The window already says where its data comes from and which version runs (c131 R1); the update's trace rides on that line instead of a new surface.
- Release notes as a by-product of the record. The partner era's notes are drafted from what the cycles already write (Key Changes, sharp edges, commits), the way
/partner-digestdrafts the weekly issue. Archaeology, not a new writing job. - Copy version info. One button that puts both versions, the SHAs, the OS and the channel on the clipboard, for a partner's bug report (c62's direct line).
- The organisation's update window, later. "Not during business hours" is a policy, the same place a pinned version lives.