---
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
plan: 03
type: tdd
wave: 2
depends_on: ["18-01"]
files_modified:
  - apps/api/src/broker/reminderScheduler.ts
  - apps/api/src/broker/outboxWorker.ts
  - apps/api/tests/broker/reminderScheduler.test.ts
  - apps/api/tests/broker/outboxWorker.test.ts
autonomous: true
requirements: [D-05, D-06, D-07]
must_haves:
  truths:
    - "reminderScheduler.ts all-day branch computes the 9 AM-local alert instant using the stored household_timezone when set (D-05)"
    - "outboxWorker.ts both all-day branches (create + update) compute the alert instant using the stored household_timezone when set (D-05)"
    - "When household_timezone is unset, all three sites fall back to process.env.TZ ?? Intl — existing all-day tests pass unmodified (D-06)"
    - "Both files route through the single getHouseholdTimezone accessor — the read/fallback logic is NOT duplicated at the call sites (D-05)"
  artifacts:
    - path: "apps/api/src/broker/reminderScheduler.ts"
      provides: "all-day TZ now sourced from getHouseholdTimezone(db) (line ~247 rewire)"
    - path: "apps/api/src/broker/outboxWorker.ts"
      provides: "all-day TZ now sourced from getHouseholdTimezone(db) (lines ~501, ~607 rewire)"
  key_links:
    - from: "apps/api/src/broker/reminderScheduler.ts"
      to: "apps/api/src/lib/householdTimezone.ts"
      via: "import { getHouseholdTimezone }"
      pattern: "getHouseholdTimezone\\(db\\)"
    - from: "apps/api/src/broker/outboxWorker.ts"
      to: "apps/api/src/lib/householdTimezone.ts"
      via: "import { getHouseholdTimezone }"
      pattern: "getHouseholdTimezone\\(db\\)"
---

<objective>
Rewire the three all-day "9 AM local" timezone lookups — one in `reminderScheduler.ts` (line ~247)
and two in `outboxWorker.ts` (lines ~501 and ~607) — to read the stored timezone through the
single `getHouseholdTimezone(db)` accessor, replacing the bare
`process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` reads.

Purpose: D-05 makes the stored `household_timezone` the source of truth for the all-day reminder
computation, and mandates ONE shared accessor at both sites (no duplicated read/fallback). D-06
keeps the exact `process.env.TZ ?? Intl` behavior when nothing is stored, so the existing all-day
tests pass unmodified. D-07 is the hard boundary: this plan must NOT touch display/timed-write paths.
Output: three rewired call sites + new stored-TZ tests; existing all-day tests green unchanged.
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-CONTEXT.md
@.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-RESEARCH.md
@.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md
@apps/api/src/lib/householdTimezone.ts
</context>

<tasks>

