{expanded && (
diff --git a/apps/web/app/office/routines/schedule-state-badge.test.tsx b/apps/web/app/office/routines/schedule-state-badge.test.tsx
new file mode 100644
index 0000000000..2619885e9a
--- /dev/null
+++ b/apps/web/app/office/routines/schedule-state-badge.test.tsx
@@ -0,0 +1,106 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+import { TooltipProvider } from "@kandev/ui/tooltip";
+import type { Routine, ScheduleState } from "@/lib/state/slices/office/types";
+import { ScheduleStateBadge, UnarmedScheduleHint } from "./schedule-state-badge";
+
+afterEach(cleanup);
+
+function renderBadge(ui: React.ReactElement) {
+ return render({ui});
+}
+
+function routine(overrides: Record = {}): Routine {
+ return {
+ id: "routine-1",
+ workspaceId: "ws-1",
+ name: "Nightly digest",
+ taskTemplate: {},
+ status: "active",
+ concurrencyPolicy: "skip_if_active",
+ createdAt: "2026-01-01T00:00:00Z",
+ updatedAt: "2026-01-01T00:00:00Z",
+ ...overrides,
+ } as unknown as Routine;
+}
+
+// AC-OFFICE-ROUTINE-ARMING-002.10: every group renders a distinct label, and
+// two states from different groups never render the same text.
+describe("ScheduleStateBadge", () => {
+ const cases: Array<[ScheduleState, string]> = [
+ ["armed", "Armed"],
+ ["trigger_invalid", "Schedule broken"],
+ ["trigger_unscheduled", "Schedule broken"],
+ ["trigger_disabled", "Schedule broken"],
+ ["event_only", "Event-triggered"],
+ ["unscheduled_manual_only", "No schedule"],
+ ["unscheduled_no_trigger", "No schedule"],
+ ["unknown", "Schedule unknown"],
+ ];
+
+ it.each(cases)("renders the group label for schedule state %s", (state, label) => {
+ renderBadge();
+ expect(screen.getByText(label)).toBeTruthy();
+ });
+
+ it("never renders event_only as having no schedule (AC-002.11)", () => {
+ renderBadge();
+ expect(screen.queryByText("No schedule")).toBeNull();
+ });
+
+ it("collapses every state in the same group onto the same label", () => {
+ const labels = new Set();
+ for (const [state, label] of cases) {
+ cleanup();
+ renderBadge();
+ labels.add(label);
+ }
+ // Five distinct groups, not nine distinct per-state labels.
+ expect(labels.size).toBe(5);
+ });
+});
+
+describe("UnarmedScheduleHint", () => {
+ it("renders nothing when the unarmed cron trigger list is empty (AC-002.4)", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("distinguishes an unarmed entry that only needs re-arming", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Needs re-arming")).toBeTruthy();
+ });
+
+ it("distinguishes an unarmed entry that needs its expression or timezone edited", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Needs schedule fix")).toBeTruthy();
+ });
+
+ it("reports 're-arming' once any entry in a mixed list is still schedulable", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Needs re-arming")).toBeTruthy();
+ });
+});
diff --git a/apps/web/app/office/routines/schedule-state-badge.tsx b/apps/web/app/office/routines/schedule-state-badge.tsx
new file mode 100644
index 0000000000..adcc35dae0
--- /dev/null
+++ b/apps/web/app/office/routines/schedule-state-badge.tsx
@@ -0,0 +1,123 @@
+"use client";
+
+import { Badge } from "@kandev/ui/badge";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
+import {
+ IconAlertTriangle,
+ IconCalendarOff,
+ IconCircleCheck,
+ IconHelpCircle,
+ IconWebhook,
+} from "@tabler/icons-react";
+import type { Icon } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import type { TFunction } from "i18next";
+import type { Routine } from "@/lib/state/slices/office/types";
+import {
+ hasSchedulableUnarmedEntry,
+ readScheduleState,
+ readUnarmedCronTriggers,
+ scheduleStateGroup,
+ type ScheduleStateGroup,
+} from "./schedule-state";
+
+const GROUP_LABEL_KEYS: Record = {
+ armed: "office:scheduleStateArmed",
+ broken: "office:scheduleStateBroken",
+ event_only: "office:scheduleStateEventOnly",
+ no_schedule: "office:scheduleStateNoSchedule",
+ unknown: "office:scheduleStateUnknown",
+};
+
+const GROUP_DESCRIPTION_KEYS: Record = {
+ armed: "office:scheduleStateArmedDescription",
+ broken: "office:scheduleStateBrokenDescription",
+ event_only: "office:scheduleStateEventOnlyDescription",
+ no_schedule: "office:scheduleStateNoScheduleDescription",
+ unknown: "office:scheduleStateUnknownDescription",
+};
+
+const GROUP_ICONS: Record = {
+ armed: IconCircleCheck,
+ broken: IconAlertTriangle,
+ event_only: IconWebhook,
+ no_schedule: IconCalendarOff,
+ unknown: IconHelpCircle,
+};
+
+const GROUP_VARIANTS: Record<
+ ScheduleStateGroup,
+ "default" | "secondary" | "destructive" | "outline"
+> = {
+ armed: "default",
+ broken: "destructive",
+ event_only: "outline",
+ no_schedule: "secondary",
+ unknown: "outline",
+};
+
+/**
+ * ScheduleStateBadge renders the second, independent switch
+ * (REQ-OFFICE-ROUTINE-ARMING-001's schedule state) alongside a routine's
+ * existing intent badge. Each of the five label groups carries its own text
+ * and icon (AC-002.9, AC-002.10) so it never collapses to color alone.
+ */
+export function ScheduleStateBadge({
+ routine,
+ className,
+}: {
+ routine: Routine;
+ className?: string;
+}) {
+ const { t } = useTranslation();
+ const group = scheduleStateGroup(readScheduleState(routine));
+ const GroupIcon = GROUP_ICONS[group];
+ const label = t(GROUP_LABEL_KEYS[group]);
+
+ return (
+
+
+
+
+ {label}
+
+
+ {t(GROUP_DESCRIPTION_KEYS[group])}
+
+ );
+}
+
+// unarmedHintLabel resolves AC-OFFICE-ROUTINE-ARMING-002.4's schedulable vs.
+// non-schedulable distinction to copy. Only called once the unarmed list is
+// confirmed non-empty, so "schedulable" and "not schedulable" are always a
+// real two-way split, never a vacuous one.
+function unarmedHintLabel(t: TFunction, schedulable: boolean): string {
+ return schedulable ? t("office:unarmedNeedsRearm") : t("office:unarmedNeedsEdit");
+}
+
+/**
+ * UnarmedScheduleHint renders nothing when a routine's unarmed cron trigger
+ * list is empty (AC-002.4's suppression rule), and otherwise distinguishes a
+ * routine that only needs its existing triggers re-armed from one whose cron
+ * expression or timezone needs editing first.
+ */
+export function UnarmedScheduleHint({
+ routine,
+ className,
+}: {
+ routine: Routine;
+ className?: string;
+}) {
+ const { t } = useTranslation();
+ const unarmed = readUnarmedCronTriggers(routine);
+ if (unarmed.length === 0) return null;
+
+ const schedulable = hasSchedulableUnarmedEntry(unarmed);
+ const label = unarmedHintLabel(t, schedulable);
+
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/web/app/office/routines/schedule-state.test.ts b/apps/web/app/office/routines/schedule-state.test.ts
new file mode 100644
index 0000000000..9548339868
--- /dev/null
+++ b/apps/web/app/office/routines/schedule-state.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from "vitest";
+import type { Routine, ScheduleState, UnarmedCronTrigger } from "@/lib/state/slices/office/types";
+import {
+ hasSchedulableUnarmedEntry,
+ readScheduleState,
+ readUnarmedCronTriggers,
+ scheduleStateGroup,
+} from "./schedule-state";
+
+// AC-OFFICE-ROUTINE-ARMING-002.10: every one of the nine wire values maps to
+// exactly one of the five label groups, and two states in different groups
+// never collapse to the same group.
+describe("scheduleStateGroup", () => {
+ it.each<[ScheduleState, string]>([
+ ["armed", "armed"],
+ ["trigger_invalid", "broken"],
+ ["trigger_unscheduled", "broken"],
+ ["trigger_disabled", "broken"],
+ ["event_only", "event_only"],
+ ["unscheduled_manual_only", "no_schedule"],
+ ["unscheduled_no_trigger", "no_schedule"],
+ ["unknown", "unknown"],
+ ])("maps %s to group %s", (state, group) => {
+ expect(scheduleStateGroup(state)).toBe(group);
+ });
+
+ it("treats a missing state as unknown rather than throwing", () => {
+ expect(scheduleStateGroup(undefined)).toBe("unknown");
+ });
+});
+
+function routineWithRaw(raw: Record): Routine {
+ return raw as unknown as Routine;
+}
+
+describe("readScheduleState / readUnarmedCronTriggers", () => {
+ it("reads the camelCase field when a mapping layer already provided it", () => {
+ const routine = routineWithRaw({ scheduleState: "armed" });
+ expect(readScheduleState(routine)).toBe("armed");
+ });
+
+ it("returns undefined when the mapping layer has no schedule state", () => {
+ expect(readScheduleState(routineWithRaw({}))).toBeUndefined();
+ });
+
+ it("reads normalized unarmed cron triggers", () => {
+ const routine = routineWithRaw({
+ unarmedCronTriggers: [{ triggerId: "trig-1", reasons: ["disabled"] }],
+ });
+ expect(readUnarmedCronTriggers(routine)).toEqual([
+ { triggerId: "trig-1", reasons: ["disabled"] },
+ ]);
+ });
+
+ it("returns an empty list when the field is absent", () => {
+ expect(readUnarmedCronTriggers(routineWithRaw({}))).toEqual([]);
+ });
+});
+
+describe("hasSchedulableUnarmedEntry", () => {
+ it("is true when at least one unarmed entry only needs re-arming", () => {
+ const unarmed: UnarmedCronTrigger[] = [
+ { triggerId: "t1", reasons: ["not_schedulable"] },
+ { triggerId: "t2", reasons: ["disabled"] },
+ ];
+ expect(hasSchedulableUnarmedEntry(unarmed)).toBe(true);
+ });
+
+ it("is false when every unarmed entry needs its expression or timezone edited", () => {
+ const unarmed: UnarmedCronTrigger[] = [{ triggerId: "t1", reasons: ["not_schedulable"] }];
+ expect(hasSchedulableUnarmedEntry(unarmed)).toBe(false);
+ });
+
+ it("is false over an empty list", () => {
+ expect(hasSchedulableUnarmedEntry([])).toBe(false);
+ });
+});
diff --git a/apps/web/app/office/routines/schedule-state.ts b/apps/web/app/office/routines/schedule-state.ts
new file mode 100644
index 0000000000..a919da032b
--- /dev/null
+++ b/apps/web/app/office/routines/schedule-state.ts
@@ -0,0 +1,50 @@
+import type { Routine, ScheduleState, UnarmedCronTrigger } from "@/lib/state/slices/office/types";
+
+/**
+ * ScheduleStateGroup collapses the nine wire values of `ScheduleState` into
+ * the five label groups AC-OFFICE-ROUTINE-ARMING-002.10 requires: two
+ * routines in different groups never render the same label.
+ */
+export type ScheduleStateGroup = "armed" | "broken" | "event_only" | "no_schedule" | "unknown";
+
+const GROUP_BY_STATE: Record = {
+ armed: "armed",
+ trigger_invalid: "broken",
+ trigger_unscheduled: "broken",
+ trigger_disabled: "broken",
+ event_only: "event_only",
+ unscheduled_manual_only: "no_schedule",
+ unscheduled_no_trigger: "no_schedule",
+ unknown: "unknown",
+};
+
+export function scheduleStateGroup(state: ScheduleState | undefined): ScheduleStateGroup {
+ return state ? (GROUP_BY_STATE[state] ?? "unknown") : "unknown";
+}
+
+// The API normalizer translates the wire fields before a routine reaches the
+// UI. These readers keep the component code independent of that wire shape.
+export function readScheduleState(routine: Routine): ScheduleState | undefined {
+ return routine.scheduleState;
+}
+
+export function readUnarmedCronTriggers(routine: Routine): UnarmedCronTrigger[] {
+ return routine.unarmedCronTriggers ?? [];
+}
+
+// isSchedulableEntry reports whether this unarmed cron trigger could still
+// fire once re-armed (enabled and/or its next occurrence recomputed), as
+// opposed to one whose cron expression or timezone needs editing first.
+function isSchedulableEntry(entry: UnarmedCronTrigger): boolean {
+ return !entry.reasons.includes("not_schedulable");
+}
+
+/**
+ * hasSchedulableUnarmedEntry implements AC-OFFICE-ROUTINE-ARMING-002.4's
+ * distinction: at least one entry that only needs re-arming, versus none.
+ * Meaningless over an empty list; the caller suppresses rendering entirely
+ * in that case rather than asking the question.
+ */
+export function hasSchedulableUnarmedEntry(unarmed: UnarmedCronTrigger[]): boolean {
+ return unarmed.some(isSchedulableEntry);
+}
diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts
index b9d9471269..4903ad7fbf 100644
--- a/apps/web/e2e/helpers/api-client.ts
+++ b/apps/web/e2e/helpers/api-client.ts
@@ -1670,6 +1670,35 @@ export class ApiClient {
return this.request("POST", "/api/v1/_test/comments", payload);
}
+ /**
+ * Inserts an office_routine_triggers row directly, bypassing the public
+ * create-trigger endpoint's cron validation. The public endpoint always
+ * persists a schedulable, enabled trigger with a computed next_run_at, so
+ * it cannot produce trigger_invalid, trigger_unscheduled, or
+ * trigger_disabled; this seed is the only way an E2E fixture reaches
+ * every REQ-OFFICE-ROUTINE-ARMING-001 schedule state.
+ */
+ async seedRoutineTrigger(opts: {
+ routineId: string;
+ kind: "cron" | "webhook" | "manual";
+ cronExpression?: string;
+ timezone?: string;
+ enabled?: boolean;
+ nextRunAt?: string;
+ lastFiredAt?: string;
+ }): Promise<{ trigger_id: string }> {
+ const payload: Record = {
+ routine_id: opts.routineId,
+ kind: opts.kind,
+ };
+ if (opts.cronExpression !== undefined) payload.cron_expression = opts.cronExpression;
+ if (opts.timezone !== undefined) payload.timezone = opts.timezone;
+ if (opts.enabled !== undefined) payload.enabled = opts.enabled;
+ if (opts.nextRunAt !== undefined) payload.next_run_at = opts.nextRunAt;
+ if (opts.lastFiredAt !== undefined) payload.last_fired_at = opts.lastFiredAt;
+ return this.request("POST", "/api/v1/_test/routine-triggers", payload);
+ }
+
// --- GitHub Mock Control ---
async mockGitHubReset(): Promise {
diff --git a/apps/web/e2e/tests/office/mobile-routine-schedule-state-layout.spec.ts b/apps/web/e2e/tests/office/mobile-routine-schedule-state-layout.spec.ts
new file mode 100644
index 0000000000..05bd0bf687
--- /dev/null
+++ b/apps/web/e2e/tests/office/mobile-routine-schedule-state-layout.spec.ts
@@ -0,0 +1,39 @@
+import { expect, test } from "../../fixtures/office-fixture";
+import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
+
+test.describe("Mobile routine schedule-state row", () => {
+ test("wraps schedule metadata and keeps row actions reachable", async ({
+ testPage,
+ officeApi,
+ officeSeed,
+ apiClient,
+ }) => {
+ const routine = (await officeApi.createRoutine(officeSeed.workspaceId, {
+ name: "E2E Mobile Routine With A Long Name For Schedule Status",
+ })) as { id: string };
+ await apiClient.seedRoutineTrigger({
+ routineId: routine.id,
+ kind: "cron",
+ cronExpression: "0 0 30 2 *",
+ enabled: true,
+ });
+
+ await testPage.goto("/office/routines");
+ const row = testPage.getByTestId(`routine-row-${routine.id}`);
+ await expect(row).toBeVisible({ timeout: 10_000 });
+ await expect(
+ row
+ .getByText(/^(Armed|Schedule broken|Event-triggered|No schedule|Schedule unknown)$/)
+ .first(),
+ ).toBeVisible();
+
+ await assertNoDocumentHorizontalOverflow(testPage, "mobile routine schedule-state row");
+
+ const viewport = testPage.viewportSize();
+ const action = row.getByRole("button").last();
+ const actionBox = await action.boundingBox();
+ expect(viewport).toBeTruthy();
+ expect(actionBox).toBeTruthy();
+ expect(actionBox!.x + actionBox!.width).toBeLessThanOrEqual(viewport!.width);
+ });
+});
diff --git a/apps/web/e2e/tests/office/routine-schedule-state.spec.ts b/apps/web/e2e/tests/office/routine-schedule-state.spec.ts
new file mode 100644
index 0000000000..e816b1c6a8
--- /dev/null
+++ b/apps/web/e2e/tests/office/routine-schedule-state.spec.ts
@@ -0,0 +1,139 @@
+import type { Page } from "@playwright/test";
+import { test, expect } from "../../fixtures/office-fixture";
+
+// REQ-OFFICE-ROUTINE-ARMING-002: schedule state is a second switch,
+// independent of a routine's intent (status). This spec seeds one routine
+// per reachable schedule state and asserts the routines list renders the
+// correct AC-002.10 label group for each, with no two groups ever
+// rendering the same label.
+//
+// office_routine_triggers has no PATCH route, and the public create-trigger
+// endpoint validates the cron expression and always computes a next_run_at,
+// so it cannot produce `trigger_invalid`, `trigger_unscheduled`, or
+// `trigger_disabled`. Those three are seeded directly through the
+// KANDEV_E2E_MOCK-gated /_test/routine-triggers route
+// (ApiClient.seedRoutineTrigger) instead. `unknown` requires a live
+// database read failure and has no fixture-reachable trigger shape; it is
+// covered by the backend unit tests in arming_test.go instead.
+
+const futureCronRun = () => new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
+
+function rowForRoutine(page: Page, name: string) {
+ return page.getByRole("link", { name, exact: true }).locator("xpath=../..");
+}
+
+test.describe("Routine schedule state rendering", () => {
+ test("routines list renders a distinct label per schedule-state group", async ({
+ testPage,
+ officeApi,
+ officeSeed,
+ apiClient,
+ }) => {
+ const cases: Array<{
+ name: string;
+ group: string;
+ expectedLabel: string;
+ seed?: (routineId: string) => Promise;
+ }> = [
+ {
+ name: "E2E Armed Routine",
+ group: "armed",
+ expectedLabel: "Armed",
+ seed: (routineId) =>
+ apiClient.seedRoutineTrigger({
+ routineId,
+ kind: "cron",
+ cronExpression: "0 9 * * *",
+ enabled: true,
+ nextRunAt: futureCronRun(),
+ }),
+ },
+ {
+ name: "E2E Invalid Cron Routine",
+ group: "broken",
+ expectedLabel: "Schedule broken",
+ seed: (routineId) =>
+ apiClient.seedRoutineTrigger({
+ routineId,
+ kind: "cron",
+ cronExpression: "not a cron expression",
+ enabled: true,
+ }),
+ },
+ {
+ name: "E2E Unscheduled Cron Routine",
+ group: "broken",
+ expectedLabel: "Schedule broken",
+ seed: (routineId) =>
+ apiClient.seedRoutineTrigger({
+ routineId,
+ kind: "cron",
+ cronExpression: "0 9 * * *",
+ enabled: true,
+ }),
+ },
+ {
+ name: "E2E Disabled Cron Routine",
+ group: "broken",
+ expectedLabel: "Schedule broken",
+ seed: (routineId) =>
+ apiClient.seedRoutineTrigger({
+ routineId,
+ kind: "cron",
+ cronExpression: "0 9 * * *",
+ enabled: false,
+ nextRunAt: futureCronRun(),
+ }),
+ },
+ {
+ name: "E2E Webhook Routine",
+ group: "event_only",
+ expectedLabel: "Event-triggered",
+ seed: (routineId) =>
+ apiClient.seedRoutineTrigger({ routineId, kind: "webhook", enabled: true }),
+ },
+ {
+ name: "E2E Manual Only Routine",
+ group: "no_schedule",
+ expectedLabel: "No schedule",
+ seed: (routineId) =>
+ apiClient.seedRoutineTrigger({ routineId, kind: "manual", enabled: true }),
+ },
+ {
+ name: "E2E No Trigger Routine",
+ group: "no_schedule",
+ expectedLabel: "No schedule",
+ },
+ ];
+
+ for (const testCase of cases) {
+ const routine = await officeApi.createRoutine(officeSeed.workspaceId, {
+ name: testCase.name,
+ });
+ if (testCase.seed) {
+ await testCase.seed(routine.id as string);
+ }
+ }
+
+ await testPage.goto("/office/routines");
+
+ for (const testCase of cases) {
+ const row = rowForRoutine(testPage, testCase.name);
+ await expect(row.getByText(testCase.expectedLabel, { exact: true })).toBeVisible({
+ timeout: 10_000,
+ });
+ }
+
+ // AC-002.11: event_only never renders as having no schedule, even
+ // though it also fires without a cron trigger.
+ const webhookRow = rowForRoutine(testPage, "E2E Webhook Routine");
+ await expect(webhookRow.getByText("No schedule", { exact: true })).toHaveCount(0);
+
+ // AC-002.10: every case sharing a group above already asserted the same
+ // literal label text; the broken group additionally proves it across
+ // three different underlying raw states (trigger_invalid,
+ // trigger_unscheduled, trigger_disabled), not just two.
+ const brokenRoutineNames = cases.filter((c) => c.group === "broken").map((c) => c.name);
+ expect(brokenRoutineNames).toHaveLength(3);
+ });
+});
diff --git a/apps/web/lib/api/domains/office-routine-normalize.test.ts b/apps/web/lib/api/domains/office-routine-normalize.test.ts
index f5c362a8ff..17c086e729 100644
--- a/apps/web/lib/api/domains/office-routine-normalize.test.ts
+++ b/apps/web/lib/api/domains/office-routine-normalize.test.ts
@@ -67,6 +67,20 @@ describe("normalizeRoutine", () => {
expect(routine.lastRunAt).toBeUndefined();
});
+ it("normalizes schedule state metadata from the list response", () => {
+ expect(
+ normalizeRoutine({
+ schedule_state: "trigger_invalid",
+ unarmed_cron_triggers: [
+ { trigger_id: "trigger-1", reasons: ["not_schedulable", "unknown"] },
+ ],
+ }),
+ ).toMatchObject({
+ scheduleState: "trigger_invalid",
+ unarmedCronTriggers: [{ triggerId: "trigger-1", reasons: ["not_schedulable"] }],
+ });
+ });
+
it("produces an empty object for task_template when absent, null, or malformed", () => {
expect(normalizeRoutine({}).taskTemplate).toEqual({});
expect(normalizeRoutine({ task_template: null }).taskTemplate).toEqual({});
diff --git a/apps/web/lib/api/domains/office-routine-normalize.ts b/apps/web/lib/api/domains/office-routine-normalize.ts
index 318c16795e..a9d60cc828 100644
--- a/apps/web/lib/api/domains/office-routine-normalize.ts
+++ b/apps/web/lib/api/domains/office-routine-normalize.ts
@@ -4,7 +4,10 @@ import type {
Routine,
RoutineRun,
RoutineTrigger,
+ ScheduleState,
UpdateRoutinePatch,
+ UnarmedCronTrigger,
+ UnarmedReason,
} from "@/lib/state/slices/office/types";
type WireRecord = Record;
@@ -33,6 +36,38 @@ function optBool(record: WireRecord, key: string): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
+const SCHEDULE_STATES = new Set([
+ "armed",
+ "trigger_invalid",
+ "trigger_unscheduled",
+ "trigger_disabled",
+ "event_only",
+ "unscheduled_manual_only",
+ "unscheduled_no_trigger",
+ "unknown",
+]);
+
+const UNARMED_REASONS = new Set(["disabled", "not_schedulable", "stalled"]);
+
+function normalizeScheduleState(value: unknown): ScheduleState | undefined {
+ if (typeof value !== "string") return undefined;
+ return SCHEDULE_STATES.has(value as ScheduleState) ? (value as ScheduleState) : undefined;
+}
+
+function normalizeUnarmedCronTriggers(value: unknown): UnarmedCronTrigger[] {
+ if (!Array.isArray(value)) return [];
+ return value.filter(isJSONObject).map((entry) => {
+ const trigger = asRecord(entry);
+ const reasons = Array.isArray(trigger.reasons)
+ ? trigger.reasons.filter(
+ (reason): reason is UnarmedReason =>
+ typeof reason === "string" && UNARMED_REASONS.has(reason as UnarmedReason),
+ )
+ : [];
+ return { triggerId: str(trigger, "trigger_id"), reasons };
+ });
+}
+
function isJSONObject(value: unknown): value is WireRecord {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -68,7 +103,7 @@ function assignIfDefined(body: WireRecord, key: string, value: unknown): void {
export function normalizeRoutine(raw: unknown): Routine {
const r = asRecord(raw);
- return {
+ const routine: Routine = {
id: str(r, "id"),
workspaceId: str(r, "workspace_id"),
name: str(r, "name"),
@@ -84,6 +119,12 @@ export function normalizeRoutine(raw: unknown): Routine {
createdAt: str(r, "created_at"),
updatedAt: str(r, "updated_at"),
};
+ const scheduleState = normalizeScheduleState(r.schedule_state);
+ if (scheduleState !== undefined) routine.scheduleState = scheduleState;
+ if (Array.isArray(r.unarmed_cron_triggers)) {
+ routine.unarmedCronTriggers = normalizeUnarmedCronTriggers(r.unarmed_cron_triggers);
+ }
+ return routine;
}
export function normalizeRoutineTrigger(raw: unknown): RoutineTrigger {
diff --git a/apps/web/lib/state/slices/office/types.ts b/apps/web/lib/state/slices/office/types.ts
index 430ac22baf..9dc44f6734 100644
--- a/apps/web/lib/state/slices/office/types.ts
+++ b/apps/web/lib/state/slices/office/types.ts
@@ -150,6 +150,25 @@ export type BudgetPolicy = {
export type RoutineStatus = "active" | "paused" | "archived";
+// ScheduleState is REQ-OFFICE-ROUTINE-ARMING-001's classification of whether a
+// routine's triggers can currently fire it, independent of `status` (intent).
+export type ScheduleState =
+ | "armed"
+ | "trigger_invalid"
+ | "trigger_unscheduled"
+ | "trigger_disabled"
+ | "event_only"
+ | "unscheduled_manual_only"
+ | "unscheduled_no_trigger"
+ | "unknown";
+
+export type UnarmedReason = "disabled" | "not_schedulable" | "stalled";
+
+export type UnarmedCronTrigger = {
+ triggerId: string;
+ reasons: UnarmedReason[];
+};
+
export type Routine = {
id: string;
workspaceId: string;
@@ -167,6 +186,8 @@ export type Routine = {
lastRunAt?: string;
createdAt: string;
updatedAt: string;
+ scheduleState?: ScheduleState;
+ unarmedCronTriggers?: UnarmedCronTrigger[];
};
export type RoutineTriggerKind = "cron" | "webhook";
diff --git a/apps/web/src/locales/en/office.json b/apps/web/src/locales/en/office.json
index ed8bae9f61..828ca50a88 100644
--- a/apps/web/src/locales/en/office.json
+++ b/apps/web/src/locales/en/office.json
@@ -780,6 +780,16 @@
"saving": "Saving...",
"savingEllipsis": "Saving…",
"schedule": "Schedule",
+ "scheduleStateArmed": "Armed",
+ "scheduleStateArmedDescription": "A cron trigger can fire this routine on schedule.",
+ "scheduleStateBroken": "Schedule broken",
+ "scheduleStateBrokenDescription": "This routine has a cron trigger, but it cannot currently fire.",
+ "scheduleStateEventOnly": "Event-triggered",
+ "scheduleStateEventOnlyDescription": "Fires when its webhook event arrives, not on a schedule.",
+ "scheduleStateNoSchedule": "No schedule",
+ "scheduleStateNoScheduleDescription": "This routine has no schedule and will not fire on its own.",
+ "scheduleStateUnknown": "Schedule unknown",
+ "scheduleStateUnknownDescription": "The routine's triggers could not be read, so its schedule state could not be determined.",
"scopeId": "Scope ID",
"scopeType": "Scope Type",
"search": "Search...",
@@ -948,6 +958,8 @@
"type": "Type",
"typeNameToConfirm": "Type {{name}} to confirm",
"typicalMappingAtLaunchTimeThe": "Typical mapping. At launch time the router walks the provider order and picks the first healthy provider, so a degraded primary may cause this run to use a different provider/model than shown here.",
+ "unarmedNeedsEdit": "Needs schedule fix",
+ "unarmedNeedsRearm": "Needs re-arming",
"unassigned": "Unassigned",
"unassignedParen": "(unassigned)",
"unknownError": "Unknown error",
diff --git a/apps/web/src/locales/pseudo/office.json b/apps/web/src/locales/pseudo/office.json
index d11ba3fb31..f35fe94dd9 100644
--- a/apps/web/src/locales/pseudo/office.json
+++ b/apps/web/src/locales/pseudo/office.json
@@ -780,6 +780,16 @@
"saving": "Śàvĩńĝ...",
"savingEllipsis": "Śàvĩńĝ…",
"schedule": "Śćĥēďũĺē",
+ "scheduleStateArmed": "Àŕḿēď",
+ "scheduleStateArmedDescription": "À ćŕōń ţŕĩĝĝēŕ ćàń ƒĩŕē ţĥĩś ŕōũţĩńē ōń śćĥēďũĺē.",
+ "scheduleStateBroken": "Śćĥēďũĺē ƀŕōķēń",
+ "scheduleStateBrokenDescription": "Ţĥĩś ŕōũţĩńē ĥàś à ćŕōń ţŕĩĝĝēŕ, ƀũţ ĩţ ćàńńōţ ćũŕŕēńţĺŷ ƒĩŕē.",
+ "scheduleStateEventOnly": "Ēvēńţ-ţŕĩĝĝēŕēď",
+ "scheduleStateEventOnlyDescription": "Ƒĩŕēś ŵĥēń ĩţś ŵēƀĥōōķ ēvēńţ àŕŕĩvēś, ńōţ ōń à śćĥēďũĺē.",
+ "scheduleStateNoSchedule": "Ńō śćĥēďũĺē",
+ "scheduleStateNoScheduleDescription": "Ţĥĩś ŕōũţĩńē ĥàś ńō śćĥēďũĺē àńď ŵĩĺĺ ńōţ ƒĩŕē ōń ĩţś ōŵń.",
+ "scheduleStateUnknown": "Śćĥēďũĺē ũńķńōŵń",
+ "scheduleStateUnknownDescription": "Ţĥē ŕōũţĩńē'ś ţŕĩĝĝēŕś ćōũĺď ńōţ ƀē ŕēàď, śō ĩţś śćĥēďũĺē śţàţē ćōũĺď ńōţ ƀē ďēţēŕḿĩńēď.",
"scopeId": "Śćōƥē ĨĎ",
"scopeType": "Śćōƥē Ţŷƥē",
"search": "Śēàŕćĥ...",
@@ -948,6 +958,8 @@
"type": "Ţŷƥē",
"typeNameToConfirm": "Ţŷƥē {{name}} ţō ćōńƒĩŕḿ",
"typicalMappingAtLaunchTimeThe": "Ţŷƥĩćàĺ ḿàƥƥĩńĝ. Àţ ĺàũńćĥ ţĩḿē ţĥē ŕōũţēŕ ŵàĺķś ţĥē ƥŕōvĩďēŕ ōŕďēŕ àńď ƥĩćķś ţĥē ƒĩŕśţ ĥēàĺţĥŷ ƥŕōvĩďēŕ, śō à ďēĝŕàďēď ƥŕĩḿàŕŷ ḿàŷ ćàũśē ţĥĩś ŕũń ţō ũśē à ďĩƒƒēŕēńţ ƥŕōvĩďēŕ/ḿōďēĺ ţĥàń śĥōŵń ĥēŕē.",
+ "unarmedNeedsEdit": "Ńēēďś śćĥēďũĺē ƒĩx",
+ "unarmedNeedsRearm": "Ńēēďś ŕē-àŕḿĩńĝ",
"unassigned": "Ũńàśśĩĝńēď",
"unassignedParen": "(ũńàśśĩĝńēď)",
"unknownError": "Ũńķńōŵń ēŕŕōŕ",
diff --git a/apps/web/src/locales/pt-pt/office.json b/apps/web/src/locales/pt-pt/office.json
index 8093c47ebe..ef584c1139 100644
--- a/apps/web/src/locales/pt-pt/office.json
+++ b/apps/web/src/locales/pt-pt/office.json
@@ -780,6 +780,16 @@
"saving": "A guardar...",
"savingEllipsis": "A guardar…",
"schedule": "Agendamento",
+ "scheduleStateArmed": "Armado",
+ "scheduleStateArmedDescription": "Existe um acionador cron que pode disparar esta rotina de acordo com o agendamento.",
+ "scheduleStateBroken": "Agendamento com falha",
+ "scheduleStateBrokenDescription": "Esta rotina tem um acionador cron, mas este não consegue disparar atualmente.",
+ "scheduleStateEventOnly": "Acionada por evento",
+ "scheduleStateEventOnlyDescription": "Dispara quando chega o respetivo evento de webhook, não segundo um agendamento.",
+ "scheduleStateNoSchedule": "Sem agendamento",
+ "scheduleStateNoScheduleDescription": "Esta rotina não tem agendamento e não dispara por si própria.",
+ "scheduleStateUnknown": "Agendamento desconhecido",
+ "scheduleStateUnknownDescription": "Não foi possível ler os acionadores desta rotina, pelo que não foi possível determinar o seu estado de agendamento.",
"scopeId": "ID do âmbito",
"scopeType": "Tipo de âmbito",
"search": "Pesquisar...",
@@ -948,6 +958,8 @@
"type": "Tipo",
"typeNameToConfirm": "Escreva {{name}} para confirmar",
"typicalMappingAtLaunchTimeThe": "Mapeamento típico. No momento do lançamento, o encaminhador percorre a ordem dos fornecedores e escolhe o primeiro que estiver saudável, pelo que um fornecedor principal degradado pode fazer com que esta execução use um fornecedor/modelo diferente do aqui apresentado.",
+ "unarmedNeedsEdit": "Necessita de correção do agendamento",
+ "unarmedNeedsRearm": "Necessita de ser reativado",
"unassigned": "Sem responsável",
"unassignedParen": "(sem responsável)",
"unknownError": "Erro desconhecido",
diff --git a/apps/web/src/locales/zh-cn/office.json b/apps/web/src/locales/zh-cn/office.json
index 4098eb9ed2..07334ffad5 100644
--- a/apps/web/src/locales/zh-cn/office.json
+++ b/apps/web/src/locales/zh-cn/office.json
@@ -780,6 +780,16 @@
"saving": "正在保存…",
"savingEllipsis": "正在保存…",
"schedule": "计划",
+ "scheduleStateArmed": "已就绪",
+ "scheduleStateArmedDescription": "存在可按计划触发此例程的定时触发器。",
+ "scheduleStateBroken": "计划异常",
+ "scheduleStateBrokenDescription": "此例程存在定时触发器,但目前无法触发。",
+ "scheduleStateEventOnly": "事件触发",
+ "scheduleStateEventOnlyDescription": "当其 Webhook 事件到达时触发,而非按计划触发。",
+ "scheduleStateNoSchedule": "无计划",
+ "scheduleStateNoScheduleDescription": "此例程没有计划,不会自行触发。",
+ "scheduleStateUnknown": "计划状态未知",
+ "scheduleStateUnknownDescription": "无法读取此例程的触发器,因此无法确定其计划状态。",
"scopeId": "范围 ID",
"scopeType": "范围类型",
"search": "搜索…",
@@ -948,6 +958,8 @@
"type": "类型",
"typeNameToConfirm": "输入 {{name}} 以确认",
"typicalMappingAtLaunchTimeThe": "典型映射。启动时路由会按提供商顺序遍历并选择第一个健康提供商,因此主提供商降级时,此运行可能使用与此处显示不同的提供商/模型。",
+ "unarmedNeedsEdit": "需要修正计划",
+ "unarmedNeedsRearm": "需要重新启用",
"unassigned": "未分配",
"unassignedParen": "(未分配)",
"unknownError": "未知错误",
diff --git a/apps/web/src/locales/zh-hk/office.json b/apps/web/src/locales/zh-hk/office.json
index b7b0a497ad..b8dfec3547 100644
--- a/apps/web/src/locales/zh-hk/office.json
+++ b/apps/web/src/locales/zh-hk/office.json
@@ -780,6 +780,16 @@
"saving": "正在儲存…",
"savingEllipsis": "正在儲存…",
"schedule": "計劃",
+ "scheduleStateArmed": "已就緒",
+ "scheduleStateArmedDescription": "存在可按計劃觸發此例程的定時觸發器。",
+ "scheduleStateBroken": "計劃異常",
+ "scheduleStateBrokenDescription": "此例程存在定時觸發器,但目前無法觸發。",
+ "scheduleStateEventOnly": "事件觸發",
+ "scheduleStateEventOnlyDescription": "當其 Webhook 事件到達時觸發,而非按計劃觸發。",
+ "scheduleStateNoSchedule": "無計劃",
+ "scheduleStateNoScheduleDescription": "此例程沒有計劃,不會自行觸發。",
+ "scheduleStateUnknown": "計劃狀態未知",
+ "scheduleStateUnknownDescription": "無法讀取此例程的觸發器,因此無法確定其計劃狀態。",
"scopeId": "範圍 ID",
"scopeType": "範圍類型",
"search": "搜尋…",
@@ -948,6 +958,8 @@
"type": "類型",
"typeNameToConfirm": "輸入 {{name}} 以確認",
"typicalMappingAtLaunchTimeThe": "典型映射。啓動時路由會按提供者順序遍歷並選擇第一個健康提供者,因此主提供者降級時,此執行可能使用與此處顯示不同的提供者/模型。",
+ "unarmedNeedsEdit": "需要修正計劃",
+ "unarmedNeedsRearm": "需要重新啓用",
"unassigned": "未分配",
"unassignedParen": "(未分配)",
"unknownError": "未知錯誤",
diff --git a/apps/web/src/locales/zh-tw/office.json b/apps/web/src/locales/zh-tw/office.json
index 9a95ac78c6..ac216f500b 100644
--- a/apps/web/src/locales/zh-tw/office.json
+++ b/apps/web/src/locales/zh-tw/office.json
@@ -780,6 +780,16 @@
"saving": "正在儲存…",
"savingEllipsis": "正在儲存…",
"schedule": "計劃",
+ "scheduleStateArmed": "已就緒",
+ "scheduleStateArmedDescription": "存在可按計劃觸發此例程的定時觸發器。",
+ "scheduleStateBroken": "計劃異常",
+ "scheduleStateBrokenDescription": "此例程存在定時觸發器,但目前無法觸發。",
+ "scheduleStateEventOnly": "事件觸發",
+ "scheduleStateEventOnlyDescription": "當其 Webhook 事件到達時觸發,而非按計劃觸發。",
+ "scheduleStateNoSchedule": "無計劃",
+ "scheduleStateNoScheduleDescription": "此例程沒有計劃,不會自行觸發。",
+ "scheduleStateUnknown": "計劃狀態未知",
+ "scheduleStateUnknownDescription": "無法讀取此例程的觸發器,因此無法確定其計劃狀態。",
"scopeId": "範圍 ID",
"scopeType": "範圍型別",
"search": "搜尋…",
@@ -948,6 +958,8 @@
"type": "型別",
"typeNameToConfirm": "輸入 {{name}} 以確認",
"typicalMappingAtLaunchTimeThe": "典型對映。啟動時路由會按提供者順序遍歷並選擇第一個健康提供者,因此主提供者降級時,此執行可能使用與此處顯示不同的提供者/模型。",
+ "unarmedNeedsEdit": "需要修正計劃",
+ "unarmedNeedsRearm": "需要重新啟用",
"unassigned": "未分配",
"unassignedParen": "(未分配)",
"unknownError": "未知錯誤",
diff --git a/apps/web/src/office-routine-client-routes.test.tsx b/apps/web/src/office-routine-client-routes.test.tsx
index 1b6192f4de..9c03e34c8c 100644
--- a/apps/web/src/office-routine-client-routes.test.tsx
+++ b/apps/web/src/office-routine-client-routes.test.tsx
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { StateProvider } from "@/components/state-provider";
+import { TooltipProvider } from "@kandev/ui/tooltip";
import { defaultOfficeState } from "@/lib/state/slices/office/office-slice";
import type { Routine, RoutineTrigger } from "@/lib/state/slices/office/types";
import { RoutineDetailRoute } from "./office-routine-client-routes";
@@ -63,7 +64,9 @@ function renderRoute() {
office: { ...defaultOfficeState.office },
}}
>
-
+
+
+
,
);
}
diff --git a/docs/plans/office-routine-arming/plan.md b/docs/plans/office-routine-arming/plan.md
new file mode 100644
index 0000000000..2f8dbb3ea7
--- /dev/null
+++ b/docs/plans/office-routine-arming/plan.md
@@ -0,0 +1,180 @@
+---
+requirements:
+ - REQ-OFFICE-ROUTINE-ARMING-001
+ - REQ-OFFICE-ROUTINE-ARMING-002
+ - REQ-OFFICE-ROUTINE-ARMING-003
+ - REQ-OFFICE-COORDINATOR-INSTALL-001
+system_design:
+ - ../../specs/office/system-design/routine-schedule-state.md
+ - ../../specs/office/system-design/coordinator-install-idempotency.md
+created: 2026-09-17
+status: done
+---
+
+# Implementation Plan: Office Routine Arming Visibility and Coordinator Install Idempotency
+
+## Overview
+
+An Office routine has two independent switches — intent (`office_routines.status`)
+and schedule state (whether its triggers can actually fire it) — and the product
+today reports only intent. This plan makes schedule state visible everywhere
+intent already is (routine reads, an unattended-install startup scan) and closes
+the one identity defect that lets onboarding duplicate the pre-installed
+coordinator routine. It repairs nothing: no criterion in any of the three
+requirement documents enables, disables, creates, or deletes a trigger, and none
+changes the existing routine-status dispatch gates. The scheduler already checks
+status before it claims a cron slot, and the manual and webhook paths reject
+non-firing statuses; this plan only makes the independent schedule state visible.
+
+Two system-design documents back this plan:
+[Routine Schedule State](../../specs/office/system-design/routine-schedule-state.md)
+covers REQ-OFFICE-ROUTINE-ARMING-001/002/003 (classification, the read-path
+report, and the startup scan — one vertical feature contract split across two
+requirement documents by scope), and
+[Coordinator Install Idempotency](../../specs/office/system-design/coordinator-install-idempotency.md)
+covers REQ-OFFICE-COORDINATOR-INSTALL-001. Both were written after Build,
+grounded directly in the merged implementation, rather than before it — the
+three requirement documents already pinned every table, column, function, and
+endpoint this plan touches at file-and-line precision, so drafting the designs
+speculatively ahead of code would have either duplicated that text or drifted
+from it. This plan itself still carries the remaining architecture decisions —
+package placement, response shape, verification boundaries — that the
+requirements deliberately left to Build.
+
+Work lands in three waves: classification is the foundation everything else
+reads (Wave 1); the read-path report, the startup scan, and the coordinator
+install fix are three independent consumers of it and can proceed in parallel
+(Wave 2); the frontend surface depends on the backend response shape Wave 2
+defines (Wave 3).
+
+## Code facts confirmed on this branch (2026-09-17)
+
+All paths relative to `apps/backend`.
+
+- **Schema.** `office_routines` and `office_routine_triggers` are defined in
+ `internal/office/repository/sqlite/base.go`, `createRoutineTables`
+ (L414-467) — one dialect-agnostic file for both SQLite and PostgreSQL via
+ `internal/db/dialect`. There is no separate PostgreSQL schema file.
+- **Trigger reads today.** `internal/office/repository/sqlite/routines.go`:
+ `GetDueTriggers` (L65-78) filters `kind='cron' AND enabled=1 AND
+ next_run_at IS NOT NULL AND next_run_at <= ?` with no `ORDER BY` and does not
+ read `office_routines.status`. `RoutineService.processCronTrigger` reads the
+ routine and applies its status gate before `ClaimTrigger`; the query is only
+ the first stage of that path. `ClaimTrigger` and `UpdateTriggerNextRun` remain
+ the trigger-state writers.
+- **Coordinator install today.** `internal/office/routines/service.go`:
+ `CreateDefaultCoordinatorRoutine` (L145-196) calls `findCoordinatorRoutine`
+ (L197-217), which matches workspace + assignee + canonical name +
+ `hasCronTrigger` (L222-239, ignores `enabled`, matches on kind + exact
+ expression only). Its two production callers —
+ `internal/office/agents/service.go:572` and
+ `internal/office/onboarding/service.go:767` — both discard the returned
+ routine and only warn-log the error (`if _, err := ...`), which is why
+ AC-OFFICE-COORDINATOR-INSTALL-001.10 forbids reporting through a return
+ value.
+- **Cron evaluation.** `internal/office/shared/cron.go`: `NextCronTime`
+ validates the timezone, requires exactly 5 fields, follows the shared
+ day-of-month/day-of-week OR and DST rules, and returns `ErrUnsatisfiableCron`
+ when an otherwise valid expression has no possible occurrence. Schedule
+ classification uses the same occurrence path.
+- **Startup sequencing.** `internal/office/infra/reconcile.go`:
+ `Reconciler.ReconcileAll` (L34-54) is synchronous with a void return;
+ `createTriggersForNewRoutines` (L113-132) gives every trigger-less routine
+ a `manual` trigger. Called from `internal/backendapp/main.go:1378-1380`,
+ immediately followed by `log.Info("Office reconciliation complete")` and,
+ a few lines later (~L1393), `services.Office.RegisterEventSubscribers`.
+ That gap is the wiring point for the startup-scan completion signal
+ required by AC-OFFICE-ROUTINE-ARMING-003.1 — nothing today observes
+ `ReconcileAll` returning.
+- **The actual routine list/detail response.**
+ `internal/office/routines/handler.go`'s `listRoutines` (L45-52) and
+ `getRoutine` (L98-105) back `GET /workspaces/:wsId/routines` and
+ `GET /routines/:id`, returning `RoutineListResponse`/`RoutineResponse`
+ (`internal/office/routines/dto.go:34-41`) — today just `*Routine`
+ (intent only). This, not the dashboard, is Task 02's target.
+ Separately, `internal/office/dashboard/service_agents.go`'s
+ `ListRoutines` block inside `runSoftQueries` (~L551-561) only counts
+ `status == "active"` into the workspace KPI tile's `RoutineCount` and is
+ not in scope.
+- **Trigger HTTP routes**, `internal/office/routines/handler.go`,
+ `RegisterRoutes` (L30-42): `GET`/`POST /routines/:id/triggers`,
+ `DELETE /routine-triggers/:triggerId`,
+ `POST /routine-triggers/:publicId/fire`. No route edits, enables, or
+ disables a trigger — confirms `## Out of scope` → *Disabling a trigger from
+ the UI* in the visibility document.
+- **Metrics prior art.** `internal/orchestrator/office_stall_metrics.go`
+ (`expvar.NewMap` + label builder, L20-46) and
+ `internal/workflow/signalmetrics/` are the two labelled-counter
+ conventions already in the codebase; this plan's new counters follow the
+ same shape rather than inventing a third.
+- **Persistence conformance.** `internal/persistence/requiredstores` already
+ registers office's schema owner (`catalog.go:52`) and
+ `internal/persistence/storeconformance` already has a fixed office
+ adapter (`adapters.go:224-229`). No new table is required by any
+ criterion in these three documents; Task 05's serialization mechanism must
+ be chosen so that stays true (see its risks).
+
+## Waves
+
+### Wave 1 — classify
+
+- [x] [Task 01: Classify a routine's schedule state](task-01-classify-schedule-state.md)
+
+### Wave 2 — three independent consumers of classification
+
+- [x] [Task 02: Report intent and schedule state on routine reads (API)](task-02-report-schedule-state-api.md)
+- [x] [Task 04: Surface unarmed routines at startup](task-04-startup-scan.md)
+- [x] [Task 05: Match the coordinator routine on identity, not mutable trigger state](task-05-coordinator-install-idempotency.md)
+
+### Wave 3 — frontend
+
+- [x] [Task 03: Render the intent/schedule-state distinction in the routines UI](task-03-report-schedule-state-ui.md)
+
+Tasks 02, 04, and 05 touch disjoint files and have independent verification
+boundaries; once Task 01 lands they may proceed in parallel. Task 03 depends
+only on Task 02's response shape, not on Task 04 or Task 05.
+
+## Required workflow verification
+
+After all task-level checks pass:
+
+1. `cd apps/backend && gofmt -l $(git diff --name-only main -- '*.go')` — must
+ be empty.
+2. `make -C apps/backend test` (full suite; the office and persistence
+ packages are the ones this plan can regress).
+3. `make -C apps/backend lint`.
+4. If Task 05 adds a schema owner (see its risks):
+ `go run ./cmd/sqlguard ./internal` and
+ `go test -race ./internal/persistence/storeconformance -count=1` from
+ `apps/backend`.
+5. `cd apps/web && pnpm run typecheck && pnpm run lint`.
+6. `cd apps/web && pnpm run i18n:check`.
+7. Playwright coverage per Task 03 (`cd apps/web && pnpm e2e:run`, scoped to
+ the affected spec — see that task).
+8. Commit the explicit changed paths with a Conventional Commit message.
+
+## Risks and non-goals
+
+- **Keep repairs separate from visibility.** The operator-initiated re-arm
+ action remains outside this plan. A work order that starts writing `enabled`
+ or `next_run_at` outside Task 05's coordinator-install path has drifted from
+ scope. Schedule classification must continue to report an impossible cron
+ expression as `trigger_invalid`, matching `NextCronTime` and the scheduler's
+ permanent-disarm behavior.
+- **Dispatch grace is one constant, not two.** AC-OFFICE-ROUTINE-ARMING-001.10
+ requires classification (Task 01) and the startup scan (Task 04) to share
+ the exact same 60-second compile-time constant. Define it once in Task 01
+ and import it in Task 04; do not redeclare it.
+- **Task 05's serialization mechanism is Build's choice, but three
+ candidates are pre-ruled-out in the requirement's own `## Out of scope`:**
+ a bare default-isolation transaction (both callers can still both read
+ "absent"), a PostgreSQL advisory lock (no SQLite equivalent), and an
+ in-process-only guard (fails the documented cross-process domain). See
+ Task 05 for the constraint set the chosen mechanism must satisfy.
+- **No coverage percentage is a project gate.** New tests must assert the
+ specific acceptance criteria named in each task, not a line-coverage
+ target.
+- **Translation completeness.** Task 03's copy must exist in all five
+ shipped locales (`pt-pt`, `zh-cn`, `zh-hk`, `zh-tw`, plus source English)
+ before `pnpm run i18n:check` passes; use `pnpm run i18n:zh-hant` for the
+ Traditional Chinese pair rather than hand-translating.
diff --git a/docs/plans/office-routine-arming/task-01-classify-schedule-state.md b/docs/plans/office-routine-arming/task-01-classify-schedule-state.md
new file mode 100644
index 0000000000..6c02beb34a
--- /dev/null
+++ b/docs/plans/office-routine-arming/task-01-classify-schedule-state.md
@@ -0,0 +1,125 @@
+---
+id: "01-classify-schedule-state"
+title: "Classify a routine's schedule state"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+requirements:
+ - REQ-OFFICE-ROUTINE-ARMING-001
+acceptance_criteria:
+ - AC-OFFICE-ROUTINE-ARMING-001.1
+ - AC-OFFICE-ROUTINE-ARMING-001.2
+ - AC-OFFICE-ROUTINE-ARMING-001.3
+ - AC-OFFICE-ROUTINE-ARMING-001.4
+ - AC-OFFICE-ROUTINE-ARMING-001.5
+ - AC-OFFICE-ROUTINE-ARMING-001.6
+ - AC-OFFICE-ROUTINE-ARMING-001.7
+ - AC-OFFICE-ROUTINE-ARMING-001.8
+ - AC-OFFICE-ROUTINE-ARMING-001.9
+ - AC-OFFICE-ROUTINE-ARMING-001.10
+ - AC-OFFICE-ROUTINE-ARMING-001.11
+ - AC-OFFICE-ROUTINE-ARMING-001.12
+ - AC-OFFICE-ROUTINE-ARMING-001.13
+system_design:
+ - ../../specs/office/system-design/routine-schedule-state.md
+---
+
+# Task 01: Classify a routine's schedule state
+
+Satisfies REQ-OFFICE-ROUTINE-ARMING-001 (AC-OFFICE-ROUTINE-ARMING-001.1 through
+.13).
+
+## Scope
+
+A pure, total classification of one routine's schedule state from its trigger
+rows alone, plus the batched-read-with-per-routine-fallback semantics that
+AC-001.12 and AC-001.13 require, plus the unarmed-cron-trigger list AC-001.8
+requires alongside it. This task defines the dispatch-grace constant
+(AC-001.10) that Task 04 (startup scan) also consumes — define it once here.
+
+## Exclusions
+
+- No HTTP response shape, no dashboard change, no UI (Task 02/03).
+- No startup scan (Task 04).
+- No coordinator-install change (Task 05) — it consumes this task's output but
+ this task does not touch `routines/service.go`'s coordinator-install path.
+- No repair: this task's function set never writes `office_routine_triggers`
+ or `office_routines`.
+
+## Acceptance conditions
+
+1. `ClassifyRoutine(triggers []models.RoutineTrigger, now time.Time) (ScheduleState, []UnarmedCronTrigger)`
+ (exact name/package left to Build; suggested home:
+ `internal/office/routines`, since that package already owns trigger
+ dispatch logic and is already imported by `office/agents` and
+ `office/onboarding`) implements the nine-rule table in rule order,
+ stopping at first match (AC-001.1), never reads `office_routines.status`
+ (AC-001.2), and treats a past-due or exactly-at-`now` `next_run_at` as
+ rule 1 (AC-001.3, AC-001.4). It returns every applicable unarmed-cron
+ reason per trigger, in the fixed order (`enabled=false`, then
+ "not schedulable", then "stalled past dispatch grace"), in trigger order
+ (`created_at` ascending, `id` ascending) (AC-001.8, AC-001.9, AC-001.11).
+ A `dispatchGrace` constant of exactly `60 * time.Second`, unexported and
+ with no environment/config/flag override, backs rule 2 and the unarmed
+ list's third reason (AC-001.10).
+2. A batch classification entry point reads every named routine's trigger
+ rows in one query, captures one `now` for the whole call, and on a batch
+ read failure (or one undecodable row) re-reads exactly the affected
+ routines individually — once each — reporting `unknown` only for those
+ whose individual re-read also fails, reusing the batch call's captured
+ `now` rather than recapturing it per routine (AC-001.5, AC-001.12,
+ AC-001.13). A routine whose reads never fail counts as one query
+ contribution regardless of how many other routines are classified in the
+ same call (feeds Task 02's AC-002.7 query-count bound).
+3. Table-driven tests cover: every one of the 9 rules in isolation; the
+ rule-1/rule-9 interaction of AC-001.9 (armed on one trigger, unarmed list
+ non-empty from a sibling); the never-fired exclusion of AC-001.11; the
+ batch-then-per-routine-fallback path of AC-001.13 with a fixture that
+ forces one row in a multi-routine batch to fail decode; and identical
+ output on SQLite and PostgreSQL for the same rows (AC-001.6, via the
+ `storeconformance`/`KANDEV_TEST_POSTGRES_DSN` pattern already used
+ elsewhere in this package's tests).
+
+## Verification
+
+- `cd apps/backend && go test -tags fts5 ./internal/office/routines/... ./internal/office/repository/sqlite/...`
+- New tests must first fail against the current tree, where no classifier
+ exists.
+- `KANDEV_TEST_POSTGRES_DSN= go test -tags fts5 ./internal/office/repository/sqlite/... -run TestClassify` (or
+ equivalent name) for the PostgreSQL leg of AC-001.6.
+
+## Files likely touched
+
+- new: `apps/backend/internal/office/routines/arming.go`
+- new: `apps/backend/internal/office/routines/arming_test.go`
+- `apps/backend/internal/office/repository/sqlite/routines.go` — add the
+ batch trigger-read query and the single-routine fallback read.
+- `apps/backend/internal/office/repository/sqlite/routines_test.go`
+
+## Dependencies
+
+None. This is the foundation for Tasks 02, 04, and 05.
+
+## Parallelism
+
+Must land before Wave 2. Nothing in Wave 1 can run in parallel with it.
+
+## Result
+
+Implemented in `internal/office/routines/arming.go` (unexported `dispatchGrace`,
+`ClassifyRoutine`, `ClassifyRoutines`, `RoutineTriggerReader`) plus
+`ListTriggersByRoutineIDs` and an `, id` trigger-order tiebreak on
+`ListTriggersByRoutineID` in `internal/office/repository/sqlite/routines.go`.
+Tests: `arming_test.go` (all 9 rules, precedence, unarmed-reason ordering, the
+armed/unarmed-sibling interaction, trigger order, batch-then-fallback),
+`arming_dialect_postgres_test.go` (AC-001.6, gated on
+`KANDEV_TEST_POSTGRES_DSN`, not run in this environment), and a new
+`TestListTriggersByRoutineIDs_Batch` in `routines_test.go` covering the id
+tiebreak and the empty-routine/empty-input cases.
+
+Verification: `gofmt -l` clean; `go test -tags fts5
+./internal/office/routines/... ./internal/office/repository/sqlite/...`
+passes except the pre-existing, unrelated `TestMigrate_PriorityIdempotent`
+failure (reproduced on the unmodified tree before this task's changes, via
+`git stash`); `golangci-lint run` clean on both packages.
diff --git a/docs/plans/office-routine-arming/task-02-report-schedule-state-api.md b/docs/plans/office-routine-arming/task-02-report-schedule-state-api.md
new file mode 100644
index 0000000000..1f14f6bdf6
--- /dev/null
+++ b/docs/plans/office-routine-arming/task-02-report-schedule-state-api.md
@@ -0,0 +1,176 @@
+---
+id: "02-report-schedule-state-api"
+title: "Report intent and schedule state on routine reads (API)"
+status: done
+wave: 2
+depends_on: ["01-classify-schedule-state"]
+plan: "plan.md"
+requirements:
+ - REQ-OFFICE-ROUTINE-ARMING-002
+acceptance_criteria:
+ - AC-OFFICE-ROUTINE-ARMING-002.1
+ - AC-OFFICE-ROUTINE-ARMING-002.4
+ - AC-OFFICE-ROUTINE-ARMING-002.5
+ - AC-OFFICE-ROUTINE-ARMING-002.6
+ - AC-OFFICE-ROUTINE-ARMING-002.7
+ - AC-OFFICE-ROUTINE-ARMING-002.11
+ - AC-OFFICE-ROUTINE-ARMING-002.12
+system_design:
+ - ../../specs/office/system-design/routine-schedule-state.md
+---
+
+# Task 02: Report intent and schedule state on routine reads (API)
+
+Satisfies REQ-OFFICE-ROUTINE-ARMING-002's data-shape criteria:
+AC-OFFICE-ROUTINE-ARMING-002.1, .4 (data only — rendering is Task 03), .5,
+.6, .7, .11, .12. The rendering-only criteria (.2, .3, .8, .9, .10) belong
+to Task 03, which depends on this task's response shape.
+
+## Scope
+
+Extend the routine list response and the single-routine response to carry,
+per routine: intent (`office_routines.status`, unchanged), schedule state,
+and the unarmed cron trigger list, both from Task 01's classifier — without
+growing the query count with the number of routines in the workspace.
+
+## Exclusions
+
+- No new classification logic — call Task 01's batch entry point.
+- No UI change (Task 03).
+- No startup scan (Task 04).
+- No coordinator-install change (Task 05).
+
+## Acceptance conditions
+
+1. The routine list response and the single-routine response each carry
+ intent, schedule state, and the unarmed cron trigger list per routine, in
+ one call each (AC-002.1). The pair is not required to be a transactional
+ snapshot: intent comes from the routine row, schedule state from the
+ trigger rows, and neither is omitted because the other read failed — a
+ routine whose triggers could not be read still carries its intent
+ alongside schedule state `unknown` (AC-002.12).
+2. Producing the list costs a bounded number of queries that does not grow
+ with routine count on the all-succeeds path: one query for routines, one
+ batch query for triggers (Task 01's batch entry point), each isolated
+ Task-001.13 re-read counted as the documented exception rather than a
+ regression (AC-002.7).
+3. Response shape distinguishes, without prescribing rendering: a schedule
+ state of `unscheduled_manual_only` or `unscheduled_no_trigger` is
+ reportable as "no schedule" (AC-002.5); `unknown` is reportable as
+ undetermined, distinct from armed or broken (AC-002.6); `event_only` is
+ never bundled with "no schedule" and never asks for a trigger to be
+ created (AC-002.11); and for a non-empty unarmed list, whether at least
+ one entry is schedulable is derivable by the caller without it having to
+ inspect the dispatch-grace math itself — an empty list carries neither
+ side of that distinction (AC-002.4).
+
+## Verification
+
+- `cd apps/backend && go test -tags fts5 ./internal/office/dashboard/... ./internal/office/routines/...`
+- Test: routine list response for a fixture with all nine schedule states
+ present carries the right state and unarmed list per routine.
+- Test: query-count assertion (via a counting DB wrapper or an existing
+ budget-test pattern in this package) proves AC-002.7 on an all-succeeds
+ fixture of N routines, and separately on a fixture that forces one
+ Task-001.13 re-read.
+- Test: a routine whose trigger read fails carries its real intent value
+ alongside `unknown` (AC-002.12).
+
+## Files likely touched
+
+- `apps/backend/internal/office/routines/handler.go` — `listRoutines`
+ (L45-52) and `getRoutine` (L98-105) are the actual routine list/detail
+ HTTP handlers (`GET /workspaces/:wsId/routines`, `GET /routines/:id`);
+ call Task 01's `ClassifyRoutines`/`ClassifyRoutine` here and populate the
+ extended response.
+- `apps/backend/internal/office/routines/dto.go` — `RoutineResponse` (L34)
+ and `RoutineListResponse` (L39) currently carry only `*Routine`
+ (`{routine}` / `{routines}` — intent only). Extend them to also carry
+ schedule state and the unarmed cron trigger list per routine (e.g. a
+ wrapper type embedding `*Routine` plus the two new fields, so intent and
+ schedule state travel in the same JSON object per AC-002.1).
+- Note: `apps/backend/internal/office/dashboard/service_agents.go`'s
+ `ListRoutines` block (~L551-561) is a separate, pre-existing aggregate —
+ it only counts `status == "active"` into the workspace dashboard's KPI
+ tile and is not this requirement's target; leave it alone unless it also
+ needs the distinction (out of scope unless the dashboard tile itself is
+ asked to change).
+- Corresponding `*_test.go` files (`internal/office/routines/handler_test.go`
+ or new).
+
+## Dependencies
+
+Task 01.
+
+## Parallelism
+
+Independent of Task 04 and Task 05 once Task 01 lands — no shared files.
+
+## Result
+
+Implemented:
+
+- `apps/backend/internal/office/routines/dto.go` — added `RoutineWithSchedule`
+ (embeds `*Routine`, adds `schedule_state` and `unarmed_cron_triggers` JSON
+ fields). `RoutineResponse.Routine` and `RoutineListResponse.Routines` now
+ carry `*RoutineWithSchedule` / `[]*RoutineWithSchedule` instead of the bare
+ `*Routine`.
+- `apps/backend/internal/office/routines/service.go` — added
+ `ListTriggersByRoutineIDs` to the `Repository` interface (structurally
+ required so `s.repo` satisfies Task 01's `RoutineTriggerReader`), and added
+ `AttachScheduleState(ctx, []*Routine) ([]*RoutineWithSchedule, error)`,
+ which calls `ClassifyRoutines` once per call (one batch trigger query,
+ independent of routine count) and pairs each routine's intent with its
+ schedule state and unarmed list.
+- `apps/backend/internal/office/routines/handler.go` — `listRoutines` and a
+ new `withScheduleState` helper (used by `createRoutine`, `getRoutine`,
+ `updateRoutine`) call `AttachScheduleState` before building the response.
+ No other file in `internal/` constructs `RoutineResponse`/
+ `RoutineListResponse` (confirmed via grep), so no other call sites needed
+ updates.
+
+AC-002.1: `RoutineWithSchedule` carries intent (`Status`, from the embedded
+`*Routine`), `ScheduleState`, and `UnarmedCronTriggers` in one JSON object.
+AC-002.12: covered by `TestAttachScheduleState_TriggerReadFailureKeepsIntent`
+— a routine whose trigger reads fail (batch and single) keeps its real
+`Status` alongside `ScheduleStateUnknown`.
+AC-002.7: covered by `TestAttachScheduleState_QueryCountBound` (one batch
+call, zero single calls, for 5 routines) and
+`TestAttachScheduleState_BatchFailureFallsBackPerRoutine` (one batch call,
+exactly one single call per routine on the documented fallback path), both
+via a `countingRepo` decorator wrapping a real in-memory-SQLite-backed
+repository.
+AC-002.5/.6/.11/.4: satisfied structurally — `ScheduleState` is a plain
+string enum a caller switches on directly (no additional derivation logic
+needed in this task), and `UnarmedCronTrigger.Reasons` already carries
+`not_schedulable` distinctly from `stalled`/`disabled`, so an empty list
+carries neither and a non-empty list is inspectable without dispatch-grace
+math. Verified in `TestAttachScheduleState_AllStatesFixture`, which covers
+armed, trigger_invalid, trigger_disabled, unscheduled_manual_only, and
+unscheduled_no_trigger routines in one list response.
+
+New test file:
+`apps/backend/internal/office/routines/schedule_response_test.go` — 4 tests
+(`TestAttachScheduleState_AllStatesFixture`,
+`TestAttachScheduleState_QueryCountBound`,
+`TestAttachScheduleState_BatchFailureFallsBackPerRoutine`,
+`TestAttachScheduleState_TriggerReadFailureKeepsIntent`), all backed by a
+real `*routines.RoutineService` over an in-memory SQLite repository (via a
+`countingRepo` decorator), not just `ClassifyRoutines` in isolation (already
+covered by Task 01's tests).
+
+Verification:
+
+- `go build -tags fts5 ./...` — clean.
+- `go test -tags fts5 ./internal/office/routines/...` — `ok`.
+- `go test -tags fts5 ./internal/office/dashboard/...` — `ok`.
+- `go test -tags fts5 ./internal/office/repository/sqlite/...` — one
+ pre-existing, unrelated failure (`TestMigrate_PriorityIdempotent`,
+ confirmed via `git stash` against the clean tree during Task 01; not
+ touched by this task).
+- `gofmt -l` on the new/changed files — clean.
+- `golangci-lint run ./internal/office/routines/... --new-from-rev=`
+ — `0 issues` (fixed a goconst hit on the repeated `gin.H{"error": ...}`
+ literal by extracting `respondInternalError` + a `jsonErrorKey` constant in
+ `handler.go`; fixed staticcheck QF1008 redundant-embedded-selector hints in
+ the new test file).
diff --git a/docs/plans/office-routine-arming/task-03-report-schedule-state-ui.md b/docs/plans/office-routine-arming/task-03-report-schedule-state-ui.md
new file mode 100644
index 0000000000..586f37a239
--- /dev/null
+++ b/docs/plans/office-routine-arming/task-03-report-schedule-state-ui.md
@@ -0,0 +1,151 @@
+---
+id: "03-report-schedule-state-ui"
+title: "Render the intent/schedule-state distinction in the routines UI"
+status: done
+wave: 3
+depends_on: ["02-report-schedule-state-api"]
+plan: "plan.md"
+requirements:
+ - REQ-OFFICE-ROUTINE-ARMING-002
+acceptance_criteria:
+ - AC-OFFICE-ROUTINE-ARMING-002.2
+ - AC-OFFICE-ROUTINE-ARMING-002.3
+ - AC-OFFICE-ROUTINE-ARMING-002.4
+ - AC-OFFICE-ROUTINE-ARMING-002.5
+ - AC-OFFICE-ROUTINE-ARMING-002.6
+ - AC-OFFICE-ROUTINE-ARMING-002.8
+ - AC-OFFICE-ROUTINE-ARMING-002.9
+ - AC-OFFICE-ROUTINE-ARMING-002.10
+ - AC-OFFICE-ROUTINE-ARMING-002.11
+system_design:
+ - ../../specs/office/system-design/routine-schedule-state.md
+---
+
+# Task 03: Render the intent/schedule-state distinction in the routines UI
+
+Satisfies REQ-OFFICE-ROUTINE-ARMING-002's rendering criteria:
+AC-OFFICE-ROUTINE-ARMING-002.2, .3, .8, .9, .10, and the rendering half of
+.4, .5, .6, .11 (data shape is Task 02).
+
+## Scope
+
+Routines list and detail view render both switches. This is a labels-and-
+distinctions change only — the operator-initiated re-arm action was cut from
+scope (see the requirement's `## Out of scope`), so there is no mutating flow
+to build or test here.
+
+## Exclusions
+
+- No action, button, or endpoint that repairs a trigger or changes intent.
+- No new backend response fields (Task 02).
+
+## Acceptance conditions
+
+1. The routines list visually and semantically distinguishes: `active` +
+ `armed` from `active` + anything else (AC-002.2); not-`active` from
+ `active`-but-broken, so deliberate and broken never render the same
+ (AC-002.3); and renders a distinct label for each of the five groups —
+ `armed`; `{trigger_invalid, trigger_unscheduled, trigger_disabled}`;
+ `event_only`; `{unscheduled_manual_only, unscheduled_no_trigger}`;
+ `unknown` — such that two routines in different groups never share a
+ label (AC-002.10). None of this distinction is carried by colour alone;
+ each has accompanying text, an icon, or an accessible label (AC-002.9).
+2. A routine whose unarmed list has at least one schedulable entry is
+ visually distinguished from one where none are (needs re-arming vs. needs
+ editing), and this rendering is entirely suppressed when the unarmed list
+ is empty rather than defaulting to either message (AC-002.4).
+ `unscheduled_manual_only`/`unscheduled_no_trigger` render as "no
+ schedule" (AC-002.5); `unknown` renders as undetermined (AC-002.6);
+ `event_only` never renders as "no schedule" and is never directed to
+ create a trigger (AC-002.11). No rendering here offers a repair action.
+3. All copy added by this task is routed through `t()`/`` and present
+ in English plus the four other shipped locales (`pt-pt`, `zh-cn`,
+ `zh-hk`, `zh-tw`); `pnpm run i18n:check` and `pnpm run i18n:ratchet` both
+ pass (AC-002.8).
+
+## Verification
+
+- `cd apps/web && pnpm run typecheck`
+- `cd apps/web && pnpm run lint`
+- `cd apps/web && pnpm run i18n:check`
+- Component test(s) covering the five-group label mapping and the
+ schedulable/non-schedulable unarmed-list distinction.
+- Playwright: extend or add a spec under `apps/web/e2e/` covering the
+ routines list/detail rendering for a fixture spanning all nine schedule
+ states (read-only assertions only — no mutating interaction to drive).
+ Confirm the e2e profile enables `KANDEV_FEATURES_OFFICE` before writing
+ the spec.
+- `cd apps/web && pnpm e2e:run` scoped to the new/changed spec (respect the
+ one-worker-per-shard and memory-aware shard budget in
+ `apps/web/e2e/README.md`).
+
+## Files likely touched
+
+- Routines list and detail components under `apps/web` (Office routines
+ surface — exact path to be located during implementation; search for the
+ existing routine-status badge component).
+- `apps/web/src/locales/en/*.json` plus `pt-pt`, `zh-cn`, `zh-hk`, `zh-tw`
+ (use `pnpm run i18n:zh-hant` for the Traditional Chinese pair).
+- New or extended Playwright spec under `apps/web/e2e/`.
+
+## Dependencies
+
+Task 02.
+
+## Parallelism
+
+Independent of Task 04 and Task 05.
+
+## Result
+
+- `apps/web/app/office/routines/schedule-state.ts`: pure read/classify
+ helpers (`readScheduleState`, `readUnarmedCronTriggers`,
+ `scheduleStateGroup`, `hasSchedulableUnarmedEntry`) following the repo's
+ established camelCase-typed/snake_case-at-runtime fallback pattern for the
+ two new wire fields from Task 02.
+- `apps/web/app/office/routines/schedule-state-badge.tsx`: `ScheduleStateBadge`
+ (five-group label + icon + tooltip, AC-002.9/.10) and
+ `UnarmedScheduleHint` (schedulable-vs-not distinction, suppressed when the
+ unarmed list is empty, AC-002.4/.5/.6/.11). Both render regardless of
+ `routine.status`, so intent and schedule state stay visually independent
+ (AC-002.1/.2/.3).
+- Wired into `routine-row.tsx` (list) and `routine-detail-view.tsx`'s
+ `DetailReadOnlyCard` (detail view).
+- 12 new `office:` copy keys added to `en` plus `pt-pt`, `zh-cn` (hand
+ translated) and `zh-hk`/`zh-tw` (via `pnpm run i18n:zh-hant`); pseudo-locale
+ regenerated and scoped to only these keys.
+- Unit tests: `schedule-state.test.ts` (pure logic, all 8 reachable states to
+ 5 groups) and `schedule-state-badge.test.tsx` (rendering, using
+ `TooltipProvider` per the repo's Radix tooltip test convention).
+- E2E: `apps/web/e2e/tests/office/routine-schedule-state.spec.ts` seeds one
+ routine per reachable schedule state and asserts the routines list renders
+ the correct AC-002.10 label group for each, plus AC-002.11 (`event_only`
+ never shows "No schedule").
+ - Confirmed `KANDEV_FEATURES_OFFICE` is already `e2e: "true"` in
+ `profiles.yaml`; no profile change needed.
+ - **Backend API gap found and fixed**: the public routine-trigger API
+ cannot produce `trigger_invalid`, `trigger_unscheduled`, or
+ `trigger_disabled` — `RoutineService.CreateRoutineTrigger` validates the
+ cron expression and always computes a non-null `NextRunAt`, and there is
+ no PATCH-trigger route to disable one after creation. Added a
+ `KANDEV_E2E_MOCK`-gated test-harness route,
+ `POST /api/v1/_test/routine-triggers`
+ (`apps/backend/internal/office/testharness/routine_triggers.go` +
+ `routine_triggers_test.go`), that inserts a trigger row directly via the
+ repository, bypassing service-layer validation, following the existing
+ `seedRun`/`seedComment` harness pattern. Exposed as
+ `ApiClient.seedRoutineTrigger` in `apps/web/e2e/helpers/api-client.ts`.
+ - `unknown` (a live trigger-read failure) has no fixture-reachable trigger
+ shape at all — it requires simulating a database read error — so it is
+ intentionally not covered by this spec. It has backend unit coverage in
+ `arming_test.go` (Task 01) instead.
+ - The fixture therefore spans 7 of the 9 raw states, covering all 5 render
+ groups (`armed`, `broken` via 3 distinct raw states, `event_only`,
+ `no_schedule` via 2 distinct raw states); `unknown` is the one documented
+ gap.
+- Verification run: `pnpm run typecheck`, `pnpm run lint`, `pnpm run
+ i18n:check`, `pnpm run i18n:ratchet`, `npx vitest run` on both new test
+ files, `go build ./...`, `gofmt -l`, `golangci-lint run
+ ./internal/office/testharness/...`, `go test
+ ./internal/office/testharness/...`, and `pnpm e2e:run
+ tests/office/routine-schedule-state.spec.ts` — all pass.
diff --git a/docs/plans/office-routine-arming/task-04-startup-scan.md b/docs/plans/office-routine-arming/task-04-startup-scan.md
new file mode 100644
index 0000000000..c1d02923fc
--- /dev/null
+++ b/docs/plans/office-routine-arming/task-04-startup-scan.md
@@ -0,0 +1,210 @@
+---
+id: "04-startup-scan"
+title: "Surface unarmed routines at startup"
+status: done
+wave: 2
+depends_on: ["01-classify-schedule-state"]
+plan: "plan.md"
+requirements:
+ - REQ-OFFICE-ROUTINE-ARMING-003
+acceptance_criteria:
+ - AC-OFFICE-ROUTINE-ARMING-003.1
+ - AC-OFFICE-ROUTINE-ARMING-003.2
+ - AC-OFFICE-ROUTINE-ARMING-003.3
+ - AC-OFFICE-ROUTINE-ARMING-003.4
+ - AC-OFFICE-ROUTINE-ARMING-003.5
+ - AC-OFFICE-ROUTINE-ARMING-003.6
+ - AC-OFFICE-ROUTINE-ARMING-003.7
+ - AC-OFFICE-ROUTINE-ARMING-003.8
+ - AC-OFFICE-ROUTINE-ARMING-003.9
+ - AC-OFFICE-ROUTINE-ARMING-003.10
+ - AC-OFFICE-ROUTINE-ARMING-003.11
+ - AC-OFFICE-ROUTINE-ARMING-003.12
+ - AC-OFFICE-ROUTINE-ARMING-003.13
+ - AC-OFFICE-ROUTINE-ARMING-003.14
+system_design:
+ - ../../specs/office/system-design/routine-schedule-state.md
+---
+
+# Task 04: Surface unarmed routines at startup
+
+Satisfies REQ-OFFICE-ROUTINE-ARMING-003 (AC-OFFICE-ROUTINE-ARMING-003.1
+through .14).
+
+## Scope
+
+A read-only pass at Office startup, run once reconciliation has returned,
+that classifies every enumerable routine in every enumerable workspace
+(via Task 01) and emits structured logs plus `expvar` counters. No
+production behavior changes if the scan is skipped or fails.
+
+## Exclusions
+
+- No classification logic of its own — consumes Task 01.
+- No repair, no retry, no periodic re-scan, no API.
+- No coordinator-install change (Task 05).
+
+## Acceptance conditions
+
+1. The scan runs after `infra.Reconciler.ReconcileAll` (main.go:1378-1380)
+ has returned, ordered by an explicit completion signal passed from the
+ startup sequence — not a timer, not an inspection of trigger rows.
+ Invoked without that signal (exercised only in a test), it classifies
+ nothing, emits one warning-level record, and increments the skipped
+ counter with a reason distinct from enumeration and per-routine
+ classification failure (AC-003.1). It iterates workspaces by
+ `workspaces.created_at` then `workspaces.id`, and within each, routines
+ by `office_routines.created_at` then `office_routines.id` (AC-003.2).
+ Office startup does not wait for the scan and no scan outcome, including
+ a database error, changes startup's result (AC-003.10). It writes
+ nothing and never enables, disables, creates, or deletes a trigger
+ (AC-003.11).
+2. Per routine reached, exactly one record: warning for `active` +
+ {`trigger_disabled`, `trigger_unscheduled`, `trigger_invalid`} naming
+ workspace, routine, state, and unarmed cron triggers in trigger order
+ (AC-003.3); warning for `active` + {`unscheduled_manual_only`,
+ `unscheduled_no_trigger`} naming workspace, routine, and state with no
+ trigger list, since the routine owns no cron trigger (AC-003.14);
+ informational, no warning, for not-`active` (AC-003.4); informational,
+ no warning, for `active` + any other non-`unknown` state (AC-003.5,
+ scoped to exclude the .3 and .14 cases); and for `unknown`, one
+ informational record naming workspace/routine/state regardless of
+ intent, which both counts once under the observation counter (AC-003.6)
+ and once under the skipped counter (AC-003.7) — intentional double
+ counting, not a bug (AC-003.9).
+3. Enumeration failures fail closed and are counted distinguishably: failing
+ to enumerate the workspace list at all increments the skipped counter
+ with its own reason, emits one warning, and returns without classifying
+ anything (AC-003.8); failing to enumerate one workspace's routines keeps
+ every record already emitted for earlier workspaces, increments the
+ skipped counter once for that workspace, emits one warning naming it,
+ does not retry it, and continues to the next workspace in order
+ (AC-003.13).
+4. Running the scan twice over unchanged data emits the same records in the
+ same order with no database change, where "same" excludes wall-clock
+ fields, counters, and a trigger that crossed the dispatch-grace boundary
+ between the two scans, and where the comparison only applies between two
+ fully-successful scans (AC-003.12).
+
+## Verification
+
+- `cd apps/backend && go test -tags fts5 ./internal/office/routines/... ./internal/office/infra/... ./internal/backendapp/...`
+- Test: scan invoked without the completion signal classifies nothing and
+ increments the "no signal" skipped reason (AC-003.1's test branch).
+- Test: fixture spanning all schedule states produces exactly the record
+ level/content matrix of AC-003.3/.4/.5/.9/.14, one record per routine.
+- Test: workspace-enumeration failure and mid-scan per-workspace
+ enumeration failure each fail closed per AC-003.8/.13, and startup itself
+ never fails or blocks on any scan outcome (AC-003.10).
+- Test: two scans over unchanged fixture data produce identical records
+ under the AC-003.12 equality (excluding wall-clock/counters and any
+ grace-boundary-straddling trigger).
+
+## Files likely touched
+
+- new: `apps/backend/internal/office/routines/startup_scan.go`
+- new: `apps/backend/internal/office/routines/startup_scan_test.go`
+- new: `apps/backend/internal/office/routines/arming_metrics.go` (expvar
+ counters, following `internal/orchestrator/office_stall_metrics.go`'s
+ `expvar.NewMap` + label-builder shape)
+- `apps/backend/internal/office/infra/reconcile.go` — emit or return the
+ completion signal `ReconcileAll` currently has no way to expose.
+- `apps/backend/internal/backendapp/main.go` (~L1378-1393) — wire the
+ signal from `ReconcileAll` into the new scan, launched so it does not
+ block startup.
+
+## Dependencies
+
+Task 01 (classification, and the shared `dispatchGrace` constant it
+defines).
+
+## Parallelism
+
+Independent of Task 02 and Task 05 once Task 01 lands — no shared files.
+
+## Result
+
+Implemented:
+
+- `apps/backend/internal/office/repository/sqlite/routine_arming_scan.go`
+ (new) — `ListWorkspaceIDsOrdered` (`SELECT id FROM workspaces ORDER BY
+ created_at, id`) and `ListRoutinesOrdered` (per-workspace, same ordering),
+ separate from the UI's name-ordered `ListRoutines`/workspace listing.
+- `apps/backend/internal/office/routines/arming_metrics.go` (new) — two
+ `expvar.Map` counters (`office_routine_arming_scan_observations_total`,
+ `office_routine_arming_scan_skipped_total`), following
+ `internal/orchestrator/office_stall_metrics.go`'s label-string shape, with
+ the four skip reasons (`no_signal`, `workspace_enum_failed`,
+ `routine_enum_failed`, `classification_unknown`).
+- `apps/backend/internal/office/routines/startup_scan.go` (new) —
+ `ReconcileSignal` (only constructible via `SignalReconcileComplete`, so an
+ unauthorized/test caller holds the refused zero value), the
+ `StartupScanReader` interface (Task 01's `RoutineTriggerReader` plus the
+ two new ordered listers), and `RunStartupScan`, which classifies every
+ workspace's routines via Task 01's `ClassifyRoutines` (one batch trigger
+ read per workspace) and emits exactly one structured log record per
+ routine reached, per the AC-003.3/.4/.5/.9/.14 matrix.
+- `apps/backend/internal/office/infra/reconcile.go` — `ReconcileAll` now
+ returns `routines.ReconcileSignal` (via `SignalReconcileComplete()`)
+ instead of nothing, so a caller has an explicit, non-timer, non-inspection
+ way to know reconciliation returned. Existing callers that ignore the
+ return value keep compiling unchanged.
+- `apps/backend/internal/backendapp/main.go` (~L1377-1384) — after
+ `reconciler.ReconcileAll(ctx)` returns, launches
+ `go officeroutines.RunStartupScan(ctx, reconcileSignal, repos.Office, log,
+ time.Now().UTC())`. `repos.Office` (`*sqlite.Repository`) satisfies
+ `StartupScanReader` structurally (confirmed by `go build` succeeding with
+ no adapter needed). The scan runs in its own goroutine so office startup
+ does not wait for it (AC-003.10).
+
+AC-003.1: `TestRunStartupScan_NoSignalClassifiesNothing` uses a
+`panicIfCalledReader` (panics on any method call) to prove the no-signal
+path makes zero reader calls, emits exactly one warning, and increments
+`no_signal`.
+AC-003.2: enumeration order comes from the two new `ORDER BY created_at, id`
+repository methods; not independently retested here since Task 01/02 already
+cover the tiebreak mechanics this reuses.
+AC-003.3/.4/.5/.9/.14: `TestRunStartupScan_AllStatesFixtureRecordMatrix`
+covers all five branches in one workspace (cannot-fire with a trigger list,
+no-schedule without one, an active+armed "observed" info record, a
+not-active routine regardless of its schedule state, and a forced-`unknown`
+routine), and asserts both the `unknown` observation-counter increment and
+the `classification_unknown` skip-counter increment fire together
+(intentional double counting per the spec).
+AC-003.6/.7: covered throughout via `armingScanObservationsTotal`/
+`armingScanSkippedTotal` counter-delta assertions.
+AC-003.8: `TestRunStartupScan_WorkspaceEnumerationFailure` — one warning,
+`workspace_enum_failed` incremented, no routine ever reached.
+AC-003.10: the scan never returns an error or a value main.go could act on;
+launched via `go`, it cannot block or fail startup by construction.
+AC-003.11: the scan only calls the two new read-only listers and Task 01's
+read-only trigger reader; no write path exists on `StartupScanReader`.
+AC-003.12: `TestRunStartupScan_IdempotentOverUnchangedData` runs the scan
+twice with the same fixed `now` and compares each run's log records
+(level, message, context fields) via `reflect.DeepEqual`, excluding
+wall-clock fields by never logging any.
+AC-003.13: `TestRunStartupScan_PerWorkspaceRoutineEnumerationFailureContinues`
+— 3 workspaces, the middle one's routine enumeration forced to fail; asserts
+the earlier and later workspaces' records are preserved, the scan continues
+past the failure, and `routine_enum_failed` increments exactly once.
+
+New files:
+`apps/backend/internal/office/repository/sqlite/routine_arming_scan.go`,
+`apps/backend/internal/office/routines/arming_metrics.go`,
+`apps/backend/internal/office/routines/startup_scan.go`,
+`apps/backend/internal/office/routines/startup_scan_test.go` (in-package
+`routines` test file, needed to assert directly on the unexported expvar
+counters, following `internal/orchestrator/office_stall_visibility_test.go`'s
+precedent for the same reason).
+
+Verification:
+
+- `go build -tags fts5 ./...` — clean.
+- `go test -tags fts5 ./internal/office/routines/... ./internal/office/infra/... ./internal/backendapp/...`
+ — all `ok`.
+- `go test -tags fts5 ./internal/office/repository/sqlite/...` — one
+ pre-existing, unrelated failure (`TestMigrate_PriorityIdempotent`,
+ confirmed during Task 01; not touched by this task).
+- `gofmt -l` on the new/changed files — clean.
+- `golangci-lint run ./internal/office/infra/... ./internal/backendapp/... ./internal/office/routines/... ./internal/office/repository/sqlite/... --new-from-rev=`
+ — `0 issues`.
diff --git a/docs/plans/office-routine-arming/task-05-coordinator-install-idempotency.md b/docs/plans/office-routine-arming/task-05-coordinator-install-idempotency.md
new file mode 100644
index 0000000000..086ba9913b
--- /dev/null
+++ b/docs/plans/office-routine-arming/task-05-coordinator-install-idempotency.md
@@ -0,0 +1,272 @@
+---
+id: "05-coordinator-install-idempotency"
+title: "Match the coordinator routine on identity, not mutable trigger state"
+status: done
+wave: 2
+depends_on: ["01-classify-schedule-state"]
+plan: "plan.md"
+requirements:
+ - REQ-OFFICE-COORDINATOR-INSTALL-001
+acceptance_criteria:
+ - AC-OFFICE-COORDINATOR-INSTALL-001.1
+ - AC-OFFICE-COORDINATOR-INSTALL-001.2
+ - AC-OFFICE-COORDINATOR-INSTALL-001.3
+ - AC-OFFICE-COORDINATOR-INSTALL-001.4
+ - AC-OFFICE-COORDINATOR-INSTALL-001.5
+ - AC-OFFICE-COORDINATOR-INSTALL-001.6
+ - AC-OFFICE-COORDINATOR-INSTALL-001.7
+ - AC-OFFICE-COORDINATOR-INSTALL-001.8
+ - AC-OFFICE-COORDINATOR-INSTALL-001.9
+ - AC-OFFICE-COORDINATOR-INSTALL-001.10
+ - AC-OFFICE-COORDINATOR-INSTALL-001.11
+ - AC-OFFICE-COORDINATOR-INSTALL-001.12
+ - AC-OFFICE-COORDINATOR-INSTALL-001.13
+ - AC-OFFICE-COORDINATOR-INSTALL-001.14
+ - AC-OFFICE-COORDINATOR-INSTALL-001.15
+system_design:
+ - ../../specs/office/system-design/coordinator-install-idempotency.md
+---
+
+# Task 05: Match the coordinator routine on identity, not mutable trigger state
+
+Satisfies REQ-OFFICE-COORDINATOR-INSTALL-001 (AC-OFFICE-COORDINATOR-INSTALL-001.1
+through .15).
+
+## Scope
+
+Redefine coordinator-routine identity as workspace + assignee agent +
+canonical routine name only, complete a half-finished install (routine with
+no cron trigger) instead of duplicating it, handle the case where more than
+one existing routine already matches that identity, and serialize
+check-and-create against concurrent installs across processes sharing one
+database.
+
+## Exclusions
+
+- Does not change the schedule-state definition (Task 01) — only reports it
+ (AC-OFFICE-COORDINATOR-INSTALL-001.5).
+- Does not merge, delete, or otherwise touch duplicate routines created by a
+ prior buggy install beyond selecting one to complete
+ (AC-OFFICE-COORDINATOR-INSTALL-001.3).
+- Does not add `enabled` to the identity match — forbidden by
+ AC-OFFICE-COORDINATOR-INSTALL-001.1, and explicitly the trap the source
+ card's framing invites.
+- Does not add a database uniqueness constraint over the identity columns
+ (AC-OFFICE-COORDINATOR-INSTALL-001.9 forbids it; upgraded installs already
+ violate it).
+
+## Acceptance conditions
+
+1. `findCoordinatorRoutine` matches on workspace + assignee + canonical name
+ only — no trigger presence, expression, `enabled`, or `next_run_at` term
+ (AC-001.1). A single match: return it, create nothing
+ (AC-001.2). No cron trigger on the match: create the canonical cron
+ trigger (canonical expression, canonical timezone, `enabled = true`,
+ `next_run_at` = first canonical occurrence strictly after one instant
+ captured once at install start) and return the existing routine; if that
+ next-occurrence computation fails, reject and report without touching
+ the routine (it is a defect in the installer's own constants, unreachable
+ by any input); if trigger creation itself fails, reject, report, and
+ leave the routine in place so a later install reaches this same branch
+ (AC-001.4). Any cron trigger already present, canonical or not, enabled
+ or not: leave every trigger unchanged, create no second one, report
+ schedule state (AC-001.5). Never change `status` on an existing match
+ (AC-001.6). Empty workspace, assignee, or canonical name: reject before
+ the identity lookup, reported distinguishably from a read failure
+ (AC-001.14). No install-call ever retries a failure internally — each
+ rejection returns to the caller as an error, optionally alongside the
+ routine found or created, matching what both callers already do today
+ (AC-001.15).
+2. More than one existing routine matches the identity: select the one with
+ the earliest `office_routines.created_at`, ties by `id` ascending;
+ delete none; report that more than one was found; then run the selected
+ routine through the same .4/.5 branch a single match would take
+ (AC-001.3). No match at all: create one routine with that identity and
+ `status = 'active'` plus its canonical trigger; a routine-insert failure
+ rejects and creates no trigger; a trigger-creation failure after a
+ successful routine insert leaves the routine in place and reports the
+ failure so a later install completes it via AC-001.4 (AC-001.12). An
+ identity-lookup failure, or a failure reading the existing routine's
+ triggers (the read that decides AC-001.4 vs. AC-001.5), rejects and
+ creates nothing — never treated as "absent" (AC-001.7).
+3. Two concurrent installs for the same workspace and assignee create at
+ most one new coordinator routine and at most one new canonical trigger
+ between them, on both SQLite and PostgreSQL, via a serialization
+ mechanism whose domain is every process sharing the database — not an
+ in-process guard alone (AC-001.8, AC-001.9, AC-001.11). Acquisition never
+ blocks indefinitely: it acquires or fails within a single named
+ compile-time 5-second bound, not configurable by environment, config, or
+ flag (AC-001.9). Failure to acquire, or losing a serialized section to a
+ concurrent install, rejects, creates nothing, and reports contention
+ distinguishably from AC-001.7's failures and is not retried within the
+ call (AC-001.13). Context cancellation while waiting likewise rejects and
+ creates nothing, reported distinguishably from both contention and
+ AC-001.7 (AC-001.13).
+4. Every condition above that must be reported (AC-001.3's duplicate count
+ and selection, AC-001.5's schedule state) is a structured log record plus
+ a counter per condition — never an additional return value, since both
+ production callers discard everything but the error today (AC-001.10).
+ Each record names workspace, assignee, and the routine it concerns.
+ Counters count calls in which a condition was *observed*, including
+ calls that are ultimately rejected. The whole requirement behaves
+ identically on SQLite and PostgreSQL (AC-001.11).
+
+## Design constraint for the serialization mechanism (AC-001.9, AC-001.13)
+
+Pick a mechanism satisfying: (a) blocks/serializes across processes sharing
+one database, not merely goroutines in one process; (b) works identically on
+SQLite and PostgreSQL; (c) bounds acquisition at a hard 5s; (d) distinguishes
+"could not acquire" (contention) from "acquired, then lost the race inside
+the critical section" from context cancellation. The requirement's own
+`## Out of scope` names three mechanisms that individually fail one of
+these and explicitly forbids treating them as interchangeable: a bare
+default-isolation-level transaction (does not stop two callers both reading
+"absent"), a PostgreSQL advisory lock (no SQLite equivalent), an in-process
+mutex (fails the cross-process domain). A row-level lock strategy
+(`SELECT ... FOR UPDATE`-equivalent per dialect, or a small dedicated lock
+row keyed by workspace+assignee with a bounded wait) is a plausible
+candidate Build should evaluate against all four constraints before
+building it.
+
+**If the chosen mechanism adds a new table or columns**, add it to
+`internal/persistence/requiredstores` and
+`internal/persistence/storeconformance` per the backend schema-owner rule,
+and add the fresh/replay conformance tests that requires — this is a risk to
+flag during implementation, not a decision to make in this work order.
+
+## Verification
+
+- `cd apps/backend && go test -tags fts5 ./internal/office/routines/... ./internal/office/agents/... ./internal/office/onboarding/...`
+- Test: identity match ignores an edited or absent trigger (regression for
+ the original card's bug — a routine with a non-canonical or deleted
+ trigger still matches).
+- Test: install against a triggerless existing match creates exactly the
+ canonical trigger and returns the existing routine (AC-001.4), and running
+ it again is a no-op via AC-001.5.
+- Test: two or more matching routines — selects earliest `created_at`/`id`,
+ deletes none, reports the count, and still completes the selected one's
+ schedule (AC-001.3).
+- Test: concurrent-install race — two goroutines (or two DB connections
+ simulating two processes) racing `CreateDefaultCoordinatorRoutine` for the
+ same identity produce exactly one routine and one trigger; run against
+ both SQLite and `KANDEV_TEST_POSTGRES_DSN` PostgreSQL.
+- Test: empty workspace/assignee/canonical-name is rejected before any read
+ (AC-001.14).
+- Test: contention and context-cancellation rejections are distinguishable
+ from each other and from AC-001.7's failures in the returned error/report.
+- If a new schema owner was added: `go run ./cmd/sqlguard ./internal` and
+ `go test -race ./internal/persistence/storeconformance -count=1`.
+
+## Files likely touched
+
+- `apps/backend/internal/office/routines/service.go` —
+ `CreateDefaultCoordinatorRoutine`, `findCoordinatorRoutine`,
+ `hasCronTrigger` (L145-239).
+- `apps/backend/internal/office/repository/sqlite/routines.go` — new
+ identity-lookup query and whatever serialization primitive is chosen.
+- new: `apps/backend/internal/office/routines/coordinator_install_metrics.go`
+ (structured records + counters, following the same `expvar.NewMap`
+ convention as Task 04).
+- `apps/backend/internal/office/agents/service.go`,
+ `apps/backend/internal/office/onboarding/service.go` — no behavior change
+ expected (both already discard the return value except the error), but
+ their tests should assert the discard contract still holds.
+- Corresponding `*_test.go` files, plus a PostgreSQL-gated concurrency test.
+
+## Dependencies
+
+Task 01 (this task reports Task 01's schedule state via AC-001.5; the
+requirement document states it "cannot ship before that one").
+
+## Parallelism
+
+Independent of Task 02 and Task 04 once Task 01 lands — no shared files
+with either.
+
+## Result
+
+Implemented as two separately-locked, separately-committed phases rather
+than one transaction, after TDD caught a real defect in the first cut: a
+single all-or-nothing transaction covering both routine-creation and
+trigger-creation would roll back an already-decided routine on a
+trigger-creation failure, violating AC-001.12's "leaves the routine in
+place" requirement. Splitting into two phases (each its own lock + tx)
+fixes this — a trigger-creation failure can now only abort the trigger's
+own transaction, never the routine's already-committed one.
+
+**Serialization mechanism** (resolves the work order's open design
+question): a real database transaction on both dialects, holding, on
+PostgreSQL only, a `pg_try_advisory_xact_lock` polled every 25ms inside a
+context bounded to a hard 5s (`coordinatorInstallLockTimeout`, not
+configurable by env/config/flag). On SQLite the existing single
+writer-connection pool (`internal/db.OpenSQLite`'s `SetMaxOpenConns(1)`)
+already serializes writers process-locally; PostgreSQL needs the explicit
+advisory lock because a plain transaction under READ COMMITTED does not
+stop two callers from both observing "absent". This combines (rather than
+picks one of) the three mechanisms the requirement's Out-of-scope section
+individually rules out.
+
+Files:
+- `apps/backend/internal/office/models/coordinator_install.go` (new) —
+ `models.ErrCoordinatorInstallContention` sentinel and the
+ `models.CoordinatorInstallTx` interface, placed in the shared `models`
+ package (rather than in `sqlite` or `routines`) so neither package has to
+ import the other.
+- `apps/backend/internal/office/repository/sqlite/coordinator_install.go`
+ (new) — `WithCoordinatorInstallLock` (generic lock-key + tx runner),
+ `CoordinatorInstallLockKey` (workspace+assignee+name), the separate
+ `CoordinatorInstallTriggerLockKey` (routine id), `InstallCoordinatorRoutine`
+ (routine-existence phase), `EnsureCoordinatorTrigger` (trigger-existence
+ phase, its own lock/tx), `acquirePostgresAdvisoryLock`,
+ `classifyCoordinatorInstallWaitErr`.
+- `apps/backend/internal/office/repository/sqlite/routines.go` — added
+ `CreateRoutineTriggerTx`, refactored `CreateRoutineTrigger` to share an
+ `insertRoutineTrigger` helper.
+- `apps/backend/internal/office/routines/coordinator_install_metrics.go`
+ (new) — `coordinatorInstallConditionsTotal` expvar map, 12 condition
+ constants, `coordinatorInstallObserved`.
+- `apps/backend/internal/office/routines/service.go` — `Repository`
+ interface gained `InstallCoordinatorRoutine` and
+ `EnsureCoordinatorTrigger`. `CreateDefaultCoordinatorRoutine` now runs
+ `ensureCoordinatorRoutine` (AC-001.2/.3/.12's routine half) then
+ `EnsureCoordinatorTrigger`'s decide callback (AC-001.4/.5/.12's trigger
+ half, via `decideCoordinatorTrigger`), each reported through the shared
+ `reportCoordinatorLockFailure` (contention / cancelled / a
+ caller-supplied fallback condition for the read-before-decide failing).
+
+Tests (all new):
+- `apps/backend/internal/office/routines/coordinator_install_test.go`
+ (internal `package routines`, for `coordinatorInstallConditionsTotal`
+ access) — empty identity (AC-001.14), no-match create (AC-001.12),
+ single match with any cron trigger unchanged (AC-001.2/.5), no-trigger
+ match completes install (AC-001.4) plus re-entrancy, duplicate matches
+ select earliest and leave the rest untouched (AC-001.3), status never
+ changes (AC-001.6), identity-lookup and trigger-read failures rejected
+ and distinguishable (AC-001.7), routine-insert failure creates no
+ trigger, trigger-create failure leaves the routine in place and a
+ follow-up call completes it (AC-001.12), contention vs. cancellation vs.
+ lookup-failure are counted on distinct counters (AC-001.13), and a real
+ two-goroutine race against one shared SQLite writer connection creates at
+ most one routine and one trigger (AC-001.8/.9).
+- `apps/backend/internal/office/routines/coordinator_install_dialect_postgres_test.go`
+ (external `package routines_test`, `testutil.PostgresDSNFromEnv` gated,
+ skips without `KANDEV_TEST_POSTGRES_DSN`) — fresh install + idempotent
+ repeat produce the same shape on SQLite and PostgreSQL (AC-001.11), and a
+ genuine concurrent-install race against PostgreSQL (where the advisory
+ lock actually runs) creates at most one routine and trigger (AC-001.8/.9).
+
+No new database table or column was introduced, so
+`internal/persistence/requiredstores`/`storeconformance` and `sqlguard`
+were not needed.
+
+Verification: `go build -tags fts5 ./...` clean; `gofmt -l` clean on every
+touched/new file; `go vet -tags fts5 ./internal/office/...` clean;
+`golangci-lint run ./internal/office/models/... ./internal/office/repository/sqlite/... ./internal/office/routines/... --new-from-rev= --timeout=5m`
+→ 0 issues; `go test -tags fts5 -race -count=1 ./internal/office/routines/... ./internal/office/agents/... ./internal/office/onboarding/... ./internal/office/wakeup/...`
+all pass, including the two pre-existing coordinator-install tests
+(`TestCreateDefaultCoordinatorRoutine_Idempotent`,
+`TestRoutine_EndToEnd_CoordinatorHeartbeatFire`) unchanged. The new
+PostgreSQL-gated tests skip locally (`KANDEV_TEST_POSTGRES_DSN` unset). The
+one pre-existing, unrelated failure (`TestMigrate_PriorityIdempotent` in
+`internal/office/repository/sqlite`) is confirmed present before this
+task's changes and out of scope.
diff --git a/docs/specs/office/requirements/coordinator-install-idempotency.md b/docs/specs/office/requirements/coordinator-install-idempotency.md
new file mode 100644
index 0000000000..aea6c91a45
--- /dev/null
+++ b/docs/specs/office/requirements/coordinator-install-idempotency.md
@@ -0,0 +1,259 @@
+---
+status: draft
+system: office
+created: 2026-09-17
+owners:
+ - kandev
+---
+
+# Office Coordinator Install Idempotency Requirements
+
+## Overview
+
+Onboarding pre-installs a `Coordinator heartbeat` routine. The installer decides
+whether that routine is already present by matching the workspace, the assignee
+agent, the canonical routine name, **and** the presence of a cron trigger whose
+expression equals the canonical one. That last clause is the problem: trigger
+presence and trigger expression are mutable, so a routine that was installed
+successfully can stop matching itself later.
+
+Two paths reach that state on an install running today. A half-finished install
+leaves a routine row with no trigger at all, because trigger creation is a
+second write that can fail after the routine is created and whose error is
+warn-logged by both callers rather than rolled back. And a coordinator trigger
+whose expression stops equalling the canonical one leaves a routine that no
+longer matches itself. No endpoint edits a trigger in place — the trigger routes
+are list, create, and delete — so that state is reached by deleting the
+coordinator's trigger and creating a replacement carrying a different expression,
+or by editing the row directly. Which path was taken matters less than the
+result, because identity must not depend on a value that any of them can change.
+Either way, re-running onboarding sees no match and creates a second coordinator
+routine, so the workspace ends up with two heartbeats or, more often, two
+routines neither of which fires.
+
+The match is *not* sensitive to `enabled` today — the installer never reads that
+column. This document's job is to keep it that way while removing the two
+mutable clauses it does match on. Making identity depend on `enabled`, which a
+reader coming from the card's "disabled trigger" framing might reasonably assume
+is already the case or is the fix, would **add a third duplication path** rather
+than close one, and is forbidden by AC-OFFICE-COORDINATOR-INSTALL-001.1.
+
+This document does not define or repair a schedule. It consumes the
+classification of
+[Office Routine Arming Visibility](routine-arming-visibility.md) — schedule state
+is defined there, and AC-OFFICE-COORDINATOR-INSTALL-001.5 requires the install to
+report it — so this document cannot ship before that one.
+
+## Terminology
+
+This document uses the terms defined in
+[Office Routine Arming Visibility § Terminology](routine-arming-visibility.md):
+*fire*, *armed*, *intent*, and *schedule state*. It adds one:
+
+- **Canonical:** the routine name, cron expression, and timezone that onboarding
+ itself supplies for the coordinator routine. Canonical values are the
+ installer's own constants, not operator input.
+
+## Requirements
+
+### REQ-OFFICE-COORDINATOR-INSTALL-001: Match the coordinator routine on identity, not on mutable trigger state
+
+**Intent:** The pre-installed coordinator routine is matched on name plus the
+presence of a cron trigger carrying an exact expression. A routine whose trigger
+creation failed, or whose expression was edited, therefore stops matching itself
+and the installer creates a second coordinator routine. Identity must not depend
+on mutable trigger state.
+
+**User story:** As an operator re-running onboarding on an upgraded install, I
+want one coordinator routine, with its schedule completed rather than duplicated.
+
+#### Acceptance criteria
+
+- **AC-OFFICE-COORDINATOR-INSTALL-001.1:** The coordinator routine's identity shall be
+ the workspace, the assignee agent, and the canonical routine name, and shall
+ not depend on any trigger's `enabled` value, `next_run_at`, cron expression, or
+ presence.
+- **AC-OFFICE-COORDINATOR-INSTALL-001.2:** Installing the coordinator routine when a
+ routine with that identity already exists shall return the existing routine and
+ shall not create a second one, whatever its schedule state.
+- **AC-OFFICE-COORDINATOR-INSTALL-001.3:** When more than one existing routine
+ satisfies that identity, the install shall return the one with the earliest
+ `office_routines.created_at`, breaking ties by `office_routines.id` ascending.
+ It shall delete none of them and shall report that more than one was found. The
+ routine it selects shall then be subject to
+ AC-OFFICE-COORDINATOR-INSTALL-001.4 and AC-OFFICE-COORDINATOR-INSTALL-001.5
+ exactly as a single match would be, so a duplicated half-finished install is
+ completed rather than left permanently unable to fire. The routines it did not
+ select shall be left entirely untouched, including their triggers.
+- **AC-OFFICE-COORDINATOR-INSTALL-001.4:** Installing the coordinator routine when a
+ routine with that identity exists with no cron trigger shall create the
+ canonical cron trigger for it and return the existing routine, completing an
+ install whose trigger creation previously failed. The created trigger shall
+ carry the canonical cron expression, the canonical timezone, `enabled = true`,
+ and a `next_run_at` equal to the first occurrence of the canonical expression
+ evaluated in the canonical timezone strictly after a single instant captured
+ once at the start of the install. A repaired trigger whose `next_run_at` is
+ null is not a completed install: it classifies `trigger_unscheduled` and still
+ cannot fire, which is the state this criterion exists to leave behind. When
+ that next occurrence cannot be computed, the install shall be rejected and
+ shall report the failure, leaving the existing routine in place; because the
+ expression and timezone are the installer's own constants, that failure is a
+ defect in the constants and no input can reach it. When that trigger creation fails, the install shall report
+ the failure and shall leave the existing routine in place rather than deleting
+ it, so that the next install reaches this same criterion and completes the
+ schedule. This criterion is therefore re-entrant: running it again against a
+ routine it already repaired takes the
+ AC-OFFICE-COORDINATOR-INSTALL-001.5 branch and changes nothing.
+- **AC-OFFICE-COORDINATOR-INSTALL-001.5:** Installing the coordinator routine when a
+ routine with that identity exists with any cron trigger, enabled or disabled,
+ canonical expression or not, shall leave every trigger unchanged, shall create
+ no second trigger, and shall report the routine's schedule state.
+- **AC-OFFICE-COORDINATOR-INSTALL-001.6:** Installing the coordinator routine shall
+ never change an existing routine's `status`.
+- **AC-OFFICE-COORDINATOR-INSTALL-001.7:** When the identity lookup itself fails, or
+ when the read of the existing routine's triggers that decides between
+ AC-OFFICE-COORDINATOR-INSTALL-001.4 and AC-OFFICE-COORDINATOR-INSTALL-001.5
+ fails, the install shall be rejected and shall create nothing. An indeterminate
+ read shall never be treated as "absent": neither as an absent routine, which
+ duplicates the routine, nor as an absent trigger, which duplicates the
+ trigger.
+- **AC-OFFICE-COORDINATOR-INSTALL-001.8:** Two concurrent installs for the same
+ workspace and assignee shall between them create at most one new coordinator
+ routine and at most one new canonical cron trigger, whichever order the two
+ callers interleave. This bounds what the install *creates*; it is not a
+ uniqueness guarantee over the table. Duplicate routines a previous install
+ already created remain, and are handled by
+ AC-OFFICE-COORDINATOR-INSTALL-001.3; triggers that already exist remain, and
+ are handled by AC-OFFICE-COORDINATOR-INSTALL-001.5.
+- **AC-OFFICE-COORDINATOR-INSTALL-001.9:** No database uniqueness constraint shall
+ be added to enforce
+ AC-OFFICE-COORDINATOR-INSTALL-001.8. Upgraded installs already hold duplicate
+ coordinator routines, so a unique index over the identity columns would fail to
+ apply on exactly the installs this document exists to fix, and the alternative
+ of deleting rows to make it apply is out of scope below. The guarantee shall
+ instead be met by serializing the check-and-create against concurrent installs
+ for the same workspace and assignee. The domain of that serialization shall be
+ every installer call that can reach the same database, not merely every call
+ within one process: the guarantee shall hold when two processes share a
+ database, which rules out a purely in-process guard as the sole mechanism. It
+ shall hold on both dialects named in
+ AC-OFFICE-COORDINATOR-INSTALL-001.11, which rules out any mechanism available
+ on only one of them. Acquisition shall not block indefinitely: it shall either
+ acquire or fail within a single named compile-time bound of 5 seconds, and that
+ bound shall not be operator-configurable — no environment variable,
+ configuration key, or runtime flag shall expose it — because both of the
+ installer's callers run on a user-facing onboarding path, where an unbounded
+ wait is indistinguishable from a hang.
+- **AC-OFFICE-COORDINATOR-INSTALL-001.10:** The reports required by
+ AC-OFFICE-COORDINATOR-INSTALL-001.3 and
+ AC-OFFICE-COORDINATOR-INSTALL-001.5 shall be a structured record plus a counter
+ per condition, not an additional value returned from the install call. The
+ routine the call already returns under
+ AC-OFFICE-COORDINATOR-INSTALL-001.2 and
+ AC-OFFICE-COORDINATOR-INSTALL-001.3 is unchanged; what this criterion forbids
+ is carrying the *reports* out that way. Both of the installer's callers today
+ discard everything the call returns except the error, and neither is being
+ changed to inspect a richer return, so a report delivered as a return value
+ would report to nobody. Each record shall name the workspace, the assignee
+ agent, and the routine it concerns — for
+ AC-OFFICE-COORDINATOR-INSTALL-001.3, the routine it selected, and the number of
+ routines that matched. Each condition that applies shall be reported exactly
+ once per install call. A call that is rejected shall still report every
+ condition it had already detected before rejecting, in particular the
+ duplicate-routines condition of AC-OFFICE-COORDINATOR-INSTALL-001.3, which
+ describes state the call found in the database rather than work the call
+ performed. These counters therefore count the calls in which a condition was
+ observed, not the installs that completed, so a duplicated coordinator is
+ reported on the runs that fail as well as the runs that succeed.
+- **AC-OFFICE-COORDINATOR-INSTALL-001.11:** The install shall behave identically on
+ SQLite and on PostgreSQL.
+- **AC-OFFICE-COORDINATOR-INSTALL-001.12:** Installing the coordinator routine when
+ no routine with that identity exists shall create one routine carrying that
+ identity, with `status = 'active'`, together with its canonical cron trigger
+ carrying every field required by AC-OFFICE-COORDINATOR-INSTALL-001.4, and shall
+ return the created routine. When the routine insert fails, the install shall be
+ rejected, shall create no trigger, and shall report the failure. When the
+ routine insert succeeds and the trigger creation fails, the install shall leave
+ the created routine in place and report the failure, so that the next install
+ reaches AC-OFFICE-COORDINATOR-INSTALL-001.4 and completes the schedule; this is
+ the half-finished install this document exists to make recoverable, and it is
+ reached by the same path whether the first install failed here or before this
+ document existed.
+- **AC-OFFICE-COORDINATOR-INSTALL-001.13:** When the serialization required by
+ AC-OFFICE-COORDINATOR-INSTALL-001.9 cannot be acquired, or the serialized
+ section fails because a concurrent install held it, the install shall be
+ rejected, shall create nothing, and shall report contention distinguishably
+ from the failures of AC-OFFICE-COORDINATOR-INSTALL-001.7. It shall not retry
+ within the same call. A rejected install leaves the routine the winning caller
+ created, so the next install returns it under
+ AC-OFFICE-COORDINATOR-INSTALL-001.2; contention therefore costs a warning, not
+ a duplicate and not a lost coordinator. Reaching the acquisition bound of
+ AC-OFFICE-COORDINATOR-INSTALL-001.9 without acquiring is contention and shall be
+ reported as contention. Cancellation of the caller's context while waiting shall
+ likewise reject the install and create nothing, and shall be reported
+ distinguishably from both contention and the failures of
+ AC-OFFICE-COORDINATOR-INSTALL-001.7, so that a shutdown part-way through
+ onboarding is not recorded as a duplicate-install race.
+
+- **AC-OFFICE-COORDINATOR-INSTALL-001.14:** The install shall reject a call whose
+ workspace, assignee agent, or canonical routine name is empty, before performing
+ the identity lookup, and shall create nothing. An empty value is not an
+ identity: accepting one lets a malformed call match, or create, a routine that
+ no real workspace or agent owns, and AC-OFFICE-COORDINATOR-INSTALL-001.1 makes
+ those three values the whole of identity. This keeps the guard the installer
+ applies today and extends it to the third component. The rejection shall be
+ reported distinguishably from the read failures of
+ AC-OFFICE-COORDINATOR-INSTALL-001.7, because nothing was read.
+- **AC-OFFICE-COORDINATOR-INSTALL-001.15:** No failure in this requirement shall
+ be retried inside the same install call: not the identity lookup or the trigger
+ read of AC-OFFICE-COORDINATOR-INSTALL-001.7, not the trigger creation of
+ AC-OFFICE-COORDINATOR-INSTALL-001.4, not the routine insert or trigger creation
+ of AC-OFFICE-COORDINATOR-INSTALL-001.12, and not the serialization of
+ AC-OFFICE-COORDINATOR-INSTALL-001.9 and
+ AC-OFFICE-COORDINATOR-INSTALL-001.13. Each rejection returns to its caller, and
+ the next install is what reaches the criterion that completes the work; that is
+ the recovery model the rest of this requirement is built on, and an in-call
+ retry would only lengthen a failure on an onboarding path. A rejected install
+ shall surface its failure as an error, because the error is the only channel
+ either caller reads. It may also return the routine it found or created
+ alongside that error, as the installer does today: no criterion here depends on
+ that value, so Build is free to keep or drop it.
+
+## Out of scope
+
+- **Defining or repairing a schedule.** The classification belongs to
+ [Office Routine Arming Visibility](routine-arming-visibility.md). This document
+ *consumes* it — AC-OFFICE-COORDINATOR-INSTALL-001.5 reports schedule state, so
+ reporting is in scope and only the definition is not. It creates a canonical
+ trigger where none exists and otherwise touches no trigger.
+- **Repairing a coordinator routine that is present but unarmed.** An install
+ reports the schedule state and stops. It does not enable a disabled trigger and
+ does not re-schedule a trigger whose `next_run_at` is null, even its own
+ canonical one, because it cannot tell an operator's deliberate choice from a
+ defect. The operator-initiated action that would repair it is deferred; see
+ that document's `## Out of scope`, which cites the successor card.
+- **Merging or deleting duplicate coordinator routines** created by an earlier
+ install. The install reports that more than one was found and deletes none;
+ deciding which to keep needs an operator. This is what makes the uniqueness
+ constraint of AC-OFFICE-COORDINATOR-INSTALL-001.9 unavailable, and the two
+ decisions stand or fall together.
+- **Correcting a coordinator trigger whose cron expression an operator edited.**
+ Identity stops depending on the expression; the edited value is left alone.
+- **Rolling back a half-finished install.** This document completes one on the
+ next run rather than unwinding it, and does not change either caller's existing
+ choice to warn-log a failed install and continue.
+- **Which serialization mechanism is used.**
+ AC-OFFICE-COORDINATOR-INSTALL-001.9 states the property, its domain, and the
+ two constraints that narrow the choice; AC-OFFICE-COORDINATOR-INSTALL-001.13
+ states what contention does. Which conforming mechanism Build picks is its
+ decision. The three candidates are not interchangeable and this document does
+ not offer them as though they were: a transaction at the default isolation
+ level of either dialect does not by itself stop two callers both reading
+ "absent" and both inserting, a PostgreSQL advisory lock has no SQLite
+ equivalent, and an in-process guard fails the cross-process domain. Whatever is
+ chosen must satisfy .9 and .13 as written.
+- **Other pre-installed routines.** Onboarding installs one routine today. A
+ second would need this contract restated for it, not inherited silently.
+- **Whether the coordinator routine should exist at all,** and what its canonical
+ cron expression should be. This document keeps the install idempotent; it does
+ not revisit the values being installed.
diff --git a/docs/specs/office/requirements/routine-arming-startup-scan.md b/docs/specs/office/requirements/routine-arming-startup-scan.md
new file mode 100644
index 0000000000..adfde6d224
--- /dev/null
+++ b/docs/specs/office/requirements/routine-arming-startup-scan.md
@@ -0,0 +1,174 @@
+---
+status: draft
+system: office
+created: 2026-09-17
+owners:
+ - kandev
+---
+
+# Office Routine Arming Startup Scan Requirements
+
+## Overview
+
+The install this capability exists for is unattended: nobody opens the routines
+page for weeks, so the read-path surface of
+[Office Routine Arming Visibility](routine-arming-visibility.md) leaves a dead
+loop undetected exactly where detection matters most. This document owns the
+read-only scan that runs once at Office startup and reports what the classifier
+found, to logs and to counters.
+
+It classifies nothing of its own: schedule state, the unarmed cron trigger list,
+and the dispatch grace are all defined by that document, and this one consumes
+them. It was authored inside it and moved here unchanged in identity to stay
+inside the requirement-file size limit: the requirement is still
+`REQ-OFFICE-ROUTINE-ARMING-003` and its criteria are still numbered
+`AC-OFFICE-ROUTINE-ARMING-003.1` onward, so every reference to them elsewhere
+still resolves.
+
+## Terminology
+
+This document uses the terms defined in
+[Office Routine Arming Visibility § Terminology](routine-arming-visibility.md),
+unchanged: *fire*, *intent*, *schedule state*, *armed*, *schedulable*, *dispatch
+grace*, *trigger order*, *unarmed cron trigger*, and *surface*.
+
+## Requirements
+
+### REQ-OFFICE-ROUTINE-ARMING-003: Surface unarmed routines at startup
+
+**Intent:** The install this capability exists for is unattended. Nobody opens
+the routines page for weeks, so a UI-only surface leaves the dead loop undetected
+exactly where detection matters most.
+
+**User story:** As an operator reading logs or metrics after an upgrade, I want a
+routine that is switched on and cannot fire to have left a trace.
+
+#### Acceptance criteria
+
+- **AC-OFFICE-ROUTINE-ARMING-003.1:** During Office startup the system shall
+ classify every routine it can enumerate, in every workspace it can enumerate,
+ and report the result. The scan shall read only after Office startup
+ reconciliation has returned — the pass that gives every trigger-less routine a
+ `manual` trigger — whether or not that pass succeeded, so that a routine the
+ scan reports as `unscheduled_no_trigger` is one reconciliation left without a
+ trigger rather than one the scan happened to read first. The ordering shall be
+ established by an explicit completion signal handed to the scan by the startup
+ sequence that ran reconciliation, and by nothing else: not a timer, not a retry,
+ and not an inspection of trigger rows. The scan is either invoked with that
+ signal or invoked without it. Invoked without it, the scan shall classify
+ nothing, emit a warning-level record, and increment the skipped counter of
+ AC-OFFICE-ROUTINE-ARMING-003.7 with a reason distinguishing this cause both from
+ enumeration failure and from per-routine classification failure. The startup
+ path that exists today always supplies the signal, so that branch is not reached
+ in production; it exists so that a caller which cannot establish the ordering is
+ refused rather than reporting a routine as `unscheduled_no_trigger` before
+ reconciliation has given it a trigger, and it is reached in a test by invoking
+ the scan without the signal.
+- **AC-OFFICE-ROUTINE-ARMING-003.2:** The scan shall iterate workspaces ordered
+ by `workspaces.created_at` ascending, breaking ties by `workspaces.id`
+ ascending, and within each workspace shall iterate routines ordered by
+ `office_routines.created_at` ascending, breaking ties by `office_routines.id`
+ ascending, so that two scans over unchanged data report in the same order.
+- **AC-OFFICE-ROUTINE-ARMING-003.3:** For a routine whose intent is `active` and
+ whose schedule state is `trigger_disabled`, `trigger_unscheduled` or
+ `trigger_invalid`, the system shall emit a warning-level record naming the
+ workspace, the routine, the schedule state, and every unarmed cron trigger in
+ trigger order.
+- **AC-OFFICE-ROUTINE-ARMING-003.4:** For a routine whose intent is not `active`,
+ the system shall emit an informational record stating that the routine is
+ deliberately off, and shall not emit a warning.
+- **AC-OFFICE-ROUTINE-ARMING-003.5:** For a routine whose intent is `active` and
+ whose schedule state is neither one of those in
+ AC-OFFICE-ROUTINE-ARMING-003.3 nor one of those in
+ AC-OFFICE-ROUTINE-ARMING-003.14 nor `unknown`, the system shall emit an
+ informational record and no warning.
+- **AC-OFFICE-ROUTINE-ARMING-003.6:** The system shall publish a counter of scan
+ observations labelled by workspace, intent, and schedule state.
+- **AC-OFFICE-ROUTINE-ARMING-003.7:** When the scan cannot classify a routine it
+ enumerated, the system shall increment a skipped counter labelled by reason, so
+ that a scan failing closed is visible rather than silent.
+- **AC-OFFICE-ROUTINE-ARMING-003.8:** When the scan cannot enumerate the
+ workspace list at all, it shall increment that skipped counter with a reason
+ distinguishing enumeration failure from per-routine classification failure,
+ emit a warning-level record, and return without classifying anything.
+- **AC-OFFICE-ROUTINE-ARMING-003.9:** A routine whose schedule state is `unknown`
+ shall emit an informational record naming the workspace, the routine, and the
+ `unknown` state, whatever its intent, and shall not emit a warning. An unreadable
+ row is not evidence of a broken routine. That record is the only one emitted for
+ such a routine: it stands in place of the record
+ AC-OFFICE-ROUTINE-ARMING-003.4 would otherwise require when the intent is not
+ `active`, so every routine the scan reaches produces exactly one record. The
+ routine shall increment the skipped counter of AC-OFFICE-ROUTINE-ARMING-003.7
+ once, and shall also be counted once by the observation counter of
+ AC-OFFICE-ROUTINE-ARMING-003.6 under the `unknown` schedule state. Incrementing
+ both is intended, not double counting: the two answer different questions — how
+ many routines were observed in each state, and how many the scan could not
+ decide.
+- **AC-OFFICE-ROUTINE-ARMING-003.10:** Office startup shall complete without
+ waiting for the scan to finish, and no scan outcome, including a database
+ error, shall fail startup or change its result.
+- **AC-OFFICE-ROUTINE-ARMING-003.11:** The scan shall write nothing, per
+ AC-OFFICE-ROUTINE-ARMING-001.7, and shall not enable, disable, create, or
+ delete a trigger.
+- **AC-OFFICE-ROUTINE-ARMING-003.12:** Running the scan twice over unchanged data
+ shall emit the same records, in the same order, and shall leave the database
+ unchanged. Two records are the same when they carry the same level, the same
+ workspace, the same routine, the same schedule state, and the same unarmed cron
+ triggers in the same order; wall-clock timestamps, durations, and fields the
+ logging layer adds are excluded from the comparison, as are counters, which
+ accumulate. The equality is asserted only where classification does not depend
+ on where the classification instant falls relative to the dispatch grace: a
+ routine is comparable between two scans when each of its cron triggers is on
+ the same side of the grace at both classification instants of
+ AC-OFFICE-ROUTINE-ARMING-001.12, where a null `last_fired_at` counts as outside
+ the grace at every instant. A trigger inside the grace at one scan and
+ outside it at the other changes schedule state with no database change, by the
+ design of rule 2, and is excluded from the comparison rather than counted as a
+ violation. The equality is likewise asserted only between two scans that each
+ enumerated the workspace list and every workspace's routines successfully and
+ classified every routine they enumerated: a scan in which
+ AC-OFFICE-ROUTINE-ARMING-003.8, AC-OFFICE-ROUTINE-ARMING-003.13, or a
+ per-routine classification failure occurred emits a different set of records with
+ no database change, by the design of those criteria, and is excluded from the
+ comparison. This criterion constrains classification, not the database's
+ willingness to answer.
+- **AC-OFFICE-ROUTINE-ARMING-003.14:** For a routine whose intent is `active` and
+ whose schedule state is `unscheduled_manual_only` or `unscheduled_no_trigger`,
+ the system shall emit a warning-level record naming the workspace, the routine,
+ and the schedule state, and stating that the routine has no schedule. The record
+ shall carry no unarmed cron trigger list, because a routine in either state owns
+ no cron trigger. This is the state
+ [Office Routine Arming Visibility](routine-arming-visibility.md) names as the
+ sharper failure — switched on, presenting as on, incapable of firing — which is
+ why it warns rather than informs. The scan cannot tell a routine an operator
+ fires by hand from one whose schedule was never created, and this criterion does
+ not ask it to: either way the routine will not fire on a schedule, the operator
+ is the only one who can say which was meant, and the cost of saying so is one
+ warning per such routine per startup.
+- **AC-OFFICE-ROUTINE-ARMING-003.13:** When the scan enumerates the workspace
+ list but then fails to enumerate the routines of one workspace, it shall retain
+ every record already emitted for earlier workspaces, increment the skipped
+ counter of AC-OFFICE-ROUTINE-ARMING-003.7 once for that workspace with the
+ enumeration-failure reason, emit a warning-level record naming it, and continue
+ to the next workspace in the order of
+ AC-OFFICE-ROUTINE-ARMING-003.2. It shall not retry that workspace within the
+ same scan and shall not abandon the scan.
+
+## Out of scope
+
+- **Classifying a routine.** The classification table, the unarmed cron trigger
+ list, and the dispatch grace belong to
+ [Office Routine Arming Visibility](routine-arming-visibility.md). This document
+ reports what that classifier returns and defines no state of its own.
+- **Repair of any kind.** The scan writes nothing, per
+ AC-OFFICE-ROUTINE-ARMING-003.11. It does not re-arm, and the operator-initiated
+ re-arm action is deferred entirely — see that document's `## Out of scope`.
+- **Running the scan again.** This is one pass at startup. A periodic re-scan, an
+ operator-invoked re-scan, and an API that returns the scan's findings are each
+ separate work; the read-path response of
+ REQ-OFFICE-ROUTINE-ARMING-002 already reports the same values on demand.
+- **Operator notification.** Inbox items, mail, and paging are separate work. The
+ scan emits records and counters and stops.
+- **What startup reconciliation itself does.** AC-OFFICE-ROUTINE-ARMING-003.1
+ orders the scan after it and reports the result; it does not change which
+ triggers reconciliation creates.
diff --git a/docs/specs/office/requirements/routine-arming-visibility.md b/docs/specs/office/requirements/routine-arming-visibility.md
new file mode 100644
index 0000000000..5f4bf9f0c3
--- /dev/null
+++ b/docs/specs/office/requirements/routine-arming-visibility.md
@@ -0,0 +1,318 @@
+---
+status: draft
+system: office
+created: 2026-09-17
+owners:
+ - kandev
+---
+
+# Office Routine Arming Visibility Requirements
+
+## Overview
+
+An Office routine fires on its own only when a cron trigger reaches the
+scheduler. Several independent states stop that happening, and none is reported
+anywhere: the operator sees a routine badged "On" and cannot tell a running loop
+from one somebody deliberately turned off, or from one silently dead.
+
+This capability makes the distinction **visible** and repairs nothing.
+Operator-initiated repair was specified alongside it and deferred to a separate
+card; see `## Out of scope`. Until that lands, an operator shown a broken
+schedule still repairs it by hand.
+
+The live install at spec time has zero armed cron triggers anywhere: one routine
+is `paused` with a disabled `cron` trigger and renders "Off", and one is `active`
+with only a `manual` trigger, so it renders **"On"**, has no schedule at all, and
+does not fire. The second is the sharper failure: switched on, presenting as on,
+incapable of firing.
+
+Every term in this contract is an Office primitive: `office_routines.status`,
+`office_routine_triggers`, and the dispatch tick in `internal/office/routines`.
+
+This document owns the classification and the read path. The startup scan that
+reports the same classification to logs and counters on an unattended install is
+`REQ-OFFICE-ROUTINE-ARMING-003`, which lives in
+[Office Routine Arming Startup Scan](routine-arming-startup-scan.md) to stay
+inside the requirement-file size limit. The two ship together.
+
+## Prior art
+
+No external knowledge base was reachable, so the prior art below is in-repo.
+
+[Office Stall Visibility](stall-visibility.md) is the same shape: a state no
+detector reports, made visible without granting anything permission to recover
+it. This document reuses its `Surface` definition, its detection/recovery split,
+and its structured-log-plus-`expvar` convention, and differs in one way: stall
+recovery is forbidden on principle, whereas here recovery is legitimate and
+merely deferred, which is why `## Out of scope` names a successor card rather
+than closing the question.
+
+## Terminology
+
+- **Fire:** the scheduler tick claims a trigger and dispatches a routine run. A
+ routine that fires and finds no work has fired.
+- **Intent:** the operator's on/off choice, held in `office_routines.status`.
+ `active` means run this routine; any other value means deliberately off.
+- **Schedule state:** whether the routine's triggers can fire it on a schedule,
+ computed from the trigger rows alone, without reference to intent.
+- **Armed:** schedule state `armed` — some cron trigger can fire the routine on
+ a schedule with no further operator action.
+- **Schedulable:** a cron trigger for which the shared scheduler can compute a
+ next occurrence. The expression and timezone must be valid, and the
+ expression must name at least one real calendar slot. The scheduler's
+ day-of-month/day-of-week OR rule and DST handling apply here too. A trigger
+ that fails these checks is not schedulable, and no amount of re-arming makes
+ it fire.
+- **Dispatch grace:** a named duration of 60 seconds, separating a trigger
+ mid-dispatch from one permanently stuck. See
+ AC-OFFICE-ROUTINE-ARMING-001.10.
+- **Trigger order:** `created_at` ascending, ties broken by `id` ascending. Every
+ ordered output over triggers in this document uses it.
+- **Unarmed cron trigger:** a cron trigger of the routine that cannot currently
+ fire, carrying the reasons why. See AC-OFFICE-ROUTINE-ARMING-001.8.
+- **Surface:** report a state to an operator without changing routine, trigger,
+ or run state.
+
+Intent and schedule state are two switches, and this document owns the
+classification and reporting of the second one. Classification reads trigger
+rows only. Dispatch uses the routine status separately: the cron path checks
+the status before it claims a slot, and the manual and webhook paths reject a
+non-firing status. This document reports both values and does not change those
+dispatch gates.
+
+## Requirements
+
+### REQ-OFFICE-ROUTINE-ARMING-001: Classify a routine's schedule state
+
+**Intent:** Every unarmed routine is unarmed for exactly one reason, and the
+reason determines what the operator must do next. A single boolean cannot carry
+that, and a state computed from intent cannot separate deliberate from broken.
+
+**User story:** As an operator, I want each routine to report why it will or will
+not fire, so I can tell a paused loop from a dead one.
+
+Schedule state is a total function of the routine's trigger rows alone. The first
+matching rule wins; the ordering is the contract, not an implementation detail.
+
+| # | Condition on the routine's triggers | State |
+|---|---|---|
+| 0 | The trigger rows could not be read | `unknown` |
+| 1 | Some `cron` trigger has `enabled = true` and `next_run_at` non-null | `armed` |
+| 2 | Some `cron` trigger has `enabled = true`, `next_run_at` null, and `last_fired_at` no older than the dispatch grace | `armed` |
+| 3 | Some `cron` trigger has `enabled = true` and is not schedulable | `trigger_invalid` |
+| 4 | Some `cron` trigger has `enabled = true` | `trigger_unscheduled` |
+| 5 | At least one `cron` trigger exists | `trigger_disabled` |
+| 6 | Some `webhook` trigger has `enabled = true`, and no `cron` trigger exists | `event_only` |
+| 7 | At least one trigger exists, none of kind `cron` | `unscheduled_manual_only` |
+| 8 | No trigger rows exist | `unscheduled_no_trigger` |
+
+Rule 2 exists because the scheduler clears `next_run_at` when it claims a trigger
+and restores it a few statements later; without it a healthy routine that happens
+to be mid-dispatch classifies as broken. Rules 3 and 4 therefore describe an
+enabled cron trigger whose `next_run_at` has been null for longer than that,
+which is a permanent stall. Rule 3 precedes rule 4 so the more actionable
+diagnosis wins when a routine owns an unschedulable enabled cron trigger
+alongside a merely unscheduled one. Rule 5 is reached only when no cron trigger is
+enabled, because rules 1 to 4 between them match every routine that has one.
+
+#### Acceptance criteria
+
+- **AC-OFFICE-ROUTINE-ARMING-001.1:** The system shall assign every routine
+ exactly one schedule state from the table above, applying the rules in the
+ order given and stopping at the first match.
+- **AC-OFFICE-ROUTINE-ARMING-001.2:** The system shall compute schedule state
+ without reading `office_routines.status`, so that a routine's schedule state is
+ identical whether its intent is `active` or not.
+- **AC-OFFICE-ROUTINE-ARMING-001.3:** A cron trigger with `enabled = true` whose
+ `next_run_at` is in the past shall match rule 1. A past-due trigger is due, not
+ broken. This says how rule 1 reads a past instant and does not override the
+ table: the same trigger with `enabled = false` reaches rule 5.
+- **AC-OFFICE-ROUTINE-ARMING-001.4:** A cron trigger with `enabled = true` whose
+ `next_run_at` is exactly the classification instant of
+ AC-OFFICE-ROUTINE-ARMING-001.12 shall match rule 1, on the same terms.
+- **AC-OFFICE-ROUTINE-ARMING-001.5:** When reading the routine's trigger rows
+ fails, the system shall report `unknown` and shall not report `armed`. A row
+ the system cannot decode is a row it could not read.
+- **AC-OFFICE-ROUTINE-ARMING-001.6:** The system shall produce the same schedule
+ state for the same trigger rows on SQLite and on PostgreSQL.
+- **AC-OFFICE-ROUTINE-ARMING-001.7:** Classification shall write no routine,
+ trigger, or run state.
+- **AC-OFFICE-ROUTINE-ARMING-001.8:** Alongside the schedule state, the system
+ shall report the routine's unarmed cron triggers: every cron trigger that is
+ `enabled = false`, or is not schedulable, or is `enabled = true` and
+ schedulable with a null `next_run_at` and a `last_fired_at` either null or
+ older than the dispatch grace. Each entry shall carry the trigger's `id` and
+ **every** one of those three reasons that applies to it, ordered as the reasons
+ are listed in this criterion; a single entry may carry more than one. The list
+ shall be in trigger order.
+- **AC-OFFICE-ROUTINE-ARMING-001.9:** A routine may be `armed` and still report a
+ non-empty unarmed cron trigger list: rule 1 matches on one trigger while
+ another remains broken.
+- **AC-OFFICE-ROUTINE-ARMING-001.10:** The dispatch grace shall be a single named
+ compile-time constant of 60 seconds, used identically by classification and by
+ the startup scan. It shall not be operator-configurable: no environment
+ variable, configuration key, or runtime flag shall expose it.
+- **AC-OFFICE-ROUTINE-ARMING-001.11:** A cron trigger with `enabled = true`, a
+ null `next_run_at`, and a null `last_fired_at` shall not match rule 2. A
+ trigger that has never fired is not mid-dispatch. Schedule state is the
+ routine's and is decided by first match across all its triggers, so such a
+ trigger sets the routine's state under rule 3 or rule 4 only when no sibling
+ matched an earlier rule; a sibling matching rule 1 leaves the routine `armed`
+ and puts this one on the unarmed cron trigger list, per
+ AC-OFFICE-ROUTINE-ARMING-001.9.
+- **AC-OFFICE-ROUTINE-ARMING-001.12:** One classification of one routine shall
+ read that routine's trigger rows once and derive both the schedule state and
+ the unarmed cron trigger list from that single read, and shall compare every
+ `last_fired_at` against one instant captured once for that classification, so
+ that no two triggers of a routine are judged against different clocks. The
+ bounded read is the one the classification derives its answer from: when a batch
+ read fails and the per-routine re-read of AC-OFFICE-ROUTINE-ARMING-001.13
+ supplies the rows, that re-read is the single read, and the instant stays the one
+ captured for the classification the re-read belongs to rather than being
+ re-captured per routine. A failed read derives nothing and does not count against
+ this criterion.
+- **AC-OFFICE-ROUTINE-ARMING-001.13:** A trigger-read failure shall make
+ `unknown` exactly those routines whose own trigger rows were not read. When one
+ query reads the trigger rows of several routines and that query fails, or one
+ row in its result cannot be decoded, the system shall re-read each routine of
+ that batch individually and report `unknown` only for those whose individual
+ read also fails, so one undecodable row makes at most its own routine
+ `unknown`, never every routine in the workspace. That re-read shall be
+ attempted at most once per routine per classification.
+
+### REQ-OFFICE-ROUTINE-ARMING-002: Report intent and schedule state on routine reads
+
+**Intent:** The operator's only view of a routine today is a badge driven by
+intent alone, which is why a routine with no schedule renders as "On". Both
+values must arrive in one response, so the surface cannot show one without the
+other.
+
+**User story:** As an operator opening the routines list, I want to see both
+whether a routine is switched on and whether it can actually fire.
+
+#### Acceptance criteria
+
+- **AC-OFFICE-ROUTINE-ARMING-002.1:** The routine list response and the single
+ routine response shall each carry, per routine, the intent value, the schedule
+ state from REQ-OFFICE-ROUTINE-ARMING-001, and the unarmed cron trigger list
+ from AC-OFFICE-ROUTINE-ARMING-001.8.
+- **AC-OFFICE-ROUTINE-ARMING-002.2:** The routines list shall distinguish a
+ routine whose intent is `active` and whose schedule state is `armed` from one
+ whose intent is `active` and whose schedule state is anything else.
+- **AC-OFFICE-ROUTINE-ARMING-002.3:** The routines list shall distinguish a
+ routine whose intent is not `active` from a routine whose intent is `active`
+ but which cannot fire, so that deliberate and broken never render the same.
+- **AC-OFFICE-ROUTINE-ARMING-002.4:** The surface shall distinguish a routine
+ whose unarmed cron trigger list holds at least one schedulable entry from one
+ whose unarmed entries are none of them schedulable, whatever its schedule
+ state: the first needs only its triggers re-armed, the second needs its cron
+ expression or timezone edited. A routine `armed` on one trigger and broken on
+ another still carries the distinction. The distinction applies only where the
+ unarmed cron trigger list is non-empty: over an empty list "none of them
+ schedulable" holds vacuously, so a routine with an empty list shall render
+ neither side of it rather than be told to edit an expression it has no problem
+ with. Neither rendering shall offer an action that performs the repair;
+ see `## Out of scope`.
+- **AC-OFFICE-ROUTINE-ARMING-002.5:** For a routine whose schedule state is
+ `unscheduled_manual_only` or `unscheduled_no_trigger`, the surface shall report
+ that the routine has no schedule.
+- **AC-OFFICE-ROUTINE-ARMING-002.6:** A routine whose schedule state is `unknown`
+ shall render as undetermined rather than as armed or as broken.
+- **AC-OFFICE-ROUTINE-ARMING-002.7:** The routine list response shall be produced
+ with a number of database queries that does not grow with the number of
+ routines in the workspace. This bounds the path where every read succeeds; the
+ per-routine re-read AC-OFFICE-ROUTINE-ARMING-001.13 requires after a failed
+ batch read is exempt, at most one extra read per routine of that batch, because
+ isolating the bad routine is worth more than the query count on a path that is
+ already failing.
+- **AC-OFFICE-ROUTINE-ARMING-002.8:** All copy added by this capability shall be
+ routed through the translation layer and present in every shipped locale.
+- **AC-OFFICE-ROUTINE-ARMING-002.9:** Every distinction this requirement demands
+ shall be carried by text, an icon, or an accessible label, never by colour
+ alone.
+- **AC-OFFICE-ROUTINE-ARMING-002.10:** The surface shall render a distinct label
+ for each of these groups: `armed`; `trigger_invalid`, `trigger_unscheduled` or
+ `trigger_disabled`; `event_only`; `unscheduled_manual_only` or
+ `unscheduled_no_trigger`; and `unknown`. Two routines in different groups shall
+ never render the same label.
+- **AC-OFFICE-ROUTINE-ARMING-002.11:** A routine whose schedule state is
+ `event_only` shall not be reported as having no schedule and shall not be
+ directed to create a trigger. It fires when its event arrives.
+- **AC-OFFICE-ROUTINE-ARMING-002.12:** Intent and schedule state shall be carried
+ in the same response for the same routine, and neither shall be omitted because
+ the other was unavailable: a routine whose triggers could not be read carries
+ its intent alongside schedule state `unknown`. The pair is not required to be a
+ transactional snapshot. Intent is read from the routine row and schedule state
+ from the trigger rows, so a write landing between those reads can yield a pair
+ never simultaneously true in the database. That is accepted, not prevented:
+ nothing here acts on the pair, and the next read reports the new value.
+
+## Out of scope
+
+Each exclusion is a contract: a later change that wants one needs its own
+requirement.
+
+- **The startup scan.** Not excluded from the capability, only from this file:
+ `REQ-OFFICE-ROUTINE-ARMING-003` is in
+ [Office Routine Arming Startup Scan](routine-arming-startup-scan.md) and is
+ part of the same delivery.
+- **Repair of any kind.** Nothing here enables, disables, creates, or deletes a
+ trigger, and nothing here changes intent. Not automatically, and not on an
+ operator's request either — see the two entries below.
+- **The operator-initiated re-arm action. Deferred to task
+ `b0382916-da13-44a5-85be-064ae6a9533c`.** A requirement for it was written
+ alongside this document and cut before Build, so this surface reports a broken
+ schedule and offers nothing that repairs it. What the successor needs: an
+ explicit action over one named routine, setting `enabled = true` and a fresh
+ `next_run_at` on exactly that routine's unarmed cron triggers that are
+ schedulable, in trigger order, in one transaction with no partial application;
+ never creating a trigger, never inferring a cron expression, never
+ re-scheduling an already-armed trigger, and never reading intent to decide what
+ to repair. It must keep four rejections distinct: no cron trigger to repair
+ (including `event_only`, which per AC-OFFICE-ROUTINE-ARMING-002.11 must not be
+ told to create one); cron triggers present but none schedulable; caller not
+ authorized for the owning workspace; no such routine. It consumes
+ AC-OFFICE-ROUTINE-ARMING-001.8's unarmed list and this document's
+ classification, so it depends on this document and not the reverse. Three
+ contract defects in the cut text travelled to that card unfixed and are
+ recorded there.
+- **Changing intent gating.** Routine status gating is owned by the routine
+ status requirement. The existing scheduler consumes a due cron slot when the
+ routine is not allowed to fire, and the manual and webhook handlers reject
+ non-firing statuses. This capability only reports the status and schedule
+ state; it does not change those gates.
+- **The coordinator install.** Keeping onboarding from duplicating the
+ pre-installed routine is
+ [Office Coordinator Install Idempotency](coordinator-install-idempotency.md).
+- **Cron evaluation changes.** Cron parsing and next-occurrence calculation use
+ the shared `NextCronTime` implementation. It follows `crontab(5)` OR semantics
+ for restricted day-of-month/day-of-week fields, applies the documented DST
+ policy, and returns `ErrUnsatisfiableCron` for an expression with no possible
+ occurrence. The classifier reports that last case as not schedulable. Changes
+ to those scheduler rules are outside this capability.
+- **The routine list's own ordering.** This document pins the order of every
+ trigger list it reports, and the startup scan document pins its own iteration
+ order. The order routines appear in the list response is pre-existing behaviour
+ and is not changed here.
+- **Editing a schedule.** Changing a cron expression, timezone, webhook secret or
+ signing mode is not classification, and would not be re-arming either: a
+ trigger that is not schedulable cannot be repaired by re-arming it and needs
+ its expression edited.
+- **Disabling a trigger from the UI.** No endpoint sets
+ `office_routine_triggers.enabled` to false today and this document adds none.
+ Whether the column should be retired is not decided here.
+- **Using `office_routines.last_run_at` as schedule state.** Dispatch updates
+ this column through `TouchRoutineLastRun`; it is a run-history value, not an
+ input to schedule-state classification.
+- **Runs stranded in `claimed`,** and any reaper for them. A stranded run does not
+ affect whether the next tick fires.
+- **Operator notification.** Inbox items, mail, and paging are separate work.
+- **Assignee validity.** A routine armed against a deleted or paused assignee
+ agent is `armed` here. Whether the woken agent can act is another contract.
+- **Trigger reconciliation against a config source.** Config sync creates routines
+ without triggers and the infrastructure reconciler gives them a `manual` one,
+ which is how the live install reached `unscheduled_manual_only`. This document
+ makes that state visible and changes what neither creates.
+- **Webhook delivery.** Whether an `event_only` routine's caller actually posts to
+ it, and whether its signature verifies, is not observed here. `event_only` says
+ a schedule is absent and an event trigger is enabled, nothing more.
diff --git a/docs/specs/office/system-design/coordinator-install-idempotency.md b/docs/specs/office/system-design/coordinator-install-idempotency.md
new file mode 100644
index 0000000000..ffaedb6b98
--- /dev/null
+++ b/docs/specs/office/system-design/coordinator-install-idempotency.md
@@ -0,0 +1,194 @@
+---
+status: current
+system: office
+requirements:
+ - REQ-OFFICE-COORDINATOR-INSTALL-001
+---
+
+# Office Coordinator Install Idempotency System Design
+
+## Purpose and boundaries
+
+Onboarding pre-installs a `Coordinator heartbeat` routine for a coordinator
+agent. The installer previously matched an existing routine on workspace +
+assignee + canonical name **and** a cron trigger with the exact canonical
+expression — a mutable-state match that let a half-finished install (routine
+row created, trigger creation failed) or a later trigger edit make the
+installer duplicate the routine on the next run. This design redefines
+identity as workspace + assignee + canonical name only, completes a
+half-finished install instead of duplicating it, and serializes install
+attempts across every process sharing the database so two concurrent
+installs can never both observe "absent." It does not touch dispatch or any
+other trigger-management path; the schedule-state classification it uses to
+report an already-scheduled routine is owned by
+[routine-schedule-state.md](routine-schedule-state.md).
+
+## Requirement mapping
+
+| Requirement | Design section |
+| --- | --- |
+| `REQ-OFFICE-COORDINATOR-INSTALL-001` | [Identity and decision flow](#identity-and-decision-flow), [Locking](#locking), [Failure and recovery](#failure-and-recovery) |
+
+## Components and responsibilities
+
+- **`RoutineService.CreateDefaultCoordinatorRoutine`**
+ (`internal/office/routines/service.go`) — the sole entry point, called by
+ `internal/office/agents/service.go` and
+ `internal/office/onboarding/service.go`. Runs the routine phase and the
+ trigger phase as two separately-locked, separately-committed steps.
+- **`Repository.InstallCoordinatorRoutine` /
+ `Repository.EnsureCoordinatorTrigger`**
+ (`internal/office/repository/sqlite/coordinator_install.go`) — the two
+ locked, transactional read-decide-write boundaries the service phases run
+ inside.
+- **`Repository.WithCoordinatorInstallLock`** (same file) — the shared
+ cross-process serialization mechanism both boundaries use, keyed by an
+ opaque lock key.
+- **`decideCoordinatorTrigger` / `selectOrCreateCoordinatorRoutine`**
+ (`internal/office/routines/service.go`) — the decision logic each locked
+ boundary runs: create-or-select for the routine phase, create-or-leave for
+ the trigger phase.
+
+## Data and contracts
+
+### Identity
+
+`(workspace_id, assignee_agent_profile_id, name=CoordinatorRoutineName)` is
+the entire identity. `hasAnyCronTrigger` — any cron trigger, enabled or not,
+canonical expression or not — is the only fact the trigger phase uses to
+decide whether to act; it never reads `enabled`, `next_run_at`, or the
+trigger's expression.
+
+### Lock keys
+
+- `CoordinatorInstallLockKey(workspaceID, agentID, canonicalName)` —
+ `"office-coordinator-install:|||"`, guards the
+ routine phase.
+- `CoordinatorInstallTriggerLockKey(routineID)` —
+ `"office-coordinator-install-trigger:"`, guards the trigger
+ phase. Deliberately a distinct namespace so a routine ID can never collide
+ with an identity key.
+- Both are exported functions, not exported namespace constants, so no
+ caller reassembles a key itself — the same rationale
+ `internal/workflow/repository.ParticipantRoleSeatLockKey` uses.
+
+### `models.CoordinatorInstallTx`
+
+The tx-scoped writer interface `decide` callbacks use:
+`ListTriggersByRoutineID`, `CreateRoutine`, `CreateRoutineTrigger` — all
+transaction-bound, so a `decide` callback cannot escape the lock's
+transaction boundary.
+
+### `models.ErrCoordinatorInstallContention`
+
+The sentinel error `WithCoordinatorInstallLock` returns when
+`coordinatorInstallLockTimeout` (5s, compile-time constant, no
+configuration surface) is reached before the lock is won —
+distinguished from the caller's own context ending by comparing which
+context is actually expired (see
+[Failure and recovery](#failure-and-recovery)).
+
+## Control flow
+
+### Identity and decision flow
+
+`CreateDefaultCoordinatorRoutine` runs two phases in sequence, both against
+the same captured `now`:
+
+1. **Routine phase** — `InstallCoordinatorRoutine` opens the identity lock,
+ lists every routine matching identity (ordered `created_at, id`), and
+ hands them to `selectOrCreateCoordinatorRoutine`: zero matches creates a
+ routine; one or more selects the earliest, logging a warning (and an
+ `expvar` observation) if more than one matched.
+2. **Trigger phase** — once the routine phase has committed,
+ `EnsureCoordinatorTrigger` opens the routine's own trigger lock, reads its
+ triggers, and hands them to `decideCoordinatorTrigger`: any existing cron
+ trigger (of any state) is left untouched and its schedule state logged
+ (via the shared classifier); no cron trigger creates the canonical one,
+ with `next_run_at` computed from the single `now` the outer call captured.
+
+The two phases are separate transactions on purpose: a trigger-creation
+failure must never roll back an already-committed routine.
+
+### Locking
+
+`WithCoordinatorInstallLock` runs `fn` inside one transaction, bounded to
+`coordinatorInstallLockTimeout` via a context derived from (not identical to)
+the caller's context:
+
+- **SQLite:** the writer connection is the only open connection
+ (`internal/db.OpenSQLite`'s `SetMaxOpenConns(1)`), and SQLite's own
+ file-level lock plus `busy_timeout` already serializes a second process's
+ write transaction — opening the transaction is the whole mechanism, the
+ same pattern `internal/workflow/repository.EnsureRoleSeat` already
+ establishes.
+- **PostgreSQL:** a plain transaction does not by itself stop two callers
+ both reading "absent" under `READ COMMITTED`, so the transaction
+ additionally holds `pg_try_advisory_xact_lock(hashtextextended(lockKey,
+ 0))`, acquired via a bounded poll (`coordinatorInstallLockPollInterval`,
+ 25ms) rather than the blocking `pg_advisory_xact_lock`, so the same hard
+ acquisition bound applies on both dialects. The lock releases automatically
+ at commit/rollback.
+
+The transaction commits when `fn` returns nil, rolls back otherwise.
+
+## Failure and recovery
+
+`reportCoordinatorLockFailure` classifies every non-nil outcome from either
+locked phase into exactly one reportable condition, checked in this order:
+
+1. `decision.errored` — `decide` already reported at its own failure site
+ (`routine_create_failed`, `trigger_create_failed`, or
+ `next_occurrence_failed`); nothing reported again here, even though the
+ busy-text/deadline heuristic below could otherwise also match `decide`'s
+ own error text.
+2. `errors.Is(err, models.ErrCoordinatorInstallContention)` — the lock
+ timeout was reached → `contention`.
+3. `errors.Is(err, context.Canceled) || errors.Is(err,
+ context.DeadlineExceeded)` on the **caller's own** context → `cancelled`,
+ distinguishable from contention because `classifyCoordinatorInstallWaitErr`
+ checks `callerCtx.Err()` before falling back to the bounded-context
+ classification.
+4. `!decision.ran` — the pre-`decide` read (identity lookup or trigger read)
+ failed before `decide` ever ran → the caller-supplied fallback condition
+ (`lookup_failed` or `trigger_read_failed`).
+5. Otherwise — `decide` ran and returned nil, so the failure is the
+ transaction's own commit → `commit_failed`.
+
+`classifyCoordinatorInstallWaitErr` folds `context.DeadlineExceeded`,
+`sql.ErrTxDone` (what `database/sql` surfaces from `Commit` when the bounded
+context's own deadline already auto-rolled the transaction back), and the
+SQLite `"database is locked"` busy text into the contention bucket, but only
+after confirming the caller's own context has not itself ended.
+
+## Persistence
+
+No new tables. Both phases write only `office_routines` and
+`office_routine_triggers` through the existing tx-scoped repository methods.
+`internal/persistence/requiredstores` / `storeconformance` already register
+office's schema owner; this design adds no new one.
+
+## Security
+
+No new authorization surface — the install path runs from onboarding and
+agent-provisioning flows that already own the workspace/agent identity they
+pass in; an empty `workspace_id` or `agent_id` is rejected before any lock is
+acquired.
+
+## Observability
+
+`office_coordinator_install_conditions_total` (`expvar.Map`,
+`internal/office/routines/coordinator_install_metrics.go`), labelled
+`condition;workspace;assignee`. Conditions: `empty_identity`,
+`lookup_failed`, `trigger_read_failed`, `duplicate_matches`,
+`schedule_state`, `trigger_completed`, `routine_created`,
+`next_occurrence_failed`, `trigger_create_failed`, `routine_create_failed`,
+`contention`, `cancelled`, `commit_failed`. Each names a condition
+*observed*, not a call that completed successfully — a call ultimately
+rejected still counts the conditions it detected before rejecting. Also
+emitted as structured (`zap`) log lines at the same sites.
+
+## Related decisions
+
+None — this design does not introduce a durable boundary or contract beyond
+what REQ-OFFICE-COORDINATOR-INSTALL-001 itself specifies.
diff --git a/docs/specs/office/system-design/routine-schedule-state.md b/docs/specs/office/system-design/routine-schedule-state.md
new file mode 100644
index 0000000000..d24b560625
--- /dev/null
+++ b/docs/specs/office/system-design/routine-schedule-state.md
@@ -0,0 +1,183 @@
+---
+status: current
+system: office
+requirements:
+ - REQ-OFFICE-ROUTINE-ARMING-001
+ - REQ-OFFICE-ROUTINE-ARMING-002
+ - REQ-OFFICE-ROUTINE-ARMING-003
+---
+
+# Office Routine Schedule State System Design
+
+## Purpose and boundaries
+
+An Office routine has two independent switches: intent
+(`office_routines.status`, operator-set) and schedule state (whether its
+triggers can actually fire it, derived). The product reported only intent.
+This design adds a second, read-only classification, surfaces it everywhere
+intent is already surfaced (routine list/detail reads, the routines UI), and
+adds an unattended-install detection path (a startup scan) for the same
+classification. It repairs nothing — no code path here creates, edits, or
+enables/disables a trigger. Dispatch keeps its existing status gates: the due
+trigger query is status-independent, then the service checks the routine before
+claiming a cron slot, while the manual and webhook paths reject non-firing
+statuses. See [coordinator-install-idempotency.md](coordinator-install-idempotency.md)
+for the one trigger-creation path this plan touches.
+
+## Requirement mapping
+
+| Requirement | Design section |
+| --- | --- |
+| `REQ-OFFICE-ROUTINE-ARMING-001` | [Classification](#classification) |
+| `REQ-OFFICE-ROUTINE-ARMING-002` | [Read-path reporting](#read-path-reporting), [Frontend rendering](#frontend-rendering) |
+| `REQ-OFFICE-ROUTINE-ARMING-003` | [Startup scan](#startup-scan) |
+
+## Components and responsibilities
+
+- **Classifier** (`apps/backend/internal/office/routines/arming.go`) — pure,
+ total function from one routine's trigger rows to a `ScheduleState` plus an
+ unarmed-cron-trigger list. No I/O, no routine status input.
+- **Batch classification** (`ClassifyRoutines`, same file) — the read
+ boundary every consumer shares: one batched trigger read across many
+ routines, with a per-routine fallback read when the batch read itself
+ fails.
+- **Read-path reporting** (`RoutineService.AttachScheduleState`,
+ `internal/office/routines/service.go`) — the API consumer: wraps the
+ existing list/get routine responses with the classification.
+- **Startup scan** (`RunStartupScan`,
+ `internal/office/routines/startup_scan.go`) — the unattended-install
+ consumer: a read-only pass over every enumerable workspace and routine at
+ boot, ordered after reconciliation, that logs and counts what it finds
+ instead of returning it to a caller.
+- **Frontend rendering**
+ (`apps/web/app/office/routines/schedule-state.ts`,
+ `schedule-state-badge.tsx`) — collapses the nine wire `ScheduleState`
+ values into five label groups and renders `ScheduleStateBadge` /
+ `UnarmedScheduleHint` on the routine list and detail views
+ (`routine-row.tsx`).
+
+## Data and contracts
+
+### `ScheduleState`
+
+One of nine values (`internal/office/routines/arming.go`):
+`armed`, `trigger_invalid`, `trigger_unscheduled`, `trigger_disabled`,
+`event_only`, `unscheduled_manual_only`, `unscheduled_no_trigger`, `unknown`.
+`unknown` is reserved for `ClassifyRoutines`' per-routine fallback-read
+failure; `ClassifyRoutine` itself never returns it.
+
+### Classification rule table
+
+`ClassifyRoutine(triggers []*RoutineTrigger, now time.Time)` evaluates, in
+order, stopping at the first match:
+
+1. Any enabled cron trigger with a next occurrence, or one that fired within
+ the last `dispatchGrace` (60s, a compile-time constant shared verbatim
+ with the startup scan) and hasn't been recomputed yet → `armed`.
+2. Any enabled cron trigger for which the shared scheduler cannot compute a
+ next occurrence (`shared.NextCronTime`), including an impossible but
+ syntactically valid expression → `trigger_invalid`.
+3. Any enabled, schedulable cron trigger with no next occurrence recorded →
+ `trigger_unscheduled`.
+4. One or more cron triggers exist but none matched 1-3 (all disabled) →
+ `trigger_disabled`.
+5. No cron trigger, but an enabled webhook trigger exists → `event_only`.
+6. No cron trigger, but some other (e.g. manual) trigger exists →
+ `unscheduled_manual_only`.
+7. No trigger at all → `unscheduled_no_trigger`.
+
+Rule 1 does not require schedulability: a trigger that already claimed a next
+occurrence still fires on it even if edited into an unparseable expression
+afterward — `unarmedCronTriggers` reports that separately, so the same
+trigger can be simultaneously `armed` and listed as unarmed-with-reasons.
+
+### `UnarmedCronTrigger`
+
+`{trigger_id, reasons: UnarmedReason[]}`, one entry per cron trigger matching
+the disabled-OR-not-schedulable-OR-stalled-past-grace predicate, independent
+of the routine's overall `ScheduleState`. `UnarmedReason` is `disabled`,
+`not_schedulable`, or `stalled`; more than one may apply to the same trigger.
+
+### API contract
+
+`RoutineListResponse` / `RoutineResponse`
+(`internal/office/routines/dto.go`) embed `RoutineWithSchedule`:
+`{*Routine, schedule_state, unarmed_cron_triggers}` (JSON tags), returned
+from `GET /workspaces/:wsId/routines`, `GET /routines/:id`, and the
+create/update routine handlers (`internal/office/routines/handler.go`).
+`AttachScheduleState` classifies with one `time.Now().UTC()` capture per
+call — never per-routine — so every routine in one response is judged
+against the same instant.
+
+### Frontend contract
+
+`readScheduleState` / `readUnarmedCronTriggers`
+(`apps/web/app/office/routines/schedule-state.ts`) tolerate the raw
+snake_case wire shape directly, matching the codebase's established
+`fetchJson`-does-not-case-convert pattern. `scheduleStateGroup` collapses the
+nine wire values into five label groups (`armed`, `broken`, `event_only`,
+`no_schedule`, `unknown`) so two routines in different groups never render
+the same label. `hasSchedulableUnarmedEntry` distinguishes an unarmed entry
+that only needs re-arming from one whose expression needs editing first.
+
+## Control flow
+
+**Read path:** handler calls `AttachScheduleState(ctx, routines)` →
+`ClassifyRoutines` batch-reads triggers for all routine IDs via one
+repository call → falls back to `ListTriggersByRoutineID` per routine only if
+the batch call errors → `ClassifyRoutine` runs the pure rule table per
+routine → handler serializes `RoutineWithSchedule`.
+
+**Startup scan:** `Reconciler.ReconcileAll` returns a `ReconcileSignal`
+(the only way to construct one is `SignalReconcileComplete`, called after
+`ReconcileAll` returns — a zero-value signal, which an unauthorized caller or
+a refusal-path test would hold, carries no completion) →
+`internal/backendapp/main.go` launches `RunStartupScan` in a goroutine after
+logging "Office reconciliation complete" → the scan enumerates workspaces,
+then routines per workspace, then classifies each batch via the same
+`ClassifyRoutines` path the read API uses → emits one structured log record
+and one `expvar` observation per routine, plus a skip counter for any input
+read that failed.
+
+## Failure and recovery
+
+- A batch trigger read failure degrades to per-routine reads; a
+ per-routine read failure yields `ScheduleStateUnknown` for that routine
+ only, never an error returned to the caller.
+- The startup scan fails closed at every enumeration boundary (no signal, no
+ workspaces, one workspace's routines unreadable) by skipping and counting
+ the skip, never retrying and never aborting the rest of the scan.
+- The scan is fire-and-forget (`go officeroutines.RunStartupScan(...)`): no
+ outcome inside it can fail or delay Office startup.
+
+## Persistence
+
+No new tables or columns. The classifier and the scan are read-only over
+`office_routines` and `office_routine_triggers`
+(`internal/office/repository/sqlite/base.go`).
+
+## Security
+
+Read-only; no new authorization surface. Existing routine-read
+authorization (workspace scoping) applies unchanged, since the classification
+is attached to the same response the caller already had access to.
+
+## Observability
+
+- `office_routine_arming_scan_observations_total` — every routine the
+ startup scan classified, labelled `workspace`, `intent`, `schedule_state`.
+- `office_routine_arming_scan_skipped_total` — a scan step abandoned because
+ an input could not be read, labelled `reason`
+ (`no_signal`, `workspace_enum_failed`, `routine_enum_failed`,
+ `classification_unknown`).
+- Both are `expvar.Map` counters (`internal/office/routines/arming_metrics.go`),
+ exposed via the stdlib `/debug/vars` handler in dev mode, following
+ `internal/orchestrator/office_stall_metrics.go`'s label-string convention.
+- The scan also emits one structured (`zap`) log record per routine reached,
+ at `Warn` for a routine that cannot fire or has no schedule, `Info`
+ otherwise.
+
+## Related decisions
+
+None — this design does not introduce a durable boundary or contract beyond
+what the linked requirements themselves specify.