etherpad-lite/docs/superpowers/plans/2026-05-15-auto-update-pr4-tier4-autonomous.md
John McLear 962bfe8649
feat(updater): tier 4 — autonomous update in maintenance window (#7607) (#7753)
* docs(updater): plan tier 4 — autonomous update in maintenance window (#7607)

Maps PR 4 of the auto-update design spec (§"Tier 4 — autonomous") to concrete
files, tasks, and verification steps. Subsequent commits scaffold against this
plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): MaintenanceWindow module — wall-clock window math for tier 4

Pure module: parseWindow, inWindow, nextWindowStart. Supports tz=local|utc
and cross-midnight ranges. Used by upcoming Scheduler + UpdatePolicy changes.

22 vitest unit tests cover format validation, same-day + cross-midnight
boundaries, and host-local vs UTC clock comparisons. DST handling is
absorbed by JS Date constructor's wall-clock normalization (documented in
the file header).

Refs #7607

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): tier 4 backend — window-gated UpdatePolicy + Scheduler

Wires MaintenanceWindow into the existing tier 3 backend so autonomous
updates only fire while `now` is inside `updates.maintenanceWindow`.

UpdatePolicy
  - new optional `maintenanceWindow` input
  - canAutonomous flips on only for git+tier=autonomous+parse-valid window
  - new reasons `maintenance-window-missing` / `maintenance-window-invalid`
  - rollback-failed still wins over window denial

Scheduler
  - decideSchedule snaps scheduledFor forward to nextWindowStart when
    canAutonomous + grace lands outside the window
  - decideTriggerApply returns a new `{action: 'defer'}` when canAutonomous
    + fire-time is outside the window; carries nextStart for the runner
  - canAutonomous=false preserves Tier 3 behavior unchanged

index.ts wires settings.updates.maintenanceWindow through both passes and
re-arms the timer on defer. Status endpoint surface (nextWindowOpensAt) +
admin UI picker land in a follow-up commit.

Settings adds `maintenanceWindow: {start, end, tz} | null`, defaulting to
null. settings.json.template / settings.json.docker document the shape.

Tests
  - 22 vitest cases for MaintenanceWindow already cover the math
  - 4 new UpdatePolicy cases for the window outcomes
  - 6 new Scheduler cases for tier-4 schedule/trigger paths
  - Full backend-new suite: 629 passed (35 files)

Refs #7607

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): tier 4 admin UI — window status, deferred subtitle, banner

GET /admin/update/status now returns:
  - `maintenanceWindow`: the parsed window object (admin sessions only)
  - `nextWindowOpensAt`: ISO of the next window opening when tier=autonomous

UpdatePage
  - new "Maintenance window" section when tier=autonomous, shows current
    window summary + next opens at, or "Not configured" when unset
  - scheduled panel now appends a "deferred until <iso>" line when the
    backend has snapped scheduledFor to the next window opening

UpdateBanner
  - new variant when tier=autonomous and policy.reason is
    `maintenance-window-missing` or `maintenance-window-invalid`, linking
    to /admin/update

i18n
  - 8 new keys under `update.banner.*`, `update.page.policy.*`,
    `update.page.scheduled.*`, `update.window.*` (en.json only;
    translations follow via the usual locale workflow)

