From 9f40245eb06563ba02ed261bbbaf4b18fe91dacb Mon Sep 17 00:00:00 2001
From: adamXbot <111877622+adamXbot@users.noreply.github.com>
Date: Tue, 7 Jul 2026 22:12:55 +1000
Subject: [PATCH 1/2] fix(device-actions): normalize ECIDs and pre-flight the
uninstall gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two safety defects in the cfgutil backup/delete flow, both invisible to CI
because tests used synthetic bare-hex ECIDs and never exercised the gate
ordering.
ECID normalization: cfgutil keys its JSON output by 0x-prefixed ECIDs
(e.g. 0x9118908BB6027), but isValidEcid rejected the `x`. With a real
device, recordBackup threw, the backup stamp was never written, and
checkUninstallGate always returned backup_missing — silently breaking the
freshness gate and the audit trail. Replace isValidEcid with normalizeEcid
(strip 0x, upper-case, validate hex body); key stamps off the canonical
form so any spelling round-trips. The backup route now 400s on a malformed
ECID instead of throwing a 500.
Gate ordering: the wizard ran the destructive cfgutil call before the
server gate, so the server could only refuse to *log*, never to *act*.
runBulkUninstall now pre-flights GET /api/device-actions/uninstall before
the first removal and fails closed on denial or an unreachable service.
Backup/uninstall recording POSTs check res.ok and surface failures as
visible warnings instead of vanishing into the console.
Tests pin the 0x round-trip and the pre-flight behaviour; correct two
route/comment claims that overstated what the server gate can prevent
(the native Touch ID prompt is the real control).
Co-Authored-By: Claude Opus 4.8
---
app/api/device-actions/backup/route.ts | 13 +-
app/api/device-actions/uninstall/route.ts | 15 ++-
app/components/ReviewRecommendationsView.tsx | 134 +++++++++++++++++--
lib/device-actions.ts | 35 +++--
locales/en.json | 11 +-
locales/zh.json | 11 +-
tests/app/uninstall-gate.test.ts | 63 ++++++++-
7 files changed, 248 insertions(+), 34 deletions(-)
diff --git a/app/api/device-actions/backup/route.ts b/app/api/device-actions/backup/route.ts
index 0c06c30..6620d4e 100644
--- a/app/api/device-actions/backup/route.ts
+++ b/app/api/device-actions/backup/route.ts
@@ -15,7 +15,7 @@
*/
import { type NextRequest, NextResponse } from "next/server";
-import { recordBackup } from "@/lib/device-actions";
+import { normalizeEcid, recordBackup } from "@/lib/device-actions";
import { getActiveFocus } from "@/lib/feature-flag-storage";
import { readBoundedJson } from "@/lib/security";
@@ -44,8 +44,15 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
- if (!body.ecid || typeof body.ecid !== "string") {
- return NextResponse.json({ error: "ecid is required" }, { status: 400 });
+ if (
+ !body.ecid ||
+ typeof body.ecid !== "string" ||
+ !normalizeEcid(body.ecid)
+ ) {
+ return NextResponse.json(
+ { error: "a valid ecid is required" },
+ { status: 400 }
+ );
}
if (!body.path || typeof body.path !== "string") {
return NextResponse.json({ error: "path is required" }, { status: 400 });
diff --git a/app/api/device-actions/uninstall/route.ts b/app/api/device-actions/uninstall/route.ts
index 252c378..d8124d9 100644
--- a/app/api/device-actions/uninstall/route.ts
+++ b/app/api/device-actions/uninstall/route.ts
@@ -6,16 +6,21 @@
* `run_cfgutil_remove_app`. This endpoint:
*
* 1. Re-runs the gate check server-side (audience + flag + backup
- * freshness) so a malicious page can't bypass the webview's
- * gating by hand-crafting an invoke. Returns 403 with a
- * structured `{ reason }` body when refused so the wizard can
- * render the right copy.
+ * freshness) before writing any audit row, so a hand-crafted API
+ * call can't stamp legitimate-looking rows for a gated-off
+ * configuration. Returns 403 with a structured `{ reason }` body
+ * when refused so the wizard can render the right copy. Note the
+ * limits of this check: the destructive call itself is a Tauri
+ * command that never passes through this server — the control
+ * that actually stops a compromised webview is the native
+ * Touch ID prompt inside `run_cfgutil_remove_app`.
* 2. Writes a `cfgutil_uninstall` activity row regardless of
* success — failures are as important to log as successes.
*
* The endpoint is GET-able too: `GET /api/device-actions/uninstall?ecid=…`
* returns the gate result without committing anything. The wizard
- * uses this to decide whether to render the uninstall buttons at all.
+ * calls this as a fail-closed pre-flight at the top of its bulk
+ * uninstall loop, before the first removal fires.
*/
import { type NextRequest, NextResponse } from "next/server";
diff --git a/app/components/ReviewRecommendationsView.tsx b/app/components/ReviewRecommendationsView.tsx
index 98b5574..c059a7a 100644
--- a/app/components/ReviewRecommendationsView.tsx
+++ b/app/components/ReviewRecommendationsView.tsx
@@ -121,6 +121,19 @@ interface Props {
*/
type Step = "review" | "compare" | "action" | "backup" | "act";
+/**
+ * Maps `DeviceActionGate` denial reasons (see lib/device-actions.ts) to
+ * `review_rec.act.*` message keys for the pre-flight banner. Unknown
+ * reasons fall back to `gate_denied_generic` in the caller — fail
+ * closed with an honest "refused" message rather than guessing.
+ */
+const GATE_DENIAL_KEYS: Record = {
+ audience: "gate_denied_audience",
+ backup_missing: "gate_denied_backup",
+ backup_stale: "gate_denied_backup",
+ flag: "gate_denied_flag",
+};
+
interface BackupState {
device: ConnectedDevice | null;
error: string | null;
@@ -223,6 +236,14 @@ export default function ReviewRecommendationsView({
const [uninstallStates, setUninstallStates] = useState<
Record
>({});
+ /**
+ * True when the backup itself succeeded but persisting its stamp
+ * (POST /api/device-actions/backup) failed. The act step's pre-flight
+ * gate reads the server-side stamp, so an unrecorded backup will be
+ * treated as missing — this flag surfaces that mismatch on the
+ * backup step instead of letting the act step refuse "mysteriously".
+ */
+ const [backupRecordFailed, setBackupRecordFailed] = useState(false);
/**
* Per-row free-text "replacing with" memo — captured during the
* Compare step. Stored in component state only (not persisted) so
@@ -368,6 +389,20 @@ export default function ReviewRecommendationsView({
null | "list" | "final" | "executing"
>(null);
const [bulkConfirmText, setBulkConfirmText] = useState("");
+ /**
+ * Set when the pre-flight gate check (GET /api/device-actions/
+ * uninstall) refuses the run or can't be reached. The bulk loop is
+ * aborted before any cfgutil call fires; this renders as an alert
+ * banner on the act step.
+ */
+ const [bulkGateError, setBulkGateError] = useState(null);
+ /**
+ * Count of activity-log writes (POST /api/device-actions/uninstall)
+ * that failed during the last bulk run. The removals themselves
+ * already ran — this only drives an "audit trail is incomplete"
+ * warning so the user knows the log can't be trusted for this run.
+ */
+ const [recordingFailures, setRecordingFailures] = useState(0);
// Modal focus management for the two bulk dialogs — declared here, after
// `bulkModal`, since the hook's `open` reads it.
const bulkListCardRef = useModalFocus({
@@ -616,6 +651,7 @@ export default function ReviewRecommendationsView({
return;
}
const device = devices.find((d) => d.ecid === selectedEcid) ?? null;
+ setBackupRecordFailed(false);
setBackup({
status: "running",
device,
@@ -635,8 +671,9 @@ export default function ReviewRecommendationsView({
});
return;
}
+ let recorded = false;
try {
- await fetch("/api/device-actions/backup", {
+ const res = await fetch("/api/device-actions/backup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -646,9 +683,17 @@ export default function ReviewRecommendationsView({
deviceName: device?.name ?? null,
}),
});
+ recorded = res.ok;
+ if (!res.ok) {
+ console.warn("[review] backup record refused:", res.status);
+ }
} catch (e) {
console.warn("[review] failed to record backup:", e);
}
+ // The backup itself succeeded — a failed recording downgrades to a
+ // warning, not a failed step. Without the stamp the act step's
+ // pre-flight will refuse the backed-up path, so tell the user now.
+ setBackupRecordFailed(!recorded);
setBackup({
status: "done",
device,
@@ -669,8 +714,9 @@ export default function ReviewRecommendationsView({
[row.id]: { status: "running", error: null },
}));
const result = await removeAppViaCfgutil(selectedEcid, row.bundleId);
+ let recorded = false;
try {
- await fetch("/api/device-actions/uninstall", {
+ const res = await fetch("/api/device-actions/uninstall", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -683,9 +729,18 @@ export default function ReviewRecommendationsView({
acknowledgeNoBackup,
}),
});
+ recorded = res.ok;
+ if (!res.ok) {
+ console.warn("[review] uninstall record refused:", res.status);
+ }
} catch (e) {
console.warn("[review] failed to record uninstall outcome:", e);
}
+ if (!recorded) {
+ // The removal already ran — losing the audit row is a
+ // warning-level problem, surfaced once per bulk run.
+ setRecordingFailures((n) => n + 1);
+ }
setUninstallStates((prev) => ({
...prev,
[row.id]: result.ok
@@ -701,21 +756,54 @@ export default function ReviewRecommendationsView({
);
/**
- * Sequential bulk-uninstall runner. Iterates the queue, calling
- * `runUninstall` per row. Errors don't abort the batch — the per-app
- * state surfaces ✓/✕/spinner inline, and the user sees a summary
- * once the loop finishes. Apps that lack a bundle ID are skipped
- * (cfgutil needs one) and surface as `error`.
+ * Sequential bulk-uninstall runner. Pre-flights the server-side gate
+ * (audience + flag + backup freshness) BEFORE the first cfgutil call
+ * and aborts fail-closed when it refuses or can't be reached — the
+ * per-row POSTs after each removal only *record* outcomes, so this
+ * is the one point where the server can still stop the run. Then it
+ * iterates the queue, calling `runUninstall` per row. Errors don't
+ * abort the batch — the per-app state surfaces ✓/✕/spinner inline,
+ * and the user sees a summary once the loop finishes. Apps that lack
+ * a bundle ID are skipped (cfgutil needs one) and surface as `error`.
*
- * The `acknowledgeNoBackup` flag flows through to every per-app
- * request so the server-side gate can allow the bypass uniformly
- * across the batch.
+ * The `acknowledgeNoBackup` flag flows through the pre-flight and
+ * every per-app request so the server-side gate can allow the bypass
+ * uniformly across the batch.
*/
const runBulkUninstall = useCallback(
async (acknowledgeNoBackup: boolean) => {
if (!selectedEcid) {
return;
}
+ setBulkGateError(null);
+ setRecordingFailures(0);
+ try {
+ const qs = new URLSearchParams({ ecid: selectedEcid });
+ if (acknowledgeNoBackup) {
+ qs.set("acknowledgeNoBackup", "1");
+ }
+ const res = await fetch(`/api/device-actions/uninstall?${qs}`, {
+ cache: "no-store",
+ });
+ const gate = res.ok
+ ? ((await res.json()) as { allowed?: boolean; reason?: string })
+ : null;
+ if (gate?.allowed !== true) {
+ const key = gate
+ ? (GATE_DENIAL_KEYS[gate.reason ?? ""] ?? "gate_denied_generic")
+ : "gate_unreachable";
+ setBulkGateError(tAct(key));
+ setBulkModal(null);
+ setBulkConfirmText("");
+ return;
+ }
+ } catch (e) {
+ console.warn("[review] gate pre-flight failed:", e);
+ setBulkGateError(tAct("gate_unreachable"));
+ setBulkModal(null);
+ setBulkConfirmText("");
+ return;
+ }
setBulkModal("executing");
for (const row of uninstallQueue) {
if (!row.bundleId) {
@@ -1725,6 +1813,11 @@ export default function ReviewRecommendationsView({
})}
)}
+ {backup.status === "done" && backupRecordFailed && (
+
+ ⚠ {tBackup("record_failed")}
+
+ )}
{backup.status === "error" && (
{backup.error}
@@ -1745,7 +1838,8 @@ export default function ReviewRecommendationsView({
on file so the user understands which Modal 2 variant
they'll see. Fresh ✓ → reassuring; missing / stale ⚠ →
warning. Drives no other behaviour here; the actual gate
- decision is made server-side when the bulk loop runs. */}
+ decision is the server-side pre-flight at the top of
+ runBulkUninstall, before any removal fires. */}
{uninstallQueue.length > 0 && (
)}
+ {/* Pre-flight refusal / audit-trail warnings. The gate error
+ means NOTHING was removed; the recording warning means
+ removals ran but one or more activity rows failed to
+ persist. Both are aria-live so screen-reader users hear
+ the outcome of a Delete click that didn't open the
+ executing state. */}
+ {bulkGateError && (
+
+ {bulkGateError}
+
+ )}
+ {recordingFailures > 0 && (
+
+ ⚠ {tAct("record_failures", { count: recordingFailures })}
+
+ )}
+
{uninstallQueue.length === 0 ? (
{tAct("empty")}
) : (
@@ -1878,6 +1989,7 @@ export default function ReviewRecommendationsView({
disabled={bulkModal === "executing"}
onClick={() => {
setBulkConfirmText("");
+ setBulkGateError(null);
setBulkModal("list");
}}
type="button"
diff --git a/lib/device-actions.ts b/lib/device-actions.ts
index 82d0f36..f5d214a 100644
--- a/lib/device-actions.ts
+++ b/lib/device-actions.ts
@@ -30,16 +30,29 @@ export const BACKUP_FRESHNESS_WINDOW_MS = 24 * 60 * 60 * 1000;
const SETTINGS_BACKUP_PREFIX = "cfgutil_last_backup_";
/**
- * Apple ECIDs are hex strings (typically 12-20 chars). Validate at every
- * TS-side entry point even though the Rust command also char-allowlists,
- * so a stray caller can't synthesise a key like
- * `cfgutil_last_backup_flag.devopts.cfgutil_uninstall` via string
+ * Canonicalise an ECID for use in a settings key, or return null when
+ * the value isn't a plausible ECID.
+ *
+ * cfgutil prints ECIDs as `0x`-prefixed hex — its JSON `Output`
+ * dictionaries are keyed by strings like `0x9118908BB6027`, and that
+ * exact spelling is what the webview passes through from
+ * `list_connected_devices`. Strip the prefix and upper-case the hex
+ * body so a stamp written under any spelling (`0x9118908bb6027`,
+ * `9118908BB6027`, …) reads back under every other.
+ *
+ * Validation still matters even though the Rust commands also
+ * char-allowlist: a stray caller must not be able to synthesise a key
+ * like `cfgutil_last_backup_flag.devopts.cfgutil_uninstall` via string
* concatenation and collide with another setting key. Defence in depth
* — if the Rust validator changes or another TS entry point is added,
* this still keeps the namespace unambiguous.
*/
-function isValidEcid(value: string): boolean {
- return /^[A-Fa-f0-9]{8,24}$/.test(value);
+export function normalizeEcid(value: string): string | null {
+ const body = value.trim().replace(/^0[xX]/, "");
+ if (!/^[A-Fa-f0-9]{8,24}$/.test(body)) {
+ return null;
+ }
+ return body.toUpperCase();
}
interface BackupStamp {
@@ -120,10 +133,11 @@ export function checkUninstallGate(
/** Most recent backup stamp for the given ECID, or null. */
export function getLastBackup(ecid: string): BackupStamp | null {
- if (!isValidEcid(ecid)) {
+ const normalized = normalizeEcid(ecid);
+ if (!normalized) {
return null;
}
- const key = SETTINGS_BACKUP_PREFIX + ecid;
+ const key = SETTINGS_BACKUP_PREFIX + normalized;
const raw = getSetting(key, "");
if (!raw) {
return null;
@@ -149,10 +163,11 @@ export function recordBackup(opts: {
finishedAt: number;
deviceName: string | null;
}): void {
- if (!isValidEcid(opts.ecid)) {
+ const normalized = normalizeEcid(opts.ecid);
+ if (!normalized) {
throw new Error(`recordBackup: invalid ECID ${opts.ecid}`);
}
- const key = SETTINGS_BACKUP_PREFIX + opts.ecid;
+ const key = SETTINGS_BACKUP_PREFIX + normalized;
const stamp: BackupStamp = {
finishedAt: opts.finishedAt,
path: opts.path,
diff --git a/locales/en.json b/locales/en.json
index 2ad05e4..025e019 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -180,7 +180,8 @@
"skip_backup_title": "Continue without a backup. We'll ask you to type DELETE again before any app is removed.",
"mismatch_heading": "Heads up — this might not be the right device",
"mismatch_body": "{count, plural, one {# of the apps you're about to delete wasn't imported from this device. cfgutil will refuse to remove an app that isn't installed.} other {# of the apps you're about to delete weren't imported from this device. cfgutil will refuse to remove apps that aren't installed.}}",
- "mismatch_more": "…and {count} more"
+ "mismatch_more": "…and {count} more",
+ "record_failed": "The backup completed, but saving its record failed. The delete step won't see this backup and may refuse or ask for the no-backup confirmation."
},
"act": {
"heading": "Remove apps from your phone",
@@ -203,7 +204,13 @@
"backup_ok": "Backup of {device} taken at {time}",
"backup_missing_warn": "No recent backup of this device.",
"backup_missing_action": "Back up first",
- "default_device_name": "the connected device"
+ "default_device_name": "the connected device",
+ "gate_denied_audience": "The safety check refused this run, so nothing was deleted: app removal is only available when your focus is set to just you.",
+ "gate_denied_flag": "The safety check refused this run, so nothing was deleted: the \"Remove apps from your phone\" option is switched off in Developer Options.",
+ "gate_denied_backup": "The safety check refused this run, so nothing was deleted: it has no record of a fresh backup (under 24 hours old) for this device. Run a backup, then try again.",
+ "gate_denied_generic": "The safety check refused this run, so nothing was deleted.",
+ "gate_unreachable": "Couldn't reach the safety check, so nothing was deleted. Check that privacytracker's local service is running and try again.",
+ "record_failures": "{count, plural, one {# removal} other {# removals}} couldn't be written to the activity log — the apps were still processed, but the audit trail for this run is incomplete."
},
"saved_notes": {
"heading": "Your notes",
diff --git a/locales/zh.json b/locales/zh.json
index 79ac72d..b63ccd9 100644
--- a/locales/zh.json
+++ b/locales/zh.json
@@ -180,7 +180,8 @@
"skip_backup_title": "不备份直接继续。在任何应用被移除之前,我们仍会要求你再次输入 DELETE。",
"mismatch_heading": "提示 — 这可能不是正确的设备",
"mismatch_body": "{count, plural, one {你即将删除的应用中有 # 个不是从该设备导入的。cfgutil 不会移除未安装的应用。} other {你即将删除的应用中有 # 个不是从该设备导入的。cfgutil 不会移除未安装的应用。}}",
- "mismatch_more": "…还有 {count} 个"
+ "mismatch_more": "…还有 {count} 个",
+ "record_failed": "备份已完成,但保存备份记录失败。删除步骤将看不到此备份,可能会拒绝执行或要求进行\"无备份\"确认。"
},
"act": {
"heading": "从手机移除应用",
@@ -203,7 +204,13 @@
"backup_ok": "已于 {time} 备份 {device}",
"backup_missing_warn": "该设备没有最近的备份。",
"backup_missing_action": "先备份",
- "default_device_name": "已连接的设备"
+ "default_device_name": "已连接的设备",
+ "gate_denied_audience": "安全检查拒绝了本次操作,未删除任何应用:只有当你的关注对象设置为\"自己\"时才能移除应用。",
+ "gate_denied_flag": "安全检查拒绝了本次操作,未删除任何应用:开发者选项中的\"从手机移除应用\"开关未开启。",
+ "gate_denied_backup": "安全检查拒绝了本次操作,未删除任何应用:没有该设备 24 小时内的备份记录。请先运行备份,然后重试。",
+ "gate_denied_generic": "安全检查拒绝了本次操作,未删除任何应用。",
+ "gate_unreachable": "无法连接安全检查服务,因此未删除任何应用。请确认 privacytracker 本地服务正在运行,然后重试。",
+ "record_failures": "{count, plural, one {# 项移除操作} other {# 项移除操作}}未能写入活动日志 — 应用仍已处理,但本次运行的审计记录不完整。"
},
"saved_notes": {
"heading": "你的备注",
diff --git a/tests/app/uninstall-gate.test.ts b/tests/app/uninstall-gate.test.ts
index 6266c80..9fd95a2 100644
--- a/tests/app/uninstall-gate.test.ts
+++ b/tests/app/uninstall-gate.test.ts
@@ -14,7 +14,12 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { checkUninstallGate } from "../../lib/device-actions";
+import {
+ checkUninstallGate,
+ getLastBackup,
+ normalizeEcid,
+ recordBackup,
+} from "../../lib/device-actions";
import {
createDevice,
getDeviceEcidsForApps,
@@ -113,6 +118,62 @@ test("a stale backup (>24h) is denied unless acknowledgeNoBackup overrides", ()
assert.equal(overrideGate.allowed, true);
});
+// ─── ECID normalisation ───────────────────────────────────────────
+// cfgutil keys its JSON `Output` by `0x`-prefixed hex ECIDs (e.g.
+// `0x9118908BB6027`), and the webview passes that spelling through
+// verbatim. The stamp store must accept it — and read back the same
+// stamp under any prefix/case spelling of the same ECID.
+
+const RAW_CFGUTIL_ECID = "0x9118908BB6027";
+
+test("recordBackup + gate accept cfgutil's 0x-prefixed ECIDs", () => {
+ beforeEach();
+ recordBackup({
+ ecid: RAW_CFGUTIL_ECID,
+ path: "/tmp/backup",
+ finishedAt: Date.now() - 60_000,
+ deviceName: "Test iPhone",
+ });
+ const gate = checkUninstallGate(RAW_CFGUTIL_ECID);
+ assert.equal(gate.allowed, true);
+});
+
+test("a stamp reads back regardless of 0x prefix or hex case", () => {
+ beforeEach();
+ recordBackup({
+ ecid: "0x9118908bb6027",
+ path: "/tmp/backup",
+ finishedAt: Date.now() - 60_000,
+ deviceName: null,
+ });
+ assert.notEqual(getLastBackup("9118908BB6027"), null);
+ assert.equal(checkUninstallGate("0X9118908BB6027").allowed, true);
+});
+
+test("normalizeEcid canonicalises valid spellings and rejects the rest", () => {
+ assert.equal(normalizeEcid("0x9118908BB6027"), "9118908BB6027");
+ assert.equal(normalizeEcid("0X9118908bb6027"), "9118908BB6027");
+ assert.equal(normalizeEcid(" ABCDEF1234567890 "), "ABCDEF1234567890");
+ // Settings-key injection shapes and non-hex garbage must not pass.
+ assert.equal(normalizeEcid("flag.devopts.cfgutil_uninstall"), null);
+ assert.equal(normalizeEcid("0x"), null);
+ assert.equal(normalizeEcid(""), null);
+ assert.equal(normalizeEcid("zzzz11112222"), null);
+ assert.equal(normalizeEcid("0x123"), null); // hex body too short
+});
+
+test("recordBackup throws on a malformed ECID", () => {
+ beforeEach();
+ assert.throws(() =>
+ recordBackup({
+ ecid: "not-an-ecid",
+ path: "/tmp/backup",
+ finishedAt: Date.now(),
+ deviceName: null,
+ })
+ );
+});
+
// ─── getDeviceEcidsForApps ────────────────────────────────────────
test("getDeviceEcidsForApps returns ECIDs only for apps with cfgutil-imported devices", () => {
From 7bb8e39e7683d70ff39b0f1c4ffff2042dbc0de7 Mon Sep 17 00:00:00 2001
From: adamXbot <111877622+adamXbot@users.noreply.github.com>
Date: Tue, 7 Jul 2026 22:13:10 +1000
Subject: [PATCH 2/2] docs: add end-to-end architecture dossier and first-issue
spec
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Document every process the app runs — system map, add/track, cfgutil
device import, backup+delete, background jobs, wayback, and the feature-
flag gate chain — as Mermaid diagrams that render on GitHub and stay
diffable. Weak points are marked inline and collected into an improvement
backlog keyed to each diagram, so findings and their fix status live in
one place instead of drifting apart.
Add docs/specs/3-5-server-stamp-backup-variant.md, a self-contained
good-first-issue spec (drive the delete-confirm modal's backup variant
from the server stamp) sized for an external agent — TypeScript-only,
self-verifiable in the node:test suite, no hardware.
Wire both into the AGENTS.md architecture section and the README docs list.
Co-Authored-By: Claude Opus 4.8
---
AGENTS.md | 2 +-
README.md | 1 +
docs/ARCHITECTURE.md | 295 ++++++++++++++++++
docs/specs/3-5-server-stamp-backup-variant.md | 168 ++++++++++
4 files changed, 465 insertions(+), 1 deletion(-)
create mode 100644 docs/ARCHITECTURE.md
create mode 100644 docs/specs/3-5-server-stamp-backup-variant.md
diff --git a/AGENTS.md b/AGENTS.md
index 343e33c..f353cde 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -30,7 +30,7 @@ Separate Python companion script in `scripts/ios-app-import/` (stdlib-only, Pyth
## Architecture
-This is a Next.js 16 App Router app (TypeScript, React 19) backed by a single local SQLite file. All scraping, parsing, diffing, and AI calls happen server-side inside API routes that import helpers from `lib/`.
+This is a Next.js 16 App Router app (TypeScript, React 19) backed by a single local SQLite file. All scraping, parsing, diffing, and AI calls happen server-side inside API routes that import helpers from `lib/`. End-to-end workflow diagrams (system map, import/delete/sync/wayback flows, gate chain) with known weak points marked live in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
### Data flow (the core loop)
diff --git a/README.md b/README.md
index 31890b7..06992b1 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,7 @@ Full documentation lives at
- [User guide](https://privacytracker-docs.privacykey.org/quickstart) — how to import apps, read privacy labels, set up alerts
- [AI provider setup](https://privacytracker-docs.privacykey.org/quickstart) — bring your own OpenAI / Anthropic / local model
- [Architecture](https://privacytracker-docs.privacykey.org/develop/architecture) — for developers and contributors
+- [Architecture & workflows (in-repo)](docs/ARCHITECTURE.md) — end-to-end diagrams of every process, with weak points marked and an improvement backlog
- [Security](https://privacytracker-docs.privacykey.org/security) — how to report a vulnerability
## License
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..41233b3
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,295 @@
+# Architecture & workflows, end to end
+
+Every process the app runs — from typing an app name to deleting one off a plugged-in
+iPhone — drawn as flow diagrams across the runtimes, with known weak points marked where
+they live. Companion to the prose in [AGENTS.md](../AGENTS.md) and the hosted docs at
+[privacytracker-docs.privacykey.org](https://privacytracker-docs.privacykey.org/develop/architecture).
+
+**How to read the markers.** `⚠ §N·M` = open finding, `✅ §N·M` = fixed. Every marker is a
+row in the [improvement backlog](#7--improvement-backlog) at the bottom. When you fix one,
+update its row and the diagram label in the same PR.
+
+*Findings audited against source on 2026-07-06 (branch `feat/eager-shannon-b87c90`).*
+
+---
+
+## 0 · System map
+
+The desktop app is a Tauri shell that boots a private Next.js server (the "sidecar") on a
+random localhost port, then points its webview at it. Everything privacy-critical happens
+on this machine: scraping, diffing, AI calls, and the SQLite database. The Rust shell is
+the only piece that can touch a connected iPhone. The web/Docker build is the same server
+without the shell column.
+
+```mermaid
+flowchart LR
+ subgraph mac["This Mac · Tauri desktop app"]
+ shell["Rust shell (src-tauri/)
tray · deep links · usb_watcher
cfgutil bridge · Touch ID gate
ACL: 14 allowed commands"]
+ webview["Webview (Next.js UI)
dashboard · onboarding wizard
review-and-act wizard · TaskCenter"]
+ sidecar["Node sidecar (Next server)
app/api/* routes → lib/*
9 boot timers · 3 bulk runners"]
+ db[("SQLite data/privacy.db
WAL · synchronous better-sqlite3
apps → types → categories")]
+ end
+ phone["iPhone / iPad over USB
list · installedApps · backup · remove-app"]
+ apple["Apple
iTunes Search API · App Store pages"]
+ archive["archive.org
availability · replay · Save Page Now"]
+ ai["AI provider (optional)
OpenAI / Anthropic / local"]
+
+ shell -->|"spawns · reveals window when /api/apps responds"| sidecar
+ webview <-->|"HTTP 127.0.0.1:<port>"| sidecar
+ webview -.->|"invoke() IPC · ACL + Touch ID"| shell
+ shell -.->|"cfgutil subprocess"| phone
+ sidecar --> db
+ sidecar -.-> apple
+ sidecar -.-> archive
+ sidecar -.-> ai
+```
+
+Boot handshake: `sidecar::boot()` binds `127.0.0.1:0` for a free port, spawns Node with
+`PORT`/`PRIVACYTRACKER_DATA_DIR`, polls `GET /api/apps` (≤60s), then navigates the webview
+and reveals the window (optionally behind a Touch ID unlock). Files:
+`src-tauri/src/main.rs`, `src-tauri/src/sidecar.rs`.
+
+---
+
+## 1 · Add & track an app (the core loop)
+
+The pipeline every other flow feeds into. A name becomes an App Store URL, the URL becomes
+parsed privacy labels, and every re-sync diffs against the previous snapshot to produce
+the change timeline and notifications. Re-syncs run the same path with `resync=true`.
+Files: `lib/scraper.ts`, `lib/changelog.ts`, `lib/privacy-policy.ts`.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant UI as Webview UI
+ participant API as Sidecar API
+ participant LIB as lib/ pipeline
+ participant DB as SQLite
+ participant EXT as Apple / AI
+
+ UI->>API: POST /api/search — names · bundleIds · country
+ API->>EXT: iTunes Search API
+ EXT-->>UI: candidates — user picks the right match
+ UI->>API: POST /api/scrape — urls · resync flag
+ API->>LIB: fetchAndParseApp(url)
+ LIB->>EXT: GET App Store page HTML
+ Note over LIB: ⚠ §1·1 parse fallback chain
shelfMapping → privacyHeader → shelves → shoebox
+ LIB->>LIB: capture previousSnapshot BEFORE the write
+ LIB->>DB: saveToDb tx — apps → privacy_types → privacy_categories
+ LIB->>DB: buildSnapshot → diffSnapshots → saveSnapshot
+ DB-->>DB: notification row · changeCount bump
+ Note over LIB: ⚠ §1·2 policy fetch + hash —
regenerate summary_json only when hash changed
+ LIB->>EXT: AI summarisation (optional, chunked for local models)
+ DB-->>UI: timeline · bell · pending-changes dot
+```
+
+---
+
+## 2 · Import your apps from a device (cfgutil)
+
+Onboarding (`OnboardWizard.tsx`, five steps: choose method → import & reconcile → confirm
+matches → import progress → policy summaries) accepts four sources: screenshots (OCR),
+CSV/TXT upload, manual typing, and — on macOS with Apple Configurator — a live `cfgutil`
+export. The cfgutil path is **read-only against the device**. There is no scheduled device
+re-sync: a USB plug-in event (IOKit watcher → `DeviceConnectedToast`) re-opens this flow
+on demand. Files: `src-tauri/src/cfgutil.rs`, `lib/desktop.ts`, `src-tauri/src/usb_watcher.rs`.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant WIZ as Onboard wizard
+ participant SH as Rust shell
+ participant PH as iPhone (USB)
+ participant API as Sidecar API
+ participant AP as Apple
+
+ WIZ->>SH: invoke check_cfgutil — PATH + app-bundle probes, cached 5 min
+ WIZ->>SH: poll list_connected_devices (5s while on the step)
+ SH->>PH: cfgutil list · get name/model
+ PH-->>WIZ: devices (ECID · name · iOS version)
+ WIZ->>SH: invoke run_cfgutil_export(ecid)
+ SH->>PH: cfgutil get installedApps (90s cap, read-only)
+ PH-->>WIZ: rows — displayName · bundleIdentifier · version
+ WIZ->>WIZ: step 2 — dedupe by bundleId, diff-preview
+ WIZ->>API: POST /api/search with bundleIds
+ API->>AP: iTunes lookup — canonical track per bundle id
+ WIZ->>WIZ: step 3 — name-search fallback for unlisted/sideloaded
+ Note over API: ⚠ §2·1 step 4 — scrape each URL (§1 flow) ·
Apple 429s park rows in the import queue (60s drain)
+ WIZ->>API: step 5 — optional policy summaries (flag-gated)
+```
+
+Non-Mac alternative: `scripts/ios-app-import/export_ios_apps.py` (stdlib-only) produces a
+`.txt`/`.csv` the user feeds back into the CSV path (⚠ §2·2 — manual round-trip).
+
+---
+
+## 3 · Back up, then delete apps off the phone
+
+The only destructive flow in the product, so it runs the deepest gate stack: audience must
+be `self`, an off-by-default Developer Options flag, a fresh backup (≤24h) or an explicit
+typed acknowledgement, two confirm modals, a server-side pre-flight, and finally a native
+Touch ID prompt per app that JavaScript cannot bypass. One cfgutil call per app — there is
+deliberately no batch primitive. Files: `app/components/ReviewRecommendationsView.tsx`,
+`lib/device-actions.ts`, `app/api/device-actions/*`, `src-tauri/src/cfgutil.rs`,
+`src-tauri/src/touch_id.rs`.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant WIZ as Review wizard
+ participant API as Sidecar (gates + audit)
+ participant SH as Rust shell
+ participant PH as iPhone (USB)
+
+ Note over WIZ: ◆ entry gate — audience=self ∧ flag on ∧ desktop build
+ WIZ->>WIZ: steps 1–3 — own "uninstall" verdicts only ·
imported recommendations never execute
+ WIZ->>WIZ: step 4 — pick device · warn when ECID ≠ app's source device
+ WIZ->>SH: invoke run_cfgutil_backup(ecid, destDir)
+ Note over SH: ⚠ §3·1 --backup-output unverified on real cfgutil
⚠ §3·2 success = exit code only, no on-disk check
⚠ §3·4 dest allowlist is lexical (symlinks not resolved)
+ SH->>PH: cfgutil backup (300s ceiling)
+ WIZ->>API: POST /api/device-actions/backup
+ Note over API: ✅ §3·6 normalizeEcid (0x-prefixed ECIDs) → stamp + activity row
+ WIZ->>WIZ: step 5 — "Delete N apps" → modal 1 (list) → modal 2 (type DELETE)
+ Note over WIZ: ⚠ §3·5 modal variant keys off session-local backup state
+ WIZ->>API: GET gate pre-flight — audience ∧ flag ∧ backup ≤24h (or acknowledged)
+ Note over API: ✅ §3·7 pre-flight BEFORE first removal, fail closed
+ loop one app at a time
+ WIZ->>SH: invoke run_cfgutil_remove_app(ecid, bundleId)
+ Note over SH: ◆ Touch ID / password per app — native LAContext,
JS cannot bypass · fails closed without biometrics+password
+ SH->>PH: cfgutil remove-app (45s timeout)
+ Note over PH: ⚠ §3·3 timed-out child is orphaned, not killed —
removal may still complete after a reported failure
+ WIZ->>API: POST record outcome — cfgutil_uninstall row (ok/error + ack flag)
+ end
+```
+
+---
+
+## 4 · Background jobs & crash-safe resume
+
+Server boot (`instrumentation.ts`) arms nine timers. Three bulk runners (App Store sync,
+Wayback import, policy sync) share one crash-safety pattern: a mutex key plus a state blob
+in `app_settings`, rewritten at every app boundary — a process kill loses at most one
+app's work, and boot-time healers resume or clear what's left.
+
+| When | What | Then every |
+| --- | --- | --- |
+| t=0 | watchdog · error ring · diagnostics · feature-flag migration · clear `import_queue_running`/`health_check_running` stale locks | — |
+| +8s / +10s / +12s | resume healers: wayback → sync → policy (resume pending run, or clear a stale mutex) | on boot |
+| +15s | scheduler tick — `getSchedulerStatus().isDue` (daily/weekly/manual) → `runBulkSync` | 30 min |
+| +20s | import-queue drain (rows parked by onboarding 429s) | 60 s |
+| +25s | update check (GitHub, 24h response cache) | 6 h |
+| +35s | whole-DB backup snapshots (`lib/backup.ts`, signed JSON — distinct from device backups) | 30 min |
+| +60s | health check — PASSIVE WAL checkpoint, clear provably-dead locks, report-only memory/orphan checks | 24 h |
+
+The resume healers are staggered *before* the 60s health check so a freshly-resumed run is
+never mistaken for a dead lock.
+
+```mermaid
+flowchart TD
+ acquire["◆ acquire mutex
sync_running · wayback_import_running · policy_sync_running"]
+ blob["state blob in app_settings
runId · queue · totals · initiator"]
+ work["process app N
blob rewritten at every app boundary"]
+ r429["429 from Apple (sync)
bail + partial activity row
clear state cleanly"]
+ done["clean finish
clear blob + mutex · summary row"]
+ crash["process killed mid-run
blob + mutex survive on disk"]
+ heal["◆ boot resume healers (+8/10/12s)
resume run · or clear stale lock"]
+ resume["runner restarts, initiator: resume
per-target dedup skips done work"]
+ ui["TaskCenter polls /api/tasks/active every 4s
resumed-run pill · per-job progress GETs"]
+
+ acquire --> blob --> work
+ work -->|"⚠ §4·1 whole run restarts next tick"| r429
+ work -->|all apps done| done
+ work -.->|kill -9 / power loss| crash
+ crash --> heal --> resume --> work
+ resume -.->|"⚠ §4·2 three separate pollers"| ui
+```
+
+Apple 429 handling is deliberate: an expected, recoverable condition clears state cleanly
+(unlike a crash) so the next 30-minute tick retries fresh.
+
+---
+
+## 5 · Wayback: back-filling label history to 2021
+
+Reconstructs an app's privacy-label history from archive.org — one target per quarter back
+to Q1 2021 plus an "install anchor" at `apps.firstSeen`, so the since-install diff has a
+real baseline. Read-only against the archive except one Save-Page-Now request per app when
+a quarter has no usable capture. Files: `lib/historical-import.ts`, `lib/wayback-bulk-runner.ts`.
+
+```mermaid
+flowchart TD
+ entry["per-app or bulk entry
POST import-history · import-all (NDJSON stream)"]
+ targets["computeHistoricalTargets
quarters to 2021-Q1 + anchor at firstSeen"]
+ avail["archive.org availability API
closest capture per target"]
+ walk{"◆ tolerance walk
±14/28/42d probes
drop if >45d drift"}
+ spn["⚠ §5·1 no capture anywhere →
Save-Page-Now for the live page
once per app per run"]
+ fetch["fetch replay (id_ URL)
clean original HTML"]
+ parse["parse — shoebox extractor for old
Ember pages · modern chain for 2025+"]
+ pipe["same §1 pipeline
source='wayback' · backdated scrapedAt
no changeCount bump"]
+ tl["timeline: purple wayback rows
'Matches live sync' badge · since-install baseline"]
+
+ entry --> targets --> avail --> walk
+ walk -->|miss| spn
+ walk -->|hit| fetch --> parse --> pipe --> tl
+```
+
+---
+
+## 6 · The gate chain every surface answers to
+
+Whether any card, step, or destructive action exists at all is resolved through one
+layered chain — later stages override earlier ones, user override always wins. The §3 flow
+adds two hard gates on top (backup freshness, Touch ID) that no flag can soften.
+
+```mermaid
+flowchart LR
+ hd["hard default
HARD_DEFAULTS"] --> aud["audience
self / loved_one / guardian"]
+ aud --> goal["goals
monitor · cleanup · minimal"]
+ goal --> a11y["accessibility
modifier bundle"]
+ a11y --> rt["runtime
Tauri-only off on web"]
+ rt --> dep["dependency
flags requiring flags"]
+ dep --> ovr(["user override — final word"])
+```
+
+Kill-switch: `flag.devopts.feature_flag_system.enabled=off` collapses everything to hard
+defaults without a code rollback. The delete flow's `flag.devopts.cfgutil_uninstall`
+defaults to **off**, and the audience gate is enforced in code — flipping the flag on
+under `guardian` still shows nothing. Modules: `lib/feature-flag-rules.ts`,
+`lib/feature-flags*.ts` (see AGENTS.md for the five-module split).
+
+---
+
+## 7 · Improvement backlog
+
+Point-in-time (2026-07-06). Refs match the `⚠`/`✅` markers in the diagrams above. Prune or
+flip rows as they land, and update the diagram label in the same PR.
+
+| Ref | Area | Status | Finding → candidate improvement | Effort |
+| --- | --- | --- | --- | --- |
+| §3·1 | Device backup | **open · high** | `cfgutil backup --backup-output` appears in no public cfgutil docs (canonical: `backup` takes no options, writes to MobileSync). Verify `cfgutil help backup` on a Mac with Configurator; if rejected, run plain `backup` and resolve the real path via `list-backups`. | S–M |
+| §3·2 | Device backup | **open · high** | Backup success is exit-code only. Verify on disk (dir non-empty / `Manifest.db`) before stamping; never record a fallback path that wasn't observed. | S |
+| §3·3 | Device delete | open | `run_with_timeout` orphans the cfgutil child on timeout — "failed" can silently become "succeeded later". Kill the process group, or re-check installed state after timeout and correct the audit row. | S |
+| §3·4 | Device backup | open | `resolve_backup_dest` claims symlink protection but checks lexically. Canonicalise the existing ancestor, or fix the comment. | S |
+| §3·5 | Delete UX / audit | open | Final modal's backup variant + acknowledge flag key off session-local state. Drive both from the server stamp (GET gate) so audit rows stop over-reporting "no backup acknowledged". Spec ready: [docs/specs/3-5-server-stamp-backup-variant.md](specs/3-5-server-stamp-backup-variant.md). | S |
+| §3·6 | Device actions | ✅ fixed | ECID normalisation (`0x`-prefixed) across stamp store, gate, and routes; pinned by tests with real-format ECIDs. | — |
+| §3·7 | Device actions | ✅ fixed | Server gate pre-flights before the first removal (fail closed); recording failures surface in the UI. | — |
+| §1·1 | Scraper | open | No parser canary. Add fixture tests against recorded App Store HTML + an alert/activity row when a scrape parses zero privacy types for an app that previously had them. | M |
+| §1·2 | AI summaries | idea | Summarisation silently degrades without a provider; chunking for local models is heuristic. Consider a visible "summary stale/unavailable" state. | S |
+| §2·1 / §4·1 | Rate limiting | open | On 429 the bulk sync abandons the run and restarts the whole fleet next tick. Resume from the state blob's cursor instead; consider shared per-app backoff with the import queue. | M |
+| §2·2 | Cross-platform import | idea | Python export needs a manual round-trip. Drag-drop hint or watch-folder hand-off. | M |
+| §4·2 | Polling | idea | Three pollers (TaskCenter 4s, notification watcher, per-job GETs) → one SSE stream from the sidecar. | L |
+| §5·1 | Wayback | idea | Track quarters skipped for lack of captures and offer "retry skipped" once Save-Page-Now requests have had time to land. | S–M |
+
+Suggested order: §3·1 and §3·2 first (they decide whether "we back up before deleting" is
+true at all), then §1·1 (protects the core product), then §3·3/§3·4/§3·5 as one small
+hardening PR, then the rate-limit resume (§2·1/§4·1).
+
+---
+
+## Keeping this document honest
+
+- Diagrams are Mermaid — edit them in place; GitHub renders them natively.
+- The backlog is point-in-time by design. When a finding is fixed, flip its row to ✅ and
+ update the matching `⚠` label in the diagram in the same PR (or delete both once stale).
+- Timings (boot delays, poll intervals, timeouts) were read from source on the date above;
+ if you change one in code, grep this file for the old value.
diff --git a/docs/specs/3-5-server-stamp-backup-variant.md b/docs/specs/3-5-server-stamp-backup-variant.md
new file mode 100644
index 0000000..eda129a
--- /dev/null
+++ b/docs/specs/3-5-server-stamp-backup-variant.md
@@ -0,0 +1,168 @@
+# Spec §3·5 — Drive the delete-confirm modal's backup state from the server stamp
+
+**Backlog ref:** §3·5 in [docs/ARCHITECTURE.md](../ARCHITECTURE.md#7--improvement-backlog) ·
+**Size:** S (~150–250 LOC including tests) · **Area:** device actions (TypeScript only — no Rust)
+· **Good first issue:** yes — single component + one new pure helper + tests, no hardware needed.
+
+Read [AGENTS.md](../../AGENTS.md) first (commands, conventions), and skim
+[docs/ARCHITECTURE.md §3](../ARCHITECTURE.md#3--back-up-then-delete-apps-off-the-phone)
+for the flow this touches.
+
+---
+
+## Background
+
+The review-and-act wizard (`app/components/ReviewRecommendationsView.tsx`) deletes apps off
+a connected iPhone via cfgutil. Before running the bulk delete it shows two modals; the
+second ("type DELETE") has two copy variants:
+
+- **fresh-backup variant** — reassuring, shows device name + backup time;
+- **no-backup variant** — louder "at your own risk" wording.
+
+Which variant renders — and whether the per-app recording POSTs carry
+`acknowledgeNoBackup: true` — is currently decided by **session-local component state**:
+
+```ts
+// ReviewRecommendationsView.tsx — Modal 2 confirm button
+const acknowledgeNoBackup = backup.status !== "done";
+```
+
+`backup.status` only becomes `"done"` when a backup ran **in this mount of the wizard**.
+The server, meanwhile, keeps its own durable stamp per device
+(`cfgutil_last_backup_` in `app_settings`, written by
+`POST /api/device-actions/backup`, read by `checkUninstallGate()` in
+`lib/device-actions.ts` with a 24h freshness window, `BACKUP_FRESHNESS_WINDOW_MS`).
+
+The two sources of truth disagree in real scenarios:
+
+1. **Backed up 2 hours ago, then reopened the wizard.** Server stamp is fresh; local state
+ is `idle`. The user sees the scary no-backup modal and the audit log records
+ `acknowledgedNoBackup: true` — over-reporting risk they didn't actually take.
+2. **Backup succeeded but its recording POST failed.** Local state says `done`; the server
+ has no stamp. The modal shows the reassuring variant, then the pre-flight gate
+ (`GET /api/device-actions/uninstall`) refuses `backup_missing` — correct but confusing,
+ because the modal just promised a backup existed.
+
+The fix: make the **server stamp** the single source of truth for the modal variant, the
+act-step banner, and the `acknowledgeNoBackup` flag. Session-local `backup.status` remains
+the source of truth only for the backup step's own progress UI (running / done / error).
+
+## Current behaviour — where to look
+
+All in `app/components/ReviewRecommendationsView.tsx` (find by searching for the quoted
+strings; line numbers drift):
+
+| Spot | Search for | Current driver |
+| --- | --- | --- |
+| Act-step banner (✓ fresh / ⚠ missing) | `review-rec-backup-status` | `backup.status === "done"` |
+| Modal 2 copy variant | `bulk-final-title` | `backup.status === "done" && backup.finishedAt` |
+| Acknowledge flag | `backup.status !== "done"` | local state |
+| Pre-flight gate (do NOT change) | `gate pre-flight` in `runBulkUninstall` | server GET, fail closed |
+
+Server side:
+
+- `GET /api/device-actions/uninstall?ecid=…` (`app/api/device-actions/uninstall/route.ts`)
+ returns `checkUninstallGate(ecid)`: `{ allowed: true }` or
+ `{ allowed: false, reason: "audience" | "flag" | "backup_missing" | "backup_stale", … }`.
+- `getLastBackup(ecid)` (`lib/device-actions.ts`) returns
+ `{ finishedAt: number, path: string } | null`. ECIDs are normalised (`normalizeEcid`)
+ so cfgutil's `0x…` spellings round-trip.
+
+## Desired behaviour
+
+1. **Extend the GET response (additive).** `GET /api/device-actions/uninstall?ecid=…`
+ additionally returns `lastBackup: { finishedAt, path } | null` alongside the existing
+ gate fields. Additive only — existing fields and the POST contract must not change.
+
+2. **New pure helper + shared types.** Create `lib/device-actions-shared.ts` (no
+ `"server-only"` import — the existing server module `lib/device-actions.ts` cannot be
+ imported from client components; follow the `lib/changelog-types.ts` precedent for
+ client-safe shared types). Export:
+
+ ```ts
+ export type UninstallGateResponse = {
+ allowed?: boolean;
+ reason?: "audience" | "flag" | "backup_missing" | "backup_stale";
+ lastBackup?: { finishedAt: number; path: string } | null;
+ };
+
+ export type ServerBackupState =
+ | { kind: "fresh"; finishedAt: number }
+ | { kind: "not_fresh" }; // missing, stale, unreadable, or fetch failed
+
+ export function deriveServerBackupState(
+ gate: UninstallGateResponse | null
+ ): ServerBackupState;
+ ```
+
+ Rules: `allowed: true` **with** a `lastBackup` → `fresh` (use its `finishedAt`);
+ `allowed: true` **without** `lastBackup` (acknowledge path / unexpected) → `not_fresh`;
+ any denial, `null`, or malformed input → `not_fresh`. Never claim a backup exists that
+ the server didn't report — conservative by construction.
+
+3. **Wizard queries the stamp at the right moments.** In
+ `ReviewRecommendationsView.tsx`, fetch
+ `GET /api/device-actions/uninstall?ecid=` with `cache: "no-store"`:
+ - when the act step becomes active with a selected ECID (one fetch per entry, in a
+ `useEffect` keyed on `[step, selectedEcid]` — mirror the existing device-polling
+ effect's cancellation pattern);
+ - after `runBackup` completes successfully (so the banner flips to ✓ without a remount).
+ Store the derived `ServerBackupState` in component state. Do **not** fetch on every
+ render and do **not** touch the existing pre-flight inside `runBulkUninstall` — that
+ stays exactly as is (it is the execution gate; this work is display/audit semantics).
+
+4. **Three consumers switch to the derived state.**
+ - Act-step banner: ✓ variant when `fresh` (time from `finishedAt`), ⚠ otherwise.
+ - Modal 2: reassuring variant when `fresh` — reuse the existing
+ `confirm_modal.final_body` copy; when the local `backup.device` name is unknown
+ (cross-session case) the existing `fallback_device_name` string covers `{device}`.
+ No new i18n keys are expected; if you do add any, add them to **both**
+ `locales/en.json` and `locales/zh.json` and run `pnpm lint:i18n`.
+ - Confirm button: `const acknowledgeNoBackup = serverBackupState.kind !== "fresh";`
+ The backup step's own status text (running/done/error + saved-path line) stays on
+ local state.
+
+5. **Conservative on failure.** If the GET fails or returns junk, behave as `not_fresh`
+ (scary variant, `acknowledgeNoBackup: true`). This is deliberately the opposite
+ direction of the pre-flight (which fails **closed** by refusing) — here nothing is
+ blocked, we just refuse to *reassure*.
+
+## Tests (node:test — see `tests/app/uninstall-gate.test.ts` for style)
+
+New file `tests/app/device-actions-shared.test.ts` covering `deriveServerBackupState`:
+`allowed+lastBackup → fresh` (finishedAt passed through) · `allowed without lastBackup →
+not_fresh` · each denial reason → `not_fresh` · `null` / `{}` / garbage → `not_fresh`.
+
+Extend `tests/app/uninstall-gate.test.ts` (or a small new route-shaped test) to pin the
+additive GET payload: after `recordBackup(...)`, the route module's GET handler for that
+ECID includes `lastBackup.finishedAt`; for an unknown ECID `lastBackup` is `null`.
+
+## Acceptance criteria
+
+- [ ] `pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm lint:i18n` all pass.
+- [ ] GET response is a strict superset of today's; POST semantics untouched.
+- [ ] `runBulkUninstall`'s pre-flight block is byte-identical (no behaviour change).
+- [ ] With a fresh server stamp and a **freshly mounted** wizard: banner shows ✓, Modal 2
+ shows the reassuring variant, recording POSTs carry `acknowledgeNoBackup: false`.
+- [ ] With no/stale stamp (even right after a local backup whose recording POST failed):
+ ⚠ banner, at-your-own-risk variant, `acknowledgeNoBackup: true`.
+- [ ] Helper lives in a client-safe module; no client import of `"server-only"` code
+ (this fails the build — see the five-module flag split in AGENTS.md for why).
+- [ ] No polling loops added; at most one gate fetch per act-step entry + one after a
+ completed backup.
+
+## Out of scope
+
+Backup verification on disk (§3·2), the `--backup-output` question (§3·1), Rust changes
+(§3·3/§3·4), batching or copy redesign, and anything touching `run_cfgutil_remove_app`.
+
+## Pitfalls
+
+- `lib/device-actions.ts` is `"server-only"` — importing it from the component is the
+ first thing that will fail the build. Hence the shared module.
+- Biome (`pnpm lint`) enforces repo style; `useExhaustiveDependencies` is off, and the
+ file uses eslint-disable-style comments for stable `t*` translators — mimic neighbours.
+- The wizard already has `bulkGateError` / pre-flight state — don't conflate it with the
+ new display state; they answer different questions ("may I run?" vs "should I reassure?").
+- `matches` in tests: use a real-format ECID (`0x9118908BB6027`) at least once — the
+ normalisation path is load-bearing (see tests already pinning it).