---
phase: 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud
plan: 02
subsystem: aws-route53
tags: [route53, sync, drift-detection, soft-delete, sync_history]
dependency-graph:
  requires:
    - "lib/services/route53-factory.ts (getRoute53Client / isRoute53Configured)"
    - "lib/types/route53.ts (Route53Record, Route53SyncResult, Route53HistorySource)"
    - "migrations/102_route53_tables.sql (route53_zones / route53_records / route53_record_history)"
  provides:
    - "lib/services/route53-record-key.ts (buildRecordKey, normalizeRecordSet, recordSetsEqual, classifyDrift, toHistoryPayload)"
    - "lib/services/route53-sync-service.ts (Route53SyncService, getRoute53SyncService(), buildDriftHistoryRows)"
  affects:
    - "plan 24-05 (CRUD routes import getRoute53SyncService for post-write history/audit patterns)"
    - "plan 24-06 (scheduler wiring calls fullSync()/incrementalSync())"
tech-stack:
  added: []
  patterns:
    - "Sync-service class shape mirrors lib/services/veeam-sync-service.ts: constructor(client?), isSyncing guard, executeSync() step loop with per-step error isolation, sync_history bookkeeping"
    - "Pure drift-classification logic extracted into an injectable, unit-testable function (buildDriftHistoryRows) rather than inlined in the DB-touching sync method"
key-files:
  created:
    - lib/services/route53-record-key.ts
    - lib/services/route53-record-key.test.ts
    - lib/services/route53-sync-service.ts
    - lib/services/route53-sync-service.test.ts
  modified: []
decisions:
  - "Combined Task 2 (sync mirror) and Task 3 (drift detection) into a single write of route53-sync-service.ts rather than writing sync-only first and retrofitting drift after — the plan's own Task 2 action already anticipated buildDriftHistoryRows()'s existence (referencing it in Task 3's read_first), so building both together avoided a throwaway intermediate version of syncRecords()."
  - "Symlinked node_modules from the main repo checkout into this worktree (git-ignored, not committed) — the worktree had no node_modules of its own and @aws-sdk/client-route-53 types were required for both writing and type-checking this plan's code."
metrics:
  duration: "~45 minutes, 3 tasks, 4 files"
  completed: "2026-08-05"
---

# Phase 24 Plan 2: Route 53 Sync + Drift Detection Summary

Built the Route 53 → Postgres mirror sync (`Route53SyncService`) with paginated
zone/record ingestion, soft-delete reconciliation, and whole-recordset drift
detection that writes `sync_detected_drift` history rows whenever a mirrored
record's live AWS value no longer matches Postgres.

## What Was Built