Interactive picker is intentionally deferred — admins edit
`updates.maintenanceWindow` via the parsed JSONC settings editor (#7709).
A follow-up commit may add a thin write-through component if the JSONC
round-trip turns out to be too rough for typical operators.

Refs #7607

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(updater): tier 4 — window gate, DST notes, runbook §12 (#7607)

CHANGELOG: flip Tier 4 from "designed, not yet implemented" to current.
Document maintenanceWindow shape, snap-forward, defer-at-fire, and the
two missing/invalid policy reasons.

doc/admin/updates.md: new "Tier 4 — autonomous in a maintenance window"
section with config example, policy gating, DST/timezone notes, admin UI
behavior.

runbook: §12 walks a disposable VM through missing-window, malformed,
outside-window deferral, fire-at-opening, and window-closes-mid-grace.
Adds five sign-off checklist items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(updater): tier 4 window-boundary integration (#7607)

Mocha integration covering the four scenarios called out in the spec
§"Tier 4 — autonomous":

  - outside-window: decideSchedule snaps scheduledFor forward to the
    next opening and the snapped value round-trips through saveState
  - inside-window at fire-time: decideTriggerApply returns fire
  - window-closes-mid-grace: decideTriggerApply returns defer with
    nextStart at the next opening; persisted state moves forward
  - cancel during deferred-grace: state returns to idle, and the next
    decideSchedule pass re-emits a schedule snapped to the next opening

All 4 cases passing locally under tsx mocha.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): real SMTP via nodemailer (mail.* settings) (#7607)

Replaces the (would send email) stub introduced in PR #7601 with a
nodemailer-backed transport. The dependency is lazy-imported so installs
that don't set mail.host pay no runtime cost.

Settings additions
  - new top-level mail block: host, port, secure, from, auth (user/pass)
  - mail.host=null keeps the legacy log-only behaviour; the Notifier
    still updates dedupe state so we don't re-evaluate every tick
  - settings.json.template documents the shape inline
  - settings.json.docker reads MAIL_HOST / MAIL_FROM / MAIL_PORT /
    MAIL_SECURE from env so operators can configure via container env

Transport
  - lazy import('nodemailer') on first send
  - transport cached by host; settings reload picks up new host without
    needing a restart
  - send errors are swallowed (logged warn) so a transient SMTP failure
    can never poison the surrounding updater state machine
  - successful sends log at info; legacy "(would send email)" path
    remains the visible signal when mail is disabled

Refs #7607

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): preflight checks target tag's engines.node (#7607)

Before mutating the working tree, runPreflight now reads the target tag's
package.json via `git show <tag>:package.json` and verifies that
process.versions.node satisfies its engines.node range. Failures land at
preflight-failed cleanly (no rollback needed — nothing has changed yet).

Motivation: a release that bumps the Node floor used to either fail
mid-`pnpm install` (which then rolls back successfully) or restart on the
new build and crash in the boot path (which then rolls back via the
health-check timer). Both paths recover, but they burn a drain + restart
cycle on a condition we can reject upfront.

Implementation
  - new PreflightReason `node-engine-mismatch`
  - new dep `readTargetEnginesNode(tag)` — runs the git-show as a child
    process with stdio captured to a string; missing tag / missing file /
    malformed JSON / missing engines.node all resolve to null (treated as
    "no constraint, pass")
  - uses existing semver dep with includePrerelease: true
  - new PreflightInput field `currentNodeVersion`; threaded from
    process.versions.node in both wirings (scheduler + manual apply)
  - check runs *after* signature verification so we trust the package.json
  - PreflightResult carries an optional `detail` string; applyPipeline
    appends it to the lastResult.reason so the admin UI shows e.g.
    "node-engine-mismatch: target requires Node >=26.0.0, running 25.0.0"

Tests: 6 new vitest cases (no engines.node, satisfies, fails below floor,
caret range, loose-spaced range, ordering after signature). Full
backend-new: 635 passed (was 629).

Refs #7607

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): email admin on auto-rollback / preflight-failed (#7607)

Before this commit, only the terminal rollback-failed state emailed the
admin. Auto-recovered failures (rolled-back-install-failed, rolled-back-
build-failed, rolled-back-health-check, rolled-back-crash-loop) and pre-
flight-failed surfaced only via the /admin/update banner — so a 3am
autonomous update that failed because of, say, a Node engine bump would
roll back silently and stay invisible until the admin next logged in.

Notifier
  - new EmailKinds: 'update-preflight-failed', 'update-rolled-back',
    'update-rollback-failed'
  - new pure decideOutcomeEmail(input) → {toSend, newState}
  - dedupe key `<outcome>:<targetTag>` in EmailSendLog.lastFailureKey:
    same outcome on same tag emits one email per cycle (kills retry-loop
    spam); a different outcome or different tag resets the key
  - rollback-failed always fires (terminal — overrides dedupe)
  - state.ts validator + loadState backfill the new field for legacy
    state files (Tier 1/2/3 installs upgrading in place)

Wiring
  - new index.ts helper notifyApplyFailure() loads state, runs the pure
    notifier, sends (via the nodemailer-backed sendEmailViaSmtp from the
    previous commit), persists the new dedupe key — all best-effort
  - schedulerTriggerApply: fires on applyUpdate returning preflight-failed
    or rolled-back
  - /admin/update/apply HTTP handler: same
  - boot path in expressCreateServer: if state.lastResult is a failure
    outcome we haven't already emailed about, fire then. Covers:
      - health-check timeout rollback (timer expired between boots)
      - crash-loop forced rollback caught on a later boot
      - preflight-failed where the process didn't get to email before exit
      - unacknowledged rollback-failed terminal

Tests
  - 8 new vitest cases for decideOutcomeEmail (adminEmail=null, each
    outcome's content, dedupe by tag, dedupe by outcome, rollback-failed
    bypass)
  - Full backend-new suite: 643 passed (was 635)

Refs #7607

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(updater): address Qodo review on tier 4

- UpdatePage: only show "deferred until" subtitle when scheduledFor
  actually matches nextWindowOpensAt. The previous `scheduledFor >
  now + 60s` heuristic misfired during a normal in-window 15-min
  grace period.
- applyPipeline: return the enriched preflight reason (`reason:
  detail`) instead of only `pf.reason`, so /admin/update/apply 409
  bodies and failure-notify emails preserve diagnostics like the
  Node engine mismatch detail.
- updater/index: key the cached nodemailer transport on the full
  set of SMTP options (host + port + secure + auth) so runtime
  changes to port/credentials via reloadSettings() invalidate
  the cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:50:34 +01:00

18 KiB

Auto-Update PR 4 — Tier 4 (autonomous in maintenance window) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Land Tier 4 of the auto-update subsystem: when a new release is detected and updates.tier == "autonomous" on a writable install with a valid updates.maintenanceWindow, schedule the update so that the drain only starts while now() is inside the window. Outside the window, the schedule is deferred to the next opening. The admin UI gains a window picker (start/end HH:MM, tz local|utc) with validation and a "next window opens at..." preview.

Architecture: Add a new pure module MaintenanceWindow.ts with inWindow(now, window) and nextWindowStart(now, window). Both handle cross-midnight (end < start), local- vs utc-tz selection, and DST transitions (compute against the configured wall clock, not UTC offsets that shift). The Scheduler.decideSchedule() and decideTriggerApply() decisions take a new maintenanceWindow input and a canAutonomous policy bit; when the tier is autonomous, schedules are placed at max(now + grace, nextWindowStart) and trigger-apply aborts (back to scheduled) if the window has closed by fire time. UpdatePolicy.canAutonomous flips on for git + tier:autonomous + valid window. Admin UI adds a picker bound to updates.maintenanceWindow via the existing settings round-trip; the UpdatePage scheduled panel shows the resolved next-window time.

Tech Stack: TypeScript (Node ≥ 25), Express, log4js, vitest (unit), mocha + supertest (HTTP integration), Playwright (admin UI), React + Zustand (admin UI).


File structure

New files

  • src/node/updater/MaintenanceWindow.ts — pure inWindow(now, window) + nextWindowStart(now, window). No I/O.
  • src/tests/backend-new/specs/updater/MaintenanceWindow.test.ts — vitest unit. Same-day, cross-midnight, exact boundary, tz=utc vs tz=local, DST spring-forward + fall-back.
  • src/tests/backend/specs/updater-window-integration.ts — mocha integration. Latest release detected outside window queues for next opening; entering window triggers fire-now (or grace+window); cancel during deferred-grace returns to idle; window closes mid-grace defers to next window without dropping the schedule.
  • admin/src/components/MaintenanceWindowPicker.tsx — small controlled component: start (HH:MM), end (HH:MM), tz select, validation message, "next window opens at..." preview.
  • src/tests/frontend-new/admin-spec/update-autonomous.spec.ts — Playwright: window picker round-trips through Settings; scheduled panel renders "next window opens at..." when waiting; cancel works.

Modified files

  • src/node/updater/types.ts — add MaintenanceWindow type ({start: string; end: string; tz: 'local' | 'utc'}), thread maintenanceWindow: MaintenanceWindow | null through PolicyInput.
  • src/node/updater/UpdatePolicy.tscanAutonomous flips on for git + tier === 'autonomous' AND a non-null, schema-valid maintenanceWindow. Add new policy reason value 'maintenance-window-missing' (denied tier 4 when window not configured) and 'maintenance-window-invalid' (denied tier 4 when window fails parse).
  • src/node/updater/Scheduler.ts — extend DecideScheduleInput with maintenanceWindow + canAutonomous; when canAutonomous, scheduledFor = max(now+grace, nextWindowStart(now+grace, window)). Extend decideTriggerApply() so that when canAutonomous and inWindow(now, window) === false, return new action {action: 'defer'; nextStart: string}. Extend SchedulerRunner to re-arm on defer.
  • src/node/updater/index.ts — pass updates.maintenanceWindow + the autonomous bit into decideSchedule/decideTriggerApply. On defer, persist new scheduledFor and re-arm. Log line at info: updater: deferred to next maintenance window at <iso>.
  • src/node/utils/Settings.ts — add maintenanceWindow: MaintenanceWindow | null to the updates settings type; default null. Validate shape on boot; on invalid, log a warning and treat as null (do not crash boot).
  • settings.json.template + settings.json.docker — add "maintenanceWindow": null line with comment showing example {"start":"03:00","end":"05:00","tz":"local"}.
  • src/node/hooks/express/updateStatus.ts — surface nextWindowStart (computed at request time when tier is autonomous + window set) in GET /admin/update/status response so the admin UI can show "next window opens at...".
  • src/locales/en.jsonupdate.window.start, update.window.end, update.window.tz_local, update.window.tz_utc, update.window.validation.format, update.window.validation.equal, update.window.next_opens_at, update.page.scheduled.deferred_until, update.page.policy.autonomous_no_window, update.page.policy.autonomous_invalid_window.
  • admin/src/store/store.ts — extend Settings.updates with maintenanceWindow; extend response shape returned by /admin/update/status with optional nextWindowOpensAt: string | null.
  • admin/src/pages/UpdatePage.tsx — render MaintenanceWindowPicker when tier === 'autonomous'. Render "Deferred — next window opens at ..." when execution.status === 'scheduled' and scheduledFor > now. Show explicit policy.reason text for autonomous_no_window and autonomous_invalid_window.
  • admin/src/components/UpdateBanner.tsx — add a banner variant when tier === 'autonomous' but window is missing/invalid: "Autonomous updates are disabled until a maintenance window is configured." Links to /admin/update.
  • doc/admin/updates.md — flip Tier 4 from "designed, not yet implemented" to current; document maintenanceWindow shape, cross-midnight, DST behavior, fallback when window is missing.
  • CHANGELOG.md — Unreleased section entry under ### Added.
  • docs/superpowers/specs/2026-04-25-auto-update-runbook.md — append Tier 4 smoke section: configure window 5 min from now, observe deferral, walk window forward, observe fire.

Task 1: Settings schema for maintenanceWindow

Files:

  • Modify: src/node/utils/Settings.ts
  • Modify: settings.json.template
  • Modify: settings.json.docker
  • Modify: src/node/updater/types.ts (export MaintenanceWindow)
  • Test: extend an existing Settings-load test if one exists for updates; otherwise rely on Task 4 unit coverage of the window module + boot-time log.

Steps:

  • In src/node/updater/types.ts add export interface MaintenanceWindow { start: string; end: string; tz: 'local' | 'utc' }.
  • In src/node/utils/Settings.ts extend the updates type with maintenanceWindow: MaintenanceWindow | null. Default to null in the literal.
  • Add boot-time validation: regex /^([01]\d|2[0-3]):[0-5]\d$/ for both start and end; tz must be 'local' | 'utc'; start !== end. On invalid, log warning via log4js category updater and set to null (do not crash). Validation lives in a small pure helper exported from MaintenanceWindow.ts (parseWindow) so the policy and the UI can reuse it.
  • Edit settings.json.template and settings.json.docker to include "maintenanceWindow": null immediately below tier, with a comment showing the shape.

Verification:

  • pnpm exec tsc --noEmit clean.
  • Boot the server with a deliberately malformed window ({"start":"oops"}) and confirm the warning is logged and tier downgrades to auto effectively (canAutonomous=false via the policy reason 'maintenance-window-invalid').

Task 2: MaintenanceWindow.ts module + unit tests

Files:

  • Create: src/node/updater/MaintenanceWindow.ts
  • Create: src/tests/backend-new/specs/updater/MaintenanceWindow.test.ts

Steps:

  • Export parseWindow(raw: unknown): MaintenanceWindow | null (returns null if shape/format invalid).
  • Export inWindow(now: Date, window: MaintenanceWindow): boolean. Compare against the configured tz's wall clock. For tz: 'utc' use getUTCHours/Minutes; for tz: 'local' use getHours/Minutes. Cross-midnight (end < start): inside if now ≥ start || now < end.
  • Export nextWindowStart(now: Date, window: MaintenanceWindow): Date. Returns the next Date whose wall-clock time equals start in the configured tz and which is ≥ now. For tz: 'local' this is straightforward; for tz: 'utc' build via Date.UTC. Document via inline comment that DST spring-forward will be handled by the host's setTimer/setTimeout and we never schedule "into the gap" because we always compare against wall clock.

Tests (vitest):

  • inWindow — same-day window 03:00-05:00 (inside at 03:30, outside at 02:59, outside at 05:00 (exclusive end)).
  • inWindow — cross-midnight 22:00-02:00 (inside at 23:00 and at 01:00; outside at 02:00 and 21:59).
  • inWindow — tz=utc respects UTC clock regardless of host TZ (run with TZ=America/Los_Angeles).
  • nextWindowStart — when now is before today's start, returns today at start.
  • nextWindowStart — when now is inside the window, returns next day's start (callers gate fire-now via inWindow, not nextWindowStart).
  • nextWindowStart — DST spring forward (America/New_York, 2026-03-08, window 02:30-03:30 local): nextWindowStart for now = 2026-03-08T06:00:00Z resolves to the next wall-clock 02:30 (which is actually 03:30 local on the DST day; document this in the test).
  • nextWindowStart — DST fall back (America/New_York, 2026-11-01, window 01:30-02:30 local): assertion that nextWindowStart returns the first 01:30 wall-clock occurrence.
  • parseWindow — accepts {start:"03:00",end:"05:00",tz:"local"}; rejects missing fields, malformed times, start===end, unknown tz.

Verification:

  • pnpm exec vitest run src/tests/backend-new/specs/updater/MaintenanceWindow.test.ts green.

Task 3: Extend UpdatePolicy with canAutonomous and window args

Files:

  • Modify: src/node/updater/UpdatePolicy.ts
  • Modify: src/node/updater/types.ts (extend PolicyInput)
  • Modify: src/tests/backend-new/specs/updater/UpdatePolicy.test.ts

Steps:

  • Extend PolicyInput with maintenanceWindow: MaintenanceWindow | null (optional, defaults to null in callers).
  • Modify evaluatePolicy: when tier === 'autonomous' and writable and not terminal:
    • if maintenanceWindow == null, canAutonomous = false, reason = 'maintenance-window-missing', but keep canAuto = true, canManual = true (degrade to Tier 3 behavior).
    • if parseWindow(maintenanceWindow) == null, same as above with reason = 'maintenance-window-invalid'.
    • otherwise canAutonomous = true.
  • Update existing tests that asserted canAutonomous: true for tier: 'autonomous' without a window — they now expect canAutonomous: false, reason: 'maintenance-window-missing'. Add new cases for the three policy outcomes.

Verification:

  • pnpm exec vitest run src/tests/backend-new/specs/updater/UpdatePolicy.test.ts green.

Task 4: Scheduler — gate scheduling + firing on window

Files:

  • Modify: src/node/updater/Scheduler.ts
  • Modify: src/tests/backend-new/specs/updater/Scheduler.test.ts (extend; create if absent)

Steps:

  • Extend DecideScheduleInput with maintenanceWindow: MaintenanceWindow | null and use policy.canAutonomous to decide whether to apply the window gate.
  • In decideSchedule, after the existing grace computation, if canAutonomous && maintenanceWindow:
    • candidate scheduledFor = now + grace.
    • if inWindow(candidate, window) === false, set scheduledFor = nextWindowStart(candidate, window).
    • keep the rest of the email/dedupe machinery untouched (grace-start email cadence still fires once per tag).
  • In decideTriggerApply, add a parameter for the resolved policy plus the window/now. If policy.canAutonomous && !inWindow(now, window), return new decision {action: 'defer'; nextStart: string}. The runner persists scheduledFor = nextStart and re-arms.
  • In SchedulerRunner, extend the timer-fire callback to call triggerApply and, on defer, re-arm without firing. (The runner is already idempotent on arm.)

Tests (vitest):

  • decideSchedule — canAutonomous + window 03:00-05:00 + now=10:00 → scheduledFor snapped to the next 03:00 (not now + grace).
  • decideSchedule — canAutonomous + window 03:00-05:00 + now=03:30 with grace=0 → scheduledFor is now (inside window, no snap).
  • decideTriggerApply — canAutonomous + outside window → {action: 'defer', nextStart: <iso>}.
  • decideTriggerApply — canAutonomous + inside window → {action: 'fire'}.
  • Email dedupe: defer does not trigger a new grace-start email.

Verification:

  • pnpm exec vitest run src/tests/backend-new/specs/updater/Scheduler.test.ts green.

Task 5: Wire scheduler runner + status endpoint to surface window state

Files:

  • Modify: src/node/updater/index.ts
  • Modify: src/node/hooks/express/updateStatus.ts
  • Modify: src/tests/backend/specs/updater-actions.ts (or the equivalent status test) — extend to assert nextWindowOpensAt is present when tier=autonomous + window set.

Steps:

  • In the periodic check loop, pass settings.updates.maintenanceWindow into decideSchedule. Pass policy result into both decideSchedule and decideTriggerApply.
  • On {action: 'defer'}, write state.execution.scheduledFor = nextStart, persist, runner.arm(...). Emit a log line at INFO category updater.
  • In updateStatus.ts, when tier === 'autonomous' and maintenanceWindow parses, compute nextWindowOpensAt = nextWindowStart(now, window) and include in the JSON response (null otherwise).

Verification:

  • pnpm exec mocha src/tests/backend/specs/updater-actions.ts green.

Task 6: Admin UI — MaintenanceWindowPicker + scheduled-panel "deferred until"

Files:

  • Create: admin/src/components/MaintenanceWindowPicker.tsx
  • Modify: admin/src/pages/UpdatePage.tsx
  • Modify: admin/src/components/UpdateBanner.tsx
  • Modify: admin/src/store/store.ts
  • Modify: src/locales/en.json
  • Test: src/tests/frontend-new/admin-spec/update-autonomous.spec.ts

Steps:

  • MaintenanceWindowPicker.tsx — controlled component over value: {start, end, tz} | null, emits onChange. Inline validation message via i18n keys update.window.validation.format / update.window.validation.equal. Below the picker, render the resolved nextWindowOpensAt (passed in via prop) with key update.window.next_opens_at.
  • In UpdatePage.tsx, when settings.updates.tier === 'autonomous', render the picker. Wiring through the existing settings round-trip (the parsed settings editor PR #7709 lands first; if it's not yet on develop at integration time, fall back to writing through /admin/settings).
  • When execution.status === 'scheduled' and policy.canAutonomous and scheduledFor > now, render the scheduled panel with the deferral subtitle (update.page.scheduled.deferred_until).
  • In UpdateBanner.tsx, render the "configure maintenance window" banner when policy.reason === 'maintenance-window-missing' | 'maintenance-window-invalid' and tier === 'autonomous'.
  • Add all i18n keys to en.json. Always i18n, never hardcoded (memory: feedback_always_i18n).

Tests (Playwright):

  • Window picker saves a value; reload restores it.
  • Invalid input shows the validation message and does not save.
  • When tier=autonomous + window set + outside window, the scheduled panel shows "Next window opens at HH:MM (local)".
  • When tier=autonomous + window missing, the banner renders the link to /admin/update.

Verification:

  • pnpm --filter ep_etherpad-lite exec playwright test src/tests/frontend-new/admin-spec/update-autonomous.spec.ts green (port 9003 per memory feedback_test_port_9003).

Task 7: Window-boundary integration test

Files:

  • Create: src/tests/backend/specs/updater-window-integration.ts

Cases:

  • Outside window: VersionChecker sees a new release; Scheduler arms scheduledFor = nextWindowStart; no drain starts.
  • Enter window: clock advances to inside-window; fire-time decideTriggerApply returns fire; drain starts.
  • Cancel during deferred-grace: /admin/update/cancel returns 200 and execution.status returns to idle.
  • Window closes mid-grace: clock advances past end before fire; decideTriggerApply returns defer; state persists with new scheduledFor; runner re-arms.

Verification:

  • pnpm exec mocha src/tests/backend/specs/updater-window-integration.ts green.

Task 8: Docs, runbook, CHANGELOG

Files:

  • Modify: doc/admin/updates.md
  • Modify: docs/superpowers/specs/2026-04-25-auto-update-runbook.md
  • Modify: CHANGELOG.md

Steps:

  • Flip the Tier 4 section in doc/admin/updates.md from "designed, not yet implemented" to current. Document maintenanceWindow shape, cross-midnight, DST behavior, and the policy fallback when the window is missing or invalid.
  • Append a Tier 4 smoke section to the runbook: configure window 5 min from now, observe deferral, walk window forward, observe fire, observe rollback path inside window still works.
  • Add an Unreleased entry to CHANGELOG.md under ### Added.

Verification:

  • Manual: pnpm run dev on a clean checkout with tier: "autonomous" + a near-future 2-minute window and confirm the admin UI matches the documented flow.

Cross-cutting checks before opening the PR

  • pnpm exec tsc --noEmit clean (root + admin).
  • pnpm exec vitest run green (backend-new).
  • pnpm exec mocha src/tests/backend/specs/updater-*.ts green.
  • Playwright admin spec green under pnpm --filter ep_etherpad-lite exec playwright test src/tests/frontend-new/admin-spec/update-autonomous.spec.ts on port 9003.
  • pnpm run build:ui succeeds.
  • Manual smoke runbook Tier 4 section completed against a disposable VM (canary deferred to merge if the 2-week canary requirement from spec §"Ship gate" is dropped; otherwise gate merge on canary).
  • PR title feat(updater): tier 4 — autonomous update in maintenance window (#7607).
  • PR body links to the spec + this plan, lists settings additions, and links to PRs #7601 / #7704 / #7720.
  • After merge, close issue #7607 with a summary comment linking all four PRs.