)}
diff --git a/apps/web/e2e/helpers/office-api-client.ts b/apps/web/e2e/helpers/office-api-client.ts
index 00cb153e7d..f4809e6d89 100644
--- a/apps/web/e2e/helpers/office-api-client.ts
+++ b/apps/web/e2e/helpers/office-api-client.ts
@@ -565,7 +565,7 @@ export class OfficeApiClient {
async createRoutine(
wsId: string,
- data: { name: string; description?: string },
+ data: { name: string; description?: string; catch_up_policy?: string },
): Promise> {
const res = await this.request<{ routine: Record }>(
"POST",
@@ -575,6 +575,11 @@ export class OfficeApiClient {
return res.routine ?? (res as unknown as Record);
}
+ async getRoutine(id: string): Promise> {
+ const res = await this.request<{ routine: Record }>("GET", `/routines/${id}`);
+ return res.routine ?? (res as unknown as Record);
+ }
+
/** Manual fire (AC-OFFICE-KILL-SWITCH-002.4). Raw: a paused workspace answers 409. */
async runRoutine(routineId: string): Promise {
return this.rawRequest("POST", `/routines/${routineId}/run`);
diff --git a/apps/web/e2e/tests/office/mobile-routine-catch-up-policy-ui.spec.ts b/apps/web/e2e/tests/office/mobile-routine-catch-up-policy-ui.spec.ts
new file mode 100644
index 0000000000..48a0b6bbff
--- /dev/null
+++ b/apps/web/e2e/tests/office/mobile-routine-catch-up-policy-ui.spec.ts
@@ -0,0 +1,86 @@
+/**
+ * Mobile-Chrome interaction and geometry coverage for the routine catch-up
+ * policy control. The create-dialog and detail-view components have no
+ * mobile-specific branching, so touch interactions prove the same policy
+ * behavior on a phone viewport while the overflow assertion protects layout.
+ */
+import { type Page } from "@playwright/test";
+import { expect, test } from "../../fixtures/office-fixture";
+import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
+
+const RETIRED_LABEL = /enqueue.?missed.?with.?cap/i;
+const SUMMARIZE_MISSED_LABEL = /Summarize missed.*(once|single)/i;
+
+function catchUpPolicyCombobox(page: Page) {
+ return page.getByText("Catch-up policy", { exact: true }).locator("..").getByRole("combobox");
+}
+
+function catchUpMaxInput(page: Page) {
+ return page.getByText("Catch-up max", { exact: true }).locator("..").getByRole("spinbutton");
+}
+
+async function assertMobileCatchUpPolicyToggles(page: Page) {
+ const catchUpMax = catchUpMaxInput(page);
+
+ await expect(catchUpPolicyCombobox(page)).toHaveText(SUMMARIZE_MISSED_LABEL);
+ await expect(catchUpMax).toHaveValue("25");
+ await catchUpMax.fill("42");
+ await expect(catchUpMax).toHaveValue("42");
+
+ await catchUpPolicyCombobox(page).tap();
+ await page.getByRole("option", { name: "Skip missed" }).tap();
+ await expect(page.getByText("Catch-up max", { exact: true })).toHaveCount(0);
+
+ await catchUpPolicyCombobox(page).tap();
+ await page.getByRole("option", { name: SUMMARIZE_MISSED_LABEL }).tap();
+ await expect(catchUpMaxInput(page)).toHaveValue("42");
+ await expect(page.getByText(RETIRED_LABEL)).toHaveCount(0);
+}
+
+test.describe("Mobile routine catch-up policy control", () => {
+ test("create dialog: catch-up policy control responds to touch input", async ({
+ testPage,
+ prCapture,
+ }) => {
+ await testPage.goto("/office/routines");
+ await testPage.getByRole("button", { name: "New Routine" }).tap();
+
+ await testPage.getByLabel("Name").fill("E2E Mobile Catch-up Dialog");
+ await testPage.getByText("Assignee", { exact: true }).locator("..").getByRole("combobox").tap();
+ await testPage.getByRole("option", { name: "CEO" }).tap();
+ await testPage.getByRole("button", { name: "Next" }).tap();
+ await testPage.getByRole("button", { name: "Next" }).tap();
+
+ await expect(testPage.getByText("Catch-up policy", { exact: true })).toBeVisible();
+ await assertMobileCatchUpPolicyToggles(testPage);
+ await assertNoDocumentHorizontalOverflow(testPage);
+
+ await prCapture.screenshot("mobile-create-dialog-catch-up-policy", {
+ caption: "Create Routine dialog on a phone viewport with the catch-up policy control",
+ });
+ });
+
+ test("detail view: catch-up policy control responds to touch input", async ({
+ officeApi,
+ officeSeed,
+ testPage,
+ prCapture,
+ }) => {
+ const routine = (await officeApi.createRoutine(officeSeed.workspaceId, {
+ name: "E2E Mobile Catch-up Detail Toggle",
+ })) as { id: string };
+ expect(routine.id).toBeTruthy();
+
+ await testPage.goto(`/office/routines/${routine.id}`);
+ await expect(testPage.getByText("E2E Mobile Catch-up Detail Toggle")).toBeVisible({
+ timeout: 10_000,
+ });
+
+ await assertMobileCatchUpPolicyToggles(testPage);
+ await assertNoDocumentHorizontalOverflow(testPage);
+
+ await prCapture.screenshot("mobile-detail-view-catch-up-policy", {
+ caption: "Routine detail view on a phone viewport with the catch-up policy control",
+ });
+ });
+});
diff --git a/apps/web/e2e/tests/office/routine-catch-up-policy-ui.spec.ts b/apps/web/e2e/tests/office/routine-catch-up-policy-ui.spec.ts
new file mode 100644
index 0000000000..a2f0d0f814
--- /dev/null
+++ b/apps/web/e2e/tests/office/routine-catch-up-policy-ui.spec.ts
@@ -0,0 +1,131 @@
+import { type Page } from "@playwright/test";
+import { test, expect } from "../../fixtures/office-fixture";
+
+/**
+ * Routine catch-up policy rename (gap 24): `enqueue_missed_with_cap` ->
+ * `summarize_missed`, with the old value accepted forever as a deprecated
+ * alias on write and normalized away on read.
+ *
+ * NOTE ON SCOPE: `apps/web/lib/state/slices/office/types.ts`'s `Routine`
+ * type (pre-existing, unchanged by this diff) declares every multi-word
+ * field in camelCase (`catchUpPolicy`, `catchUpMax`, `concurrencyPolicy`,
+ * `assigneeAgentProfileId`, `taskTemplate`, `workspaceId`), but the backend
+ * (`apps/backend/internal/office/models/models.go`) serializes and binds
+ * all of them in snake_case. Neither the Create Routine dialog's submit
+ * nor the detail view's Save round-trips any of those fields — they're
+ * silently dropped on write and read back as `undefined` (falling through
+ * to each field's hardcoded UI default) regardless of what's actually
+ * persisted. This is pre-existing (present unchanged at the merge base)
+ * and spans the whole Routines feature, not just catch-up policy — filed
+ * as a follow-up, not fixed here. Consequence for this spec: the create
+ * dialog's and detail view's catch-up-policy controls can only be proven
+ * correct through pure client-side interaction (matching AC-003.8's own
+ * "component test, not a flow" framing) — a real "seed via API, load the
+ * page, expect the persisted policy to render" round trip is not currently
+ * possible for either surface. The alias-normalization contract itself
+ * (AC-003.1) is still verified for real over HTTP, via the API layer.
+ */
+
+const RETIRED_LABEL = /enqueue.?missed.?with.?cap/i;
+
+function catchUpPolicyCombobox(page: Page) {
+ return page.getByText("Catch-up policy", { exact: true }).locator("..").getByRole("combobox");
+}
+
+// AC-OFFICE-ROUTINE-CATCHUP-003.6: the summarizing policy's own label
+// states a single summarized wake, distinct from catch_up_max's label
+// (which states the bound is on ticks counted, not runs created).
+const SUMMARIZE_MISSED_LABEL = /Summarize missed.*(once|single)/i;
+
+async function assertCatchUpPolicyToggles(page: Page, catchUpMaxInput: () => Promise | void) {
+ // Default: summarize_missed, catch-up max visible.
+ await expect(catchUpPolicyCombobox(page)).toHaveText(SUMMARIZE_MISSED_LABEL);
+ await catchUpMaxInput();
+
+ await catchUpPolicyCombobox(page).click();
+ await page.getByRole("option", { name: "Skip missed" }).click();
+ await expect(page.getByText("Catch-up max", { exact: true })).toHaveCount(0);
+
+ await catchUpPolicyCombobox(page).click();
+ await page.getByRole("option", { name: SUMMARIZE_MISSED_LABEL }).click();
+ await catchUpMaxInput();
+
+ await expect(page.getByText(RETIRED_LABEL)).toHaveCount(0);
+}
+
+test.describe("Routine catch-up policy UI", () => {
+ test("create dialog: catch-up policy control visibility toggles live", async ({
+ testPage,
+ prCapture,
+ }) => {
+ await testPage.goto("/office/routines");
+ await testPage.getByRole("button", { name: "New Routine" }).click();
+
+ await testPage.getByLabel("Name").fill("E2E Catch-up Dialog");
+ await testPage
+ .getByText("Assignee", { exact: true })
+ .locator("..")
+ .getByRole("combobox")
+ .click();
+ await testPage.getByRole("option", { name: "CEO" }).click();
+ await testPage.getByRole("button", { name: "Next" }).click();
+ await testPage.getByRole("button", { name: "Next" }).click();
+
+ await assertCatchUpPolicyToggles(testPage, async () => {
+ await expect(testPage.getByLabel("Catch-up max")).toHaveValue("25");
+ });
+ await prCapture.screenshot("create-dialog-catch-up-policy", {
+ caption:
+ "Create Routine dialog with the summarize_missed catch-up policy and its catch-up max field",
+ });
+ });
+
+ test("detail view: catch-up policy control visibility toggles live", async ({
+ officeApi,
+ officeSeed,
+ testPage,
+ prCapture,
+ }) => {
+ const routine = (await officeApi.createRoutine(officeSeed.workspaceId, {
+ name: "E2E Catch-up Detail Toggle",
+ })) as { id: string };
+ expect(routine.id).toBeTruthy();
+
+ await testPage.goto(`/office/routines/${routine.id}`);
+ await expect(testPage.getByText("E2E Catch-up Detail Toggle")).toBeVisible({
+ timeout: 10_000,
+ });
+
+ await assertCatchUpPolicyToggles(testPage, async () => {
+ await expect(testPage.getByText("Catch-up max", { exact: true })).toBeVisible();
+ });
+ await prCapture.screenshot("detail-view-catch-up-policy", {
+ caption:
+ "Routine detail view with the summarize_missed catch-up policy and its catch-up max field",
+ });
+ });
+
+ test("API: the deprecated catch-up policy alias is accepted and normalized on read", async ({
+ officeApi,
+ officeSeed,
+ testPage,
+ }) => {
+ const routine = (await officeApi.createRoutine(officeSeed.workspaceId, {
+ name: "E2E Catch-up Legacy Alias",
+ catch_up_policy: "enqueue_missed_with_cap",
+ })) as { id: string };
+ expect(routine.id).toBeTruthy();
+
+ // Real HTTP round trip against the running backend (not just the Go
+ // repository-level unit tests): the deprecated alias was accepted on
+ // write and is normalized away on read, per AC-003.1/AC-003.2.
+ const stored = await officeApi.getRoutine(routine.id);
+ expect(stored["catch_up_policy"]).toBe("summarize_missed");
+
+ // The detail page loads cleanly for a routine created with the alias,
+ // and the retired string never leaks into rendered copy.
+ await testPage.goto(`/office/routines/${routine.id}`);
+ await expect(testPage.getByText("E2E Catch-up Legacy Alias")).toBeVisible({ timeout: 10_000 });
+ await expect(testPage.getByText(RETIRED_LABEL)).toHaveCount(0);
+ });
+});
diff --git a/apps/web/lib/state/slices/office/types.ts b/apps/web/lib/state/slices/office/types.ts
index b81abbb44e..e0cf7dfc07 100644
--- a/apps/web/lib/state/slices/office/types.ts
+++ b/apps/web/lib/state/slices/office/types.ts
@@ -202,6 +202,12 @@ export type RoutineRun = {
linkedTaskId?: string;
coalescedIntoRunId?: string;
dispatchFingerprint?: string;
+ // Gap summary measured for the claim that created this run (absent when no
+ // gap was recorded — never a stored zero). See
+ // docs/specs/office/requirements/routine-catch-up.md AC-002.
+ catchUpMissedTicks?: number;
+ catchUpFirstMissedAt?: string;
+ catchUpTruncated?: boolean;
startedAt?: string;
completedAt?: string;
createdAt: string;
diff --git a/apps/web/src/locales/en/office.json b/apps/web/src/locales/en/office.json
index da02e4a97c..596dc70583 100644
--- a/apps/web/src/locales/en/office.json
+++ b/apps/web/src/locales/en/office.json
@@ -98,7 +98,7 @@
"backToTasks": "Back to tasks",
"barTotal": "{{label}}: {{total}}",
"beingWorkedOn": "being worked on",
- "beyondThisCountMissedTicksAre": "Beyond this count, missed ticks are dropped (default 25)",
+ "beyondThisCountMissedTicksAreNotCounted": "Beyond this count, missed ticks are not counted or reported (default 25). The agent still wakes exactly once.",
"blockedActionRequired": "Blocked; action required",
"blockNewTasks": "Block New Tasks",
"boardView": "Board view",
@@ -298,7 +298,6 @@
"effectiveActionsThisAgentCanRequest": "Effective actions this agent can request during Office runs.",
"eGCodeReview": "e.g. code-review",
"eGFrontendWorker": "e.g. Frontend Worker",
- "enqueueMissedWithCap": "Enqueue missed (with cap)",
"entityId": "Entity ID",
"events": "Events",
"exceeded": "Exceeded",
@@ -862,6 +861,7 @@
"subscriptionQuota": "Subscription Quota",
"succeeded": "Succeeded",
"successRate": "Success Rate",
+ "summarizeMissed": "Summarize missed (wakes once)",
"sync": "Sync",
"syncTheWorkspaceDatabaseWithOn": "Sync the workspace database with on-disk YAML files, or download a portable export bundle.",
"system": "System",
diff --git a/apps/web/src/locales/pseudo/office.json b/apps/web/src/locales/pseudo/office.json
index 237adbe1ea..dbfaf2a1c8 100644
--- a/apps/web/src/locales/pseudo/office.json
+++ b/apps/web/src/locales/pseudo/office.json
@@ -98,7 +98,7 @@
"backToTasks": "Ɓàćķ ţō ţàśķś",
"barTotal": "{{label}}: {{total}}",
"beingWorkedOn": "ƀēĩńĝ ŵōŕķēď ōń",
- "beyondThisCountMissedTicksAre": "Ɓēŷōńď ţĥĩś ćōũńţ, ḿĩśśēď ţĩćķś àŕē ďŕōƥƥēď (ďēƒàũĺţ 25)",
+ "beyondThisCountMissedTicksAreNotCounted": "Ɓēŷōńď ţĥĩś ćōũńţ, ḿĩśśēď ţĩćķś àŕē ńōţ ćōũńţēď ōŕ ŕēƥōŕţēď (ďēƒàũĺţ 25). Ţĥē àĝēńţ śţĩĺĺ ŵàķēś ēxàćţĺŷ ōńćē.",
"blockedActionRequired": "Ɓĺōćķēď; àćţĩōń ŕēqũĩŕēď",
"blockNewTasks": "Ɓĺōćķ Ńēŵ Ţàśķś",
"boardView": "Ɓōàŕď vĩēŵ",
@@ -298,7 +298,6 @@
"effectiveActionsThisAgentCanRequest": "Ēƒƒēćţĩvē àćţĩōńś ţĥĩś àĝēńţ ćàń ŕēqũēśţ ďũŕĩńĝ Ōƒƒĩćē ŕũńś.",
"eGCodeReview": "ē.ĝ. ćōďē-ŕēvĩēŵ",
"eGFrontendWorker": "ē.ĝ. Ƒŕōńţēńď Ŵōŕķēŕ",
- "enqueueMissedWithCap": "Ēńqũēũē ḿĩśśēď (ŵĩţĥ ćàƥ)",
"entityId": "Ēńţĩţŷ ĨĎ",
"events": "Ēvēńţś",
"exceeded": "Ēxćēēďēď",
@@ -862,6 +861,7 @@
"subscriptionQuota": "Śũƀśćŕĩƥţĩōń Qũōţà",
"succeeded": "Śũććēēďēď",
"successRate": "Śũććēśś Ŕàţē",
+ "summarizeMissed": "Śũḿḿàŕĩźē ḿĩśśēď (ŵàķēś ōńćē)",
"sync": "Śŷńć",
"syncTheWorkspaceDatabaseWithOn": "Śŷńć ţĥē ŵōŕķśƥàćē ďàţàƀàśē ŵĩţĥ ōń-ďĩśķ ŶÀḾĹ ƒĩĺēś, ōŕ ďōŵńĺōàď à ƥōŕţàƀĺē ēxƥōŕţ ƀũńďĺē.",
"system": "Śŷśţēḿ",
diff --git a/apps/web/src/locales/pt-pt/office.json b/apps/web/src/locales/pt-pt/office.json
index 84cf9d46b7..d71112ebf3 100644
--- a/apps/web/src/locales/pt-pt/office.json
+++ b/apps/web/src/locales/pt-pt/office.json
@@ -98,7 +98,7 @@
"backToTasks": "Voltar às tarefas",
"barTotal": "{{label}}: {{total}}",
"beingWorkedOn": "em curso",
- "beyondThisCountMissedTicksAre": "Acima deste número, os ticks perdidos são descartados (pré-definição: 25)",
+ "beyondThisCountMissedTicksAreNotCounted": "Acima deste número, os ticks perdidos não são contados nem comunicados (pré-definição: 25). O agente continua a acordar exatamente uma vez.",
"blockedActionRequired": "Bloqueada; é necessária uma ação",
"blockNewTasks": "Bloquear novas tarefas",
"boardView": "Vista de quadro",
@@ -298,7 +298,6 @@
"effectiveActionsThisAgentCanRequest": "Ações efetivas que este agente pode pedir durante as execuções do Office.",
"eGCodeReview": "por exemplo, code-review",
"eGFrontendWorker": "por exemplo, Trabalhador de Frontend",
- "enqueueMissedWithCap": "Colocar em fila os perdidos (com limite)",
"entityId": "ID da entidade",
"events": "Eventos",
"exceeded": "Excedido",
@@ -862,6 +861,7 @@
"subscriptionQuota": "Quota da subscrição",
"succeeded": "Bem-sucedidas",
"successRate": "Taxa de sucesso",
+ "summarizeMissed": "Resumir os perdidos (acorda uma vez)",
"sync": "Sincronizar",
"syncTheWorkspaceDatabaseWithOn": "Sincronize a base de dados da área de trabalho com os ficheiros YAML em disco ou transfira um pacote de exportação portátil.",
"system": "Sistema",
diff --git a/apps/web/src/locales/zh-cn/office.json b/apps/web/src/locales/zh-cn/office.json
index 9f49598b39..88e24cf0ef 100644
--- a/apps/web/src/locales/zh-cn/office.json
+++ b/apps/web/src/locales/zh-cn/office.json
@@ -98,7 +98,7 @@
"backToTasks": "返回任务",
"barTotal": "{{label}}:{{total}}",
"beingWorkedOn": "处理中",
- "beyondThisCountMissedTicksAre": "超过此数量后,错过的触发会被丢弃(默认 25)",
+ "beyondThisCountMissedTicksAreNotCounted": "超过此数量后,错过的触发不会被计入或报告(默认 25)。代理仍然只会被唤醒一次。",
"blockedActionRequired": "已阻塞;需要操作",
"blockNewTasks": "阻止新任务",
"boardView": "看板视图",
@@ -298,7 +298,6 @@
"effectiveActionsThisAgentCanRequest": "此智能体在办公室运行期间可请求的有效操作。",
"eGCodeReview": "例如 code-review",
"eGFrontendWorker": "例如 Frontend Worker",
- "enqueueMissedWithCap": "将错过的加入队列(有上限)",
"entityId": "实体 ID",
"events": "事件",
"exceeded": "已超出",
@@ -862,6 +861,7 @@
"subscriptionQuota": "订阅配额",
"succeeded": "成功",
"successRate": "成功率",
+ "summarizeMissed": "汇总错过的(只唤醒一次)",
"sync": "同步",
"syncTheWorkspaceDatabaseWithOn": "将工作区数据库与磁盘上的 YAML 同步,或下载可移植导出包。",
"system": "系统",
diff --git a/apps/web/src/locales/zh-hk/office.json b/apps/web/src/locales/zh-hk/office.json
index 24d873b1d4..607525b753 100644
--- a/apps/web/src/locales/zh-hk/office.json
+++ b/apps/web/src/locales/zh-hk/office.json
@@ -98,7 +98,7 @@
"backToTasks": "返回任務",
"barTotal": "{{label}}:{{total}}",
"beingWorkedOn": "處理中",
- "beyondThisCountMissedTicksAre": "超過此數量後,錯過的觸發會被丟棄(預設 25)",
+ "beyondThisCountMissedTicksAreNotCounted": "超過此數量後,錯過的觸發不會被計入或報告(預設 25)。代理仍然只會被喚醒一次。",
"blockedActionRequired": "已阻塞;需要操作",
"blockNewTasks": "阻止新任務",
"boardView": "看板視圖",
@@ -298,7 +298,6 @@
"effectiveActionsThisAgentCanRequest": "此代理程式在辦公室執行期間可請求的有效操作。",
"eGCodeReview": "例如 code-review",
"eGFrontendWorker": "例如 Frontend Worker",
- "enqueueMissedWithCap": "將錯過的加入佇列(有上限)",
"entityId": "實體 ID",
"events": "事件",
"exceeded": "已超出",
@@ -862,6 +861,7 @@
"subscriptionQuota": "訂閲配額",
"succeeded": "成功",
"successRate": "成功率",
+ "summarizeMissed": "彙總錯過的(只喚醒一次)",
"sync": "同步",
"syncTheWorkspaceDatabaseWithOn": "將工作區數據庫與磁碟上的 YAML 同步,或下載可移植匯出包。",
"system": "系統",
diff --git a/apps/web/src/locales/zh-tw/office.json b/apps/web/src/locales/zh-tw/office.json
index bda14f2929..1bb1a62c3d 100644
--- a/apps/web/src/locales/zh-tw/office.json
+++ b/apps/web/src/locales/zh-tw/office.json
@@ -98,7 +98,7 @@
"backToTasks": "返回任務",
"barTotal": "{{label}}:{{total}}",
"beingWorkedOn": "處理中",
- "beyondThisCountMissedTicksAre": "超過此數量後,錯過的觸發會被丟棄(預設 25)",
+ "beyondThisCountMissedTicksAreNotCounted": "超過此數量後,錯過的觸發不會被計入或報告(預設 25)。代理仍然只會被喚醒一次。",
"blockedActionRequired": "已阻塞;需要操作",
"blockNewTasks": "阻止新任務",
"boardView": "看板檢視",
@@ -298,7 +298,6 @@
"effectiveActionsThisAgentCanRequest": "此代理程式在辦公室執行期間可請求的有效操作。",
"eGCodeReview": "例如 code-review",
"eGFrontendWorker": "例如 Frontend Worker",
- "enqueueMissedWithCap": "將錯過的加入佇列(有上限)",
"entityId": "實體 ID",
"events": "事件",
"exceeded": "已超出",
@@ -862,6 +861,7 @@
"subscriptionQuota": "訂閱配額",
"succeeded": "成功",
"successRate": "成功率",
+ "summarizeMissed": "彙總錯過的(只喚醒一次)",
"sync": "同步",
"syncTheWorkspaceDatabaseWithOn": "將工作區資料庫與磁碟上的 YAML 同步,或下載可移植匯出包。",
"system": "系統",
diff --git a/docs/plans/routine-catch-up/plan.md b/docs/plans/routine-catch-up/plan.md
new file mode 100644
index 0000000000..95fc532813
--- /dev/null
+++ b/docs/plans/routine-catch-up/plan.md
@@ -0,0 +1,92 @@
+---
+created: 2026-09-09
+status: done
+requirements:
+ - REQ-OFFICE-ROUTINE-CATCHUP-001
+ - REQ-OFFICE-ROUTINE-CATCHUP-002
+ - REQ-OFFICE-ROUTINE-CATCHUP-003
+system_design:
+ - ../../specs/office/system-design/routine-catch-up-01.md
+ - ../../specs/office/system-design/routine-catch-up-02.md
+---
+
+# Implementation plan: Office routine catch-up semantics (gap 24)
+
+## Overview
+
+Four surfaces disagreed about what happens when a routine's cron trigger comes
+due while the backend is down: the policy name `enqueue_missed_with_cap`, the
+`catch_up_max` field, the scheduler design docs, and the web UI label all said
+missed ticks are enqueued up to a cap, while the code counted elapsed ticks and
+then dispatched **exactly one** run regardless of the count. This plan makes
+the code's behavior the deliberate, reviewed contract: one resume produces one
+wake, `catch_up_max` bounds only how many elapsed ticks are counted, and the
+crossed gap (missed-tick count, first-missed timestamp, truncated flag) is
+measured, stored durably on the routine run, and — for a lightweight routine
+under the summarizing policy — rendered into the woken agent's prompt. The
+policy value is renamed `enqueue_missed_with_cap` -> `summarize_missed`, with
+the old value accepted forever as a deprecated alias on write and normalized
+away on read.
+
+## Scope
+
+In scope: the tick/claim/gap measurement and its durable storage (REQ-001), the
+gap's delivery to the agent for a lightweight routine under the summarizing
+policy (REQ-002), and renaming the misdescribing policy value across every
+surface that names it — the Go enum, six creation paths, a SQLite table
+rebuild, two frontend files, five locale catalogs, and both design documents
+(REQ-003).
+
+Out of scope: enqueueing N missed runs, renaming `catch_up_max` itself, a
+trigger-update operation, downgrade compatibility, a paused routine still
+firing, tick-level cron correctness (a separate gap), WIP/budget enforcement
+during catch-up, and retention of gap summaries.
+
+## Technical approach
+
+`routines/service.go`'s `computeCatchUp` replaces the previous
+`computeRoutineMissed`: it walks cron ticks capped at
+`models.NormaliseCatchUpMax(routine.CatchUpMax)`, but the tick dispatches
+exactly one run either way — the cap bounds counting, never dispatch.
+`buildGapSummary` turns that result into a durable `catch_up_missed_ticks` /
+`catch_up_first_missed_at` / `catch_up_truncated` record on the created
+`RoutineRun`, written once at creation so a run later marked skipped,
+coalesced or failed still carries the gap measured for its tick. For a
+lightweight routine under `summarize_missed`, the wakeup payload and prompt
+builder surface the same three fields. `models/catchup.go` owns the
+policy rename (with the deprecated alias normalized on read and write) and the
+`catch_up_max` clamp, funneled through `insertRoutine`/`UpdateRoutine` so every
+write path and the one-time upgrade migration agree.
+
+## Implementation waves and parallel candidates
+
+- [x] [Task 01: Tick, claim, and gap durability](task-01-tick-and-gap.md) (`done`) — REQ-001, REQ-002
+- [x] [Task 02: Rename the catch-up policy across every surface](task-02-policy-rename.md) (`done`) — REQ-003
+
+Sequential: task 02's rename touches the same `Routine`/`RoutineTrigger`
+plumbing task 01 introduces, and both share the frontend catch-up policy
+control.
+
+## Verification results
+
+Full `internal/office/...` suite green (`go test ./internal/office/...`),
+`go vet`, `golangci-lint run --new-from-rev= ./...` 0 issues,
+`go run ./cmd/sqlguard ./internal` clean, `pnpm run i18n:check` /
+`i18n:ratchet` clean (5 locales + pseudo), `pnpm vitest run
+app/office/routines` green, `python3 scripts/lint-spec-files.py --all` clean.
+E2E: `routine-catch-up-policy-ui.spec.ts` and the mobile counterpart, plus the
+`routines-ui`/`routines`/`routine-fire` regression trio, all green against a
+rebuilt frontend/backend E2E bundle. A real PostgreSQL run proved the
+default-policy migration's dialect-gated `ALTER TABLE ... SET DEFAULT` path
+(no table rebuild needed on PG).
+
+## Risks and out of scope
+
+- `catch_up_max` reaches SQL at exactly two statements (`insertRoutine`,
+ `UpdateRoutine`) — the single normalization funnel every write path shares.
+- Nothing is discarded past the cap: no run was ever going to exist for a
+ missed tick, so truncation bounds the count, never the gap's start
+ timestamp.
+- A pre-existing, unrelated mismatch between the Routines create-dialog's
+ camelCase request fields and the backend's snake_case JSON tags (also
+ affecting `concurrencyPolicy`) is tracked separately, not fixed here.
diff --git a/docs/plans/routine-catch-up/task-01-tick-and-gap.md b/docs/plans/routine-catch-up/task-01-tick-and-gap.md
new file mode 100644
index 0000000000..198f31eab0
--- /dev/null
+++ b/docs/plans/routine-catch-up/task-01-tick-and-gap.md
@@ -0,0 +1,115 @@
+---
+id: "01-tick-and-gap"
+title: "Tick, claim, and gap durability"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+requirements:
+ - REQ-OFFICE-ROUTINE-CATCHUP-001
+ - REQ-OFFICE-ROUTINE-CATCHUP-002
+acceptance_criteria:
+ - AC-OFFICE-ROUTINE-CATCHUP-001.1
+ - AC-OFFICE-ROUTINE-CATCHUP-001.2
+ - AC-OFFICE-ROUTINE-CATCHUP-001.3
+ - AC-OFFICE-ROUTINE-CATCHUP-001.4
+ - AC-OFFICE-ROUTINE-CATCHUP-001.5
+ - AC-OFFICE-ROUTINE-CATCHUP-001.6
+ - AC-OFFICE-ROUTINE-CATCHUP-001.7
+ - AC-OFFICE-ROUTINE-CATCHUP-001.8
+ - AC-OFFICE-ROUTINE-CATCHUP-001.9
+ - AC-OFFICE-ROUTINE-CATCHUP-001.10
+ - AC-OFFICE-ROUTINE-CATCHUP-001.11
+ - AC-OFFICE-ROUTINE-CATCHUP-001.12
+ - AC-OFFICE-ROUTINE-CATCHUP-001.13
+ - AC-OFFICE-ROUTINE-CATCHUP-002.1
+ - AC-OFFICE-ROUTINE-CATCHUP-002.2
+ - AC-OFFICE-ROUTINE-CATCHUP-002.3
+ - AC-OFFICE-ROUTINE-CATCHUP-002.4
+ - AC-OFFICE-ROUTINE-CATCHUP-002.5
+ - AC-OFFICE-ROUTINE-CATCHUP-002.6
+ - AC-OFFICE-ROUTINE-CATCHUP-002.7
+ - AC-OFFICE-ROUTINE-CATCHUP-002.8
+ - AC-OFFICE-ROUTINE-CATCHUP-002.9
+ - AC-OFFICE-ROUTINE-CATCHUP-002.10
+ - AC-OFFICE-ROUTINE-CATCHUP-002.11
+ - AC-OFFICE-ROUTINE-CATCHUP-002.12
+system_design:
+ - ../../specs/office/system-design/routine-catch-up-01.md
+---
+
+# Task 01: Tick, claim, and gap durability
+
+## Summary
+
+Make a cron trigger due while the backend is down produce exactly one run on
+resume, whatever the gap size, and give that gap a durable, observable record.
+
+## In scope
+
+- `apps/backend/internal/office/routines/service.go`: `computeCatchUp`
+ (replaces `computeRoutineMissed`), `buildGapSummary`, `processCronTrigger`,
+ `reconcileStrandedTriggers`, `CreateRoutineTrigger` cron-satisfiability
+ validation.
+- `apps/backend/internal/office/models/catchup.go`: `NormaliseCatchUpMax`
+ clamp [1, 1000]; `RoutineRun.CatchUpMissedTicks` /
+ `CatchUpFirstMissedAt` / `CatchUpTruncated`.
+- `apps/backend/internal/office/repository/sqlite/{routines.go,
+ catchup_migrations.go,catchup_migrations_postgres.go,wakeup_requests.go}`:
+ schema default, idempotent `ADD COLUMN`, table-rebuild migration (SQLite) /
+ dialect-gated `ALTER TABLE ... SET DEFAULT` (Postgres); `GetDueTriggers`
+ ordering; `ListStrandedTriggers` / `ReconcileTriggerNextRun`.
+- `apps/backend/internal/office/service/{prompt_builder.go,
+ scheduler_integration.go}`: `appendMissedTicksSection`,
+ `applyRoutineCatchUpContext`.
+
+## Acceptance
+
+- A cron trigger with any elapsed-tick count on resume dispatches exactly one
+ run; `catch_up_max` bounds only how many ticks are counted.
+- The gap (missed-tick count, first-missed timestamp, truncated flag) is
+ stored durably on the created `RoutineRun`, written once at creation so a
+ run later marked skipped, coalesced, or failed still carries it.
+- A cron trigger of kind "cron" is never persisted with a null `next_run_at`
+ at creation; a claim left unarmed by a process stopping mid-tick is
+ reconciled by a later tick without dispatching a spurious run.
+- When the elapsed-tick computation fails, `next_run_at` arms to the
+ processing instant plus 24 hours with a warning naming the trigger and the
+ underlying error; a syntactically valid but permanently unsatisfiable
+ expression is instead left disarmed rather than retried forever.
+
+## Verification
+
+```bash
+cd apps/backend && go test ./internal/office/routines/... ./internal/office/repository/sqlite/... -count=1
+cd apps/backend && go run ./cmd/sqlguard ./internal
+cd apps/backend && KANDEV_TEST_POSTGRES_DSN=... go test ./internal/office/repository/sqlite/... -run Postgres -count=1
+```
+
+## Files likely touched
+
+- `apps/backend/internal/office/routines/service.go`
+- `apps/backend/internal/office/models/catchup.go`
+- `apps/backend/internal/office/repository/sqlite/routines.go`
+- `apps/backend/internal/office/repository/sqlite/catchup_migrations.go`
+- `apps/backend/internal/office/repository/sqlite/catchup_migrations_postgres.go`
+- `apps/backend/internal/office/repository/sqlite/wakeup_requests.go`
+- `apps/backend/internal/office/service/prompt_builder.go`
+- `apps/backend/internal/office/service/scheduler_integration.go`
+
+## Dependencies
+
+None.
+
+## Parallelism
+
+Sequential with task 02, which renames the policy this task's dispatch path
+reads.
+
+## Results
+
+Implemented with TDD across the build history recorded in this task's Kandev
+plan: the tick/claim/reconciliation path, the gap-summary funnel, the
+SQLite/Postgres schema and migrations, and the prompt/wakeup payload fields.
+Full `internal/office` suite green; a real PostgreSQL run proved the
+dialect-gated default-policy migration.
diff --git a/docs/plans/routine-catch-up/task-02-policy-rename.md b/docs/plans/routine-catch-up/task-02-policy-rename.md
new file mode 100644
index 0000000000..3545699728
--- /dev/null
+++ b/docs/plans/routine-catch-up/task-02-policy-rename.md
@@ -0,0 +1,89 @@
+---
+id: "02-policy-rename"
+title: "Rename the catch-up policy across every surface"
+status: done
+wave: 1
+depends_on: ["01-tick-and-gap"]
+plan: "plan.md"
+requirements:
+ - REQ-OFFICE-ROUTINE-CATCHUP-003
+acceptance_criteria:
+ - AC-OFFICE-ROUTINE-CATCHUP-003.1
+ - AC-OFFICE-ROUTINE-CATCHUP-003.2
+ - AC-OFFICE-ROUTINE-CATCHUP-003.3
+ - AC-OFFICE-ROUTINE-CATCHUP-003.4
+ - AC-OFFICE-ROUTINE-CATCHUP-003.5
+ - AC-OFFICE-ROUTINE-CATCHUP-003.6
+ - AC-OFFICE-ROUTINE-CATCHUP-003.7
+ - AC-OFFICE-ROUTINE-CATCHUP-003.8
+system_design:
+ - ../../specs/office/system-design/routine-catch-up-02.md
+---
+
+# Task 02: Rename the catch-up policy across every surface
+
+## Summary
+
+`enqueue_missed_with_cap` is a false statement of what the code does (fires
+once, never enqueues N runs). Rename it to `summarize_missed` everywhere it is
+written or displayed, while accepting the old value forever as a deprecated
+alias so existing rows and API callers keep working.
+
+## In scope
+
+- `apps/backend/internal/office/models/catchup.go`: the policy enum,
+ `NormaliseCatchUpPolicy` (accepts the deprecated alias on write, normalizes
+ on read), the one-time upgrade migration for pre-existing rows.
+- `apps/web/app/office/routines/{create-routine-dialog.tsx,
+ [id]/routine-detail-view.tsx}` and `apps/web/src/locales/*/office.json`
+ (5 languages + pseudo): the policy label, including the "(wakes once)"
+ qualifier.
+- `docs/specs/office/system-design/scheduler-01.md` /
+ `scheduler-02.md`: correct the design-doc prose that previously matched the
+ misdescribing name.
+
+## Acceptance
+
+- The policy value is `summarize_missed`; `enqueue_missed_with_cap` is
+ accepted as a deprecated alias on every write path and normalized away on
+ every read path, including a one-time upgrade pass over existing rows.
+- The web UI's catch-up policy label states, in every required locale, that
+ the policy wakes once rather than enqueueing per missed tick.
+- `scheduler-01.md` and `scheduler-02.md` no longer describe the retired
+ enqueue-N-runs behavior.
+
+## Verification
+
+```bash
+cd apps/backend && go test ./internal/office/models/... ./internal/office/repository/sqlite/... -run CatchUpPolicy -count=1
+cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet
+cd apps/web && pnpm vitest run app/office/routines
+python3 scripts/lint-spec-files.py --all
+```
+
+## Files likely touched
+
+- `apps/backend/internal/office/models/catchup.go`
+- `apps/web/app/office/routines/create-routine-dialog.tsx`
+- `apps/web/app/office/routines/[id]/routine-detail-view.tsx`
+- `apps/web/src/locales/*/office.json`
+- `docs/specs/office/system-design/scheduler-01.md`
+- `docs/specs/office/system-design/scheduler-02.md`
+
+## Dependencies
+
+Depends on task 01 for the `Routine.CatchUpPolicy` plumbing this rename reads
+and writes.
+
+## Parallelism
+
+Sequential with task 01 (shared frontend control and Go enum).
+
+## Results
+
+Implemented with TDD: policy rename plus deprecated-alias normalization on
+both write and read paths, upgrade migration for pre-existing rows, frontend
+label update across both routine dialogs and all required locales (including
+the "(wakes once)" qualifier added during PR review), and both scheduler
+design docs corrected. `i18n:check` / `i18n:ratchet` clean; full frontend
+vitest suite green.
diff --git a/docs/specs/office/requirements/routine-catch-up.md b/docs/specs/office/requirements/routine-catch-up.md
new file mode 100644
index 0000000000..7a69b12dba
--- /dev/null
+++ b/docs/specs/office/requirements/routine-catch-up.md
@@ -0,0 +1,346 @@
+---
+status: draft
+system: office
+created: 2026-09-07
+owners:
+ - kandev
+---
+
+# Office Routine Catch-Up Requirements
+
+## Overview
+
+A routine's cron trigger can come due while the backend is not running to
+process it. On resume its armed `next_run_at` is in the past, sometimes far in
+the past. What the system does at that moment is a correctness property of an
+unattended loop: it decides how much work resumes, how much is spent in the
+first minute after restart, and what the prompt says. The launcher rejects
+taskless runs, so lightweight runs may not reach an agent session.
+
+Four surfaces describe this moment, and they do not agree.
+
+| Surface | What it says |
+|---|---|
+| Policy value `enqueue_missed_with_cap` | missed ticks are enqueued, up to a cap |
+| Field `catch_up_max`, default 25 | the cap is 25 |
+| `scheduler-01.md` and `scheduler-02.md` | "fire missed ticks up to the cap (default 25)" |
+| Web UI label `office:enqueueMissedWithCap` (5 locales) | "Enqueue missed (with cap)" |
+| The code (`routines/service.go`) | counts up to 25 elapsed ticks, dispatches **exactly one** run |
+
+The code is silently authoritative. An operator sizing spend or work-in-progress
+from the policy name, the field, the design doc, or the UI control would be
+wrong by a factor of 25.
+
+This capability resolves the disagreement in favour of the code and makes
+every other surface state it. **One resume produces one wake.** It also
+supplies what the code lacks: a durable, observable account of the gap crossed,
+so collapsing the backlog is a reported fact rather than a silent one.
+
+### Why one wake, and not twenty-five
+
+The decision is chosen here, not inherited from the code. Enqueueing up to
+`catch_up_max` runs was rejected on four grounds: the idempotency key already
+collapses a fan-out to one wake; the concurrency policy would then skip or merge
+whatever survived; a fan-out creates a burst hazard before either bounding
+control exists; and there is no per-tick work to replay. Each is verified
+against the tree, with its mechanism and citations, in
+[Why the fan-out was rejected](../system-design/routine-catch-up-01.md#why-the-fan-out-was-rejected).
+
+## Terminology
+
+- **Claim:** the atomic acquisition of a due cron trigger by one processor;
+ exactly one may hold a given claim.
+- **Processing instant:** the time the scheduler uses for the tick in which a
+ claim is taken.
+- **Gap:** the interval from the trigger's armed `next_run_at` at claim time up
+ to the processing instant.
+- **Elapsed ticks:** how many times the trigger's cron expression matches within
+ the gap, including the tick that came due and is being dispatched now.
+- **Missed ticks:** elapsed ticks minus one — the ticks that came due and will
+ never be dispatched.
+- **Gap summary:** the durable record of a gap: missed-tick count, first-missed
+ timestamp, and whether the count was truncated.
+- **Summarizing policy:** records the gap in the lightweight prompt, named
+ `summarize_missed` here.
+- **Lightweight / heavy routine:** empty versus non-empty `task_template`, as
+ in [Office Scheduler](scheduler.md).
+
+## Requirements
+
+### REQ-OFFICE-ROUTINE-CATCHUP-001: One resume, one wake
+
+**Intent:** Freeze the resume behaviour as a stated contract rather than an
+emergent property of a counting loop, and close the boundary cases the
+implementation leaves to invention: ordering, concurrent claims, a failed
+computation, a crash-orphaned trigger, and a dispatch that fails after arming.
+
+**As an** operator running an unattended workspace, **I want** a restart after
+an outage to produce a bounded, predictable amount of agent work, **so that** a
+long outage cannot turn into a burst of spend I did not authorize.
+
+#### Acceptance criteria
+
+- **AC-OFFICE-ROUTINE-CATCHUP-001.1:** When a due cron trigger is claimed, the
+ system shall dispatch exactly one routine run for that claim, whatever the
+ number of elapsed ticks in the gap and whatever the catch-up policy. The only
+ cases producing none are a failed arming write (AC-001.2), an unsatisfiable
+ expression (AC-001.11), and a run that cannot be created (AC-001.12); no case
+ produces two.
+- **AC-OFFICE-ROUTINE-CATCHUP-001.2:** When a claim is processed and the
+ elapsed-tick computation succeeds, the system shall arm the trigger's
+ `next_run_at` to the first match of its cron expression strictly after the
+ processing instant. This shall hold identically under every catch-up policy.
+ The failure path is governed by AC-001.6 and AC-001.11 instead. When the
+ arming write itself fails, the system shall dispatch nothing for that claim
+ and leave `next_run_at` null for AC-001.9 to arm later; that tick is lost and
+ no routine run records it.
+- **AC-OFFICE-ROUTINE-CATCHUP-001.3:** `catch_up_max` shall bound only the
+ number of elapsed ticks the system counts. It shall have no effect on the
+ number of runs, tasks, or wakeup requests created per claim, which is at most
+ one.
+- **AC-OFFICE-ROUTINE-CATCHUP-001.4:** When the stored `catch_up_max` is less
+ than 1, the system shall use 25; when it is greater than 1000, it shall use
+ 1000. Both clamp rather than reject, so no stored value can stop a routine
+ firing.
+- **AC-OFFICE-ROUTINE-CATCHUP-001.5:** When two processors evaluate the same
+ due trigger concurrently, exactly one shall take the claim and dispatch;
+ the other shall dispatch nothing, record no gap summary, and change no
+ trigger state.
+- **AC-OFFICE-ROUTINE-CATCHUP-001.6:** When the elapsed-tick computation fails,
+ the system shall record no gap summary. For `ErrUnsatisfiableCron` it shall
+ leave `next_run_at` null and dispatch no run. For another failure it shall
+ arm `next_run_at` to the processing instant plus 24 hours and dispatch one
+ run. It shall warn with the underlying error.
+- **AC-OFFICE-ROUTINE-CATCHUP-001.7:** Within one scheduler tick, due triggers
+ shall be processed in ascending `office_routine_triggers.next_run_at` order,
+ with ties broken by ascending `office_routine_triggers.id`.
+- **AC-OFFICE-ROUTINE-CATCHUP-001.8:** When a cron trigger is created, the
+ system shall arm `next_run_at` to the first match of its expression strictly
+ after the creation instant, and shall reject the creation when that
+ expression is empty or when it or the timezone cannot be parsed. A trigger of
+ kind `cron` shall never be persisted with a null `next_run_at` at creation,
+ on every creation path, including one that writes the trigger row directly.
+ **No operation that mutates an existing trigger's cron expression, timezone,
+ or enabled flag exists** (see `## Out of scope`); changing any of the three is
+ a delete followed by a create, which arms by this same criterion. A recreated
+ trigger shall never report a gap spanning the interval in which it did not
+ exist, so a deliberate pause is never a gap.
+- **AC-OFFICE-ROUTINE-CATCHUP-001.9:** When an enabled cron trigger has a null
+ `next_run_at`, a subsequent scheduler tick shall arm it to the first match
+ strictly after that tick's processing instant and shall not dispatch a run for
+ it, so that a process stopping between claiming a trigger and arming it does
+ not leave the trigger permanently unable to fire. The write shall be
+ conditional on `next_run_at` still being null when applied, so a concurrent
+ reconciliation is never overwritten: exactly one processor shall arm it, and
+ none shall dispatch by way of this path. A trigger whose claim is still in
+ flight shall not be armed by it. A trigger with `enabled` false shall not be
+ armed by this path. When the expression or timezone cannot be parsed, the
+ system shall leave `next_run_at` null, warn, and dispatch nothing; no
+ computation-failure fallback applies because no claim was taken and no run is
+ owed.
+- **AC-OFFICE-ROUTINE-CATCHUP-001.10:** When a routine run has been created and
+ its materialisation then fails — the heavy path failing to create the task, or
+ the lightweight path failing to create or to dispatch the wakeup request — the
+ system shall set that run's status to `failed` and shall not re-dispatch it. A
+ wakeup request refused because its idempotency key already exists is **not** a
+ materialisation failure; that run's status shall be left as the outcome the
+ concurrency policy assigned.
+- **AC-OFFICE-ROUTINE-CATCHUP-001.11:** When the elapsed-tick computation fails
+ after a claim, the system shall warn with the trigger and underlying error.
+ For `ErrUnsatisfiableCron`, it shall leave `next_run_at` null and dispatch no
+ run. For another failure, it shall arm `next_run_at` to the processing instant
+ plus 24 hours and dispatch one run. Neither path shall record a gap summary.
+- **AC-OFFICE-ROUTINE-CATCHUP-001.12:** When the routine run for a claim cannot
+ be created at all, the system shall dispatch nothing and record no gap
+ summary, and shall not re-attempt that tick. The trigger remains armed forward
+ per AC-001.2 or AC-001.11, whichever governed the claim, or stays disarmed
+ for an unsatisfiable expression. The absence of a routine run is the only
+ record of that tick.
+- **AC-OFFICE-ROUTINE-CATCHUP-001.13:** `catch_up_max` shall be normalized to
+ the value AC-001.4 defines before it is persisted, on every create and update
+ path, and rows written before this change shall be normalized once on upgrade.
+ A routine read back through the API, and every web UI control showing it,
+ shall report the same `catch_up_max` the tick honours.
+
+### REQ-OFFICE-ROUTINE-CATCHUP-002: The gap is reported, not discarded silently
+
+**Intent:** Answer what is discarded past the cap and whether it is recorded
+anywhere. Nothing is discarded, because no run was ever going to be created for
+a missed tick; what is bounded is the counting. The exact gap boundary is
+preserved even when the count is truncated.
+
+**As an** assembled run after an outage, **I want** prompt context to state the
+gap and whether its count is exact, **so that** an agent can choose what to do.
+
+#### Acceptance criteria
+
+- **AC-OFFICE-ROUTINE-CATCHUP-002.1:** When a claim's gap contains at least one
+ missed tick and the policy is `summarize_missed`, the system shall record a
+ gap summary on the routine run containing the missed-tick count, the
+ timestamp of the first missed tick, and whether the count was truncated.
+- **AC-OFFICE-ROUTINE-CATCHUP-002.2:** The missed-tick count shall exclude the
+ tick being dispatched. When that count is zero, the system shall record no
+ gap summary, whatever the value of truncated.
+- **AC-OFFICE-ROUTINE-CATCHUP-002.3:** When no gap summary is recorded, the
+ routine run shall carry no missed-tick field, and the agent's assembled
+ prompt shall contain no missed-tick statement. Absence shall not be
+ represented as a count of zero.
+- **AC-OFFICE-ROUTINE-CATCHUP-002.4:** When `catch_up_max` is greater than 1
+ and the elapsed-tick count reaches it while further ticks remain within the
+ gap, the system shall record the missed-tick count as `catch_up_max - 1`,
+ shall record truncated as true, and shall record the first-missed-tick
+ timestamp exactly and un-truncated. At a `catch_up_max` of 1 this criterion
+ does not apply and AC-002.11 governs.
+- **AC-OFFICE-ROUTINE-CATCHUP-002.5:** When the routine is lightweight and the
+ policy is `summarize_missed` and a gap summary was recorded, the assembled
+ prompt for the run created by that claim shall state the missed-tick count and
+ the first-missed-tick timestamp, and, when truncated is true, shall state that
+ the count is a lower bound.
+- **AC-OFFICE-ROUTINE-CATCHUP-002.6:** When the routine is heavy and the policy
+ is `summarize_missed` and a gap summary was recorded, it shall be readable
+ through the routine-run API. The created task's title and description shall be
+ exactly what the routine's template renders, unmodified by the gap. A heavy
+ routine under `skip_missed` records none, per AC-002.7.
+- **AC-OFFICE-ROUTINE-CATCHUP-002.7:** When the policy is `skip_missed`, the
+ system shall record no gap summary and the assembled prompt shall contain no
+ missed-tick statement, whatever the number of elapsed ticks.
+- **AC-OFFICE-ROUTINE-CATCHUP-002.8:** A recorded gap summary shall remain
+ readable after the routine run reaches a terminal status and after a backend
+ restart.
+- **AC-OFFICE-ROUTINE-CATCHUP-002.9:** The system shall record the gap as
+ measured, without attributing a cause. A gap produced by a backend outage, a
+ suspended host, and a forward clock correction shall be recorded identically.
+- **AC-OFFICE-ROUTINE-CATCHUP-002.10:** A gap summary shall be written exactly
+ once, as part of the creation of the routine run it belongs to, and shall not
+ be modified afterwards by any later status transition, including `skipped`,
+ `coalesced`, `failed`, `done`, and `cancelled`. When more than one routine run
+ is created for the same routine — two triggers due in the same tick, or a
+ manual fire alongside a cron fire — each run shall carry only the summary
+ measured for its own claim, and shall neither read nor overwrite another run's.
+ A wakeup request refused as an idempotency duplicate, or coalesced into an
+ in-flight run, shall not rewrite any gap summary, shall not alter the gap
+ statement that run's agent already carries, and shall not give a gap statement
+ to a run that had none.
+- **AC-OFFICE-ROUTINE-CATCHUP-002.11:** When `catch_up_max` is 1, the
+ missed-tick count is zero for every gap, and the system shall therefore record
+ no gap summary for that routine however long the gap. Setting `catch_up_max`
+ to 1 is an opt-out of gap reporting.
+- **AC-OFFICE-ROUTINE-CATCHUP-002.12:** A routine run created by a manual or a
+ webhook fire shall record no gap summary. Gap measurement applies only to a
+ cron trigger's armed `next_run_at`.
+
+### REQ-OFFICE-ROUTINE-CATCHUP-003: Every surface states the same behaviour
+
+**Intent:** `enqueue_missed_with_cap` is a false statement in an enum value, two
+design documents, and a UI control in five locales. Renaming it is the
+deliverable; accepting the old value forever is what makes the rename safe for
+installs that already store it.
+
+**As an** operator reading the policy control, **I want** its name to describe
+what happens, **so that** I do not size spend or work-in-progress from a name
+that is 25 times wrong.
+
+#### Acceptance criteria
+
+- **AC-OFFICE-ROUTINE-CATCHUP-003.1:** The summarizing catch-up policy shall be
+ named `summarize_missed`. A routine created with no catch-up policy supplied
+ shall be persisted with `summarize_missed`, **whatever creation path is
+ used** — the HTTP create handler, config-sync reconciliation, the
+ pre-installed coordinator routine, or any direct repository insert. No
+ creation path shall persist the deprecated alias.
+- **AC-OFFICE-ROUTINE-CATCHUP-003.2:** `enqueue_missed_with_cap` shall be
+ accepted on routine create and routine update as a deprecated alias for
+ `summarize_missed`, and shall be normalized to `summarize_missed` before
+ persistence.
+- **AC-OFFICE-ROUTINE-CATCHUP-003.3:** A routine persisted before this change
+ with `catch_up_policy = 'enqueue_missed_with_cap'` shall read as
+ `summarize_missed`, and the column's stored default shall be
+ `summarize_missed` **on an upgraded install as well as on a fresh one**.
+ Inspecting the live schema of a database that predates this change shall not
+ show the retired value.
+- **AC-OFFICE-ROUTINE-CATCHUP-003.4:** `enqueue_missed_with_cap` shall not
+ appear in any API response body or in any web UI control.
+- **AC-OFFICE-ROUTINE-CATCHUP-003.5:** A stored `catch_up_policy` value that is
+ neither `summarize_missed`, nor `skip_missed`, nor the deprecated alias shall
+ be treated as `summarize_missed` and shall not prevent the routine from
+ firing.
+- **AC-OFFICE-ROUTINE-CATCHUP-003.6:** The web UI shall label the summarizing
+ policy so that it states a single summarized wake, and shall label
+ `catch_up_max` so that it states the bound is on the number of ticks counted
+ and reported, not on the number of runs created. This copy shall be provided
+ in `en`, `pt-pt`, `zh-cn`, `zh-hk`, and `zh-tw`.
+- **AC-OFFICE-ROUTINE-CATCHUP-003.7:** `system-design/scheduler-01.md` and
+ `system-design/scheduler-02.md` shall not describe missed cron ticks as
+ fired, enqueued, or dispatched as multiple runs, and shall reference this
+ requirement for catch-up semantics rather than restating them. Neither
+ document shall name `enqueue_missed_with_cap` as the catch-up policy value
+ except where it explicitly labels it a deprecated alias. Both shall state the
+ routine wakeup payload's catch-up fields wherever they describe that payload's
+ shape. The constraint is scoped to those two files; this document necessarily
+ quotes the retired value.
+- **AC-OFFICE-ROUTINE-CATCHUP-003.8:** Under the summarizing policy the web UI
+ shall render the `catch_up_max` control and allow it to be edited, in both the
+ routine-create dialog and the routine-detail view. Under `skip_missed` it
+ shall not be rendered. The rename shall not make the control unreachable.
+
+## Out of scope
+
+Each exclusion is a decision, not an omission.
+
+- **Enqueueing N missed runs.** Rejected on the four grounds in the
+ Overview. Reopening it depends on work-in-progress enforcement and an enforced
+ budget ceiling landing first, and on the wakeup idempotency key gaining a
+ generation component.
+- **Renaming `catch_up_max`.** Its meaning changes (a bound on counting, not on
+ runs) but the name stays accurate enough, and renaming the column, the API
+ field and the web state would double the migration surface for no change in
+ observable behaviour. The UI copy is corrected instead, under AC-003.6. Its
+ stored-versus-effective divergence is **not** deferred: AC-001.13 closes it.
+- **A trigger-update operation.** There is none today: the trigger surface is
+ create, list, delete and webhook-fire, and the only mutation of an existing
+ trigger row is the scheduler's `next_run_at` re-arm. Editing a cron
+ expression, timezone or enabled flag therefore means delete-then-create.
+ Building the endpoint is a feature in its own right, including a decision
+ about what an in-flight claim does when the expression changes underneath it.
+ An update path added later inherits AC-001.8's rule: arm strictly after the
+ change instant.
+- **Downgrade compatibility.** A payload carrying `summarize_missed` sent to a
+ build predating this change will be rejected by it; the alias is read-forward
+ only. No exported artifact carries it: the config-sync YAML has no catch-up
+ field.
+- **A paused routine still fires.** `office_routines.status` is never consulted
+ in the cron tick path; only `office_routine_triggers.enabled` gates firing. A
+ defect, but not catch-up specific; a follow-up card carries it.
+- **Cron correctness at the tick level.** Day-of-month versus day-of-week
+ conjunction and DST behaviour are gap 23, carded. A claimed unsatisfiable
+ expression follows AC-001.11; matching rules remain outside this document.
+- **Work-in-progress limits and budget enforcement.** Gaps 11 and 19, carded.
+ This document creates no work that would need them.
+- **Retention of gap summaries.** They inherit whatever retention
+ `office_routine_runs` gets, which is none today (gap 20, carded).
+
+## Prior art
+
+**Wiki leg — did not run.** Config resolved from `~/.obsidian-wiki/config.henry`
+via the `@henry` pin, giving
+`OBSIDIAN_VAULT_PATH=/Users/henry/Documents/henry/wiki`, collection `wiki`.
+Reading the vault returns `EPERM` (macOS privacy protection on `~/Documents`
+from this worktree), and neither `obsidian-wiki` nor `qmd` is on `PATH` or as an
+MCP tool. An environment failure, not an empty result: the vault may hold a
+position here and was not consulted.
+
+**saas-kb leg — did not run.** The `saas-kb` MCP server and its
+`search_fsm_docs` tool are absent from this session; the `ai_sdlc` slice was not
+queried.
+
+**What informed it instead:** the tree, and
+`Forge/docs/kandev/office/BETA-REQUIREMENTS.md` gap 24 and section 7a.
+
+## References
+
+- [Office Scheduler](scheduler.md) — the wakeup pipeline this sits inside.
+- Catch-Up System Design
+ [part 1](../system-design/routine-catch-up-01.md),
+ [part 2](../system-design/routine-catch-up-02.md)
+- [Scheduler System Design part 1](../system-design/scheduler-01.md),
+ [part 2](../system-design/scheduler-02.md) — the documents AC-003.7 corrects.
diff --git a/docs/specs/office/system-design/routine-catch-up-01.md b/docs/specs/office/system-design/routine-catch-up-01.md
new file mode 100644
index 0000000000..552a234a03
--- /dev/null
+++ b/docs/specs/office/system-design/routine-catch-up-01.md
@@ -0,0 +1,522 @@
+---
+status: draft
+system: office
+requirements:
+ - REQ-OFFICE-ROUTINE-CATCHUP-001
+ - REQ-OFFICE-ROUTINE-CATCHUP-002
+---
+
+# Office Routine Catch-Up System Design, part 1: the tick and the gap
+
+## Purpose and boundaries
+
+This design makes the routine cron tick's resume behaviour explicit, gives the
+gap it crosses a durable record, and carries that record into the assembled
+prompt for eligible lightweight runs — REQ-001 and REQ-002. The existing shared
+launcher rejects taskless runs, so prompt assembly is the boundary covered here;
+actual taskless session delivery is a separate shared-scheduler contract.
+Renaming the policy value that misdescribes all of it is REQ-003, in
+[part 2](routine-catch-up-02.md).
+
+The Office system owns the outcome: the trigger table, the routine-run table,
+the wakeup queue, and the routine prompt path are all Office primitives.
+
+Adjacent contracts read and constrained, but not owned:
+
+- `internal/scheduler/cron` — the shared 30-second cron loop that calls
+ `RoutineService.TickScheduledTriggers`. Its interval and handler ordering are
+ unchanged.
+- `internal/office/shared/cron.go` — the cron evaluator. Its tick times are
+ taken as given; its day-of-month/day-of-week conjunction, DST behaviour, and
+ unsatisfiable-expression fallback are gap 23 and out of scope.
+- `internal/office/wakeup` — the wakeup queue, its idempotency index, and the
+ dispatcher's concurrency policy. Unchanged except that `RoutinePayload` gains
+ two fields alongside the existing `MissedTicks`.
+
+## Requirement mapping
+
+| Requirement | Design section |
+|---|---|
+| REQ-OFFICE-ROUTINE-CATCHUP-001 | [The tick](#the-tick), [Normalizing catch_up_max](#normalizing-catch_up_max), [Ordering, claiming and re-arming](#ordering-claiming-and-re-arming), [Dispatch failure](#dispatch-failure) |
+| REQ-OFFICE-ROUTINE-CATCHUP-002 | [The gap summary](#the-gap-summary), [Delivering the gap to the agent](#delivering-the-gap-to-the-agent) |
+| REQ-OFFICE-ROUTINE-CATCHUP-003 | [part 2](routine-catch-up-02.md): Renaming the policy, Documentation consistency |
+
+## Current behaviour, as measured
+
+Recorded because these claims are load-bearing and none is written down today.
+
+- `processCronTrigger` (`internal/office/routines/service.go:360-397`) claims
+ the trigger, calls `computeRoutineMissed`, arms `next_run_at`, then calls
+ `DispatchRoutineRunWithMissed` **once**, passing `runCount - 1`.
+- `computeRoutineMissed` (`:409-447`) walks `NextCronTime` from the armed
+ `next_run_at` until the cursor passes `now` or the count reaches
+ `routine.CatchUpMax` (default 25 when the stored value is not positive).
+ Nothing bounds `CatchUpMax` above: `routines/handler.go` assigns it from the
+ request unvalidated on both create and update, and the walk is one synchronous
+ `NextCronTime` call per counted tick inside the 30-second scheduler tick.
+- The count reaches the wakeup request's payload as `missed_ticks`
+ (`marshalRoutinePayload`, `:665`). `createFreshRun` copies that payload onto
+ `runs.context_snapshot` **verbatim** (`wakeup/dispatcher.go:291`); the
+ coalesce paths do not, and `json_patch` it in instead, at **two separate SQL
+ sites** that do not share an implementation — see
+ [Delivering the gap to the agent](#delivering-the-gap-to-the-agent).
+- **It stops there.** `buildPromptContext` is called with `run.Payload`
+ (`service/scheduler_integration.go:375`), and `createFreshRun` sets
+ `Payload: "{}"` on every wakeup-derived run. `prompt_builder.go` contains no
+ reader for `ContextSnapshot`. No web view renders `context_snapshot` either —
+ it is typed in `apps/web/lib/api/domains/office-runs-api.ts:113` and never
+ read. `missed_ticks` is written and never observed, by an agent or a human.
+
+Two further measurements shape the design:
+
+- `GetDueTriggers` (`repository/sqlite/routines.go:65-79`) has no `ORDER BY`.
+- `ClaimTrigger` (`:84-96`) sets `next_run_at = NULL` as its CAS and stamps
+ `updated_at`; `UpdateTriggerNextRun` re-arms in a separate, unconditional
+ statement. A process that stops between the two leaves an enabled cron trigger
+ with a null `next_run_at`, which `GetDueTriggers` filters out — permanently.
+ That `updated_at` stamp is what lets the reconciliation pass tell a live claim
+ from an abandoned one.
+- `models.RoutineRunStatusFailed` is declared in `models/enums.go` and set
+ nowhere in the tree.
+
+## Why the fan-out was rejected
+
+The requirement records the decision; this is the mechanism behind each of its
+four grounds, verified against the tree.
+
+1. **It is not implementable without changing a different contract.** The wakeup
+ idempotency key is `routine:::`
+ (`buildRoutineIdempotencyKey`) and `agent_wakeup_requests.idempotency_key`
+ carries a partial unique index. N requests created inside one tick share one
+ wall-clock minute, so the second through Nth are rejected as idempotency
+ conflicts and absorbed as a warning. A naive fan-out silently produces one
+ wake anyway. Re-keying on scheduled tick time is the generation-identity work
+ tracked separately as gap 26.
+2. **Whichever concurrency policy is in force would collapse them regardless.**
+ The shipped default is `skip_if_active` — both `routines/handler.go`'s create
+ path and the `office_routines.concurrency_policy` column default name it, and
+ it drops a request while a run is in flight. `coalesce_if_active`, which only
+ the pre-installed coordinator routine sets, merges the request into the
+ in-flight run instead. Either way N requests become one wake. A fan-out is
+ observable only under the third policy, `always_create` — that is the
+ routine-layer name an operator sets; the wakeup layer calls the same policy
+ `always_enqueue` after `dispatcher.go` maps it.
+3. **Under `always_create` it would fan out into a system with no
+ work-in-progress limit and a budget check that fails open.**
+ `max_concurrent_sessions` is persisted and has no scheduler consumer;
+ `checkBudget` returns allow on checker error. Both are separately carded and
+ unshipped. Building the fan-out first builds the hazard before the two
+ controls that would bound it.
+4. **There is no per-tick work to replay.** A lightweight routine's payload is
+ the routine id plus variables, and the time-valued built-ins (`{{date}}`,
+ `{{datetime}}`) are resolved at dispatch time, not at tick time. A replayed
+ missed tick would carry today's values and be indistinguishable from the
+ current one.
+
+## The tick
+
+`computeRoutineMissed` is replaced by a function returning one value object
+instead of `(int, time.Time, error)`, so the caller receives the computation
+error and handles trigger state explicitly:
+
+```go
+type catchUpResult struct {
+ ElapsedTicks int // >= 1 on success; includes the tick due now
+ FirstMissedAt time.Time // zero when ElapsedTicks <= 1
+ Truncated bool // ElapsedTicks reached the cap with ticks still pending
+ NextRunAt time.Time // success or recoverable-error re-arm
+ Unknown bool // the walk failed; ElapsedTicks is not meaningful
+}
+```
+
+Invariants the constructor guarantees, so no call site has to restate them:
+
+- `NextRunAt` is strictly after the processing instant on a successful walk or
+ a recoverable failure. On `Unknown`, `computeCatchUp` supplies the
+ processing instant plus 24 hours. `ErrUnsatisfiableCron` leaves the claimed
+ trigger disarmed; another failure re-arms it and dispatches one run without a
+ gap summary. Both paths log the underlying error.
+- `FirstMissedAt` is the armed `next_run_at` at claim time — read directly, not
+ derived from the walk, so it is exact even when `Truncated` is true.
+- `Truncated` is true only when the walk stopped at the cap with the cursor
+ still at or before the processing instant.
+
+`missedTicks = ElapsedTicks - 1`, floored at zero. The catch-up policy decides
+whether that number is *reported*, never whether runs are *created*: the tick
+dispatches exactly one run either way. `skip_missed` and `summarize_missed`
+therefore differ in exactly one observable: whether the eligible lightweight
+run's assembled prompt includes the gap context. Whether a taskless run reaches
+an agent session remains the separate shared-launcher contract described above.
+
+**A gap summary exists only when `missedTicks >= 1`.** This single rule collapses
+what would otherwise be four near-identical states into two, and it is why
+`Unknown` needs no representation in storage:
+
+- `Unknown` (the walk failed) drives the error path and no gap summary. The
+ caller either disarms an unsatisfiable trigger or re-arms a recoverable
+ failure for 24 hours and dispatches one run. A due trigger has at least one
+ elapsed tick but may have no *missed* one, so an unmeasured gap is a false
+ positive (AC-001.6).
+- `catch_up_max == 1` makes `missedTicks` structurally zero, so that
+ configuration never produces a summary. That is a named consequence, not an
+ edge case to detect (AC-002.11).
+- A truncated count is always at least `catch_up_max - 1`, so truncation and a
+ zero count cannot co-occur for any `catch_up_max` above 1.
+
+## Normalizing catch_up_max
+
+AC-001.4 clamps `catch_up_max` at **use** time, which is the fail-open safety
+net: no stored value, however it got there, can stop a routine firing. On its
+own that reintroduces the defect this whole capability exists to close, one
+field over. A stored 5000 would keep being returned by `GetRoutine` /
+`ListRoutines`, rendered in the routine-detail form, and edited there, while the
+tick honoured 1000 — an operator sizing spend from the field would be wrong by
+a factor of five, which is the Overview's disagreement table with a different
+number in it.
+
+Note the divergence is not created by the upper bound; it exists today on the
+lower one. `CreateRoutine` / `CreateRoutineTx` clamp `<= 0` to 25 at write time,
+but `UpdateRoutine` (`repository/sqlite/routines.go:200`) writes the field with
+no bound at all and `routines/handler.go:297` assigns it unvalidated, so a
+stored 0 is reachable now and already reads back as 0 while the tick uses 25.
+AC-001.4's upper bound extends an existing hole rather than digging a new one,
+which is why AC-001.13 closes both ends at once.
+
+`CatchUpMax` is a plain `int` (`models/models.go:548`), not a named type, so the
+`sql.Scanner` hook [part 2](routine-catch-up-02.md) uses for
+`catch_up_policy` has nothing to attach to. Changing its type to get one would
+ripple through the DTO, the JSON contract and the web state for no behavioural
+gain — the same reasoning that keeps `## Out of scope`'s "Renaming
+`catch_up_max`" excluded.
+
+**Normalize on write instead, which the policy field cannot do and this one
+can — through one named funnel, applied at the repository.**
+`models.NormaliseCatchUpMax(int) int` is the `catch_up_max` sibling of part 2's
+`NormaliseCatchUpPolicy`. It applies AC-001.4's clamp and is the only place the
+two bounds exist in Go, as named constants beside it. Today neither bound has a
+single point of change: `defaultCatchUpMax = 25` lives only in
+`routines/service.go:401` and no `1000` appears anywhere.
+
+**It is applied in the repository, not in the handlers, because the repository
+is the only exhaustive choke point.** `catch_up_max` reaches SQL at exactly two
+statements: `insertRoutine` (`repository/sqlite/routines.go:150`), the shared
+INSERT that both `CreateRoutine` and `CreateRoutineTx` call, and `UpdateRoutine`
+(:200). Config-sync's own update path, `updateRoutineConfigFields` (:243), does
+not write the column at all. Normalizing in those two functions covers every
+present and future writer by construction.
+
+Clamping in `routines/handler.go` instead would not, and the enumeration is the
+whole point of this section: **two independent service layers reach the same
+repository methods without passing through that handler** —
+`office/service.Service.CreateRoutine` and `UpdateRoutine` (`service/service.go`
+:713 and :732), the second of which `config_import.go`'s `applyRoutines` (:259)
+calls for every imported routine. That is the same bypass shape that lost
+`catch_up_policy` to `CreateRoutineTx`, and the same reasoning part 2 uses to put
+the policy's read-side normalization in `sql.Scanner` rather than in each query.
+The existing `<= 0 → 25` clamps in `CreateRoutine` and `CreateRoutineTx` are
+removed in favour of the funnel call in `insertRoutine`, so the lower bound stops
+being stated twice as well.
+
+Existing rows are corrected by one `r.migrate.Apply` step alongside the policy
+migration. This SQL is the one place the bounds are restated outside the funnel,
+so it carries the same two values by construction:
+
+```sql
+UPDATE office_routines SET catch_up_max = 25 WHERE catch_up_max < 1;
+UPDATE office_routines SET catch_up_max = 1000 WHERE catch_up_max > 1000;
+```
+
+The boundary is exclusive on both ends and the SQL says so: `< 1` and `> 1000`,
+so a stored 1 and a stored 1000 are both left alone, matching AC-001.4's "less
+than 1" and "greater than 1000" exactly. There is no null case to consider —
+the column is `catch_up_max INTEGER NOT NULL DEFAULT 25` (`base.go:424`), so
+every row matches one predicate or neither, and a `NULL` cannot silently escape
+a comparison. Both statements are idempotent, and their order relative to the
+`office_routines` table rebuild in [part 2](routine-catch-up-02.md) does not
+matter: the rebuild copies column values verbatim, so normalizing before or
+after it yields the same rows.
+
+Four consequences of clamping at the write, stated because a builder would
+otherwise have to derive them. **The funnel reassigns `routine.CatchUpMax` on the
+passed-in struct, before the statement binds that field**, rather than
+normalizing the bind parameter alone — mutating
+in place as the `CreateRoutine` clamp it replaces already does
+(`routines.go:125-127`), with `UpdateRoutine` gaining the same behaviour.
+Load-bearing, not stylistic: `routines/handler.go` serializes the SAME `*Routine`
+it handed the service on both paths (`:95` create, `:136` update), so a bind-only
+clamp would store 1000 while that one response reported the submitted 5000 —
+AC-001.13's own divergence, reopened for one round-trip.
+`NormaliseCatchUpMax` is a **fixpoint** —
+normalizing an already-normalized value returns it unchanged — so applying it
+twice is safe and a belt-and-braces call in a handler cannot corrupt anything.
+Two concurrent updates of one routine row stay last-write-wins, unchanged by this
+design, and both writers store a normalized value, so the row is in range
+whichever lands second. And because the clamp sits on what the repository
+*writes* rather than on what the request *carries*, an update that omits
+`catch_up_max` altogether — it is `*int` with `omitempty` (`routines/dto.go:24`),
+so omitting it means "leave it alone" — still rewrites the stored value
+normalized. An out-of-range row is therefore corrected by the next edit routed
+through `Repository.UpdateRoutine`, not only by the migration. It is **not**
+corrected by config-sync's column-scoped update (`config/import.go`,
+`configsync/reconcile_routines.go`), which does not write the column at all, so a
+routine only ever touched that way waits for the migration.
+
+Write-side normalization plus a migration makes AC-001.4's use-time clamp
+belt-and-braces rather than load-bearing, exactly as the `sql.Scanner` does for
+the policy: a row that somehow escapes both still ticks correctly, it just also
+now reads correctly. AC-001.4 is unchanged and still governs the tick.
+
+## Ordering, claiming and re-arming
+
+- `GetDueTriggers` gains `ORDER BY next_run_at ASC, id ASC`. `id` is the named
+ tiebreak column; it is a primary key, so the order is total and stable.
+- Claiming is unchanged. The CAS on `next_run_at` already gives exactly one
+ winner; the loser returns before computing or recording anything.
+- The claim-then-arm window is closed by a reconciliation pass in
+ `TickScheduledTriggers`: enabled cron triggers with a null `next_run_at` are
+ armed to the first match strictly after the processing instant and are **not**
+ dispatched in that tick. Re-arming rather than firing is deliberate: the row
+ cannot distinguish "crashed after claiming" from "crashed after claiming and
+ dispatching", and a spurious wake is the more expensive error.
+- **The pass runs after the due-trigger loop, and only on stale rows.** A null
+ `next_run_at` is precisely what `ClaimTrigger` writes to take a claim, so a
+ live claim and an abandoned one are identical in the row. Without a second
+ predicate the pass would arm a trigger whose claimant is still mid-tick.
+ `ClaimTrigger` also sets `updated_at`, which is the discriminator: the pass
+ considers only rows whose `updated_at` is older than `catchUpReclaimAfter`,
+ **60 seconds** — two scheduler intervals, so a claim taken in this tick or the
+ one before is never reconciled, while an abandoned one is recovered on the
+ following tick. Running after the due-trigger loop means a trigger claimed and
+ armed within this same tick is already non-null and never reaches the pass.
+ This is what AC-001.9's "none shall dispatch **by way of this path**" scopes:
+ a live claimant's own dispatch is legitimate and unaffected.
+- That reconciliation write is also a CAS. The `UPDATE` carries `WHERE id = ?
+ AND next_run_at IS NULL AND enabled = 1`, so a second reconciling processor
+ affects zero rows and returns, and a trigger disabled or armed between the
+ select and the write is left alone. Without the predicate two processors would
+ each compute their own `NextCronTime(expr, tz, now)` from slightly different
+ instants and the later write would silently move a trigger another had already
+ armed. Note what the CAS does **not** buy: it cannot protect against a live
+ claimant, whose own re-arm through `UpdateTriggerNextRun` is an unconditional
+ write that wins regardless. The staleness guard is what keeps the two paths
+ disjoint; the CAS only orders reconcilers among themselves.
+ `UpdateTriggerNextRun` stays unconditional and the reconciliation path needs a
+ conditional sibling rather than reusing it.
+- Arming on create uses `NextCronTime(expr, tz, now)` and rejects the trigger
+ when that call errors (`CreateRoutineTrigger`, `service.go:305-314`).
+ **AC-001.8's invariant is enforced in the service: `CreateRoutineTrigger` is
+ the only place that may arm a cron trigger.** Today's guard is
+ `if t.Kind == "cron" && t.CronExpression != ""`, which lets an empty
+ expression fall through and persist a cron trigger with a null `next_run_at`;
+ dropping that second condition and rejecting the empty case is the change
+ AC-001.8 asks for. One caller reaches the repository directly —
+ `infra/reconcile.go`'s `createTriggersForNewRoutines` calls
+ `repo.CreateRoutineTrigger` past the service. It is named here because this
+ design already lost `catch_up_policy` to exactly this shape of bypass
+ (config-sync reaching `CreateRoutineTx`). It writes `Kind: "manual"` only, so
+ it cannot violate the cron invariant and needs no change; the rule it inherits
+ is that any future path creating a `cron` trigger goes through the service,
+ not the repository. **There is no trigger-update path to align with it.** The
+ repository interface exposes `CreateRoutineTrigger`, `UpdateTriggerNextRun`
+ (the scheduler's own re-arm, `next_run_at` only) and `DeleteRoutineTrigger`;
+ the HTTP surface is `GET`/`POST /routines/:id/triggers` and
+ `DELETE /routine-triggers/:triggerId` plus the webhook fire; and the web
+ client has only `deleteRoutineTrigger`. Changing a cron expression, timezone
+ or enabled flag is therefore a delete followed by a create, which arms from
+ the create instant and satisfies AC-001.8 with no new code. The update
+ endpoint is excluded in the requirement's `## Out of scope`, so this design
+ creates no trigger-mutation surface.
+
+## Dispatch failure
+
+AC-001.10 and AC-001.12 have no implementation to point at today:
+`models.RoutineRunStatusFailed` is declared in `models/enums.go` and **set
+nowhere in the tree**. Both dispatch paths lose their failures:
+
+- Heavy (`materialiseHeavyRoutineRun`) returns an error when
+ `EnsureRoutineWorkflow` or `CreateOfficeTaskInWorkflow` fails, without
+ touching run status, so the run sits at `received` forever.
+- Lightweight (`materialiseLightweightRoutineRun`) logs a `Warn` and returns
+ `nil` for both `CreateWakeupRequest` and `Dispatch` failures, so the failure
+ never reaches `processCronTrigger` as an error at all.
+
+Three changes, in the order the tick executes:
+
+1. **`CreateRoutineRun` fails.** `dispatchRoutineRun` already returns
+ `(nil, err)` here, before any run row exists. There is nothing to mark and
+ nothing to re-attempt; the trigger was armed forward at the previous step, so
+ the tick is simply lost. AC-001.12 makes that the stated contract rather than
+ an accident. The caller logs and returns.
+2. **The lightweight path stops swallowing.** `CreateWakeupRequest` and
+ `Dispatch` errors are returned rather than absorbed, with one exception: the
+ sentinel the office repository returns for an idempotency-key conflict stays
+ a no-op and is still absorbed as a `Warn`. That distinction is the whole
+ point of AC-001.10's second sentence — a duplicate is a successful dedup, not
+ a failed dispatch, and marking it `failed` would turn correct coalescing into
+ a red run in the UI.
+3. **The status flip has one home.** `processCronTrigger` owns it: when
+ `DispatchRoutineRunWithMissed` returns a non-nil run together with an error,
+ it calls `UpdateRunStatus(run.ID, RoutineRunStatusFailed, "")`. Putting it in
+ the caller rather than in each materialise path means the heavy and
+ lightweight branches need no duplicate error handling, and the run is marked
+ exactly once whichever branch failed.
+
+The gap summary is untouched by any of this — it was written with the run row
+(see below), so a run that later goes `failed` still carries the gap that was
+measured for its tick.
+
+## The gap summary
+
+Stored on `office_routine_runs`, which both the lightweight and the heavy path
+create — one durable surface for both routine shapes. Three columns, added via
+`r.migrate.Apply` in `repository/sqlite/base_migrations.go` and to the
+`CREATE TABLE` in `base.go:448`:
+
+| Column | Type | Meaning |
+|---|---|---|
+| `catch_up_missed_ticks` | `INTEGER` nullable | missed ticks; **NULL means no gap summary** |
+| `catch_up_first_missed_at` | `TIMESTAMP` nullable | exact timestamp of the first missed tick |
+| `catch_up_truncated` | `INTEGER NOT NULL DEFAULT 0` | 1 when the count is a lower bound |
+
+Nullable rather than `NOT NULL DEFAULT 0` because AC-002.3 requires absence to
+be distinguishable from a measured zero. Per the rule above, a NULL
+`catch_up_missed_ticks` always implies `catch_up_first_missed_at` is NULL and
+`catch_up_truncated` is 0; the three columns are written together or not at all.
+
+Explicit columns rather than folding into `trigger_payload`: that column is the
+resolved-variables contract, and a queryable "which routines resumed after a
+gap" is the operability question gap 21 will need.
+
+The columns are written once, in the same repository call that creates the
+routine run, before the concurrency policy is applied. A run that is
+subsequently marked `skipped`, `coalesced` or `failed` therefore still carries
+the gap that was measured — the gap is a property of the tick, not of the
+dispatch outcome.
+
+That write-once property, not any claim about deduplication, is what satisfies
+AC-002.10, and the ordering is worth stating because the obvious reading is
+wrong: `dispatchRoutineRun` calls `CreateRoutineRun` **unconditionally and
+first**, before `applyConcurrencyPolicy` and long before
+`materialiseLightweightRoutineRun` inserts the wakeup request carrying the
+idempotency key. `CreateRoutineRun` is neither gated by nor aware of the
+`agent_wakeup_requests` unique index, so a wakeup rejected as a duplicate does
+**not** prevent a second `office_routine_runs` row — it only prevents a second
+wake. Two rows, each with its own measured summary, is the correct outcome: no
+row is ever rewritten, because nothing writes these columns after the insert.
+
+For a cron claim two rows are hard to reach anyway — `ClaimTrigger` is a CAS on
+`next_run_at`, the trigger is always re-armed strictly forward, and the
+idempotency key is per *trigger* and per wall-clock minute. The reachable
+multi-run cases are two triggers on one routine due in the same tick, and a
+manual or webhook fire alongside a cron fire; AC-002.12 gives the latter no gap
+summary at all. Note that AC-002.10 constrains the agent-run
+`runs.context_snapshot` as well as these rows — see
+[Delivering the gap to the agent](#delivering-the-gap-to-the-agent), where the
+coalesce merge is the surface that can actually rewrite a delivered gap.
+
+They surface on the routine-run API DTO and are the source for any future UI.
+
+## Delivering the gap to the agent
+
+Three hops, of which only the last is new.
+
+1. `marshalRoutinePayload` already carries `missed_ticks`; it gains
+ `missed_since` (RFC 3339) and `missed_truncated`. `wakeup.RoutinePayload`
+ gains the matching fields. Both remain `omitempty`, so a gapless fire has a
+ payload byte-identical to today's.
+2. **One payload column feeds two destinations, and only one of them may
+ carry the gap.** `createFreshRun` sets `ContextSnapshot: req.Payload`
+ verbatim (`wakeup/dispatcher.go:291`) and is unchanged — that is the path
+ AC-002.5 needs the three fields on. The **coalesce** paths merge the same
+ persisted `agent_wakeup_requests.payload` row into an existing run's
+ snapshot with `json_patch`, RFC 7386 merge-patch, so every top-level key the
+ incoming payload carries **overwrites** the one already there. Left alone
+ that gives two wrong outcomes: a second gap-carrying routine wake replaces
+ the first claim's measurement with its own, and because the three fields are
+ `omitempty`, a *gapless* later wake does not clear them, so the run keeps a
+ gap belonging to a tick it is not answering.
+
+ **There are TWO merge sites, not one, and they do not share an
+ implementation.** `MarkWakeupRequestCoalesced`
+ (`repository/sqlite/wakeup_requests.go:168`) calls
+ `mergeWakeupPayloadIntoRunSnapshot` (:256, whose `json_patch` is at :265).
+ `PromoteRunAndCoalesceWakeupIfQueued` (:193) does **not** call that helper:
+ it carries its own inline copy of the identical `json_patch` statement at
+ :229-238, executed on `tx` rather than `r.db` because it runs inside the
+ promote transaction. `grep -n json_patch` over that file returns THREE lines:
+ these two SQL sites, plus the helper's own doc comment, which names the
+ function in prose and survives the unification.
+ Naming only the helper would send a builder to patch one of them, and the
+ one left unpatched is the promoted-run path — the same path
+ [part 2](routine-catch-up-02.md)'s test plan already singles out as the one
+ a naive implementation misses for the prompt gate.
+
+ **The strip cannot live at the routine layer.** The routine layer writes the
+ payload once, at wakeup-request creation, and `agent_wakeup_requests` has a
+ single `payload` column. At that moment it is not yet known whether the
+ request will become a fresh run or be coalesced — the dispatcher decides
+ later, and both destinations read the same stored row. Stripping at
+ creation would strip the fresh-run path too, which AC-002.5 forbids; not
+ stripping leaves both merge sites carrying the keys. So the placement this
+ design previously asserted is not implementable, and the decision is
+ corrected here.
+
+ **The strip happens at the merge, in exactly one place.** The two merge
+ sites are unified: the `json_patch` statement moves into a single helper
+ taking an executor (`sqlx.ExtContext`, satisfied by both `r.db` and `tx`),
+ and `PromoteRunAndCoalesceWakeupIfQueued` calls it inside its transaction
+ instead of duplicating the SQL. That refactor is what makes "one merge path"
+ a true statement rather than the false one this design shipped. The helper
+ patches `json_remove(payload, '$.missed_ticks', '$.missed_since',
+ '$.missed_truncated')` rather than `payload`.
+
+ Putting three catch-up key names in an otherwise generic helper is the cost,
+ and it is the right trade against the alternative of a second payload column
+ read only by `createFreshRun`: `json_remove` of an absent key is a no-op in
+ SQLite (verified: `json_remove('{"a":1}', '$.missed_ticks')` returns
+ `{"a":1}`), so the helper stays byte-for-byte correct for every other wakeup
+ source, none of which writes these keys. A named constant holds the key list
+ beside the helper so the two never drift.
+
+ The decision itself is unchanged: the three keys are written once, by the
+ wake that created the run, and no coalesced request adds, changes or removes
+ them (AC-002.10). Nothing is lost, because each claim's gap is already
+ durable on its own `office_routine_runs` row and readable through the
+ routine-run API (AC-002.6, AC-002.8). The assembled prompt carries one
+ coherent statement about the tick its run was created for, rather than a
+ silent mixture of two claims' measurements.
+3. `buildPromptContext` is called with `run.Payload`, which is `"{}"` for every
+ wakeup-derived run. The call site at `scheduler_integration.go:375` passes
+ `run.ContextSnapshot` as an additional argument, and `buildPromptContext`
+ decodes it as `wakeup.RoutinePayload` when the run's reason is a routine
+ dispatch. **The constant to test is not `RunReasonRoutine`, which does not
+ exist.** `shared/runreasons.go` declares three:
+ `RunReasonRoutineDispatchCron`, `RunReasonRoutineDispatchEvent`, and the
+ legacy `RunReasonRoutineDispatch`. The decode gates on **all three**, not on
+ Cron alone. Cron alone is the tempting choice, since only a cron claim ever
+ measures a gap (AC-002.12), and it is wrong:
+ `PromoteRunAndCoalesceWakeupIfQueued` rewrites an in-flight run's `reason`,
+ so a cron-created run carrying a correctly measured gap can be holding the
+ Event reason by the time its prompt is built, and a Cron-only gate would drop
+ that gap silently. Gating on all three is safe in the other direction,
+ because a manual or webhook claim never writes the fields at all, so an
+ Event-reason run has nothing to render. `PromptContext` gains
+ `MissedTicks int`, `MissedSince string`, `MissedTruncated bool`, and
+ `BuildPrompt` renders a wake-context line when `MissedTicks > 0`.
+
+Scoping the decode to the routine reasons keeps every other run's prompt
+byte-identical, which is what makes this change safe to land without a prompt
+regression sweep across all reasons.
+
+The heavy path does not deliver the gap to the prompt: it creates a task from
+the routine's template, and mutating a user's template text with scheduler
+metadata is a worse contract than leaving the gap on the routine run where the
+API exposes it (AC-002.6).
+
+## Renaming the policy, documentation and tests
+
+REQ-003's rename, the corrections to `scheduler-01.md` / `scheduler-02.md`, and
+the test plan for both parts live in
+[part 2](routine-catch-up-02.md). They are a separate deliverable: this part
+changes what the system does on resume, part 2 changes what every surface says
+about it.
diff --git a/docs/specs/office/system-design/routine-catch-up-02.md b/docs/specs/office/system-design/routine-catch-up-02.md
new file mode 100644
index 0000000000..1b4ba26397
--- /dev/null
+++ b/docs/specs/office/system-design/routine-catch-up-02.md
@@ -0,0 +1,291 @@
+---
+status: draft
+system: office
+requirements:
+ - REQ-OFFICE-ROUTINE-CATCHUP-003
+---
+
+# Office Routine Catch-Up System Design, part 2: renaming the policy
+
+Part 1 ([routine-catch-up-01.md](routine-catch-up-01.md)) covers the tick, the
+claim, the gap summary and its delivery to the agent — REQ-001 and REQ-002.
+This part covers REQ-003 alone: renaming `enqueue_missed_with_cap` to
+`summarize_missed` across every surface that names it, and the test plan for
+both parts.
+
+The split is by deliverable, not by convenience. Part 1 changes what the system
+*does* on resume; this part changes what every surface *says* about it, which is
+a wider but shallower blast radius — a Go enum, six creation paths, a SQLite
+table rebuild, two frontend files, five locale catalogs and two design
+documents.
+
+## Renaming the policy
+
+`models.CatchUpPolicySummarizeMissed = "summarize_missed"` replaces
+`CatchUpPolicyEnqueueMissedWithCap` as the canonical value.
+`CatchUpPolicyEnqueueMissedWithCap` is retained as a deprecated alias constant,
+not deleted, so the normalizer has a name to compare against.
+
+One normalization funnel, `models.NormaliseCatchUpPolicy(string) RoutineCatchUpPolicy`:
+
+| Input | Result |
+|---|---|
+| `summarize_missed` | `summarize_missed` |
+| `enqueue_missed_with_cap` | `summarize_missed` (deprecated alias) |
+| `skip_missed` | `skip_missed` |
+| empty, or anything else | `summarize_missed` |
+
+`RoutineCatchUpPolicy.Valid()` accepts the alias so an API client sending the
+old value gets 200, not 400; the persisted and returned value is the new one,
+satisfying AC-003.2 and AC-003.4 together.
+
+**Where the funnel is applied.** AC-003.1 binds every creation path, not just
+the HTTP one, so the enumeration has to be exhaustive:
+
+| Entry | Why it is on the list |
+|---|---|
+| `routines/handler.go` create | Sets its own default when the request omits the field |
+| `routines/handler.go` update | Assigns the request value straight onto the routine |
+| `Repository.CreateRoutine` | **Injects the literal old value** when the field is empty |
+| `Repository.CreateRoutineTx` | Same injection, separate copy of the code |
+| `CreateDefaultCoordinatorRoutine` | Hard-codes the old constant on the pre-installed routine |
+| Read paths (`GetRoutine`, `ListRoutines`) | Normalize the alias and unknown values on the way out |
+
+The two repository entries are the ones a line-by-line reading of the HTTP layer
+misses, and they are not hypothetical: config-sync's routine reconciler builds a
+`models.Routine` with **no `CatchUpPolicy` set at all** and calls
+`CreateRoutineTx` directly, bypassing the handler and its defaulting. Left
+unchanged, every config-sync-created routine would keep writing the retired
+string forever, after the one-time migration had cleaned up every pre-existing
+row — a permanent AC-003.1 violation on a live path.
+
+**Read-side mechanism.** `GetRoutine` and `ListRoutines` are `SELECT *` through
+`sqlx` `StructScan`/`SelectContext`; there is no post-scan hook to put the
+normalizer in, so "the row scanner" is not an existing function to edit.
+Implement `sql.Scanner` on `RoutineCatchUpPolicy` and normalize there. That
+covers every present and future read path in one place, where per-call
+post-processing would have to be added to each new query and would be forgotten
+by exactly the same reasoning that produced this defect. `driver.Valuer` is not
+needed: writes already go through the funnel above.
+
+Note the earlier claim that "Office routines are not part of the config-sync
+YAML contract" is about the *field* — the YAML carries no catch-up policy — and
+must not be read as "config-sync does not create routines". It does.
+
+**Migrating persisted rows and the stored default.** Two separate problems, and
+one `UPDATE` only solves the first.
+
+Row *values* migrate with a single `r.migrate.Apply` step:
+`UPDATE office_routines SET catch_up_policy = 'summarize_missed' WHERE catch_up_policy = 'enqueue_missed_with_cap'`.
+With the `sql.Scanner` normalization above this is belt-and-braces rather than
+load-bearing — a row that escapes it still reads correctly.
+
+The column's *stored default* needs more, and AC-003.3 requires it on upgraded
+installs too. Editing the `DEFAULT 'enqueue_missed_with_cap'` text in
+`createRoutineTables` fixes nothing on an existing database: the statement is
+`CREATE TABLE IF NOT EXISTS` and is a no-op there, and SQLite has no
+`ALTER COLUMN ... SET DEFAULT`. Changing it requires a table rebuild, following
+the pattern this repository already documents (`apps/backend/AGENTS.md`,
+"Table-rebuild migrations") and already uses in this very file —
+`runTaskPriorityRecreate` in `base_migrations.go` is the worked precedent:
+acquire a dedicated connection, `PRAGMA foreign_keys=OFF` with a deferred
+re-enable, create `office_routines_new` with the corrected default, `INSERT ...
+SELECT` every column, `DROP TABLE office_routines`, rename.
+
+The `foreign_keys=OFF` is not incidental. `office_routine_runs` declares
+`FOREIGN KEY (routine_id) REFERENCES office_routines(id) ON DELETE CASCADE`, so
+dropping the old table with enforcement on would cascade every routine run out
+of existence. Mirror every column in the replacement `CREATE TABLE` and the copy
+list, and add a replay regression test proving values and timestamps survive —
+the run rows in particular.
+
+Leaving the default clause stale because no insert exercises it was rejected: an
+operator reading an upgraded install's live schema would see a default naming a
+value the system no longer treats as canonical, the same
+disagreement-between-surfaces defect this capability exists to close.
+
+**Frontend.** Replace *every* occurrence of the `enqueue_missed_with_cap`
+literal in `create-routine-dialog.tsx` and `routine-detail-view.tsx` — do not
+work from a fixed line list, which is what produced the omission this design
+previously shipped. There are four distinct kinds of site, and only two are
+`SelectItem` values:
+
+| Kind | Effect if missed |
+|---|---|
+| `SelectItem value=` (both files) | The control writes the retired value |
+| Create-dialog initial state default | New routines are created with the retired value |
+| Detail-view fallback default | An unset policy renders as the retired value |
+| `catchUpPolicy === "enqueue_missed_with_cap"` guards (both files) | **The `catch_up_max` input stops rendering entirely** |
+
+The last row is why AC-003.8 exists. Those two comparisons decide whether the
+cap input is shown at all; once every routine reads as `summarize_missed` they
+are permanently false, so a builder who updates only the option values and
+defaults ships a UI where the cap field has silently vanished — while still
+passing AC-003.4 and AC-003.6, neither of which observes the control.
+
+The i18n key `office:enqueueMissedWithCap` is replaced (not re-worded in place —
+the key name would then lie too) by `office:summarizeMissed`, and
+`office:beyondThisCountMissedTicksAre` is replaced by a key stating that the
+bound is on the ticks counted and reported. Both across `en`, `pt-pt`, `zh-cn`,
+`zh-hk`, `zh-tw`; use `pnpm run i18n:zh-hant` for the Traditional pair and
+`pnpm run i18n:pseudo` to regenerate the pseudo catalog. No em dash in any
+locale value.
+
+## Documentation consistency
+
+Three distinct corrections, and AC-003.7 requires all three. Work by **grepping
+both files for `enqueue_missed_with_cap`, for "missed", and for `missed_ticks`,
+and correcting every hit** — the lists below name the sites that exist today and
+are illustrative, not a work list to tick off. This design shipped that mistake
+once already, in the frontend notes above, and repeating it here would leave
+the fourth surface disagreeing after a capability whose entire purpose is to
+stop surfaces disagreeing.
+
+*Statements that imply multiple fires:* `scheduler-01.md`'s "Catch-up policy"
+section ("fire missed ticks up to the cap"), `scheduler-02.md`'s failure-mode
+table row, its restart scenario, and the retention paragraph's "catch-up cap
+(default 25) drops missed routine ticks" sentence. Each is corrected to state
+one summarized wake and to reference
+[the requirement](../requirements/routine-catch-up.md) rather than restating its
+terms.
+
+*Places that merely name the value:* `scheduler-01.md`'s routine-field list
+(`catch_up_policy: enqueue_missed_with_cap (default, cap 25)`), its worked
+routine-config example block, and `scheduler-02.md`'s schema column comment.
+None claims multiple fires, so the first correction leaves them untouched — and
+they would then name a policy value the system no longer accepts as canonical,
+which is the exact class of defect this capability exists to remove. All become
+`summarize_missed`, with the alias mentioned only where it is labelled
+deprecated.
+
+*Places that document the routine wakeup payload's shape:* `scheduler-01.md`'s
+run-reason table row (`{routine_id, variables, missed_ticks?}`) and
+`scheduler-02.md`'s `RoutinePayload` struct listing, whose `MissedTicks` field
+carries a "when catch-up cap collapsed N fires" comment. Neither names the
+policy value nor claims multiple fires, so neither correction above reaches
+them, yet both describe a contract this design extends with `missed_since` and
+`missed_truncated`. Each gains the two new fields, and the `MissedTicks` comment
+is corrected to say the count is of ticks *counted and reported*, never fired.
+
+`docs/specs/office/README.md` gains both new files in its specification map.
+
+## Testing
+
+Backend, in `internal/office/routines/`:
+
+- `catch_up_test.go` already covers the counting loop and the default cap; it is
+ extended for `FirstMissedAt` exactness under truncation, `Truncated`, the
+ `ElapsedTicks == 1` no-summary case, the failure path's strictly-after re-arm,
+ and both `catch_up_max` clamps — `0 -> 25` and `5000 -> 1000` (AC-001.4). The
+ existing tests referencing `CatchUpPolicyEnqueueMissedWithCap` move to the new
+ constant, with one test retained on the alias to prove normalization.
+- The `catch_up_max == 1` boundary (AC-002.4, AC-002.11): a gap spanning many
+ ticks records **no** gap summary, and in particular does not record
+ `truncated = true` with a zero count. This is the case where the guard added
+ to AC-002.4 is the only thing standing between two ACs, so assert the absence
+ directly rather than inferring it from a count.
+- A tick test asserting exactly one routine run and one wakeup request for a
+ gap spanning more than `catch_up_max` ticks — the AC-001.1 regression that
+ would have caught the original misnaming.
+- A `GetDueTriggers` ordering test over rows with equal `next_run_at`.
+- Reconciliation (AC-001.9): an enabled cron trigger with a null `next_run_at`
+ and a stale `updated_at` is armed and does not dispatch; a second reconciler
+ affects zero rows and does not move `next_run_at`; and a trigger whose
+ `updated_at` is inside `catchUpReclaimAfter` is left untouched, which is the
+ live-claim case the CAS alone does not cover.
+- Re-arm failure (AC-001.2): `UpdateTriggerNextRun` returns an error, no routine
+ run is created for that claim, and a later tick's reconciliation arms the
+ trigger without dispatching.
+- A malformed-expression case asserting the re-arm lands exactly 24 hours after
+ the processing instant and that a second tick a day later dispatches once
+ more, not repeatedly (AC-001.11).
+- Dispatch-failure cases (AC-001.10): heavy-path task creation fails and the run
+ reads `failed`; lightweight `CreateWakeupRequest` fails and the run reads
+ `failed`; an idempotency-conflict sentinel does **not** mark the run failed.
+ And AC-001.12: `CreateRoutineRun` itself fails, no run row exists, and the
+ trigger stays armed forward.
+- A write-once case for AC-002.10: a run carrying a gap summary is transitioned
+ through `coalesced` and `failed`, and the three columns are unchanged after
+ each.
+
+Wakeup, in `internal/office/wakeup/` and its repository — the coalesce merge
+(AC-002.10). **Every case below runs against BOTH merge entry points**,
+`MarkWakeupRequestCoalesced` and `PromoteRunAndCoalesceWakeupIfQueued`, as a
+table test over the two. That is not belt-and-braces: until the refactor in
+[part 1](routine-catch-up-01.md#delivering-the-gap-to-the-agent) lands they are
+two separate SQL statements, and a suite that exercises only the helper passes
+green with the promoted-run path fully broken — the exact failure the design
+text previously invited. What the table does **not** do is catch a third merge
+site: a table over two named entry points cannot observe a statement that is not
+in it, so do not rely on it for that. The guard for that is a separate
+source-level pinning assertion: that `json_patch` appears exactly once in
+`wakeup_requests.go` **outside comments**, once the unification has landed.
+
+The qualifier is the whole assertion, not pedantry. `grep -c json_patch` on that
+file returns **three** lines today, and only two of them are SQL: the third is the
+helper's own doc comment, which names the function in prose and survives the
+refactor untouched. An unqualified "exactly once" would therefore FAIL against a
+correct implementation, and the builder would be left inventing the real
+predicate. Assert on SQL occurrences — parse the file, or match the statement
+rather than the bare identifier.
+
+The in-tree precedent for a test that reads Go source and asserts a structural
+count is `internal/common/subproc/raw_git_audit_test.go`'s
+`TestProductionGitCommandsUseTheAdmissionSeam`, which walks the tree with
+`go/parser` and reports violations by file and line. It is **not** the auth
+middleware allowlist: `apps/backend/CLAUDE.md` describes that one as a "pinning
+test", but `auth/httpmw/middleware_test.go`'s `TestEnabledModeAllowlistMatrix`
+issues real HTTP requests and asserts status codes per path — a behavioral
+matrix, and the wrong shape to model this on.
+
+Two directions per entry point, and the second is the one `omitempty` alone
+would pass for the wrong reason: a gap-carrying routine wake coalescing into a
+gapless in-flight run leaves that run gapless, and a gapless wake coalescing
+into a gap-carrying run leaves the original gap intact rather than
+half-overwriting it. Add a third: a non-routine wakeup source whose payload
+contains none of the three keys round-trips byte-identically through the merge,
+which is what makes the `json_remove` safe to put in a shared helper.
+
+Repository: round-trip of the three columns including the NULL-versus-zero
+distinction, in `routines_config_fields_test.go`'s neighbourhood. Plus a
+default-injection test covering `CreateRoutine` and `CreateRoutineTx` with an
+empty `CatchUpPolicy` (AC-003.1), and a replay regression test for the table
+rebuild proving every column and timestamp survives and that
+`office_routine_runs` rows are not cascaded away (AC-003.3).
+
+`catch_up_max` write normalization (AC-001.13) is tested on **every** path that
+can currently store an out-of-range value, which is what makes the test
+meaningful — and that is both repository statements, not only the update.
+`CreateRoutine` and `CreateRoutineTx` clamp `<= 0 → 25` today and have no upper
+bound at all, so a create carrying `5000` persists `5000`; the create paths are
+therefore in scope by the same rule that puts the update path there. So:
+`CreateRoutine`, `CreateRoutineTx` and `UpdateRoutine` each with `0` and with
+`5000`, each read back at the clamped value rather than the submitted one, plus
+the handler's create and update paths end to end — **asserting on the create and
+update RESPONSE BODY itself, not only on a subsequent `GET`**. Those two
+responses are the only place part 1's in-place-reassignment decision
+([Normalizing catch_up_max](routine-catch-up-01.md#normalizing-catch_up_max)) is
+observable: both handlers serialize the same `*Routine` they passed to the
+service, so a test that POSTs `5000` and then re-reads the routine with `GET`
+passes identically whether the funnel mutated the struct or only the SQL bind,
+and would not catch the one divergence AC-001.13 exists to prevent. Cover
+`office/service.Service.UpdateRoutine` as well — it is the second service layer
+reaching the repository without the routines handler, and it is what
+`config_import.go` calls, so it is the regression that would prove the clamp was
+put in the handler instead of the repository. Plus a migration
+replay proving a pre-existing row at `0` and one at `5000` are corrected in
+place, and that a row already in range is left untouched — the read-back is the
+assertion AC-001.13 actually names, so assert on the returned routine, not on
+the tick's behaviour, which AC-001.4 already covers.
+
+Prompt: `BuildPromptContextForTest` cases asserting the wake context renders for
+a run with a gap under **each** of the three routine reasons — including
+`RunReasonRoutineDispatchEvent`, which is the promoted-run case a Cron-only gate
+would silently drop (AC-002.5) — and that a non-routine reason with the same
+`context_snapshot` renders nothing.
+
+Frontend: a store/API test that `summarize_missed` round-trips, a test that the
+`catch_up_max` control renders under the summarizing policy and not under
+`skip_missed` (AC-003.8), and `pnpm run i18n:check` for the five locales.
+
+E2E is proposed rather than assumed; the surfaces are listed in the task plan.
diff --git a/docs/specs/office/system-design/scheduler-01.md b/docs/specs/office/system-design/scheduler-01.md
index 45bd8aec06..aeb05902da 100644
--- a/docs/specs/office/system-design/scheduler-01.md
+++ b/docs/specs/office/system-design/scheduler-01.md
@@ -55,7 +55,7 @@ A SQLite-persisted queue of "wake this agent up" requests. Every periodic, event
| Source | Trigger | Payload |
|--------|---------|---------|
-| `routine` | A routine's cron / webhook / manual trigger fires | `{routine_id, variables, missed_ticks?}` |
+| `routine` | A routine's cron / webhook / manual trigger fires | `{routine_id, variables, missed_ticks?, missed_since?, missed_truncated?}` |
| `comment` | Comment posted on a task assigned to this agent (non-self). Also the channel pathway: inbound Telegram/Slack messages become comments on a channel task. | `{task_id, comment_id}` |
| `agent_error` | A sub-agent's session failed (escalation to coordinator) | `{failed_agent_id, failed_session_id?, run_id?, error}` |
| `self` | Agent self-wake via tool call | `{reason, payload?}` |
@@ -112,7 +112,7 @@ Routine fields:
- `assignee_agent_instance_id` - who gets the resulting task / run.
- `status`: `active` | `paused` | `archived`.
- `concurrency_policy`: `coalesce_if_active` (default) | `skip_if_active` | `always_enqueue`.
-- `catch_up_policy`: `enqueue_missed_with_cap` (default, cap 25) | `skip_missed`.
+- `catch_up_policy`: `summarize_missed` (default, cap 25; `enqueue_missed_with_cap` is a deprecated alias) | `skip_missed`.
- `catch_up_max`: integer, default 25.
- `task_template`: JSON. Empty means **lightweight** routine (taskless run per fire). Non-empty means **heavy** routine (fresh task created on the `routine` workflow).
- `variables`: declared template variables (type, default, required).
@@ -144,9 +144,17 @@ Evaluated at dispatch by querying for an in-flight run for the same routine fing
#### Catch-up policy
-If the scheduler was down and missed cron ticks:
-- `skip_missed`: fire only the current tick.
-- `enqueue_missed_with_cap` (default, cap 25): fire missed ticks up to the cap; dropped ticks are not recorded individually but summarized into the next prompt's wake context ("you missed N ticks since X").
+If the scheduler was down and missed cron ticks, resuming always produces exactly
+one run per due trigger, never one run per missed tick. `catch_up_policy` only
+governs whether that one run's gap is measured and reported:
+- `skip_missed`: no gap is recorded or reported.
+- `summarize_missed` (default, cap 25; `enqueue_missed_with_cap` is a deprecated
+ alias): the gap (missed-tick count, first-missed timestamp, truncated flag) is
+ measured, capped at `catch_up_max`, and summarized into the run's wake context
+ ("you missed N ticks since X").
+
+See [the routine catch-up requirement](../requirements/routine-catch-up.md) for
+the full contract.
#### The pre-installed coordinator routine
@@ -158,7 +166,7 @@ description: "Wakes the coordinator every 5 minutes to check workspace a
assignee_agent_id:
status: active
concurrency_policy: coalesce_if_active
-catch_up_policy: enqueue_missed_with_cap
+catch_up_policy: summarize_missed
catch_up_max: 25
task_template: ""
variables: []
diff --git a/docs/specs/office/system-design/scheduler-02.md b/docs/specs/office/system-design/scheduler-02.md
index 12b8530d8e..23131716d0 100644
--- a/docs/specs/office/system-design/scheduler-02.md
+++ b/docs/specs/office/system-design/scheduler-02.md
@@ -71,7 +71,7 @@ description TEXT
assignee_agent_id TEXT NOT NULL
status TEXT NOT NULL -- active | paused | archived
concurrency_policy TEXT NOT NULL -- coalesce_if_active | skip_if_active | always_enqueue
-catch_up_policy TEXT NOT NULL -- enqueue_missed_with_cap | skip_missed
+catch_up_policy TEXT NOT NULL -- summarize_missed (enqueue_missed_with_cap deprecated alias) | skip_missed
catch_up_max INT NOT NULL DEFAULT 25
task_template TEXT NOT NULL DEFAULT '' -- JSON; empty -> lightweight
variables TEXT NOT NULL DEFAULT '[]' -- JSON array
@@ -148,9 +148,11 @@ last_wakeup_finished_at TIMESTAMP
```go
type RoutinePayload struct {
- RoutineID string `json:"routine_id"`
- Variables map[string]any `json:"variables,omitempty"`
- MissedTicks int `json:"missed_ticks,omitempty"` // when catch-up cap collapsed N fires
+ RoutineID string `json:"routine_id"`
+ Variables map[string]any `json:"variables,omitempty"`
+ MissedTicks int `json:"missed_ticks,omitempty"` // ticks counted and reported for this claim's gap, excluding the tick dispatched
+ MissedSince string `json:"missed_since,omitempty"` // RFC3339 timestamp of the first missed tick
+ MissedTruncated bool `json:"missed_truncated,omitempty"` // true when the gap exceeded catch_up_max
}
type CommentPayload struct {
TaskID string `json:"task_id"`
@@ -287,7 +289,7 @@ A formal per-route authorization model (workspace membership, admin role, RBAC o
| Atomic checkout finds task locked by another agent | Wakeup skipped, no retry. |
| Summary builder fails on successful taskless run | Previous summary left intact; failure logged; next successful run rebuilds. |
| Coordinator's pre-installed routine fails to install at onboarding | Logged + warned; coordinator's agent detail UI shows "no scheduled wake-ups" empty state. User can install one manually. |
-| Routine cron tick missed (scheduler down) | `enqueue_missed_with_cap` (default): fire missed ticks up to cap=25 with "missed N ticks" in next prompt context. `skip_missed`: fire current tick only. |
+| Routine cron tick missed (scheduler down) | Resume always produces exactly one run, never one per missed tick. `summarize_missed` (default, cap 25): the gap is measured and summarized as "missed N ticks" in the next prompt context. `skip_missed`: no gap is recorded or reported. See [the routine catch-up requirement](../requirements/routine-catch-up.md). |
| Webhook signature verification fails | Trigger rejected with 401; no routine run created. |
| Graceful shutdown begins | Queue and cron loops cancel and join before repositories and SQLite close; no scheduler logs `database is closed`. |
@@ -297,7 +299,7 @@ Survives a kandev process restart: all `agent_wakeup_requests` rows including `q
Does NOT survive (reconstructed on next tick): in-memory claim leases - a `claimed` wakeup whose process died is picked up by the staleness/recovery path; the scheduler's claim query is the source of truth. The unstarted-task recovery sweep suppresses duplicates with its `NOT EXISTS` check on `runs`: any queued, claimed, or finished run of any age blocks redispatch, while failed and cancelled runs do not.
-Retention: the idempotency lookup window is 24 hours, while persisted idempotency keys remain unique; summary cap 8 KB per row; routine run history retained for inspection (no automatic prune in scope here); catch-up cap (default 25) drops missed routine ticks beyond it (not recorded individually). A live linked task remains in the concurrency gate regardless of age.
+Retention: the idempotency lookup window is 24 hours, while persisted idempotency keys remain unique; summary cap 8 KB per row; routine run history retained for inspection (no automatic prune in scope here); catch-up cap (default 25) bounds only how many missed ticks are counted and reported in the gap summary, never how many runs are dispatched — see [the routine catch-up requirement](../requirements/routine-catch-up.md). A live linked task remains in the concurrency gate regardless of age.
The scheduler reads all `queued` and unexpired-retry wakeup requests on boot and resumes processing them.
@@ -326,7 +328,7 @@ The scheduler reads all `queued` and unexpired-retry wakeup requests on boot and
- **GIVEN** a routine with a webhook trigger and `signing_mode=hmac_sha256`, **WHEN** an external system POSTs to the trigger URL with valid signature and payload `{"branch": "release/2.0"}`, **THEN** the routine fires with `{{branch}}` resolved to "release/2.0" in the task template.
-- **GIVEN** the scheduler was down for 3 hours, **WHEN** it restarts, **THEN** routines with `catch_up_policy=skip_missed` fire only the current tick; routines with `enqueue_missed_with_cap` fire missed ticks up to the cap (default 25), with overflow summarized as "missed N ticks" in the next prompt context.
+- **GIVEN** the scheduler was down for 3 hours, **WHEN** it restarts, **THEN** each due trigger produces exactly one run, regardless of `catch_up_policy`; routines with `catch_up_policy=skip_missed` record no gap; routines with `catch_up_policy=summarize_missed` (default) record the gap, capped at `catch_up_max` (default 25), summarized as "missed N ticks" in the next prompt context. See [the routine catch-up requirement](../requirements/routine-catch-up.md).
- **GIVEN** a user on the routines page, **WHEN** they click "Run Now" on a routine with a required `{{reason}}` variable, **THEN** a modal prompts for the variable value before firing.