<task type="tdd" tdd="true">
  <name>Task 1: RED — failing stored-TZ all-day tests for scheduler + outbox</name>
  <files>apps/api/tests/broker/reminderScheduler.test.ts, apps/api/tests/broker/outboxWorker.test.ts</files>
  <read_first>
    - apps/api/tests/broker/reminderScheduler.test.ts (lines ~656–729: existing all-day tests that pin process.env.TZ='America/New_York' — DO NOT modify them; they must keep passing via D-06 fallback)
    - apps/api/tests/broker/outboxWorker.test.ts (how its mock db chain is built for the all-day branches)
    - apps/api/src/broker/vevent.ts §240 (computeAlertInstantUtc(eventDateStr, leadDays, tz): Date — contract unchanged; assert the 9 AM-local instant for the stored zone)
    - apps/api/src/lib/householdTimezone.ts (the accessor whose stored-value path you are now exercising)
    - .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-RESEARCH.md (Pitfall 5: why existing tests stay green; new tests mock db to return the app_config row)
  </read_first>
  <behavior>
    - reminderScheduler all-day: when the mock db ALSO returns { key: 'household_timezone', value: 'America/Chicago' }, the computed alert instant equals 9 AM America/Chicago for the event date (a different UTC instant than 9 AM America/New_York would give).
    - outboxWorker all-day (create branch): with stored 'America/Chicago', the create-branch alert instant is 9 AM Chicago for the event date.
    - outboxWorker all-day (update branch): with stored 'America/Chicago', the update-branch alert instant is 9 AM Chicago for the event date.
    - Backward-compat (assert, do not modify): the pre-existing all-day tests that pin process.env.TZ and return NO app_config row still pass (fallback fires).
  </behavior>
  <action>
    Add new stored-TZ test cases alongside the existing all-day tests. Where the existing tests mock
    the Drizzle query for events, extend the mock so the household_timezone SELECT resolves to a row
    with value 'America/Chicago' (match the accessor's query shape so getHouseholdTimezone returns it).
    Assert the resulting UTC instant equals 9 AM Chicago for the event date (compute the expected UTC
    via a fixed reference, e.g. using computeAlertInstantUtc with 'America/Chicago' directly, or an
    explicit ISO instant). Do NOT alter the existing process.env.TZ-pinned cases. Run the two suites;
    confirm the NEW cases FAIL (code still reads process.env.TZ, so stored 'America/Chicago' has no
    effect). Commit as `test(18-03): add failing stored-TZ all-day tests for scheduler + outbox`.
  </action>
  <verify>
    <automated>pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts tests/broker/outboxWorker.test.ts 2>&1 | grep -qi "fail" && echo RED-OK</automated>
  </verify>
  <acceptance_criteria>
    - New stored-TZ cases exist in both test files and FAIL before the rewire (code still reads process.env.TZ).
    - The existing process.env.TZ-pinned all-day cases are unchanged (no edits to those test bodies).
    - `test(18-03): ...` commit precedes the rewire commit.
  </acceptance_criteria>
  <done>RED gate met: stored-TZ tests fail because the call sites are not yet rewired.</done>
</task>

<task type="tdd" tdd="true">
  <name>Task 2: GREEN — rewire all three all-day TZ call sites through getHouseholdTimezone(db)</name>
  <files>apps/api/src/broker/reminderScheduler.ts, apps/api/src/broker/outboxWorker.ts</files>
  <read_first>
    - apps/api/src/broker/reminderScheduler.ts (line ~247 serverTz lookup; line ~256 computeAlertInstantUtc call; db already imported line 46)
    - apps/api/src/broker/outboxWorker.ts (lines ~501 + ~607 tz lookups; runOutboxDrain line ~710; db already imported line 31)
    - apps/api/src/lib/householdTimezone.ts (import getHouseholdTimezone)
    - .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md (reminderScheduler + outboxWorker sections: exact replacement lines)
  </read_first>
  <action>
    In `reminderScheduler.ts`: add `import { getHouseholdTimezone } from '../lib/householdTimezone.js'`
    and replace the line-247 `const serverTz = process.env.TZ ?? Intl…` with
    `const serverTz = await getHouseholdTimezone(db);` (the enclosing function is already async / awaits
    DB queries; `db` is the module import on line 46). Leave the line-256 computeAlertInstantUtc call
    unchanged — serverTz is still a string.
    In `outboxWorker.ts`: add the same import. Replace BOTH bare `const tz = process.env.TZ ?? Intl…`
    lookups (~501 and ~607) with `const tz = await getHouseholdTimezone(db);`. `db` is the module import
    on line 31; `runOutboxDrain` is async. (If both branches are reachable in one pass and lint allows,
    you may hoist a single `const tz = await getHouseholdTimezone(db)` to the top of the all-day block
    and reuse it — but do NOT duplicate the read/fallback inline; route through the accessor only.)
    HARD BOUNDARY (D-07): do not open, import from, or modify `apps/pwa/src/lib/eventDateTime.ts` or
    `apps/pwa/src/lib/hydrateEvents.ts`, and do not change any timed-event serialization or display path.
    Run both suites; iterate to GREEN. Confirm the existing process.env.TZ-pinned tests still pass.
    Commit as `feat(18-03): route all-day reminder TZ through stored household_timezone`.
  </action>
  <verify>
    <automated>pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts tests/broker/outboxWorker.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c "getHouseholdTimezone(db)" apps/api/src/broker/reminderScheduler.ts` ≥ 1 and same for outboxWorker.ts ≥ 1.
    - No bare `process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` remains at the rewired all-day sites (assert: `grep -v '^\s*\*' apps/api/src/broker/reminderScheduler.ts | grep -c "process.env.TZ ?? Intl"` → 0; same for outboxWorker.ts). The fallback now lives only inside getHouseholdTimezone.
    - D-07 boundary: `git diff --name-only` for this plan does NOT include `apps/pwa/src/lib/eventDateTime.ts` or `apps/pwa/src/lib/hydrateEvents.ts` (assert they are absent from the changed-files list).
    - New stored-TZ tests GREEN; existing process.env.TZ all-day tests still GREEN (unmodified).
    - `pnpm --filter @familysync/api test -- --run` fully green; `tsc --noEmit` clean.
    - `feat(18-03): ...` commit follows the RED commit.
  </acceptance_criteria>
  <done>GREEN gate met: stored TZ drives all three all-day sites; D-06 fallback + D-07 boundary intact.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| scheduler/outbox → DB | trusted server worker reads the validated stored timezone to compute fire times |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-18-08 | Tampering | stored TZ fed to computeAlertInstantUtc could be garbage | mitigate | The value can only have been written via Plan 02's IANA-validated PUT/seed; the accessor returns either a validated stored string or the D-06 fallback. No unvalidated value reaches computeAlertInstantUtc. |
| T-18-09 | Tampering (regression) | accidental change to display/timed-write path | mitigate | D-07 boundary enforced as an acceptance criterion: changed-files list must exclude eventDateTime.ts + hydrateEvents.ts; no timed-event serialization touched. Blast radius confined to the all-day branches. |
| T-18-10 | Denial of Service | extra DB read per tick at 3 call sites | accept | PK lookups on a 60s interval; negligible (RESEARCH A1). Read-per-run keeps changes propagating within one tick without a worker restart. |
| T-18-SC | Tampering | npm/pip/cargo installs | mitigate | No new packages this plan (RESEARCH Package Legitimacy Audit: zero installs). |
</threat_model>

<verification>
- `pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts tests/broker/outboxWorker.test.ts` green.
- `pnpm --filter @familysync/api test -- --run` green (all 244+ existing tests, including unmodified all-day cases).
- D-07: changed-files exclude the two PWA display-path files.
</verification>

<success_criteria>
- All three all-day TZ sites route through getHouseholdTimezone(db); no duplicated read/fallback.
- D-05 source-of-truth, D-06 backward-compat, D-07 boundary all satisfied.
- RED then GREEN commits present.
</success_criteria>

<output>
Create `.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-03-SUMMARY.md` when done.
</output>