**`lib/services/route53-record-key.ts`** — pure, dependency-free helpers:
- `buildRecordKey({ zoneId, name, type, setIdentifier })` → `${zoneId}:${name}:${type}:${setIdentifier ?? ''}`, the `route53_records.record_key` primary key
- `normalizeRecordSet(rs, zoneId)` — lowercases name (preserving trailing dot), uppercases type, coerces missing TTL to `null`, sorts `resourceRecords` by value (so AWS's unordered list never produces phantom drift), passes through `aliasTarget`
- `recordSetsEqual(a, b)` — TTL + serialized resourceRecords + serialized aliasTarget comparison; both-null is equal, one-null is not
- `classifyDrift(prev, next)` → `'create' | 'update' | 'delete' | null`, returning `null` for no material difference (no-op history row avoided)
- `toHistoryPayload(ns)` — whole-recordset JSONB snapshot for `before_value`/`after_value`

**`lib/services/route53-sync-service.ts`** — `Route53SyncService` class:
- `fullSync()` / `incrementalSync()` both delegate to `executeSync()`, which runs the same two steps in both cases (Route 53 exposes no modification cursor, so "incremental" is the same full diff run more often — documented in a code comment per D-11)
- `syncZones()` — paginates `ListHostedZonesCommand` (Marker/IsTruncated/NextMarker), strips the `/hostedzone/` prefix from each zone id, fetches `GetHostedZoneCommand` per zone for `DelegationSet.NameServers` (backs plan 24-04's D-12 NS-delegation check), upserts into `route53_zones`, then soft-deletes zones no longer returned by AWS
- `syncRecords()` — for each live zone, paginates `ListResourceRecordSetsCommand` (StartRecordName/Type/Identifier), normalizes each recordset, loads the zone's current mirror rows, computes drift via `buildDriftHistoryRows()` **before** upserting (so the pre-sync state is still available), inserts drift history rows in a transaction, then upserts records and soft-deletes anything no longer present
- `buildDriftHistoryRows(prevByKey, nextByKey, zoneId)` — exported pure function; unions both keyed maps, classifies drift per key via `classifyDrift`, skips `null` (no-change) keys, builds `DriftHistoryRow[]` with whole-recordset before/after payloads
- Initial-import guard: if a zone's mirror had zero rows before this sync, drift history generation is skipped entirely for that zone (logged once) — prevents flooding the append-only ledger with meaningless `create` rows on first import
- `sync_history` bookkeeping: `entity_type='route53'`, `sync_type` written as the literal `'full'`/`'incremental'` (matching the table's CHECK constraint), catastrophic-failure catch marks the row `status='failed'`
- All error logging uses `error instanceof Error ? error.message : String(error)` — never the raw AWS SDK error object (T-24-03: avoids leaking `$metadata`/request headers into logs)
- No `integration_settings` check anywhere in the file (D-10: Route 53's disable toggle is display-only, must never gate sync)
- `getRoute53SyncService()` module-level singleton, following `pax8-sync-service.ts`'s lazy-instance pattern, exported for plans 24-05/24-06

## Deviations from Plan

None — plan executed as written. Tasks 2 and 3 were combined into a single write of `route53-sync-service.ts` (see Decisions above) since the file only exists once either way; both tasks' acceptance criteria are independently verified below.

## Verification

- `npx vitest run lib/services/route53-record-key.test.ts` — 16 tests passed
- `npx vitest run lib/services/route53-sync-service.test.ts` — 6 tests passed
- `npx tsc --noEmit --pretty` — exits 0
- `npm test` (full suite) — 489 passed, 2 pre-existing failures in `lib/services/analyzer/itglue-search.test.ts`, unrelated to this plan's files and already logged in `deferred-items.md` from plan 24-01 (neither `itglue-search.ts` nor its test were touched by this plan)
- Acceptance-criteria greps all confirmed: `integration_settings` count 0, `entity_type` present, both pagination loops present (`IsTruncated` count 2), both soft-delete statements present (`is_deleted = true` count 2), `sync_detected_drift` literal present, `changed_by_user_id` NULL for drift rows, initial-import guard present (`prevByKey.size === 0`), `buildDriftHistoryRows` call precedes the `INSERT INTO route53_records` statement in source order

## Environment Note (not a code deviation)

This worktree had no `node_modules` directory. Symlinked it from the main
repo checkout (`ln -s /opt/stacks/pulse/node_modules ./node_modules`) so
`@aws-sdk/client-route-53` types and `vitest`/`tsc` were available. This is a
local filesystem convenience, not a git-tracked change — `node_modules` is
`.gitignore`d and no commit references it.

## Self-Check: PASSED

- `lib/services/route53-record-key.ts` — FOUND
- `lib/services/route53-record-key.test.ts` — FOUND
- `lib/services/route53-sync-service.ts` — FOUND
- `lib/services/route53-sync-service.test.ts` — FOUND
- Commit `c18271d` (Task 1) — FOUND in `git log --oneline --all`
- Commit `d8c0912` (Task 2) — FOUND in `git log --oneline --all`
- Commit `0acf1fa` (Task 3) — FOUND in `git log --oneline --all`
