';
+
+/**
+ * Drives the Go-rendered startup page (internal/backendapp/startup_page.go)
+ * through a real browser instead of httptest string-matching: the initial
+ * markup comes from the production renderer via the E2E-only fixture route,
+ * and only /ready and the app navigation are mocked, so the embedded poll
+ * script runs unmodified.
+ */
+test.describe("Startup page (real render)", () => {
+ test("polls for step progress and reloads once the snapshot reports ready", async ({
+ backend,
+ browser,
+ request,
+ }) => {
+ const startingHtml = await request
+ .post(`${backend.baseUrl}/api/v1/e2e/startup-page-fixture`, { data: STARTING_SNAPSHOT })
+ .then((res) => res.text());
+
+ const context = await browser.newContext({ baseURL: backend.frontendUrl });
+ const page = await context.newPage();
+
+ let readyBody: unknown = { status: "starting", startup: STARTING_SNAPSHOT };
+ let appIsReady = false;
+
+ const fulfillReady = (route: Route) =>
+ route.fulfill({
+ status: appIsReady ? 200 : 503,
+ contentType: "application/json",
+ body: JSON.stringify(readyBody),
+ });
+
+ const fulfillAppRoute = (route: Route) =>
+ appIsReady
+ ? route.fulfill({ status: 200, contentType: "text/html", body: POST_RELOAD_HTML })
+ : route.fulfill({ status: 503, contentType: "text/html", body: startingHtml });
+
+ await page.route(
+ (url) => url.pathname === "/ready",
+ (route) => fulfillReady(route),
+ );
+ await page.route(
+ (url) => url.pathname === "/startup-page-e2e-fixture",
+ (route) => fulfillAppRoute(route),
+ );
+
+ await page.goto("/startup-page-e2e-fixture");
+
+ await expect(page.locator("#startup-phase")).toHaveText("Applying migrations");
+ await expect(page.locator("#startup-step-label")).toHaveText("Service stores");
+ await expect(page.locator("#startup-progress")).toHaveText("400 of 1000 messages");
+ await expect(page.locator("#startup-eta")).toHaveText("About 30 seconds remaining");
+
+ // A failing response with a malformed snapshot must keep the last valid
+ // detail and mark it as stale. The status code remains authoritative when
+ // the body shape cannot be trusted.
+ readyBody = { status: "starting", startup: { ...STARTING_SNAPSHOT, step: {} } };
+ await expect(page.getByText("Last known")).toBeVisible();
+ await expect(page.locator("#startup-step-label")).toHaveText("Service stores");
+ await expect(page.locator("#startup-progress")).toHaveText("400 of 1000 messages");
+
+ // Poll-driven update: the embedded script re-fetches /ready on its own
+ // schedule and re-renders in place, with no test-issued reload.
+ readyBody = ADVANCED_BODY;
+ await expect(page.locator("#startup-progress")).toHaveText("700 of 1000 messages");
+ await expect(page.locator("#startup-eta")).toHaveText("About 10 seconds remaining");
+
+ // Reload-on-ready: once a poll response's snapshot reports phase "ready",
+ // the script calls location.reload() itself; the re-navigation this test
+ // observes is the real app route being requested a second time.
+ appIsReady = true;
+ readyBody = READY_BODY;
+ await expect(page.getByTestId("post-reload-marker")).toBeVisible();
+
+ await context.close();
+ });
+});
diff --git a/apps/web/hooks/domains/system/use-kandev-restart.test.ts b/apps/web/hooks/domains/system/use-kandev-restart.test.ts
index db966629f6..0c804c9f5a 100644
--- a/apps/web/hooks/domains/system/use-kandev-restart.test.ts
+++ b/apps/web/hooks/domains/system/use-kandev-restart.test.ts
@@ -1,5 +1,5 @@
import { act, renderHook, waitFor } from "@testing-library/react";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
fetchSystemInfo: vi.fn(),
@@ -28,6 +28,10 @@ beforeEach(() => {
mocks.releaseBackendReloadOwner.mockReset();
});
+afterEach(() => {
+ vi.useRealTimers();
+});
+
describe("useKandevRestart", () => {
it("starts a restart and waits for a new boot id", async () => {
mocks.fetchSystemInfo
@@ -64,6 +68,28 @@ describe("useKandevRestart", () => {
expect(result.current.isRestarting).toBe(true);
});
+ it("keeps waiting when startup takes longer than three minutes", async () => {
+ vi.useFakeTimers();
+ mocks.fetchSystemInfo
+ .mockResolvedValueOnce({ boot_id: "boot-1" })
+ .mockRejectedValue(new Error("connection refused"));
+ mocks.requestRestart.mockResolvedValue({ accepted: true, message: "Restarting" });
+
+ const { result } = renderHook(() => useKandevRestart());
+
+ await act(async () => {
+ await result.current.start();
+ });
+ expect(result.current.phase).toBe("restarting");
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(180_001);
+ });
+
+ expect(result.current.phase).toBe("restarting");
+ expect(result.current.errorMessage).toBeNull();
+ });
+
it("reports an error when the restart request fails", async () => {
mocks.fetchSystemInfo.mockResolvedValueOnce({ boot_id: "boot-1" });
mocks.requestRestart.mockRejectedValue(new Error("unsupported launch mode"));
diff --git a/apps/web/hooks/domains/system/use-kandev-restart.ts b/apps/web/hooks/domains/system/use-kandev-restart.ts
index e3a3f2f89a..8235628fe9 100644
--- a/apps/web/hooks/domains/system/use-kandev-restart.ts
+++ b/apps/web/hooks/domains/system/use-kandev-restart.ts
@@ -11,7 +11,6 @@ import { t } from "@/lib/i18n";
export type KandevRestartPhase = "idle" | "starting" | "restarting" | "done" | "error";
const POLL_INTERVAL_MS = 2000;
-const MAX_DURATION_MS = 3 * 60 * 1000;
type UseKandevRestartArgs = {
onComplete?: () => void;
@@ -32,7 +31,6 @@ export function useKandevRestart({
const [errorMessage, setErrorMessage] = useState(null);
const [previousBootID, setPreviousBootID] = useState(null);
const activeRef = useRef(false);
- const startedAtRef = useRef(null);
const ownerReleaseRef = useRef<(() => void) | null>(null);
const claimReloadOwnership = useCallback(() => {
@@ -63,7 +61,6 @@ export function useKandevRestart({
try {
const before = await fetchSystemInfo({ cache: "no-store" });
setPreviousBootID(before.boot_id);
- startedAtRef.current = Date.now();
await requestRestart();
setPhase("restarting");
} catch (e) {
@@ -77,7 +74,6 @@ export function useKandevRestart({
setPhase("idle");
setErrorMessage(null);
setPreviousBootID(null);
- startedAtRef.current = null;
}, [releaseReloadOwnership]);
useEffect(() => releaseReloadOwnership, [releaseReloadOwnership]);
@@ -88,13 +84,6 @@ export function useKandevRestart({
let cancelled = false;
const tick = async () => {
- const startedAt = startedAtRef.current ?? Date.now();
- if (Date.now() - startedAt > MAX_DURATION_MS) {
- if (!cancelled) {
- fail(t("system:restartTimedOut"));
- }
- return;
- }
try {
const info = await fetchSystemInfo({ cache: "no-store" });
if (cancelled) return;
diff --git a/apps/web/hooks/domains/system/use-startup-progress.test.ts b/apps/web/hooks/domains/system/use-startup-progress.test.ts
new file mode 100644
index 0000000000..96af4af20d
--- /dev/null
+++ b/apps/web/hooks/domains/system/use-startup-progress.test.ts
@@ -0,0 +1,193 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { ApiError } from "@/lib/api/client";
+import type { StartupSnapshot } from "@/lib/startup-progress/types";
+
+const mocks = vi.hoisted(() => ({ fetchJson: vi.fn() }));
+
+vi.mock("@/lib/api/client", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, fetchJson: mocks.fetchJson };
+});
+
+import { useStartupProgress } from "./use-startup-progress";
+
+function snapshot(overrides: Partial = {}): StartupSnapshot {
+ return {
+ phase: "applying_migrations",
+ boot: 1,
+ seq: 1,
+ elapsed_ms: 1000,
+ phase_elapsed_ms: 1000,
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ mocks.fetchJson.mockReset();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("useStartupProgress", () => {
+ it("does not poll while disabled", async () => {
+ renderHook(() => useStartupProgress(false));
+ await Promise.resolve();
+ expect(mocks.fetchJson).not.toHaveBeenCalled();
+ });
+
+ it("applies the snapshot from a successful poll", async () => {
+ mocks.fetchJson.mockResolvedValue({ startup: snapshot() });
+ const { result } = renderHook(() => useStartupProgress(true));
+
+ await waitFor(() => expect(result.current.snapshot).not.toBeNull());
+ expect(result.current.snapshot?.phase).toBe("applying_migrations");
+ expect(result.current.lastKnown).toBe(false);
+ });
+
+ it("treats a parseable 503 body as fresh data, not a failure", async () => {
+ mocks.fetchJson.mockRejectedValue(
+ new ApiError("starting", 503, {
+ status: "starting",
+ startup: snapshot({ phase: "opening_database" }),
+ }),
+ );
+ const { result } = renderHook(() => useStartupProgress(true));
+
+ await waitFor(() => expect(result.current.snapshot?.phase).toBe("opening_database"));
+ expect(result.current.lastKnown).toBe(false);
+ });
+
+ it("falls back to last known on a response with no parseable snapshot", async () => {
+ vi.useFakeTimers();
+ mocks.fetchJson
+ .mockResolvedValueOnce({ startup: snapshot() })
+ .mockRejectedValueOnce(new TypeError("fetch failed"));
+ const { result } = renderHook(() => useStartupProgress(true));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(result.current.snapshot).not.toBeNull();
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1000);
+ });
+
+ expect(result.current.lastKnown).toBe(true);
+ expect(result.current.snapshot?.phase).toBe("applying_migrations");
+ });
+
+ it("treats a successful response with no snapshot as ready, not last known", async () => {
+ vi.useFakeTimers();
+ mocks.fetchJson
+ .mockResolvedValueOnce({ startup: snapshot() })
+ .mockResolvedValueOnce({ service: "kandev", version: "e2e-test", status: "ok" });
+ const { result } = renderHook(() => useStartupProgress(true));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(result.current.snapshot).not.toBeNull();
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1000);
+ });
+
+ expect(result.current.lastKnown).toBe(false);
+ });
+
+ it("treats invalid JSON from a successful response as ready", async () => {
+ vi.useFakeTimers();
+ mocks.fetchJson
+ .mockResolvedValueOnce({ startup: snapshot() })
+ .mockRejectedValueOnce(new ApiError("invalid JSON", 200, null));
+ const { result } = renderHook(() => useStartupProgress(true));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1000);
+ });
+
+ expect(result.current.lastKnown).toBe(false);
+ expect(result.current.snapshot).not.toBeNull();
+ });
+});
+
+describe("useStartupProgress validation and cleanup", () => {
+ it("keeps the last valid snapshot when a later body is malformed", async () => {
+ vi.useFakeTimers();
+ const first = snapshot();
+ mocks.fetchJson
+ .mockResolvedValueOnce({ startup: first })
+ .mockResolvedValueOnce({ startup: { ...first, step: {} } });
+ const { result } = renderHook(() => useStartupProgress(true));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(result.current.snapshot).toEqual(first);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1000);
+ });
+
+ expect(result.current.snapshot).toEqual(first);
+ expect(result.current.lastKnown).toBe(false);
+ });
+
+ it("skips a tick while the previous poll is still outstanding", async () => {
+ vi.useFakeTimers();
+ let resolveFirst!: (value: { startup: StartupSnapshot }) => void;
+ mocks.fetchJson.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveFirst = resolve;
+ }),
+ );
+
+ renderHook(() => useStartupProgress(true));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1000);
+ });
+ expect(mocks.fetchJson).toHaveBeenCalledTimes(1);
+
+ resolveFirst({ startup: snapshot() });
+ await act(async () => {
+ await Promise.resolve();
+ });
+ });
+
+ it("resets to null when disabled after being enabled", async () => {
+ mocks.fetchJson.mockResolvedValue({ startup: snapshot() });
+ const { result, rerender } = renderHook(({ enabled }) => useStartupProgress(enabled), {
+ initialProps: { enabled: true },
+ });
+
+ await waitFor(() => expect(result.current.snapshot).not.toBeNull());
+
+ rerender({ enabled: false });
+ expect(result.current.snapshot).toBeNull();
+ expect(result.current.lastKnown).toBe(false);
+ });
+
+ it("aborts an active request when disabled", async () => {
+ let signal: AbortSignal | undefined;
+ mocks.fetchJson.mockImplementation((_path: string, options: { init?: RequestInit }) => {
+ signal = options.init?.signal as AbortSignal | undefined;
+ return new Promise(() => {});
+ });
+ const { rerender } = renderHook(({ enabled }) => useStartupProgress(enabled), {
+ initialProps: { enabled: true },
+ });
+
+ await waitFor(() => expect(signal).toBeDefined());
+ rerender({ enabled: false });
+
+ expect(signal?.aborted).toBe(true);
+ });
+});
diff --git a/apps/web/hooks/domains/system/use-startup-progress.ts b/apps/web/hooks/domains/system/use-startup-progress.ts
new file mode 100644
index 0000000000..60f2f1ca6c
--- /dev/null
+++ b/apps/web/hooks/domains/system/use-startup-progress.ts
@@ -0,0 +1,108 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { ApiError, fetchJson } from "@/lib/api/client";
+import { parseStartupSnapshot, type StartupSnapshot } from "@/lib/startup-progress/types";
+
+const POLL_MS = 1000;
+const TIMEOUT_MS = 5000;
+
+type ReadyBody = {
+ startup?: unknown;
+};
+
+export type StartupProgressState = {
+ snapshot: StartupSnapshot | null;
+ lastKnown: boolean;
+};
+
+function readyBodyOf(err: unknown): ReadyBody | null {
+ if (!(err instanceof ApiError) || !err.body || typeof err.body !== "object") return null;
+ return err.body as ReadyBody;
+}
+
+/**
+ * Polls GET /ready for step-level startup detail while `enabled`. This is
+ * additive to, and independent of, useKandevRestart's boot_id-based
+ * completion polling of /api/v1/system/info — that loop is left untouched,
+ * this one only feeds display detail into RestartProgressDialog.
+ *
+ * Mirrors the cadence and fallback rules of the Go startup page's embedded
+ * poll script (AC-PLATFORM-STARTUP-PROGRESS-003): one request in flight,
+ * abandoned after TIMEOUT_MS, any tick skipped while one is outstanding
+ * (AC-002.2). A response this hook cannot parse a snapshot out of resolves
+ * by the status code, not by assuming staleness (AC-002.6/003.13): a
+ * successful status with no snapshot means ready, so it clears "last known"
+ * without touching the last-rendered detail; a failing status falls back to
+ * "last known" instead. The 503 "still starting" response carries a
+ * parseable snapshot either way and is therefore always treated as fresh
+ * data, not a failure.
+ */
+export function useStartupProgress(enabled: boolean): StartupProgressState {
+ const [snapshot, setSnapshot] = useState(null);
+ const [lastKnown, setLastKnown] = useState(false);
+ const inFlightRef = useRef(false);
+ const activeControllerRef = useRef(null);
+
+ useEffect(() => {
+ if (!enabled) {
+ setSnapshot(null);
+ setLastKnown(false);
+ activeControllerRef.current?.abort();
+ activeControllerRef.current = null;
+ inFlightRef.current = false;
+ return;
+ }
+ let cancelled = false;
+
+ const applySnapshot = (ok: boolean, body: ReadyBody | null) => {
+ const parsed = parseStartupSnapshot(body?.startup);
+ if (!parsed) {
+ // AC-PLATFORM-STARTUP-PROGRESS-002.6: a response with no parseable
+ // snapshot resolves by the status code, not by assuming staleness -
+ // a successful status means ready, only a failing one falls back to
+ // last-known.
+ setLastKnown(!ok);
+ return;
+ }
+ setLastKnown(false);
+ setSnapshot(parsed);
+ };
+
+ const tick = async () => {
+ if (inFlightRef.current) return;
+ inFlightRef.current = true;
+ const controller = new AbortController();
+ activeControllerRef.current = controller;
+ const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
+ try {
+ const body = await fetchJson("/ready", {
+ cache: "no-store",
+ init: { signal: controller.signal },
+ });
+ if (!cancelled) applySnapshot(true, body);
+ } catch (err) {
+ const ok = err instanceof ApiError && err.status >= 200 && err.status < 300;
+ if (!cancelled) applySnapshot(ok, readyBodyOf(err));
+ } finally {
+ clearTimeout(timeoutId);
+ if (activeControllerRef.current === controller) {
+ activeControllerRef.current = null;
+ inFlightRef.current = false;
+ }
+ }
+ };
+
+ void tick();
+ const interval = setInterval(() => void tick(), POLL_MS);
+ return () => {
+ cancelled = true;
+ clearInterval(interval);
+ activeControllerRef.current?.abort();
+ activeControllerRef.current = null;
+ inFlightRef.current = false;
+ };
+ }, [enabled]);
+
+ return { snapshot, lastKnown };
+}
diff --git a/apps/web/lib/api/client.test.ts b/apps/web/lib/api/client.test.ts
index fddb6f3b95..8ec4de68f6 100644
--- a/apps/web/lib/api/client.test.ts
+++ b/apps/web/lib/api/client.test.ts
@@ -119,6 +119,24 @@ describe("fetchJson", () => {
});
});
+describe("fetchJson response parsing", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("preserves the HTTP status when a successful response is not JSON", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(new Response("not-json", { status: 200, statusText: "OK" })),
+ );
+
+ await expect(fetchJson("/ready", { baseUrl: BACKEND_URL })).rejects.toMatchObject({
+ name: "ApiError",
+ status: 200,
+ });
+ });
+});
+
describe("ApiError response classification", () => {
afterEach(() => {
vi.unstubAllGlobals();
diff --git a/apps/web/lib/api/client.ts b/apps/web/lib/api/client.ts
index e8cbdd729f..075a8002cc 100644
--- a/apps/web/lib/api/client.ts
+++ b/apps/web/lib/api/client.ts
@@ -107,7 +107,15 @@ export async function fetchJson(pathOrUrl: string, options?: ApiRequestOption
if (response.status === 204) return undefined as T;
const text = await response.text();
if (!text) return undefined as T;
- return JSON.parse(text) as T;
+ try {
+ return JSON.parse(text) as T;
+ } catch {
+ throw new ApiError(
+ `Request failed: ${response.status} ${response.statusText}`,
+ response.status,
+ null,
+ );
+ }
}
/**
diff --git a/apps/web/lib/startup-progress/duration.test.ts b/apps/web/lib/startup-progress/duration.test.ts
new file mode 100644
index 0000000000..4476fef16d
--- /dev/null
+++ b/apps/web/lib/startup-progress/duration.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+import { estimateDurationComponents } from "./duration";
+
+describe("estimateDurationComponents", () => {
+ it.each([
+ ["zero floors to one second", 0, 1, false],
+ ["sub-second floors to one second", 250, 1, false],
+ ["exact second stays whole", 1000, 1, false],
+ ["partial second rounds up", 1001, 2, false],
+ ["boundary of 60000ms renders in seconds", 60000, 60, false],
+ ["just over the boundary renders in minutes", 60001, 2, true],
+ ["exact minute stays whole", 120000, 2, true],
+ ["partial minute rounds up", 120001, 3, true],
+ ] as const)("%s", (_name, ms, wantValue, wantMinutes) => {
+ expect(estimateDurationComponents(ms)).toEqual({ value: wantValue, minutes: wantMinutes });
+ });
+});
diff --git a/apps/web/lib/startup-progress/duration.ts b/apps/web/lib/startup-progress/duration.ts
new file mode 100644
index 0000000000..9c07e4be26
--- /dev/null
+++ b/apps/web/lib/startup-progress/duration.ts
@@ -0,0 +1,25 @@
+export type DurationComponents = {
+ value: number;
+ minutes: boolean;
+};
+
+function ceilDiv(numerator: number, denominator: number): number {
+ if (numerator <= 0) return 0;
+ return Math.ceil(numerator / denominator);
+}
+
+/**
+ * Mirrors apps/backend/internal/startup.EstimateComponents
+ * (AC-PLATFORM-STARTUP-PROGRESS-003.10): 60000 ms or less rounds up to whole
+ * seconds with a floor of one second while work remains; anything longer
+ * rounds up to whole minutes. Every rendering surface that formats a
+ * startup-progress duration (the Go page's embedded script and this web
+ * dialog) must use the same rule so they never disagree on one snapshot.
+ */
+export function estimateDurationComponents(ms: number): DurationComponents {
+ if (ms <= 60000) {
+ const seconds = Math.max(1, ceilDiv(ms, 1000));
+ return { value: seconds, minutes: false };
+ }
+ return { value: ceilDiv(ms, 60000), minutes: true };
+}
diff --git a/apps/web/lib/startup-progress/format.test.ts b/apps/web/lib/startup-progress/format.test.ts
new file mode 100644
index 0000000000..c635312bbc
--- /dev/null
+++ b/apps/web/lib/startup-progress/format.test.ts
@@ -0,0 +1,118 @@
+import type { TFunction } from "i18next";
+import i18next from "i18next";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ formatDuration,
+ formatElapsed,
+ formatEta,
+ formatPhaseLabel,
+ formatStalled,
+ formatStepLabel,
+ formatStepProgress,
+ percentDone,
+} from "./format";
+import type { StartupStepSnapshot } from "./types";
+
+const t = i18next.t.bind(i18next) as TFunction;
+
+afterEach(async () => {
+ await i18next.changeLanguage("en");
+});
+
+function step(overrides: Partial): StartupStepSnapshot {
+ return {
+ id: "stores.repositories",
+ label_key: "startup.step.stores_repositories",
+ measure: "counted",
+ unit: "turns",
+ elapsed_ms: 0,
+ stalled: false,
+ ...overrides,
+ };
+}
+
+describe("formatDuration", () => {
+ it("renders singular seconds", () => {
+ expect(formatDuration(t, 1000)).toBe("1 second");
+ });
+
+ it("renders plural seconds", () => {
+ expect(formatDuration(t, 2000)).toBe("2 seconds");
+ });
+
+ it("renders minutes past the 60s boundary", () => {
+ expect(formatDuration(t, 60001)).toBe("2 minutes");
+ });
+});
+
+describe("formatElapsed", () => {
+ it("wraps the duration in the elapsed template", () => {
+ expect(formatElapsed(t, 5000)).toBe("5 seconds elapsed");
+ });
+});
+
+describe("formatEta", () => {
+ it("wraps the duration in the eta template", () => {
+ expect(formatEta(t, 30000)).toBe("About 30 seconds remaining");
+ });
+});
+
+describe("formatStalled", () => {
+ it("wraps the duration in the stalled template", () => {
+ expect(formatStalled(t, 125000)).toBe("Stalled for 3 minutes");
+ });
+});
+
+describe("formatPhaseLabel", () => {
+ it("resolves a known phase", () => {
+ expect(formatPhaseLabel(t, "opening_database")).toBe("Opening database");
+ });
+});
+
+describe("formatStepLabel", () => {
+ it("strips the startup. prefix before the namespace lookup", () => {
+ expect(formatStepLabel(t, step({ label_key: "startup.step.database_backup" }))).toBe(
+ "Database backup",
+ );
+ });
+});
+
+describe("formatStepProgress", () => {
+ it("renders the progress-unavailable statement for an opaque step", () => {
+ expect(formatStepProgress(t, step({ measure: "opaque" }))).toBe(
+ "Progress cannot be measured for this step.",
+ );
+ });
+
+ it("pluralizes a counting step's unit off done", () => {
+ expect(formatStepProgress(t, step({ measure: "counting", unit: "bytes", done: 1 }))).toBe(
+ "1 byte",
+ );
+ });
+
+ it("pluralizes a counted step's unit off total", () => {
+ expect(
+ formatStepProgress(t, step({ measure: "counted", unit: "turns", done: 5, total: 10 })),
+ ).toBe("5 of 10 turns");
+ });
+
+ it("uses a singular unit when total is 1", () => {
+ expect(
+ formatStepProgress(t, step({ measure: "counted", unit: "turns", done: 1, total: 1 })),
+ ).toBe("1 of 1 turn");
+ });
+});
+
+describe("percentDone", () => {
+ it("computes the plain percentage", () => {
+ expect(percentDone(5, 10)).toBe(50);
+ });
+
+ it("clamps to 0 when total is 0", () => {
+ expect(percentDone(0, 0)).toBe(0);
+ });
+
+ it("clamps above 100", () => {
+ expect(percentDone(15, 10)).toBe(100);
+ });
+});
diff --git a/apps/web/lib/startup-progress/format.ts b/apps/web/lib/startup-progress/format.ts
new file mode 100644
index 0000000000..bfe612fd30
--- /dev/null
+++ b/apps/web/lib/startup-progress/format.ts
@@ -0,0 +1,66 @@
+import type { TFunction } from "i18next";
+import { estimateDurationComponents } from "./duration";
+import type { StartupStepSnapshot } from "./types";
+
+const STARTUP_KEY_PREFIX = "startup.";
+
+/** Strips the backend's "startup." key prefix so it can be looked up in the web's `startup` i18next namespace. */
+function stripStartupPrefix(key: string): string {
+ return key.startsWith(STARTUP_KEY_PREFIX) ? key.slice(STARTUP_KEY_PREFIX.length) : key;
+}
+
+export function formatDuration(t: TFunction, ms: number): string {
+ const { value, minutes } = estimateDurationComponents(ms);
+ const key = minutes ? "startup:duration.minutes" : "startup:duration.seconds";
+ return t(key, { count: value });
+}
+
+export function formatElapsed(t: TFunction, ms: number): string {
+ return t("startup:page.elapsed", { duration: formatDuration(t, ms) });
+}
+
+export function formatEta(t: TFunction, etaMs: number): string {
+ return t("startup:page.eta", { duration: formatDuration(t, etaMs) });
+}
+
+export function formatStalled(t: TFunction, sinceAdvanceMs: number): string {
+ return t("startup:page.stalled", { duration: formatDuration(t, sinceAdvanceMs) });
+}
+
+export function formatPhaseLabel(t: TFunction, phase: string): string {
+ return t(`startup:phase.${phase}`);
+}
+
+export function formatStepLabel(t: TFunction, step: StartupStepSnapshot): string {
+ return t(`startup:${stripStartupPrefix(step.label_key)}`);
+}
+
+function unitLabel(t: TFunction, unit: string, count: number): string {
+ return t(`startup:unit.${unit}`, { count });
+}
+
+/**
+ * Mirrors the Go page's countingProgressText/applyCountedProgress
+ * (AC-PLATFORM-STARTUP-PROGRESS-001.4/001.5/001.6, AC-003.8): opaque steps
+ * report no count, counting steps pluralize the unit off `done`, counted
+ * steps pluralize off `total`.
+ */
+export function formatStepProgress(t: TFunction, step: StartupStepSnapshot): string {
+ if (step.measure === "opaque") return t("startup:page.progressUnavailable");
+ if (step.measure === "counting") {
+ const done = step.done ?? 0;
+ return t("startup:page.countingProgress", { done, unit: unitLabel(t, step.unit, done) });
+ }
+ const done = step.done ?? 0;
+ const total = step.total ?? 0;
+ return t("startup:page.countedProgress", { done, total, unit: unitLabel(t, step.unit, total) });
+}
+
+/** Mirrors Go's percentDone (AC-PLATFORM-STARTUP-PROGRESS-003.9): clamped to [0, 100]. */
+export function percentDone(done: number, total: number): number {
+ if (total <= 0) return 0;
+ const pct = (done / total) * 100;
+ if (pct < 0) return 0;
+ if (pct > 100) return 100;
+ return pct;
+}
diff --git a/apps/web/lib/startup-progress/types.test.ts b/apps/web/lib/startup-progress/types.test.ts
new file mode 100644
index 0000000000..7b246dd49d
--- /dev/null
+++ b/apps/web/lib/startup-progress/types.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+import { parseStartupSnapshot } from "./types";
+
+const validSnapshot = {
+ phase: "applying_migrations",
+ boot: 1,
+ seq: 2,
+ elapsed_ms: 1000,
+ phase_elapsed_ms: 500,
+ step: {
+ id: "stores.services",
+ label_key: "startup.step.stores_services",
+ measure: "counted",
+ unit: "messages",
+ elapsed_ms: 500,
+ done: 4,
+ total: 8,
+ rate_per_second: 2,
+ eta_ms: 2000,
+ since_advance_ms: 100,
+ stalled: false,
+ },
+};
+
+describe("parseStartupSnapshot", () => {
+ it("accepts a complete startup snapshot", () => {
+ expect(parseStartupSnapshot(validSnapshot)).toEqual(validSnapshot);
+ });
+
+ it.each([
+ { name: "unknown phase", value: { ...validSnapshot, phase: "booting" } },
+ {
+ name: "missing step label",
+ value: { ...validSnapshot, step: { ...validSnapshot.step, label_key: "" } },
+ },
+ { name: "negative elapsed time", value: { ...validSnapshot, elapsed_ms: -1 } },
+ {
+ name: "non-boolean stalled flag",
+ value: { ...validSnapshot, step: { ...validSnapshot.step, stalled: 0 } },
+ },
+ ])("rejects $name", ({ value }) => {
+ expect(parseStartupSnapshot(value)).toBeNull();
+ });
+});
diff --git a/apps/web/lib/startup-progress/types.ts b/apps/web/lib/startup-progress/types.ts
new file mode 100644
index 0000000000..8db30d9ab5
--- /dev/null
+++ b/apps/web/lib/startup-progress/types.ts
@@ -0,0 +1,123 @@
+/**
+ * Wire shapes for GET /ready's `startup` field, mirroring
+ * apps/backend/internal/startup's Snapshot/StepSnapshot JSON tags
+ * (AC-PLATFORM-STARTUP-PROGRESS-002/003).
+ */
+
+export type StartupPhase =
+ | "opening_database"
+ | "backing_up_database"
+ | "applying_migrations"
+ | "initializing_services"
+ | "recovering_sessions"
+ | "ready";
+
+export type StartupMeasure = "opaque" | "counting" | "counted";
+
+export type StartupUnit = "rows" | "messages" | "turns" | "sessions" | "bytes" | "stores";
+
+export type StartupStepSnapshot = {
+ id: string;
+ label_key: string;
+ measure: StartupMeasure;
+ unit: StartupUnit;
+ elapsed_ms: number;
+ done?: number;
+ total?: number;
+ rate_per_second?: number;
+ eta_ms?: number;
+ since_advance_ms?: number;
+ stalled: boolean;
+};
+
+export type StartupSnapshot = {
+ phase: StartupPhase;
+ boot: number;
+ seq: number;
+ elapsed_ms: number;
+ phase_elapsed_ms: number;
+ step?: StartupStepSnapshot;
+};
+
+const STARTUP_PHASES = new Set([
+ "opening_database",
+ "backing_up_database",
+ "applying_migrations",
+ "initializing_services",
+ "recovering_sessions",
+ "ready",
+]);
+const STARTUP_MEASURES = new Set(["opaque", "counting", "counted"]);
+const STARTUP_UNITS = new Set([
+ "rows",
+ "messages",
+ "turns",
+ "sessions",
+ "bytes",
+ "stores",
+]);
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+function isNonNegativeInteger(value: unknown): value is number {
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
+}
+
+function isNonNegativeNumber(value: unknown): value is number {
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
+}
+
+function hasValidStepIdentity(value: Record): boolean {
+ return (
+ typeof value.id === "string" &&
+ value.id.length > 0 &&
+ typeof value.label_key === "string" &&
+ value.label_key.length > 0 &&
+ typeof value.measure === "string" &&
+ STARTUP_MEASURES.has(value.measure as StartupMeasure) &&
+ typeof value.unit === "string" &&
+ STARTUP_UNITS.has(value.unit as StartupUnit) &&
+ isNonNegativeInteger(value.elapsed_ms) &&
+ typeof value.stalled === "boolean"
+ );
+}
+
+function hasValidOptionalStepValues(value: Record): boolean {
+ const optionalIntegers = ["done", "total", "eta_ms", "since_advance_ms"] as const;
+ for (const key of optionalIntegers) {
+ if (value[key] !== undefined && !isNonNegativeInteger(value[key])) return false;
+ }
+ if (value.rate_per_second !== undefined && !isNonNegativeNumber(value.rate_per_second)) {
+ return false;
+ }
+ return true;
+}
+
+function parseStartupStep(value: unknown): StartupStepSnapshot | null {
+ if (!isRecord(value) || !hasValidStepIdentity(value) || !hasValidOptionalStepValues(value)) {
+ return null;
+ }
+ return value as StartupStepSnapshot;
+}
+
+/** Returns a trusted startup snapshot or null for malformed wire data. */
+export function parseStartupSnapshot(value: unknown): StartupSnapshot | null {
+ if (!isRecord(value)) return null;
+ if (
+ typeof value.phase !== "string" ||
+ !STARTUP_PHASES.has(value.phase as StartupPhase) ||
+ !isNonNegativeInteger(value.boot) ||
+ !isNonNegativeInteger(value.seq) ||
+ !isNonNegativeInteger(value.elapsed_ms) ||
+ !isNonNegativeInteger(value.phase_elapsed_ms)
+ ) {
+ return null;
+ }
+ if (value.step !== undefined) {
+ const step = parseStartupStep(value.step);
+ if (!step) return null;
+ }
+ return value as StartupSnapshot;
+}
diff --git a/apps/web/src/locales/en/startup.json b/apps/web/src/locales/en/startup.json
new file mode 100644
index 0000000000..ba5334ad60
--- /dev/null
+++ b/apps/web/src/locales/en/startup.json
@@ -0,0 +1,40 @@
+{
+ "duration.minutes_one": "{{count}} minute",
+ "duration.minutes_other": "{{count}} minutes",
+ "duration.seconds_one": "{{count}} second",
+ "duration.seconds_other": "{{count}} seconds",
+ "page.countedProgress": "{{done}} of {{total}} {{unit}}",
+ "page.countingProgress": "{{done}} {{unit}}",
+ "page.elapsed": "{{duration}} elapsed",
+ "page.eta": "About {{duration}} remaining",
+ "page.lastKnown": "Last known",
+ "page.progressUnavailable": "Progress cannot be measured for this step.",
+ "page.stalled": "Stalled for {{duration}}",
+ "page.title": "Kandev is starting",
+ "page.waiting": "Kandev is starting and not yet reachable.",
+ "phase.applying_migrations": "Applying migrations",
+ "phase.backing_up_database": "Backing up database",
+ "phase.initializing_services": "Initializing services",
+ "phase.opening_database": "Opening database",
+ "phase.ready": "Ready",
+ "phase.recovering_sessions": "Recovering sessions",
+ "step.database_backup": "Database backup",
+ "step.message_timestamps": "Message timestamps",
+ "step.prompt_seq": "Prompt sequence numbers",
+ "step.session_recovery": "Session recovery",
+ "step.stores_repositories": "Repository stores",
+ "step.stores_services": "Service stores",
+ "step.subagent_context": "Subagent context",
+ "unit.bytes_one": "byte",
+ "unit.bytes_other": "bytes",
+ "unit.messages_one": "message",
+ "unit.messages_other": "messages",
+ "unit.rows_one": "row",
+ "unit.rows_other": "rows",
+ "unit.sessions_one": "session",
+ "unit.sessions_other": "sessions",
+ "unit.stores_one": "store",
+ "unit.stores_other": "stores",
+ "unit.turns_one": "turn",
+ "unit.turns_other": "turns"
+}
diff --git a/apps/web/src/locales/pseudo/startup.json b/apps/web/src/locales/pseudo/startup.json
new file mode 100644
index 0000000000..beda973e29
--- /dev/null
+++ b/apps/web/src/locales/pseudo/startup.json
@@ -0,0 +1,40 @@
+{
+ "duration.minutes_one": "{{count}} ḿĩńũţē",
+ "duration.minutes_other": "{{count}} ḿĩńũţēś",
+ "duration.seconds_one": "{{count}} śēćōńď",
+ "duration.seconds_other": "{{count}} śēćōńďś",
+ "page.countedProgress": "{{done}} ōƒ {{total}} {{unit}}",
+ "page.countingProgress": "{{done}} {{unit}}",
+ "page.elapsed": "{{duration}} ēĺàƥśēď",
+ "page.eta": "Àƀōũţ {{duration}} ŕēḿàĩńĩńĝ",
+ "page.lastKnown": "Ĺàśţ ķńōŵń",
+ "page.progressUnavailable": "Ƥŕōĝŕēśś ćàńńōţ ƀē ḿēàśũŕēď ƒōŕ ţĥĩś śţēƥ.",
+ "page.stalled": "Śţàĺĺēď ƒōŕ {{duration}}",
+ "page.title": "Ķàńďēv ĩś śţàŕţĩńĝ",
+ "page.waiting": "Ķàńďēv ĩś śţàŕţĩńĝ àńď ńōţ ŷēţ ŕēàćĥàƀĺē.",
+ "phase.applying_migrations": "Àƥƥĺŷĩńĝ ḿĩĝŕàţĩōńś",
+ "phase.backing_up_database": "Ɓàćķĩńĝ ũƥ ďàţàƀàśē",
+ "phase.initializing_services": "Ĩńĩţĩàĺĩźĩńĝ śēŕvĩćēś",
+ "phase.opening_database": "Ōƥēńĩńĝ ďàţàƀàśē",
+ "phase.ready": "Ŕēàďŷ",
+ "phase.recovering_sessions": "Ŕēćōvēŕĩńĝ śēśśĩōńś",
+ "step.database_backup": "Ďàţàƀàśē ƀàćķũƥ",
+ "step.message_timestamps": "Ḿēśśàĝē ţĩḿēśţàḿƥś",
+ "step.prompt_seq": "Ƥŕōḿƥţ śēqũēńćē ńũḿƀēŕś",
+ "step.session_recovery": "Śēśśĩōń ŕēćōvēŕŷ",
+ "step.stores_repositories": "Ŕēƥōśĩţōŕŷ śţōŕēś",
+ "step.stores_services": "Śēŕvĩćē śţōŕēś",
+ "step.subagent_context": "Śũƀàĝēńţ ćōńţēxţ",
+ "unit.bytes_one": "ƀŷţē",
+ "unit.bytes_other": "ƀŷţēś",
+ "unit.messages_one": "ḿēśśàĝē",
+ "unit.messages_other": "ḿēśśàĝēś",
+ "unit.rows_one": "ŕōŵ",
+ "unit.rows_other": "ŕōŵś",
+ "unit.sessions_one": "śēśśĩōń",
+ "unit.sessions_other": "śēśśĩōńś",
+ "unit.stores_one": "śţōŕē",
+ "unit.stores_other": "śţōŕēś",
+ "unit.turns_one": "ţũŕń",
+ "unit.turns_other": "ţũŕńś"
+}
diff --git a/apps/web/src/locales/pt-pt/_verbatim.json b/apps/web/src/locales/pt-pt/_verbatim.json
index e64bd4a54e..e4710741cf 100644
--- a/apps/web/src/locales/pt-pt/_verbatim.json
+++ b/apps/web/src/locales/pt-pt/_verbatim.json
@@ -3,6 +3,8 @@
"kanban:menu": "\"Menu\" is the ordinary Portuguese word; translating it would change nothing.",
"office:desc": "\"Desc\" is the same abbreviation for descending order in Portuguese.",
"office:total": "\"Total\" is the ordinary Portuguese word; translating it would change nothing.",
+ "startup:unit.bytes_one": "\"Byte\" is used unchanged in Portuguese computing terminology.",
+ "startup:unit.bytes_other": "\"Byte\" is used unchanged in Portuguese computing terminology.",
"stats:summaryGit": "The only word is the tool name Git; the rest of the line is the interpolated value.",
"system:diskTotal": "\"Total\" is the ordinary Portuguese word; translating it would change nothing.",
"system:featureToggleExperimental": "\"Experimental\" is spelled identically in Portuguese.",
diff --git a/apps/web/src/locales/pt-pt/startup.json b/apps/web/src/locales/pt-pt/startup.json
new file mode 100644
index 0000000000..1afd6a7c81
--- /dev/null
+++ b/apps/web/src/locales/pt-pt/startup.json
@@ -0,0 +1,40 @@
+{
+ "duration.minutes_one": "{{count}} minuto",
+ "duration.minutes_other": "{{count}} minutos",
+ "duration.seconds_one": "{{count}} segundo",
+ "duration.seconds_other": "{{count}} segundos",
+ "page.countedProgress": "{{done}} de {{total}} {{unit}}",
+ "page.countingProgress": "{{done}} {{unit}}",
+ "page.elapsed": "Tempo decorrido: {{duration}}",
+ "page.eta": "Tempo restante aproximado: {{duration}}",
+ "page.lastKnown": "Último valor conhecido",
+ "page.progressUnavailable": "Não é possível medir o progresso deste passo.",
+ "page.stalled": "Parado há {{duration}}",
+ "page.title": "O Kandev está a iniciar",
+ "page.waiting": "O Kandev está a iniciar e ainda não está acessível.",
+ "phase.applying_migrations": "A aplicar migrações",
+ "phase.backing_up_database": "A fazer cópia de segurança da base de dados",
+ "phase.initializing_services": "A inicializar serviços",
+ "phase.opening_database": "A abrir a base de dados",
+ "phase.ready": "Pronto",
+ "phase.recovering_sessions": "A recuperar sessões",
+ "step.database_backup": "Cópia de segurança da base de dados",
+ "step.message_timestamps": "Carimbos de data/hora das mensagens",
+ "step.prompt_seq": "Números de sequência de prompts",
+ "step.session_recovery": "Recuperação de sessões",
+ "step.stores_repositories": "Armazenamentos de repositórios",
+ "step.stores_services": "Armazenamentos de serviços",
+ "step.subagent_context": "Contexto de subagentes",
+ "unit.bytes_one": "byte",
+ "unit.bytes_other": "bytes",
+ "unit.messages_one": "mensagem",
+ "unit.messages_other": "mensagens",
+ "unit.rows_one": "linha",
+ "unit.rows_other": "linhas",
+ "unit.sessions_one": "sessão",
+ "unit.sessions_other": "sessões",
+ "unit.stores_one": "armazenamento",
+ "unit.stores_other": "armazenamentos",
+ "unit.turns_one": "turno",
+ "unit.turns_other": "turnos"
+}
diff --git a/apps/web/src/locales/zh-cn/startup.json b/apps/web/src/locales/zh-cn/startup.json
new file mode 100644
index 0000000000..09dac2ddcc
--- /dev/null
+++ b/apps/web/src/locales/zh-cn/startup.json
@@ -0,0 +1,40 @@
+{
+ "duration.minutes_one": "{{count}} 分钟",
+ "duration.minutes_other": "{{count}} 分钟",
+ "duration.seconds_one": "{{count}} 秒",
+ "duration.seconds_other": "{{count}} 秒",
+ "page.countedProgress": "{{done}} / {{total}} {{unit}}",
+ "page.countingProgress": "{{done}} {{unit}}",
+ "page.elapsed": "已用时 {{duration}}",
+ "page.eta": "预计还需 {{duration}}",
+ "page.lastKnown": "最后已知状态",
+ "page.progressUnavailable": "无法测量此步骤的进度。",
+ "page.stalled": "已停滞 {{duration}}",
+ "page.title": "Kandev 正在启动",
+ "page.waiting": "Kandev 正在启动,尚无法访问。",
+ "phase.applying_migrations": "正在应用迁移",
+ "phase.backing_up_database": "正在备份数据库",
+ "phase.initializing_services": "正在初始化服务",
+ "phase.opening_database": "正在打开数据库",
+ "phase.ready": "就绪",
+ "phase.recovering_sessions": "正在恢复会话",
+ "step.database_backup": "数据库备份",
+ "step.message_timestamps": "消息时间戳",
+ "step.prompt_seq": "提示序列号",
+ "step.session_recovery": "会话恢复",
+ "step.stores_repositories": "仓库存储",
+ "step.stores_services": "服务存储",
+ "step.subagent_context": "子智能体上下文",
+ "unit.bytes_one": "字节",
+ "unit.bytes_other": "字节",
+ "unit.messages_one": "条消息",
+ "unit.messages_other": "条消息",
+ "unit.rows_one": "行",
+ "unit.rows_other": "行",
+ "unit.sessions_one": "个会话",
+ "unit.sessions_other": "个会话",
+ "unit.stores_one": "个存储",
+ "unit.stores_other": "个存储",
+ "unit.turns_one": "个轮次",
+ "unit.turns_other": "个轮次"
+}
diff --git a/apps/web/src/locales/zh-hk/startup.json b/apps/web/src/locales/zh-hk/startup.json
new file mode 100644
index 0000000000..fdf59e8eb0
--- /dev/null
+++ b/apps/web/src/locales/zh-hk/startup.json
@@ -0,0 +1,40 @@
+{
+ "duration.minutes_one": "{{count}} 分鐘",
+ "duration.minutes_other": "{{count}} 分鐘",
+ "duration.seconds_one": "{{count}} 秒",
+ "duration.seconds_other": "{{count}} 秒",
+ "page.countedProgress": "{{done}} / {{total}} {{unit}}",
+ "page.countingProgress": "{{done}} {{unit}}",
+ "page.elapsed": "已用時 {{duration}}",
+ "page.eta": "預計還需 {{duration}}",
+ "page.lastKnown": "最後已知狀態",
+ "page.progressUnavailable": "無法測量此步驟的進度。",
+ "page.stalled": "已停滯 {{duration}}",
+ "page.title": "Kandev 正在啓動",
+ "page.waiting": "Kandev 正在啓動,尚無法存取。",
+ "phase.applying_migrations": "正在應用遷移",
+ "phase.backing_up_database": "正在備份資料庫",
+ "phase.initializing_services": "正在初始化服務",
+ "phase.opening_database": "正在開啟資料庫",
+ "phase.ready": "就緒",
+ "phase.recovering_sessions": "正在恢復工作階段",
+ "step.database_backup": "資料庫備份",
+ "step.message_timestamps": "訊息時間戳",
+ "step.prompt_seq": "提示序列號",
+ "step.session_recovery": "工作階段恢復",
+ "step.stores_repositories": "儲存庫存儲",
+ "step.stores_services": "服務存儲",
+ "step.subagent_context": "子代理程式上下文",
+ "unit.bytes_one": "字節",
+ "unit.bytes_other": "字節",
+ "unit.messages_one": "條訊息",
+ "unit.messages_other": "條訊息",
+ "unit.rows_one": "行",
+ "unit.rows_other": "行",
+ "unit.sessions_one": "個工作階段",
+ "unit.sessions_other": "個工作階段",
+ "unit.stores_one": "個存儲",
+ "unit.stores_other": "個存儲",
+ "unit.turns_one": "個輪次",
+ "unit.turns_other": "個輪次"
+}
diff --git a/apps/web/src/locales/zh-tw/startup.json b/apps/web/src/locales/zh-tw/startup.json
new file mode 100644
index 0000000000..a17cb5e309
--- /dev/null
+++ b/apps/web/src/locales/zh-tw/startup.json
@@ -0,0 +1,40 @@
+{
+ "duration.minutes_one": "{{count}} 分鐘",
+ "duration.minutes_other": "{{count}} 分鐘",
+ "duration.seconds_one": "{{count}} 秒",
+ "duration.seconds_other": "{{count}} 秒",
+ "page.countedProgress": "{{done}} / {{total}} {{unit}}",
+ "page.countingProgress": "{{done}} {{unit}}",
+ "page.elapsed": "已用時 {{duration}}",
+ "page.eta": "預計還需 {{duration}}",
+ "page.lastKnown": "最後已知狀態",
+ "page.progressUnavailable": "無法測量此步驟的進度。",
+ "page.stalled": "已停滯 {{duration}}",
+ "page.title": "Kandev 正在啟動",
+ "page.waiting": "Kandev 正在啟動,尚無法存取。",
+ "phase.applying_migrations": "正在應用遷移",
+ "phase.backing_up_database": "正在備份資料庫",
+ "phase.initializing_services": "正在初始化服務",
+ "phase.opening_database": "正在開啟資料庫",
+ "phase.ready": "就緒",
+ "phase.recovering_sessions": "正在恢復工作階段",
+ "step.database_backup": "資料庫備份",
+ "step.message_timestamps": "訊息時間戳",
+ "step.prompt_seq": "提示序列號",
+ "step.session_recovery": "工作階段恢復",
+ "step.stores_repositories": "儲存庫儲存",
+ "step.stores_services": "服務儲存",
+ "step.subagent_context": "子代理程式上下文",
+ "unit.bytes_one": "位元組",
+ "unit.bytes_other": "位元組",
+ "unit.messages_one": "條訊息",
+ "unit.messages_other": "條訊息",
+ "unit.rows_one": "行",
+ "unit.rows_other": "行",
+ "unit.sessions_one": "個工作階段",
+ "unit.sessions_other": "個工作階段",
+ "unit.stores_one": "個儲存",
+ "unit.stores_other": "個儲存",
+ "unit.turns_one": "個輪次",
+ "unit.turns_other": "個輪次"
+}
diff --git a/docs/plans/startup-progress-visibility/plan.md b/docs/plans/startup-progress-visibility/plan.md
new file mode 100644
index 0000000000..62a83da588
--- /dev/null
+++ b/docs/plans/startup-progress-visibility/plan.md
@@ -0,0 +1,149 @@
+---
+created: 2026-09-17
+status: done
+requirements:
+ - REQ-PLATFORM-STARTUP-PROGRESS-001
+ - REQ-PLATFORM-STARTUP-PROGRESS-002
+ - REQ-PLATFORM-STARTUP-PROGRESS-003
+ - REQ-PLATFORM-STARTUP-PROGRESS-004
+ - REQ-PLATFORM-STARTUP-PROGRESS-005
+ - REQ-PLATFORM-STARTUP-LIFECYCLE-001
+system_design:
+ - ../../specs/platform/system-design/startup-progress-visibility.md
+ - ../../specs/platform/system-design/startup-lifecycle.md
+legacy_specs: []
+---
+
+# Implementation Plan: Startup progress visibility
+
+## Overview
+
+On 2026-09-17, an incident left the backend apparently hung during startup
+with no way to distinguish stuck from slow: two large migrations ran with no
+progress reporting at all. This initiative adds a protocol-owned `Step` layer
+below the existing startup `Phase`, a stable `/ready` snapshot contract, and
+step-level rendering on every startup-visible surface except the desktop
+shell (an authorized, separately tracked deferral).
+
+## Scope
+
+### In scope
+
+- A step registry of seven normative identifiers, each declaring phase,
+ measure (`opaque`/`counting`/`counted`), unit, label key, and applicable
+ dialects. Task 01 shipped ten; upstream `2eee90d38` deleted the work three of
+ them measured, and the spec was amended on 2026-09-18 to retire those three
+ (AC-PLATFORM-STARTUP-PROGRESS-005.3 and 005.6). Task 02 carries that out.
+- Lifecycle API (`BeginStep`/`EndStep`/`SeedDone`/`SetTotal`/`Advance`/
+ `Degrade`), 30s trailing-window rate, ETA, and a 120s stall threshold.
+- `GET /ready` carrying the snapshot in both its `starting` 503 and `ready`
+ 200 bodies, never 404.
+- RFC 7231 Accept-header content negotiation for the Go-rendered startup
+ page, the page itself, the Settings restart dialog's step-level detail,
+ and the launcher's stdout reporting.
+- Full i18n coverage (backend + web, all five locales plus pseudo) for every
+ step label key.
+- Amending `AC-PLATFORM-STARTUP-LIFECYCLE-001.4` in place to cite the new
+ step-level percentage contract.
+
+### Out of scope
+
+- The desktop status bridge — deferred to follow-up card
+ `c99c6111-e982-4313-b165-5c9b8aa03e46`, the only authorized deferral in
+ this initiative's history.
+- Making startup faster, or cancelling/skipping/retrying/reordering startup
+ work.
+- Progress for work after readiness (background maintenance, retention
+ sweeps).
+
+## Technical approach
+
+### Step registry and lifecycle
+
+`internal/startup` gains a step registry declaring, per identifier: phase,
+measure, unit, label key, applicable dialects, and — where applicable — a
+corpus, an outstanding-work predicate, or ordering/tiebreak columns. A step
+intending `counted` reports `opaque` until `SetTotal` promotes it; `SeedDone`
+is valid only before that promotion. Steps never nest — every apparent
+nesting bug found during spec review was a call-site sequencing defect, fixed
+by relocating call sites rather than by adding nesting support.
+
+### Store-admission sweeps
+
+Two sweep steps (`stores.repositories`, `stores.services`) count every
+required persisted store exactly once; `internal/persistence/requiredstores`
+carries an automated completeness check that fails if an entry is counted by
+zero or more than one sweep.
+
+### `/ready` contract and content negotiation
+
+`GET /ready` answers from listener bind, carrying the snapshot in both its
+503 (`starting`) and 200 (`ready`) bodies. `internal/webapp/accept.go` parses
+the `Accept` header into media ranges and resolves whether `text/html` clears
+a strictly higher quality than `application/json` before serving the
+Go-rendered startup page instead of JSON.
+
+### Surface rendering
+
+The Go-rendered startup page (`internal/backendapp/startup_page.go`) polls
+`/ready` every second and reloads once ready. The web Settings restart
+dialog (`apps/web/components/settings/system/restart-progress-dialog.tsx`,
+`apps/web/hooks/domains/system/use-startup-progress.ts`) polls the same
+endpoint independently of the existing boot-id completion polling. The
+launcher (`internal/launcher/health.go`) writes one stdout line per observed
+phase/sequence/stall change, plus a periodic floor line while none change.
+All three surfaces render the same stall state after 120s without an
+advance.
+
+## Tests
+
+- `AC-PLATFORM-STARTUP-PROGRESS-001.*`: `internal/startup/step_lifecycle_test.go`,
+ `step_snapshot_test.go`, `registry_completeness_test.go`.
+- `AC-PLATFORM-STARTUP-PROGRESS-002.*`: `internal/backendapp/startup_order_test.go`,
+ `httpserver_test.go`.
+- `AC-PLATFORM-STARTUP-PROGRESS-003.*`: `internal/webapp/accept_test.go`,
+ `internal/backendapp/startup_page_test.go`,
+ `apps/web/hooks/domains/system/use-startup-progress.test.ts`,
+ `apps/web/components/settings/system/restart-progress-dialog.test.tsx`.
+- `AC-PLATFORM-STARTUP-PROGRESS-004.*`: `internal/startup/step_snapshot_test.go`
+ (stall math), `internal/launcher/health_startup_line_test.go`.
+- `AC-PLATFORM-STARTUP-PROGRESS-005.*`: `internal/startup/registry_completeness_test.go`,
+ `internal/persistence/requiredstores/sweep_test.go`.
+- `AC-PLATFORM-STARTUP-LIFECYCLE-001.4`: covered by the same
+ `internal/startup` snapshot tests plus `startup_order_test.go`.
+
+## E2E tests
+
+- `apps/web/e2e/tests/layout/restart-progress-startup-detail.spec.ts`: restart
+ dialog step-level detail across base, last-known, and stalled states.
+- `apps/web/e2e/tests/layout/startup-page.spec.ts`: the real Go-rendered
+ startup page through a debug-only fixture route, covering initial render,
+ poll-driven re-render, and reload-on-ready.
+
+## Work orders
+
+- [x] [Task 01: Startup step registry, /ready contract, and surface rendering](task-01-step-registry-and-surfaces.md) (done)
+- [x] [Task 02: Retire the three step identifiers whose work upstream deleted](task-02-retire-deleted-step-identifiers.md) (done)
+
+## Verification results
+
+- Full backend gauntlet (`make fmt`, `make typecheck`, `make lint`,
+ `make lint-format`) clean; `make test`'s unrelated-package failures
+ confirmed pre-existing/environmental via a merge-base comparison.
+- `pnpm run i18n:ratchet` clean (4 added + 1 modified locale file).
+- Targeted e2e run (`restart-progress-startup-detail.spec.ts`,
+ `startup-page.spec.ts`, chromium project): 3/3 passed.
+- Independent adversarial review (four parallel review legs plus a
+ cross-vendor outside-voice pass): no production-bug residual survives
+ verification; all open items are test-rigor gaps on already-correct code,
+ tracked as non-blocking.
+
+## Risks
+
+- Reporting must stay best-effort: a reporting failure must never fail
+ startup itself.
+- Every rendering surface must derive stall/progress state from the same
+ snapshot fields to avoid surfaces disagreeing about whether startup is
+ stuck.
+- The desktop deferral's scope must stay visible in the shipped system-design
+ doc's prose so a future reader does not mistake it for a finished surface.
diff --git a/docs/plans/startup-progress-visibility/task-01-step-registry-and-surfaces.md b/docs/plans/startup-progress-visibility/task-01-step-registry-and-surfaces.md
new file mode 100644
index 0000000000..eb7a8fb609
--- /dev/null
+++ b/docs/plans/startup-progress-visibility/task-01-step-registry-and-surfaces.md
@@ -0,0 +1,172 @@
+---
+id: "01-step-registry-and-surfaces"
+title: "Startup step registry, /ready contract, and surface rendering"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+requirements:
+ - REQ-PLATFORM-STARTUP-PROGRESS-001
+ - REQ-PLATFORM-STARTUP-PROGRESS-002
+ - REQ-PLATFORM-STARTUP-PROGRESS-003
+ - REQ-PLATFORM-STARTUP-PROGRESS-004
+ - REQ-PLATFORM-STARTUP-PROGRESS-005
+ - REQ-PLATFORM-STARTUP-LIFECYCLE-001
+acceptance_criteria:
+ - AC-PLATFORM-STARTUP-PROGRESS-001.1
+ - AC-PLATFORM-STARTUP-PROGRESS-001.2
+ - AC-PLATFORM-STARTUP-PROGRESS-001.3
+ - AC-PLATFORM-STARTUP-PROGRESS-001.4
+ - AC-PLATFORM-STARTUP-PROGRESS-001.5
+ - AC-PLATFORM-STARTUP-PROGRESS-001.6
+ - AC-PLATFORM-STARTUP-PROGRESS-001.7
+ - AC-PLATFORM-STARTUP-PROGRESS-001.8
+ - AC-PLATFORM-STARTUP-PROGRESS-001.9
+ - AC-PLATFORM-STARTUP-PROGRESS-001.10
+ - AC-PLATFORM-STARTUP-PROGRESS-001.11
+ - AC-PLATFORM-STARTUP-PROGRESS-001.12
+ - AC-PLATFORM-STARTUP-PROGRESS-001.13
+ - AC-PLATFORM-STARTUP-PROGRESS-001.14
+ - AC-PLATFORM-STARTUP-PROGRESS-001.15
+ - AC-PLATFORM-STARTUP-PROGRESS-001.16
+ - AC-PLATFORM-STARTUP-PROGRESS-001.17
+ - AC-PLATFORM-STARTUP-PROGRESS-001.18
+ - AC-PLATFORM-STARTUP-PROGRESS-001.19
+ - AC-PLATFORM-STARTUP-PROGRESS-001.20
+ - AC-PLATFORM-STARTUP-PROGRESS-001.21
+ - AC-PLATFORM-STARTUP-PROGRESS-001.22
+ - AC-PLATFORM-STARTUP-PROGRESS-002.1
+ - AC-PLATFORM-STARTUP-PROGRESS-002.2
+ - AC-PLATFORM-STARTUP-PROGRESS-002.3
+ - AC-PLATFORM-STARTUP-PROGRESS-002.4
+ - AC-PLATFORM-STARTUP-PROGRESS-002.5
+ - AC-PLATFORM-STARTUP-PROGRESS-002.6
+ - AC-PLATFORM-STARTUP-PROGRESS-003.1
+ - AC-PLATFORM-STARTUP-PROGRESS-003.2
+ - AC-PLATFORM-STARTUP-PROGRESS-003.3
+ - AC-PLATFORM-STARTUP-PROGRESS-003.4
+ - AC-PLATFORM-STARTUP-PROGRESS-003.5
+ - AC-PLATFORM-STARTUP-PROGRESS-003.6
+ - AC-PLATFORM-STARTUP-PROGRESS-003.7
+ - AC-PLATFORM-STARTUP-PROGRESS-003.8
+ - AC-PLATFORM-STARTUP-PROGRESS-003.9
+ - AC-PLATFORM-STARTUP-PROGRESS-003.10
+ - AC-PLATFORM-STARTUP-PROGRESS-003.11
+ - AC-PLATFORM-STARTUP-PROGRESS-003.12
+ - AC-PLATFORM-STARTUP-PROGRESS-003.13
+ - AC-PLATFORM-STARTUP-PROGRESS-004.1
+ - AC-PLATFORM-STARTUP-PROGRESS-004.2
+ - AC-PLATFORM-STARTUP-PROGRESS-004.3
+ - AC-PLATFORM-STARTUP-PROGRESS-004.4
+ - AC-PLATFORM-STARTUP-PROGRESS-004.5
+ - AC-PLATFORM-STARTUP-PROGRESS-004.6
+ - AC-PLATFORM-STARTUP-PROGRESS-004.7
+ - AC-PLATFORM-STARTUP-PROGRESS-004.8
+ - AC-PLATFORM-STARTUP-PROGRESS-005.1
+ - AC-PLATFORM-STARTUP-PROGRESS-005.2
+ - AC-PLATFORM-STARTUP-PROGRESS-005.3
+ - AC-PLATFORM-STARTUP-PROGRESS-005.4
+ - AC-PLATFORM-STARTUP-PROGRESS-005.5
+ - AC-PLATFORM-STARTUP-PROGRESS-005.6
+ - AC-PLATFORM-STARTUP-PROGRESS-005.7
+ - AC-PLATFORM-STARTUP-LIFECYCLE-001.4
+system_design:
+ - ../../specs/platform/system-design/startup-progress-visibility.md
+ - ../../specs/platform/system-design/startup-lifecycle.md
+---
+
+# Task 01: Startup step registry, /ready contract, and surface rendering
+
+## Summary
+
+The 2026-09-17 incident left the backend apparently hung during two large,
+unreported startup migrations with no way to tell stuck from slow. This task
+adds a `Step` layer below the existing startup `Phase`: a protocol-owned
+registry of ten identifiers, each declaring phase, measure
+(`opaque`/`counting`/`counted`), unit, label key, and applicable dialects. The
+lifecycle API (`BeginStep`/`EndStep`/`SeedDone`/`SetTotal`/`Advance`/`Degrade`)
+computes rate over a 30s trailing window, an ETA, and a 120s stall threshold.
+`GET /ready` carries the snapshot in both its `starting` 503 and `ready` 200
+bodies. Three of the four described rendering surfaces ship in this task: the
+Go-rendered localized startup page (with RFC 7231 Accept-header content
+negotiation), the web Settings restart dialog widened with step-level detail,
+and the launcher's stdout reporting. The fourth, the desktop status bridge, is
+an authorized out-of-scope deferral tracked on a separate follow-up card (see
+Out of scope).
+
+## In scope
+
+- `internal/startup`: step registry (`step.go`), the ten normative
+ identifiers as the contract stood when this task shipped (three were retired
+ by the 2026-09-18 spec amendment; see Task 02), lifecycle state machine (`step_lifecycle.go`), and
+ rate/ETA/stall snapshot math (`step_snapshot.go`), each best-effort and
+ never able to fail startup.
+- Two store-admission sweep steps (`stores.repositories`, `stores.services`)
+ in `internal/persistence/requiredstores`, each required-store entry counted
+ by exactly one sweep, enforced by an automated completeness check.
+- `GET /ready`'s stable snapshot contract in both its 503 and 200 bodies.
+- `internal/webapp/accept.go`: RFC 7231 Accept-header content negotiation
+ deciding `text/html` vs `application/json` for the startup page.
+- `internal/backendapp/startup_page.go`: the Go-rendered, localized,
+ polling startup page.
+- Web surface: `apps/web/hooks/domains/system/use-startup-progress.ts` and
+ `apps/web/components/settings/system/restart-progress-dialog.tsx`, widened
+ with step-level progress, rate/ETA, and stall detail.
+- Launcher stdout reporting (`internal/launcher/health.go`): one line per
+ observed phase/sequence/stall change, plus a periodic floor while none of
+ those change.
+- Backend i18n (`internal/i18n`) and web i18n (`startup.json`) catalogs across
+ all five supported locales plus pseudo, for every step label key.
+- Amend `AC-PLATFORM-STARTUP-LIFECYCLE-001.4` in place to cite the new
+ step-level percentage contract instead of restating it.
+
+## Out of scope
+
+- The desktop status bridge (F76/F77 in this initiative's spec-review
+ history) — deferred to follow-up card `c99c6111-e982-4313-b165-5c9b8aa03e46`,
+ the only authorized deferral.
+- Making startup faster, or cancelling/skipping/retrying/reordering startup
+ work — every surface here is read-only.
+- Progress for work after readiness (background maintenance, retention
+ sweeps).
+
+## Acceptance
+
+- A registered step's snapshot always carries phase, measure, and label key;
+ a `counted` step also carries done/total and, once rate is measurable, an
+ ETA; an `opaque` step omits both counts and is never reported stalled.
+- `GET /ready` never 404s during startup and always carries the current
+ snapshot in both its 503 and 200 responses.
+- The startup page, the Settings restart dialog, and the launcher's stdout
+ all render the same step, phase, progress, and stall state from the same
+ snapshot, each localized through `label_key`.
+- `PrefersHTML` resolves the startup page only when `text/html` clears a
+ strictly higher quality than `application/json`, matching RFC 7231
+ media-range specificity and quality-parameter parsing.
+- An automated check fails if a required persisted store is counted by zero
+ or more than one sweep step, if a registered identifier falls outside the
+ normative list of seven, or if a step's label key is missing from any
+ supported locale.
+
+## Verification
+
+```bash
+cd apps/backend && go test ./internal/startup/... ./internal/webapp/... ./internal/backendapp/... ./internal/persistence/requiredstores/... ./internal/launcher/... ./internal/i18n/... -count=1
+cd apps/web && pnpm vitest run hooks/domains/system/use-startup-progress.test.ts components/settings/system/restart-progress-dialog.test.tsx lib/startup-progress
+cd apps/web && e2e/scripts/run-e2e.sh --host --no-build --no-strict --shards 1 --project chromium -- e2e/tests/layout/restart-progress-startup-detail.spec.ts e2e/tests/layout/startup-page.spec.ts
+```
+
+## Files likely touched
+
+- `apps/backend/internal/startup/step.go`, `step_lifecycle.go`, `step_snapshot.go`, `progress.go`, `duration.go`
+- `apps/backend/internal/persistence/requiredstores/catalog.go`, `tracker.go`
+- `apps/backend/internal/webapp/accept.go`
+- `apps/backend/internal/backendapp/startup_page.go`, `httpserver.go`
+- `apps/backend/internal/launcher/health.go`
+- `apps/backend/internal/i18n/locales/*.json`
+- `apps/web/hooks/domains/system/use-startup-progress.ts`
+- `apps/web/components/settings/system/restart-progress-dialog.tsx`
+- `apps/web/lib/startup-progress/*.ts`
+- `apps/web/src/locales/*/startup.json`
+- `apps/web/e2e/tests/layout/restart-progress-startup-detail.spec.ts`, `startup-page.spec.ts`
+- `docs/specs/platform/requirements/startup-lifecycle.md` (AC-...-001.4 amendment only)
diff --git a/docs/plans/startup-progress-visibility/task-02-retire-deleted-step-identifiers.md b/docs/plans/startup-progress-visibility/task-02-retire-deleted-step-identifiers.md
new file mode 100644
index 0000000000..2aa3cb0a13
--- /dev/null
+++ b/docs/plans/startup-progress-visibility/task-02-retire-deleted-step-identifiers.md
@@ -0,0 +1,101 @@
+---
+id: "02-retire-deleted-step-identifiers"
+title: "Retire the three step identifiers whose work upstream deleted"
+status: done
+wave: 2
+depends_on:
+ - "01-step-registry-and-surfaces"
+plan: "plan.md"
+requirements:
+ - REQ-PLATFORM-STARTUP-PROGRESS-005
+acceptance_criteria:
+ - AC-PLATFORM-STARTUP-PROGRESS-005.3
+ - AC-PLATFORM-STARTUP-PROGRESS-005.6
+system_design:
+ - ../../specs/platform/system-design/startup-progress-visibility.md
+---
+
+# Task 02: Retire the three step identifiers whose work upstream deleted
+
+## Summary
+
+Upstream `2eee90d38` ("test: close conversation storage follow-up gates", #3742)
+landed on `origin/main` the same day Task 01 shipped and deleted, in full, the
+work that three of Task 01's registered steps measure:
+`apps/backend/internal/task/repository/sqlite/conversation_journal.go` (945 lines,
+including `backfillConversationJournal` and both pairs of corpus/outstanding count
+helpers) and `apps/backend/internal/plugins/conversation_journal.go` (721 lines,
+including `syncAllCommittedSessionEvents`, whose `provider.go` call site was
+removed with no replacement). The successor `conversation_session_revisions` table
+is trigger-maintained and treats revision zero as correct for an unchanged session,
+so it has no batch backfill to measure, and `cleanupLegacyConversationJournal`
+(`base_schema.go:65`) drops the journal tables in one transaction of DDL with no
+row loop and no unit to count.
+
+The spec was amended on 2026-09-18 accordingly: AC-PLATFORM-STARTUP-PROGRESS-005.3
+now enumerates seven identifiers, and AC-PLATFORM-STARTUP-PROGRESS-005.6 names the
+three retired ones. This task brings the branch's code to that contract so it can
+rebase onto `origin/main`.
+
+## In scope
+
+- Delete `StepJournalTurnsBackfill`, `StepJournalMessagesBackfill`, and
+ `StepPluginsSessionEventsMirror` and their registry entries from
+ `internal/startup/step.go`, and add all three identifiers to the append-only
+ retired list the `init()` guard reads.
+- Delete this branch's own copies of the now-dead call sites and their helpers,
+ which the rebase will otherwise reintroduce alongside files upstream deleted:
+ `beginCountedJournalBackfillStep` and the count helpers in
+ `internal/task/repository/sqlite/conversation_journal.go`, and the mirror's
+ `BeginStep`/`Advance`/`EndStep` calls in `internal/plugins/conversation_journal.go`.
+ Both files are deleted outright on `origin/main`; take the deletion.
+- Update the registry completeness tests' expected identifier set and count
+ (10 to 7) in `internal/startup/registry_completeness_test.go`, and extend
+ completeness test 5 to assert the three real retirements rather than only its
+ throwaway-registry stand-in.
+- Delete `conversation_journal_steps_test.go` and the three label keys
+ (`startup.step.journal_turns`, `startup.step.journal_messages`,
+ `startup.step.plugin_session_events`) from every backend and web locale catalog,
+ so AC-PLATFORM-STARTUP-PROGRESS-005.4's label check and `i18n:check` both stay
+ clean with no orphan keys.
+- Rebase the branch onto `origin/main` once the above is committed.
+
+## Out of scope
+
+- Registering a step for `cleanupLegacyConversationJournal`. It is DDL in one
+ transaction with no countable unit; the exclusion is recorded in the system
+ design. A later change that gives it a row loop is what the registry checks
+ exist to catch.
+- Reinstating any deleted upstream mechanism to keep an identifier alive.
+- `SeedDone` and the `Outstanding`/`Order` registry fields. They stay, unexercised
+ by any registered step, because AC-PLATFORM-STARTUP-PROGRESS-001.8 and 001.21
+ still bind the next step that resumes or rides an ordered loop. Their unit tests
+ in `internal/startup` are unaffected by this task and must stay green.
+
+## Acceptance
+
+- `Registry()` holds exactly the seven identifiers AC-PLATFORM-STARTUP-PROGRESS-005.3
+ lists, asserted in both directions.
+- Registering any of the three retired identifiers panics at `init()`.
+- No reference to `backfillConversationJournal`, `syncAllCommittedSessionEvents`,
+ `conversation_turn_versions`, `conversation_message_versions`, or
+ `conversation_session_streams` survives in `internal/startup` or in this branch's
+ step instrumentation.
+- The branch rebases onto `origin/main` with no modify/delete conflict on either
+ `conversation_journal.go`.
+
+## Validation
+
+```bash
+cd apps/backend && go test ./internal/startup/... ./internal/task/repository/sqlite/... ./internal/plugins/... -count=1
+cd apps/backend && make lint
+cd apps/web && pnpm run i18n:check
+```
+
+## Files likely touched
+
+- `apps/backend/internal/startup/step.go`, `registry_completeness_test.go`
+- `apps/backend/internal/task/repository/sqlite/conversation_journal.go` (deleted upstream), `conversation_journal_steps_test.go`
+- `apps/backend/internal/plugins/conversation_journal.go` (deleted upstream)
+- `apps/backend/internal/i18n/locales/*.json`
+- `apps/web/src/locales/*/startup.json`
diff --git a/docs/specs/platform/README.md b/docs/specs/platform/README.md
index 6b2612f904..5f2710b334 100644
--- a/docs/specs/platform/README.md
+++ b/docs/specs/platform/README.md
@@ -41,6 +41,7 @@ retains ownership of its values, validation, authority, and persistence.
- [CI performance](requirements/ci-performance.md)
- [Startup lifecycle](requirements/startup-lifecycle.md)
+- [Startup progress visibility](requirements/startup-progress-visibility.md)
- [Agent-accessible Kandev settings](requirements/agent-settings-parity.md)
@@ -91,6 +92,7 @@ retains ownership of its values, validation, authority, and persistence.
- [CI performance](system-design/ci-performance.md)
- [Startup lifecycle](system-design/startup-lifecycle.md)
+- [Startup progress visibility](system-design/startup-progress-visibility.md)
- [Agent-accessible Kandev settings](system-design/agent-settings-parity.md)
- [Core settings domain adoption](system-design/agent-settings-domains.md)
diff --git a/docs/specs/platform/requirements/startup-lifecycle.md b/docs/specs/platform/requirements/startup-lifecycle.md
index f8aed31435..eaae9a35ca 100644
--- a/docs/specs/platform/requirements/startup-lifecycle.md
+++ b/docs/specs/platform/requirements/startup-lifecycle.md
@@ -24,7 +24,7 @@ Large databases must complete initialization without a listener-health deadline
- **AC-PLATFORM-STARTUP-LIFECYCLE-001.1:** During database opening, backup, migration, and required recovery, `/health` shall answer successfully with existing version and identity semantics.
- **AC-PLATFORM-STARTUP-LIFECYCLE-001.2:** Until required initialization and router installation finish, `/ready` and application endpoints shall remain unsuccessful.
- **AC-PLATFORM-STARTUP-LIFECYCLE-001.3:** After liveness succeeds, the launcher shall wait for readiness without an initialization deadline. Explicit listener-timeout overrides shall remain effective.
-- **AC-PLATFORM-STARTUP-LIFECYCLE-001.4:** Startup shall report phase transitions and elapsed time, with bounded periodic status during long phases and no estimated percentages.
+- **AC-PLATFORM-STARTUP-LIFECYCLE-001.4:** Startup shall report phase transitions and elapsed time, with bounded periodic status during long phases. A completion percentage shall be reported only for a step whose total was measured, as defined by [startup progress visibility](startup-progress-visibility.md) (AC-PLATFORM-STARTUP-PROGRESS-001.6 and AC-PLATFORM-STARTUP-PROGRESS-001.7).
- **AC-PLATFORM-STARTUP-LIFECYCLE-001.5:** Failed initialization shall report its phase and underlying error, release resources, and exit unsuccessfully. The launcher shall detect child exit.
- **AC-PLATFORM-STARTUP-LIFECYCLE-001.6:** Cancellation shall close startup listeners and stop further initialization admission at safe schema and store boundaries. Signal handling shall remain available throughout startup and shutdown. Restore worker quiescing shall not cancel the process and HTTP lifetime before the restore job can publish its result.
- **AC-PLATFORM-STARTUP-LIFECYCLE-001.7:** A required upgrade backup shall complete before application migrations mutate the database. Backup failure shall prevent migrations.
diff --git a/docs/specs/platform/requirements/startup-progress-visibility.md b/docs/specs/platform/requirements/startup-progress-visibility.md
new file mode 100644
index 0000000000..caa9707793
--- /dev/null
+++ b/docs/specs/platform/requirements/startup-progress-visibility.md
@@ -0,0 +1,321 @@
+---
+status: draft
+system: platform
+created: 2026-09-17
+owners:
+ - kandev
+---
+
+# Startup progress visibility requirements
+
+## Overview
+
+A Kandev upgrade can spend tens of minutes initializing. An operator sees one
+repeating phase line and an unreachable board, and cannot tell which migration is
+running, how much is left, or whether it is stuck.
+
+Platform already owns startup phases, liveness, and readiness
+([startup lifecycle](startup-lifecycle.md)). This capability adds the layer below a
+phase: the named unit of work inside it, its measured progress, and delivery of
+both to every startup surface.
+
+It refines **AC-PLATFORM-STARTUP-LIFECYCLE-001.4**, which forbade every percentage.
+It survives as AC-PLATFORM-STARTUP-PROGRESS-001.6 and 001.7, which it now cites.
+
+No external prior-art source was reachable while this was written, so nothing here
+is confirmed against another product.
+
+## Terminology
+
+- **Phase:** An existing coarse stage of startup, `opening_database` through
+ `ready`, owned by [startup lifecycle](startup-lifecycle.md).
+- **Step:** One named unit of work inside a phase, identified by a protocol-owned
+ identifier; at most one is active at a time.
+- **Measure:** How a step reports work done: `counted` (done and total known),
+ `counting` (done only), or `opaque` (elapsed time only).
+- **Activation:** One interval during which a given identifier is the active step;
+ an identifier may be activated more than once per process.
+- **Snapshot:** The startup state a caller reads in one atomic observation: startup
+ identifier, phase, sequence number, active step, measure, counts, timing, stall.
+- **Startup surface:** The launcher output, the desktop shell window, a browser
+ reaching Kandev before it is ready, or an open application document.
+- **Sweep step:** One step covering a set of same-shaped units, counting over that
+ set rather than opening one step per unit.
+- **Progress:** What AC-PLATFORM-STARTUP-PROGRESS-003.1, 003.4, and 003.7 require of
+ a surface for the active step: for `counted`, done and total with the unit and a
+ determinate bar; for `counting`, done with the unit; for `opaque`, elapsed time and
+ the AC-PLATFORM-STARTUP-PROGRESS-001.5 statement; plus the estimate whenever the
+ snapshot carries one. The rate is not among them; only the launcher reports it,
+ under AC-PLATFORM-STARTUP-PROGRESS-003.5.
+
+## Requirements
+
+### REQ-PLATFORM-STARTUP-PROGRESS-001: Measured startup steps
+
+**Intent:** Replace "applying migrations for 13 minutes" with the name of the work
+in progress and an honest measurement of how much remains, so an operator can
+decide whether to wait.
+
+#### Acceptance criteria
+
+- **AC-PLATFORM-STARTUP-PROGRESS-001.1:** While work belonging to a registered step
+ is running, the snapshot shall report that step's identifier, label key, measure, unit, and elapsed milliseconds, and shall report the phase the process is live in, not the
+ phase the registry declares for the step.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.2:** When no registered step is running, the
+ snapshot shall report the phase with no step and shall not retain an earlier
+ step's identifier.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.3:** A `counted` step shall carry a done count
+ and a total, both non-negative integers, with done at most total.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.4:** A `counting` step shall carry a done count
+ and omit the total.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.5:** An `opaque` step shall omit both counts,
+ and every surface shall state that it cannot report progress rather than show a
+ bar at any position.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.6:** A completion percentage shall be derived
+ only from a `counted` measure, and no surface shall render a percentage,
+ determinate bar, or completion fraction for a `counting` or `opaque` step.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.7:** A step intending to report `counted` shall
+ report `opaque` until its total and initial done count are both known, and shall
+ never report `counting`, an assumed total, or a placeholder total while measuring.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.8:** A step resuming after durably applied work
+ shall report the whole corpus as its total and the corpus size minus the rows
+ still requiring work as its initial done count, clamped to that range; the two
+ counts need not observe one database snapshot.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.9:** For a `counted` or `counting` step that
+ has advanced at least once, the snapshot shall carry a rate in units per second
+ over a trailing 30000 millisecond window, falling back to the step's start below
+ two samples, decaying toward zero once advancing stops, and treating a window
+ shorter than one millisecond as one millisecond.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.10:** For a `counted` step with a rate above
+ zero, the snapshot shall carry a remaining-time estimate of outstanding divided by
+ rate, omitted for any other measure, a zero or unmeasured rate, or a result above
+ 86400000 milliseconds.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.11:** When a total or done count cannot be
+ obtained, the step shall degrade one way (`counted` to `counting`, `counting` to
+ `opaque`), record one warning, continue the underlying work, and never strengthen
+ its measure again before its next activation.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.12:** A snapshot read concurrently with a step
+ transition shall observe one consistent state and shall never pair one step's
+ identifier with another step's counts, unit, or timing.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.13:** Each snapshot shall carry a sequence
+ number beside its phase whether or not a step is active, starting at zero,
+ increasing by exactly one per step transition, and never decreasing or wrapping
+ within a process lifetime, plus a startup identifier drawn once per process that
+ is neither a counter nor a clock reading; consumers shall compare sequence numbers
+ only within one startup identifier, discarding a lower one, ordering equal ones by
+ the snapshot's process elapsed milliseconds and preferring the later read on an
+ exact tie, and shall replace all retained state whenever the startup identifier
+ changes.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.14:** Beginning an already-active step shall
+ leave its start time, counts, measure, and sequence number unchanged, and ending a
+ step that is no longer active shall change no snapshot field.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.15:** Beginning a step while another is active
+ shall end that step and open the new one as one transition with a single increment
+ and no observable no-step interval; ending with none opened after it is likewise
+ one transition. Steps shall not nest.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.16:** A done count that would exceed a `counted`
+ total shall be reported as equal to the total, with one warning naming the step.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.17:** A step whose measured total is zero shall
+ end without ever reporting `counted`, and no surface shall render a fraction,
+ percentage, or bar with a zero denominator; it may still be open while it measures
+ that total.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.18:** Each registered step shall declare its
+ applicable database dialects and shall not be opened, reported, or rendered under
+ any other dialect, with no empty or zero-valued step rendered in its place.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.19:** An advance of zero or less, a total equal
+ to the current one, and any advance or total naming a non-active identifier shall
+ each change nothing; a negative total shall be rejected; a different total shall be
+ accepted only while still measuring; a total below done shall clamp done; an
+ advance that would overflow shall saturate.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.20:** Each distinct warning condition shall be
+ recorded at most once per activation.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.21:** A step added to an existing loop shall
+ preserve that loop's ordering and tiebreak columns as the registry records them,
+ and shall count a unit done only once its work is durably committed, never when it
+ is merely selected.
+- **AC-PLATFORM-STARTUP-PROGRESS-001.22:** A `counted` step ending below its total
+ shall record one warning naming the step, its done count, and its total.
+
+### REQ-PLATFORM-STARTUP-PROGRESS-002: Stable startup readiness contract
+
+**Intent:** One unauthenticated endpoint that answers "starting, and here is what it
+is doing", then "ready", without changing shape or disappearing.
+
+#### Acceptance criteria
+
+- **AC-PLATFORM-STARTUP-PROGRESS-002.1:** `GET /ready` shall be answerable from listener
+ bind to shutdown and shall never return a not-found status in that window.
+- **AC-PLATFORM-STARTUP-PROGRESS-002.2:** While startup is incomplete, `GET /ready`
+ shall return an unsuccessful status carrying the status value `starting`, the
+ service identity, the running version, and the snapshot.
+- **AC-PLATFORM-STARTUP-PROGRESS-002.3:** After startup completes, `GET /ready` shall
+ carry the service identity, the running version, and a `ready` snapshot with no
+ step, returning a successful status unless an existing post-startup health
+ condition makes it unsuccessful, which keeps its own status, status value, and
+ explanatory fields beside that same snapshot; consumers shall tell "starting" from
+ "since degraded" by the snapshot phase, not the status value.
+- **AC-PLATFORM-STARTUP-PROGRESS-002.4:** The snapshot shall contain only
+ protocol-owned identifiers, numbers, and booleans, and no free text, file path, SQL
+ statement, error message, credential, or task, session, workspace, or user
+ identifier.
+- **AC-PLATFORM-STARTUP-PROGRESS-002.5:** `GET /health` shall keep its existing body,
+ status, and desktop token header, and shall not carry the snapshot.
+- **AC-PLATFORM-STARTUP-PROGRESS-002.6:** A consumer that cannot parse the snapshot,
+ or receives a body without one, shall treat the status code as authoritative: a
+ status reporting readiness means ready, and any other status means starting and
+ shall render AC-PLATFORM-STARTUP-PROGRESS-003.13.
+
+### REQ-PLATFORM-STARTUP-PROGRESS-003: Startup visible on every surface
+
+**Intent:** An operator should reach the answer from wherever they are looking,
+including a browser pointed at a migrating backend.
+
+#### Acceptance criteria
+
+- **AC-PLATFORM-STARTUP-PROGRESS-003.1:** While startup is incomplete, a browser
+ navigating to an application route shall receive a rendered startup page carrying
+ the phase, step name, and progress, not a machine-readable error body or an
+ unreachable application shell.
+- **AC-PLATFORM-STARTUP-PROGRESS-003.2:** The startup page shall poll every 1000
+ milliseconds with at most one request in flight, retry after any failed or
+ unparseable response without abandoning the poll while open, load the application
+ once readiness is reported, and meanwhile render
+ AC-PLATFORM-STARTUP-PROGRESS-003.13.
+- **AC-PLATFORM-STARTUP-PROGRESS-003.3:** The startup page shall be served only for a
+ `GET` on an application route whose `Accept` header resolves HTML, by the most
+ specific matching media range, to a quality strictly greater than the one it
+ resolves for `application/json`; any other method, any non-application route, an
+ absent or malformed `Accept`, and any header not clearing that bar, including a
+ bare `*/*`, shall keep the machine-readable body and status.
+- **AC-PLATFORM-STARTUP-PROGRESS-003.4:** An open application document awaiting a
+ backend restart shall show phase, step name, and progress while it starts, retry
+ on the AC-PLATFORM-STARTUP-PROGRESS-003.2 interval without giving up, and render
+ AC-PLATFORM-STARTUP-PROGRESS-003.13 while the backend is unreachable, including
+ between process exit and listener bind, and resolve an unparseable or snapshot-free response by AC-PLATFORM-STARTUP-PROGRESS-002.6.
+- **AC-PLATFORM-STARTUP-PROGRESS-003.5:** The launcher shall write one line per read that observes a phase, sequence-number, or stall-state change, and exactly one further line per 15000 milliseconds while none of those changes, each carrying phase, elapsed time, and the step name when one is active, plus done, total, rate, and estimate whenever the snapshot carries them. A transition not observed between two reads shall not be reported.
+- **AC-PLATFORM-STARTUP-PROGRESS-003.6:** When the backend exits during startup, the
+ launcher shall name the last observed step as well as the last observed phase.
+- **AC-PLATFORM-STARTUP-PROGRESS-003.7 (deferred):** The desktop shell progress
+ display is deferred to follow-up card `c99c6111-e982-4313-b165-5c9b8aa03e46`.
+ This initiative keeps the existing desktop readiness behavior and does not
+ implement the progress display.
+- **AC-PLATFORM-STARTUP-PROGRESS-003.8:** Every startup string rendered by the web
+ application or the backend shall be translated in each supported locale for that
+ surface, and a count rendered beside a noun shall use plural-aware translation
+ rather than an assembled ending.
+- **AC-PLATFORM-STARTUP-PROGRESS-003.9:** Every backend-rendered startup string shall
+ resolve for the locale derived from the request, preferring the locale cookie, then
+ the accepted languages, then the default, falling through on any missing, empty,
+ malformed, or unsupported value.
+- **AC-PLATFORM-STARTUP-PROGRESS-003.10:** An estimate of 60000 milliseconds or less
+ shall render in whole seconds rounded up with a floor of one second while work
+ remains, a longer one in whole minutes rounded up, never sub-second.
+- **AC-PLATFORM-STARTUP-PROGRESS-003.11:** A startup surface shall remain usable
+ without animation under a reduced-motion preference and shall expose the current
+ step and progress to assistive technology as a live region.
+- **AC-PLATFORM-STARTUP-PROGRESS-003.12:** The startup page shall carry an
+ unsuccessful status and response headers instructing caches and intermediaries not
+ to store or reuse it; behaviour those headers cannot control, such as session
+ history, is out of scope.
+- **AC-PLATFORM-STARTUP-PROGRESS-003.13:** A snapshot carrying no step shall render
+ phase and elapsed time alone; an unreadable or absent response shall render the
+ last snapshot read, labelled as such; and before any snapshot has been read the
+ surface shall state only that Kandev is starting and not yet reachable. No case
+ shall render a placeholder step name, an empty progress element, a bar, or an
+ unread phase or elapsed time.
+
+### REQ-PLATFORM-STARTUP-PROGRESS-004: Stuck distinguished from slow
+
+**Intent:** A backfill that is slow and a process that is wedged look identical
+today. They must not.
+
+#### Acceptance criteria
+
+- **AC-PLATFORM-STARTUP-PROGRESS-004.1:** The snapshot shall carry milliseconds
+ elapsed since the active step's done count last advanced, measured from the step's
+ start before its first advance.
+- **AC-PLATFORM-STARTUP-PROGRESS-004.2:** When a `counted` or `counting` step has not
+ advanced for at least the stall threshold, the snapshot shall report it as stalled.
+- **AC-PLATFORM-STARTUP-PROGRESS-004.3:** The stall threshold shall be 120000
+ milliseconds and not operator-configurable.
+- **AC-PLATFORM-STARTUP-PROGRESS-004.4:** When the count advances, the step shall stop
+ being reported as stalled and the elapsed-since-advance value shall reset.
+- **AC-PLATFORM-STARTUP-PROGRESS-004.5:** An `opaque` step shall never be reported as
+ stalled, including while it measures a total it intends to report as `counted`.
+- **AC-PLATFORM-STARTUP-PROGRESS-004.6:** Every surface shall distinguish a stalled
+ step from a running one, state how long it has gone without progress, and reflect a stall-state change within one refresh interval.
+- **AC-PLATFORM-STARTUP-PROGRESS-004.7:** A stalled step shall not cancel, restart,
+ time out, or otherwise alter the work.
+- **AC-PLATFORM-STARTUP-PROGRESS-004.8:** Only an advance that increases the reported
+ done count shall reset the elapsed-since-advance value; a resume seed, an advance of
+ zero or less, one naming a non-active identifier, and one clamped to a total already
+ reached shall each leave it running.
+
+### REQ-PLATFORM-STARTUP-PROGRESS-005: No new silent startup work
+
+**Intent:** The 2026-09-17 incident was two steps that shipped with no reporting.
+A third must not.
+
+#### Acceptance criteria
+
+- **AC-PLATFORM-STARTUP-PROGRESS-005.1:** The startup step registry shall be the only
+ source of valid step identifiers and shall declare each one's phase, measure, unit,
+ label key, and applicable dialects; a step reporting a count shall declare the corpus
+ that count covers; a step seeding its done count from durable state shall declare the
+ predicate selecting the work still outstanding; and a step advancing over a
+ query-ordered row set shall declare that query's ordering and tiebreak columns. A
+ step shall declare nothing it does not have.
+- **AC-PLATFORM-STARTUP-PROGRESS-005.2:** Every required persisted store in the
+ catalog shall be counted by exactly one store-admission sweep step and never
+ reported as a step of its own, and an automated check shall fail on an entry
+ counted by none or by more than one.
+- **AC-PLATFORM-STARTUP-PROGRESS-005.3:** Exactly these identifiers shall be
+ registered: `database.backup`, `stores.repositories`, `stores.services`,
+ `task.prompt_seq.backfill`, `task.message_timestamps.backfill`,
+ `task.subagent_context.backfill`, and `sessions.recovery`; an automated check
+ shall fail on a registered identifier absent from this list or a listed
+ identifier that is unregistered.
+- **AC-PLATFORM-STARTUP-PROGRESS-005.4:** An automated check shall fail when a
+ registered step's label key is missing from any supported backend or web locale,
+ and no surface shall build a step name from the identifier.
+- **AC-PLATFORM-STARTUP-PROGRESS-005.5:** Beginning a step with an unregistered
+ identifier shall record one warning naming it, open no step, leave the sequence
+ number unchanged, and not fail startup.
+- **AC-PLATFORM-STARTUP-PROGRESS-005.6:** A removed step identifier shall never be
+ reused: `task.journal.turns.backfill`, `task.journal.messages.backfill`, and
+ `plugins.session_events.mirror`, retired with their work by `2eee90d38`.
+- **AC-PLATFORM-STARTUP-PROGRESS-005.7:** Opening a registered step outside its
+ declared phase shall record one warning while the snapshot reports the live phase,
+ and an automated check shall assert that each step's declared phase matches its call
+ site.
+
+## Out of scope
+
+- **Making startup faster.** Backfill performance belongs to its own change. The one
+ cost added is AC-PLATFORM-STARTUP-PROGRESS-001.8's total measurement; no latency
+ budget is set for it.
+- **Cancelling, skipping, retrying, or reordering startup work.** Every surface here
+ is read-only.
+- **Progress for work after readiness.** Background maintenance, retention sweeps,
+ and post-ready jobs keep their reporting.
+- **A dedicated version endpoint.** An undocumented version path's 404 is answered
+ by `GET /ready`, which carries the version.
+- **Localizing the desktop shell.** Its window is a separate bundle with no
+ translation pipeline, so its strings stay in the default locale;
+ AC-PLATFORM-STARTUP-PROGRESS-003.8 covers the web and backend surfaces.
+- **Per-store startup steps.** An earlier draft gave each of the 41 catalog entries
+ its own step: 41 names per locale, for work whose duration is not what the
+ operator waits on. AC-PLATFORM-STARTUP-PROGRESS-005.2's two sweeps report the
+ same coverage; a store later found slow can be promoted.
+- **Sharpening "bounded periodic status" in AC-PLATFORM-STARTUP-LIFECYCLE-001.4.**
+ AC-PLATFORM-STARTUP-PROGRESS-003.5 bounds the launcher; tightening that clause
+ belongs to its own requirement.
+- **Persisting progress across process lifetimes.** The snapshot describes the
+ running process; sequence numbers are scoped to its startup identifier.
+- **An operator-configurable stall threshold, a progress log file, a metrics export,
+ or a step per DDL statement.** The snapshot and existing structured logs are the
+ only outputs; a step is per sweep or named backfill.
+
+## System design
+
+See [startup progress visibility](../system-design/startup-progress-visibility.md).
diff --git a/docs/specs/platform/system-design/startup-lifecycle.md b/docs/specs/platform/system-design/startup-lifecycle.md
index 4a88cf6e2a..22bbdb252c 100644
--- a/docs/specs/platform/system-design/startup-lifecycle.md
+++ b/docs/specs/platform/system-design/startup-lifecycle.md
@@ -35,6 +35,11 @@ returns 503 with a `startup` object containing a fixed phase name, total elapsed
milliseconds, and phase elapsed milliseconds. No paths, SQL, credentials, or raw
errors enter this unauthenticated payload. Application routes remain 503.
+[Startup progress visibility](startup-progress-visibility.md) extends that same
+object with a sequence number and an optional step below the phase, and serves a
+rendered page rather than JSON to an application route that prefers HTML. The
+phase-only fields above keep their names and meanings.
+
Phases cover database opening, backup, migrations, service initialization,
required session recovery, and ready. Structured logs record transitions and
phase duration. Launcher readiness polling reads JSON, reports transitions and
@@ -92,7 +97,7 @@ crash-safe retention, and restore tests. Never reuse by filename or target versi
| Criteria | Mechanism |
| --- | --- |
| .1, .2, .3, .8 | Early bootstrap binding, atomic router handoff, existing launcher waits |
-| .4, .5 | Typed phase snapshot, structured timing, child-exit diagnostics |
+| .4, .5 | Typed phase snapshot, structured timing, child-exit diagnostics; the step layer and measured percentages live in [startup progress visibility](startup-progress-visibility.md) |
| .6, .7 | Separate process/worker cancellation ownership, schema/store admission barriers, sequential initialization, backup gate |
## Decisions
diff --git a/docs/specs/platform/system-design/startup-progress-visibility.md b/docs/specs/platform/system-design/startup-progress-visibility.md
new file mode 100644
index 0000000000..cd8760213f
--- /dev/null
+++ b/docs/specs/platform/system-design/startup-progress-visibility.md
@@ -0,0 +1,468 @@
+---
+status: draft
+system: platform
+requirements:
+ - REQ-PLATFORM-STARTUP-PROGRESS-001
+ - REQ-PLATFORM-STARTUP-PROGRESS-002
+ - REQ-PLATFORM-STARTUP-PROGRESS-003
+ - REQ-PLATFORM-STARTUP-PROGRESS-004
+ - REQ-PLATFORM-STARTUP-PROGRESS-005
+---
+
+# Startup progress visibility design
+
+## Purpose and boundaries
+
+Platform owns the startup snapshot, so this design extends the existing
+`internal/startup` reporter rather than adding a second progress channel.
+
+What the reporter reaches today, verified rather than assumed:
+
+- It is created and put into the startup context at
+ `backendapp/startup.go:184` (`startup.WithReporter`).
+- `internal/persistence` uses it (`provider.go:36`, `:101`).
+- `backendapp/storage.go` uses it (`:62`, `:215`).
+- The SQLite task repository reaches it through `migrationContext()`, which is
+ why the backfills can report without new arguments.
+- `newBootstrapHandler` already serves `Snapshot()` on `/ready`.
+
+What it does **not** reach, and what this design therefore has to plumb:
+
+- **Router composition.** `routeParams` (`backendapp/helpers.go:703`) carries
+ `persistenceHealth` but no reporter, and `readyHandler` (`:1013`) builds its
+ 503 and 200 bodies without a snapshot. A reporter field is added to
+ `routeParams` and populated where the other params are.
+- **The desktop client.** Its progress display is deferred to follow-up card
+ `c99c6111-e982-4313-b165-5c9b8aa03e46`. The existing readiness wait remains
+ unchanged in this initiative.
+
+Adjacent contracts used but not owned here: the bootstrap listener and handler
+switch ([startup lifecycle](startup-lifecycle.md)), the required-store catalog in
+`internal/persistence/requiredstores`, the backend localization catalog in
+`internal/i18n`, and the web application's localization catalogs.
+
+## Requirement mapping
+
+| Requirement | Design section |
+| --- | --- |
+| `REQ-PLATFORM-STARTUP-PROGRESS-001` | [Step model](#step-model), [Reporter API](#reporter-api), [Measuring each step](#measuring-each-step) |
+| `REQ-PLATFORM-STARTUP-PROGRESS-002` | [Readiness payload](#readiness-payload) |
+| `REQ-PLATFORM-STARTUP-PROGRESS-003` | [Surfaces](#surfaces), [Naming and localization](#naming-and-localization) |
+| `REQ-PLATFORM-STARTUP-PROGRESS-004` | [Stall detection](#stall-detection) |
+| `REQ-PLATFORM-STARTUP-PROGRESS-005` | [Step registry](#step-registry) |
+
+## Components and responsibilities
+
+- **`internal/startup`** owns `Phase`, `Step`, the registry, the reporter, and the
+ snapshot. Its only dependency is the logger, so any startup-time package can
+ import it without a cycle.
+- **`internal/persistence`** opens the backup step and reports bytes written.
+- **`internal/backendapp/storage.go`** opens and advances the repository store
+ sweep; all 19 of its admissions run before the phase change.
+- **`internal/backendapp`** (`services.go`, `orchestrator.go`,
+ `storage_maintenance.go`, `main.go`) advances the service store sweep.
+- **`internal/task/repository/sqlite`** opens the named backfill steps.
+- **`internal/agent/runtime/lifecycle`** opens the session recovery step.
+- **`internal/backendapp/httpserver.go`** serves the readiness payload and the
+ server-rendered startup page from the bootstrap handler.
+- **`internal/launcher`** reads the snapshot in its readiness wait.
+- **`apps/web`** renders startup state inside the application document.
+- **`apps/desktop`** keeps its existing readiness behavior; progress integration
+ is deferred to follow-up card `c99c6111-e982-4313-b165-5c9b8aa03e46`.
+
+## Data and contracts
+
+### Step model
+
+`seq` sits at the snapshot top level, not inside `step`, because
+AC-PLATFORM-STARTUP-PROGRESS-001.13 requires it to be present whether or not a
+step is active and to increment on the transition to no step. `boot` sits beside
+it and scopes it: `seq` is only comparable between two reads carrying the same
+`boot`.
+
+```json
+{
+ "phase": "recovering_sessions",
+ "boot": 8231904417,
+ "seq": 7,
+ "elapsed_ms": 812431,
+ "phase_elapsed_ms": 42112,
+ "step": {
+ "id": "sessions.recovery",
+ "label_key": "startup.step.session_recovery",
+ "measure": "counted",
+ "unit": "sessions",
+ "elapsed_ms": 41904,
+ "done": 1180,
+ "total": 2945,
+ "rate_per_second": 28.2,
+ "eta_ms": 62600,
+ "since_advance_ms": 812,
+ "stalled": false
+ }
+}
+```
+
+`total` is present only for `counted`; `done`, `rate_per_second`, and
+`since_advance_ms` are absent for `opaque`; `eta_ms` is present only for `counted`
+with a positive rate. The existing snapshot fields keep their names and meanings,
+so a consumer that ignores `boot`, `seq`, and `step` behaves exactly as it does
+today.
+
+`boot` is a positive integer below 2^53 drawn once at reporter construction from
+the same source the process uses for other non-guessable values, never a counter, a
+clock reading, or a process identifier, all three of which repeat across restarts on
+a restarted host. It is a number, which AC-PLATFORM-STARTUP-PROGRESS-002.4 admits; the 2^53 bound is what makes that safe, since every integer below it decodes exactly in a consumer reading JSON numbers as doubles. It is drawn in `startup.New` from `crypto/rand`, masked to 53 bits and redrawn on a zero. There is no fallback value, no last-resort composite, and no draw-failure path to specify: `crypto/rand.Read` never returns an error on the Go version this module requires, which is what lets `New` keep its error-free signature (`startup/progress.go:59`). It is never logged as an identity, never persisted, and carries
+no information about the installation.
+
+### Reporter API
+
+`Reporter` gains `BeginStep(id)`, `SeedDone(id, done)`, `SetTotal(id, total)`,
+`Advance(id, delta)`, `Degrade(id)`, and `EndStep(id)`, each guarded by the
+reporter's existing mutex so a snapshot taken during a transition is internally
+consistent. Package-level
+`startup.BeginStep(ctx, id)` helpers follow the existing `SetPhase` pattern and
+are no-ops when the context carries no reporter, which keeps every call site safe
+in tests and embedded use.
+
+Boundary behaviour, implementing AC-PLATFORM-STARTUP-PROGRESS-001.14, .15, .16 and
+.19 in one place rather than at each call site:
+
+| Call | Not the active id | Active id |
+| --- | --- | --- |
+| `BeginStep` | ends the active step and opens this one, `seq += 1` | no-op |
+| `EndStep` | no-op | closes the step, `seq += 1` |
+| `Advance(d)` | no-op | `d <= 0` no-op; else add, saturating; clamp to total |
+| `SeedDone(n)` | no-op | `n < 0` rejected; equal no-op; else assign; rejected once the total is set or the step has advanced |
+| `SetTotal(t)` | no-op | `t < 0` rejected; equal no-op; a different `t` accepted only while the step is still measuring, which any accepted total ends, `t == 0` included; `t == 0` blocks promotion and so seals the activation `opaque`; otherwise promotes a `counted` step, clamping a seeded `done` to `t` |
+| `Degrade` | no-op | `counted`→`counting`→`opaque`, never upward; on a step already reporting `opaque` the measure is unchanged and promotion is refused for the rest of the activation |
+
+A step whose registry entry declares `counted` opens reporting `opaque` and
+promotes to `counted` on `SetTotal`, which is the single promotion point. Both
+inputs AC-PLATFORM-STARTUP-PROGRESS-001.7 names are therefore known at promotion:
+the total is the argument, and the initial done count is whatever `SeedDone` has
+already assigned, or zero for a step that starts from nothing. Until then there is
+no total to render a fraction against, so the snapshot admits it cannot report
+progress. A step declaring `counting` reports `counting` from its first snapshot
+with `done` at zero; a step declaring `opaque` never promotes.
+
+Ordering is fixed rather than free: `SeedDone` is valid only before `SetTotal`, so
+promotion is never observed with a done count that a later seed then moves. A seed
+above the total it is later given is clamped down by `SetTotal` under the rule
+AC-PLATFORM-STARTUP-PROGRESS-001.19 already states for a total below done, which is
+why the seed itself needs no knowledge of the total.
+
+`SeedDone` exists so AC-PLATFORM-STARTUP-PROGRESS-001.8's resume seed is not an
+`Advance`. It assigns `done` outright rather than adding to it, appends no rate
+sample, and does not touch the last-advance timestamp, so a resumed backfill seeded at 78,300 of 669,423 neither invents throughput nor resets the stall clock AC-PLATFORM-STARTUP-PROGRESS-004.8 requires a seed to leave alone. It is rejected once the total is set or the step has advanced, so no path rewinds an advancing count.
+
+A measured total of zero never promotes. Accepting it ends measuring, and AC-PLATFORM-STARTUP-PROGRESS-001.19 admits a different total only while measuring, so no later `SetTotal` revives it: the step ends `opaque`. That is why AC-PLATFORM-STARTUP-PROGRESS-001.17 holds at a concurrent `/ready` read and not only at `EndStep` - no reader observes a zero denominator, because no such state exists.
+
+`Degrade` exists so a failed counter query need not be a second `BeginStep`, which would move the step's start time and sequence number. Promotion and `Degrade` are the only ways a measure changes after `BeginStep`, and they cannot fight: promotion happens at most once per activation and only out of the opening `opaque`, `Degrade` only downward, and a degraded step cannot promote again before its next activation (AC-PLATFORM-STARTUP-PROGRESS-001.11). `Degrade` on a step already reporting `opaque` moves no measure but latches the activation, so a step that opened `counted` and lost its counter query before `SetTotal` never promotes; without the latch that criterion would be unfalsifiable for the step that degrades earliest.
+
+Warnings are deduplicated inside the reporter by (activation, condition), so
+AC-PLATFORM-STARTUP-PROGRESS-001.20 holds even when a loop calls a rejected
+boundary every batch.
+
+Rate is computed inside the reporter from a bounded ring of advance samples, so
+every surface shows the same number and no consumer re-derives it. A sample records
+the actual increase in reported `done`, not the requested delta, so an advance that
+saturates or clamps at the total contributes only the work that was really counted;
+recording the request would overstate throughput at exactly the moment a sweep
+finishes and project an estimate out of work that never landed. An advance whose
+actual increase is zero appends no sample at all, so it neither dilutes the rate nor
+refreshes the window, for the same reason
+AC-PLATFORM-STARTUP-PROGRESS-004.8 leaves the stall clock running through it. The window runs from the read time back to the oldest sample no older than 30 s, so elapsed always covers the silence since the last advance and the rate begins decaying the moment advancing stops rather than holding its last value for a further window; with fewer than two samples the step's start is the older point. A window that computes shorter than one
+millisecond is treated as one millisecond, so two advances landing inside the same
+clock tick produce a large finite rate rather than a division by zero: `+Inf` and
+`NaN` have no JSON number encoding and would break every consumer, which is the
+boundary AC-PLATFORM-STARTUP-PROGRESS-001.9 closes.
+
+`eta_ms` is `(total - done) / rate * 1000`, since `rate` is per second, emitted raw: rounding for display is the
+surface's job. An estimate above 86400000 ms is omitted entirely rather than
+emitted or clamped, per AC-PLATFORM-STARTUP-PROGRESS-001.10 — a rate small enough
+to project more than a day is measurement noise, and "no estimate" is the honest
+report of it. Surfaces already render a `counted` step with no estimate, because
+a zero rate produces the same shape.
+
+### Step registry
+
+```go
+type StepSpec struct {
+ ID StepID
+ Phase Phase
+ Measure Measure
+ Unit Unit
+ LabelKey string
+ Dialects []Dialect
+ Corpus string // record set the reported count covers, "" when Measure is opaque
+ Outstanding string // predicate selecting work still outstanding, "" when done is not seeded
+ Order []string // the loop query's existing ordering columns, outermost
+ // first, empty when the loop walks no ordered row set
+}
+```
+
+`LabelKey` is a localization key, not a rendered string, and it is the only name a
+step has (AC-PLATFORM-STARTUP-PROGRESS-005.4). `Unit` is an enum (`rows`,
+`messages`, `turns`, `sessions`, `bytes`, `stores`), not a free string, so the
+localized noun and its plural are chosen by the rendering surface rather than
+shipped in the payload.
+
+`Corpus`, `Outstanding`, and `Order` are the declaration
+AC-PLATFORM-STARTUP-PROGRESS-005.1 requires, and they live in the struct, not only in the table below, because that criterion makes the registry the artifact the completeness tests read.
+The table below is the rendering of those fields, not a second source: test 6
+asserts the two agree. `Corpus` and `Outstanding` are the declared identity of
+what is counted, checked by review and by the count query that reads them, not a
+query fragment the reporter executes.
+
+The three are not all owed by every measured step, which is why
+AC-PLATFORM-STARTUP-PROGRESS-005.1 conditions each one separately and closes with
+a step declaring nothing it does not have. A step that reports any count declares
+`Corpus`. Only a step seeding `done` from durable state declares `Outstanding`, and
+only a step advancing over a query-ordered row set declares `Order`: after the
+2026-09-18 retirement recorded below, no registered step declares either. The store
+sweeps and session recovery walk a catalog slice and a Go map, which have an
+iteration order but no ordering columns, and naming one would invent a contract the
+loop does not have; all three count forward from zero on every boot and have no
+resume predicate to name. Both fields stay in the struct and stay conditioned
+separately, because AC-PLATFORM-STARTUP-PROGRESS-001.8 and 001.21 still bind the
+next step that resumes or rides an ordered loop, and `SeedDone` remains the
+reporter's only seeding entry point. An `opaque` step declares none of the three.
+
+| Identifier | Phase | Measure | Unit | Dialects | Corpus | Outstanding | Order |
+| --- | --- | --- | --- | --- | --- | --- | --- |
+| `database.backup` | `backing_up_database` | `counting` | bytes | SQLite | staging file size in bytes | n/a | n/a |
+| `stores.repositories` | `applying_migrations` | `counted` | stores | both | catalog entries whose descriptor names this sweep | n/a | n/a |
+| `stores.services` | `initializing_services` | `counted` | stores | both | catalog entries whose descriptor names this sweep | n/a | n/a |
+| `task.prompt_seq.backfill` | `applying_migrations` | `opaque` | rows | both | n/a | n/a | n/a |
+| `task.message_timestamps.backfill` | `applying_migrations` | `opaque` | rows | both | n/a | n/a | n/a |
+| `task.subagent_context.backfill` | `applying_migrations` | `opaque` | rows | both | n/a | n/a | n/a |
+| `sessions.recovery` | `recovering_sessions` | `counted` | sessions | both | the `executors_running` record set read before the sweep | n/a | n/a |
+
+Three identifiers were retired on 2026-09-18, after this design first froze, and
+AC-PLATFORM-STARTUP-PROGRESS-005.6 bars their reuse: `task.journal.turns.backfill`,
+`task.journal.messages.backfill`, and `plugins.session_events.mirror`. Upstream
+`2eee90d38` ("test: close conversation storage follow-up gates", #3742) deleted
+`backfillConversationJournal`, both of its count helpers, and
+`syncAllCommittedSessionEvents` in full, dropped `conversation_turn_versions`,
+`conversation_message_versions`, and `conversation_session_streams`, and removed the
+mirror's `provider.go` call site without a replacement. The successor
+`conversation_session_revisions` table is trigger-maintained and treats revision zero
+as correct for a session that has not changed, so no batch backfill is left to
+measure. The 2026-09-17 incident's two named steps are therefore unreachable on this
+code. What replaced them, `cleanupLegacyConversationJournal` (`base_schema.go:65`),
+is one transaction of DDL with no row loop and no unit to count, so it registers no
+step; that exclusion is deliberate, and a later change that gives it a row loop is
+exactly what the registry checks below exist to catch. The three identifiers move
+into the append-only retired list beside the registry, which `init()` already panics
+on at process start and which completeness test 5 exercises; that list, not this
+paragraph, is what enforces AC-PLATFORM-STARTUP-PROGRESS-005.6.
+
+Completeness tests give the registry teeth:
+
+1. Every `requiredstores.Catalog()` entry is claimed by exactly one sweep step
+ (AC-PLATFORM-STARTUP-PROGRESS-005.2). Every catalog entry already has an admission site: the 36 `recordRequiredStore` call sites cover all 41 because `recordPluginStores` (`services.go:1420`) is one site looping over six ids. What no descriptor carries is a sweep assignment, so this test fails until all 41 have one.
+2. The registered identifier set equals the list in
+ AC-PLATFORM-STARTUP-PROGRESS-005.3 exactly, in both directions.
+3. Every `LabelKey` resolves in every supported backend locale and every supported
+ web locale.
+4. Every step's declared `Phase` matches the phase its call site runs in
+ (AC-PLATFORM-STARTUP-PROGRESS-005.7).
+5. Retired identifiers live in an append-only list and cannot be re-registered.
+6. Declarations match the measure, and nothing is declared that the step does not
+ have. A `counting` or `counted` entry declares a non-empty `Corpus`; an `opaque`
+ entry leaves `Corpus`, `Outstanding`, and `Order` all empty. A non-empty
+ `Outstanding` requires `Measure == counted`, because a resume seed is only
+ meaningful against a total. `Outstanding` and `Order` are otherwise independently
+ optional, for the reasons above. Every field the table renders must match its cell, which is what keeps the two renderings from drifting. Whether a step that could resume or could declare an order *ought*
+ to is a review question, not a test one: no unit test can know that a loop has an
+ ordered query it failed to declare, which is why
+ AC-PLATFORM-STARTUP-PROGRESS-005.1 puts the obligation on the registry entry.
+
+## Control flow
+
+### Measuring each step
+
+- **Database backup** (`backing_up_database`): `counting` in bytes. A single
+ `VACUUM INTO` (`persistence/snapshot.go:63`) cannot report rows, and its output
+ is defragmented so the source file size is not a valid total. A ticker stats the
+ staging file and calls `Advance` with the delta. An `ENOENT` is not a measurement failure at any
+ point in the step: `VACUUM INTO` creates the file only once it begins writing, so
+ the ticker reports no bytes and waits. Any other stat error calls `Degrade` once, not per
+ tick. Postgres skips backup entirely, which is why the step
+ declares SQLite only.
+- **Store admission sweeps** (`applying_migrations`, then
+ `initializing_services`): the two sweeps split on the existing
+ `startup.SetPhase(ctx, startup.InitializingServices)` at `storage.go:215`.
+ Runtime order decides the owner, not file and not definition line: everything
+ admitted before that call executes belongs to `stores.repositories`, everything
+ after it to `stores.services`. All 19 `storage.go` admissions are
+ `stores.repositories`, the five inside `provideSupportRepos` (defined at `:240`,
+ admitting at `:249`, `:261`, `:271`, `:281`, `:291`) included, because
+ `provideRepositories` calls it at `:107` - same function, before `:215`.
+ `stores.services` is `services.go`, `orchestrator.go`, `storage_maintenance.go`,
+ and `main.go`. A sweep
+ cannot infer its own share at runtime, so `requiredstores.Descriptor` gains a
+ sweep field naming which of the two owns that entry. That single declaration is both the source of each sweep's
+ total, read once at `BeginStep`, and what the
+ AC-PLATFORM-STARTUP-PROGRESS-005.2 check asserts is total and non-overlapping.
+ `recordRequiredStore` advances the sweep its descriptor names, by one.
+- **The two journal backfills and the plugin session-event mirror**: retired with
+ their work by `2eee90d38`, as recorded above. They were this design's only
+ `SeedDone` and `Order` callers; both reporter paths remain, exercised by unit
+ tests rather than by a registered step, and `SetTotal` stays the single promotion
+ point for the `counted` steps that survive.
+- **Prompt sequence, message timestamp, and subagent context backfills**
+ (`applying_migrations`): `opaque`. `backfillPromptSeq` is one `UPDATE` plus one
+ `INSERT … SELECT`, the timestamp migration is a single `UPDATE`
+ (`base_migrations.go:208`), and `migrateSubagentContextBackfill` is a single
+ `INSERT … SELECT`. None has a loop to advance from, and inventing one would
+ change the work being measured.
+- **Session recovery** (`recovering_sessions`): `counted` in sessions. The total is
+ the record set actually handed to the fan-out, after `recoverableRecords`
+ (`manager_lifecycle.go:96`) drops confirmed-passthrough sessions - not the raw
+ inventory read, because a passthrough record is never a re-tracking candidate and
+ counting it would put the total permanently out of reach. The sweep advances by
+ one per instance the consumer loop (`manager_lifecycle.go:122`) finishes
+ reconstructing, which is the only per-record point at which
+ AC-PLATFORM-STARTUP-PROGRESS-001.21's rule is met. It cannot advance inside the
+ fan-out: `RecoverInstances` (`executor_registry.go:138`) returns only the
+ instances a backend revived, so no backend can report that it examined and
+ declined a record, and six backends share that signature. Nor is `RecoverAll`'s
+ return (`:114`) such a point: it yields partial results beside an error, and
+ reconstruction, deadline expiry and refusal all happen after it. A record no
+ backend revived, or one the loop declines, is therefore never counted, so this
+ step too can end below its total, which AC-PLATFORM-STARTUP-PROGRESS-001.22 makes
+ audible. It declares no order because it advances in no query's order (`RecoverAll`
+ iterates a Go map, `executor_registry.go:137`). A failed inventory read leaves the
+ set unknown rather than empty (`manager_lifecycle.go:70`) and skips recovery
+ outright, so the step calls `Degrade` once and ends `opaque`: reporting an unknown
+ inventory as a measured zero is the dishonest count
+ AC-PLATFORM-STARTUP-PROGRESS-001.11 exists to prevent. A read that succeeds and
+ finds nothing is the other case: the total is honestly zero, and
+ AC-PLATFORM-STARTUP-PROGRESS-001.17 ends the step without ever reporting
+ `counted`. Nothing is counted while a record is merely selected.
+
+Degradation is uniform: any counter source that fails calls `Degrade` once and the
+work continues. No measurement is on a failure path.
+
+### Readiness payload
+
+The bootstrap handler already returns the snapshot on `/ready` and gains `seq` and
+`step`. `readyHandler` gains the same `startup` object on every branch, which
+requires the `routeParams` plumbing named above. Its existing
+`persistenceHealth` branch keeps its 503, its `starting` status value, its
+`reason` and its `store_ids`, and carries a `ready` snapshot beside them: the
+snapshot phase is what distinguishes "still starting" from "started and since
+degraded" (AC-PLATFORM-STARTUP-PROGRESS-002.3). `/health` is untouched.
+
+### Naming and localization
+
+`Phase.Label()` in `startup/progress.go:25` returns hardcoded English and is used
+by the launcher's child-exit message (`launcher/health.go:370`). It stays as it
+is: it is diagnostic output, which `docs/i18n.md` keeps in English.
+
+Step names are a different thing and must not reuse it. The snapshot carries the step's `LabelKey` as `label_key`, because the web application reads the wire, not the Go registry, and AC-PLATFORM-STARTUP-PROGRESS-005.4 forbids building a name from the identifier. The backend-rendered page resolves it through `i18n.T`/`i18n.Tf` with
+the locale from `i18n.FromRequest` (cookie, then accepted languages, then default). That chain is the one AC-PLATFORM-STARTUP-PROGRESS-003.9 names, and `parseAcceptLanguage` (`i18n/i18n.go:124`) applies its fallback and quality rules. The web application resolves the same key through i18next. Counts
+beside a unit noun use `Tf` with a `count` argument so `_one`/`_other` selection
+happens in the catalog, never by assembling an ending in Go or TypeScript.
+
+Rendering an estimate is the surface's job: 60000 ms or less renders in whole
+seconds rounded up with a floor of one second, anything longer in whole minutes
+rounded up.
+
+### Stall detection
+
+The reporter records the time of the last advance that increased `done`. A resume
+seed (`SeedDone`, which is not an advance at all), a zero-or-negative advance, an advance for a non-active identifier, and an
+advance clamped to a total already reached all leave that timestamp alone, so none
+of them masks a stall. `stalled` is computed at read time as
+`since_advance_ms >= 120000` for `counted` and `counting` steps, and is always
+false for `opaque` steps.
+
+### Surfaces
+
+- **Server-rendered startup page.** The bootstrap handler answers a request that
+ both classifies as an application route and, per
+ AC-PLATFORM-STARTUP-PROGRESS-003.3, gives `text/html` a strictly greater `Accept`
+ quality value than `application/json` with a self-contained HTML page instead of
+ the JSON body. An equal or lower quality keeps the JSON, so
+ `application/json;q=1, text/html;q=0.1` stays a JSON client. The status stays 503, and the
+ response carries no-store cache headers so no intermediary can serve it after
+ startup. The page is rendered by Go from `internal/i18n`, embeds no bundle, and
+ polls `/ready` every second with one request in flight, abandoning a request after 5000 ms so a hung connection cannot hold that slot and skipping any tick that falls while one is still outstanding, and loads the application when the snapshot reports ready rather than on a successful status: AC-PLATFORM-STARTUP-PROGRESS-002.3 has a post-startup degradation answer unsuccessfully with a `ready` snapshot.
+ The route test is `webapp.IsSPARoute` minus the paths the bootstrap handler answers
+ itself, `/ready` and `/health`:
+ `classifyNonSPA` (`webapp/routes.go:66`) enumerates only `/api`, `/ws`, `/health`
+ and static paths, so `/ready` itself classifies as an application route, and an
+ HTML-preferring browser navigating to it would otherwise be answered with the page
+ instead of the snapshot AC-PLATFORM-STARTUP-PROGRESS-002.2 requires. The exclusion
+ belongs to the bootstrap handler; global route classification is unchanged.
+ This is the only surface that can work before the SPA is serveable, because the
+ boot payload needs repositories that do not exist yet.
+- **Application document.** The existing restart flow polls system info and learns
+ nothing while the backend is down. It gains a `/ready` poll on the same
+ one-second cadence, bounded exactly as the page's poll is - a request abandoned
+ after 5000 ms, a tick skipped while one is outstanding - and with no give-up while
+ the dialog is open: while the backend is unreachable, including between process exit and listener bind, the restart
+ dialog keeps the last snapshot it read and labels it as last known rather than
+ blanking or showing an error. Before any snapshot has been read at all there is
+ no phase and no elapsed time to keep, so the dialog shows the waiting state of
+ AC-PLATFORM-STARTUP-PROGRESS-003.13 instead: a statement that Kandev is starting
+ and not yet reachable, with no step name, no timer, and no bar. The same component renders when a loaded document
+ finds the backend starting.
+- **Launcher.** `probeReadyStatus` (`launcher/health.go:379`) already decodes the
+ snapshot. The printed line gains the step name, and gains each of `done`,
+ `total`, the rate, and the estimate whenever the snapshot carries that field,
+ rather than only for a `counted` step, since `database.backup` carries a done
+ count and a rate with no total. The existing 15-second
+ floor is kept, with an immediate line on a phase change, on a `seq` change, and
+ on a stall-state transition. The
+ 8192-byte `io.LimitReader` at `:427` stays sufficient: the snapshot adds
+ bounded enum and integer fields.
+- **Desktop (deferred).** The shell keeps its existing readiness wait and startup
+ window behavior. Progress body parsing and status display are deferred to
+ follow-up card `c99c6111-e982-4313-b165-5c9b8aa03e46`.
+
+## Failure and recovery
+
+Progress reporting is strictly best-effort and never on a failure path. A counter
+query error, a stat error on the backup staging file, an unregistered identifier,
+or a malformed snapshot each degrade the report and leave the work running.
+
+A stalled step changes no behavior: nothing cancels, retries, or times out because
+of it. The existing listener-health budget and its overrides are the only
+mechanisms that can end a slow startup.
+
+## Persistence
+
+Nothing here persists. The snapshot lives in process memory for one process
+lifetime, and `seq` is not comparable across processes; `boot` is what makes that
+detectable, so a document holding `seq: 42` from a previous process replaces its
+retained state on the restarted process's `seq: 0` instead of discarding it as
+stale (AC-PLATFORM-STARTUP-PROGRESS-001.13). No registered step resumes from durable
+state once the three retired identifiers are gone, so every current step counts
+forward from zero on each boot; AC-PLATFORM-STARTUP-PROGRESS-001.8 governs the next
+step that does.
+
+## Security
+
+The snapshot is unauthenticated, so it carries only registry enums and integers.
+No path, SQL, error text, credential, or task, session, workspace, or user
+identifier enters it. The row counts it does expose describe corpus size only, and
+the default bind is loopback.
+
+The server-rendered startup page contains no operator-supplied or database-derived
+text; every string comes from the backend catalog and every number is an integer
+from the snapshot.
+
+## Observability
+
+Existing phase-transition logs are kept. Step transitions log at info with the
+step identifier, its measure, and its final counts. Degradations, unregistered
+identifiers, rejected boundary values, and a done count exceeding its total each
+log one warning per activation naming the step. No new metrics export is added.
+
+## Related decisions
+
+- [Database upgrade safety](../../../decisions/0008-db-upgrade-safety.md)