wulf-pulse/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-02-PLAN.md

25 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud 02 execute 2
24-01
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
true
SC-1
SC-4
truths artifacts key_links
SC-1: A scheduled sync pulls every hosted zone and every resource record set from AWS Route 53 into route53_zones / route53_records, with AWS as the source of truth
SC-1: Records that disappear from AWS are soft-deleted in the mirror (is_deleted=true, deleted_at set) rather than left stale
SC-4: D-06 — when a synced record differs from the mirror row, the sync writes a route53_record_history row tagged source='sync_detected_drift', so 'did someone change this outside Pulse?' is answerable from the ledger
SC-4: Drift history rows carry the whole-recordset before/after snapshot (Route 53 models an update as a whole-recordset replace, so per-value diffing is not attempted)
A sync run is bookkept in the existing sync_history table with entity_type='route53', and a catastrophic failure marks that row status='failed' with the error message
path provides exports
lib/services/route53-record-key.ts Pure record-key derivation, recordset normalization, and drift classification helpers (unit-testable without AWS)
buildRecordKey
normalizeRecordSet
classifyDrift
recordSetsEqual
path provides exports min_lines
lib/services/route53-sync-service.ts Route53SyncService with fullSync/incrementalSync + getRoute53SyncService() singleton
Route53SyncService
getRoute53SyncService
200
path provides
lib/services/route53-sync-service.test.ts Drift-classification and history-row-shape coverage
from to via pattern
lib/services/route53-sync-service.ts lib/services/route53-factory.ts getRoute53Client() import getRoute53Client
from to via pattern
lib/services/route53-sync-service.ts route53_record_history INSERT with source='sync_detected_drift' sync_detected_drift
from to via pattern
lib/services/route53-sync-service.ts sync_history INSERT/UPDATE bookkeeping with entity_type='route53' sync_history
Build the Route 53 → Postgres mirror sync: paginate hosted zones and resource record sets from AWS, upsert them into the Phase 24 tables, soft-delete anything AWS no longer returns, and append a `sync_detected_drift` history row for every record whose content changed outside Pulse (D-06).

Purpose: SC-1 (scheduled sync with AWS as source of truth) and the drift half of SC-4 (queryable record-level history, not just current state). Output: lib/services/route53-record-key.ts (pure helpers + tests), lib/services/route53-sync-service.ts (+ tests), exporting getRoute53SyncService() for plans 24-05 and 24-06 to consume.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md @.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-PATTERNS.md @.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-01-SUMMARY.md

lib/services/route53-factory.ts: isRoute53Configured(): boolean getRoute53Client(): Route53Client resetRoute53Client(): void

lib/types/route53.ts: Route53Record { recordKey, zoneId, name, type, setIdentifier, ttl, resourceRecords, aliasTarget, syncedAt, isDeleted } Route53SyncResult { syncId, syncType, status, startedAt, completedAt, duration, entities, errors } Route53HistorySource = 'pulse_crud' | 'sync_detected_drift'

Postgres (migration 102): route53_zones(id TEXT PK, name, comment, private_zone, record_count, authoritative_name_servers JSONB, raw_payload JSONB, created_at, updated_at, synced_at, is_deleted, deleted_at) route53_records(record_key TEXT PK, zone_id TEXT FK->route53_zones(id) ON DELETE CASCADE, name, type, set_identifier, ttl, resource_records JSONB, alias_target JSONB, raw_payload JSONB, created_at, updated_at, synced_at, is_deleted, deleted_at) route53_record_history(id UUID PK, zone_id, record_key, record_name, record_type, change_action CHECK IN ('create','update','delete'), before_value JSONB, after_value JSONB, source CHECK IN ('pulse_crud','sync_detected_drift'), changed_by_user_id, changed_by_email, audit_log_id, changed_at)

Pre-existing (migration 001, do not alter): sync_history(id SERIAL PK, entity_type VARCHAR(100), sync_type VARCHAR(50) CHECK IN ('full','incremental','entity-specific'), status CHECK IN ('started','in_progress','completed','failed'), started_at, completed_at, records_added, records_updated, records_deleted, error_message, triggered_by, entity_details)

lib/services/postgres-client.ts default export postgresClient: .query(sql, params?) -> { rows: T[] } .transaction(fn)

Task 1: Pure record-key, normalization, and drift-classification helpers lib/services/route53-record-key.ts, lib/services/route53-record-key.test.ts - lib/types/route53.ts (types created in plan 24-01) - lib/services/analyzer/link-discovery.ts (the `_INTERNALS` export convention used in this codebase for testing private helpers) - lib/services/pax8-company-matcher.test.ts (existing pure-helper test style: explicit vitest imports, table-driven cases) - .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md (Anti-Patterns: no per-value diffing; whole-recordset replace) - `buildRecordKey({ zoneId: 'Z123', name: 'www.example.com.', type: 'A', setIdentifier: null })` returns `'Z123:www.example.com.:A:'` - `buildRecordKey` with a non-null `setIdentifier` appends it after the final colon - `normalizeRecordSet` lowercases `name`, preserves the trailing dot, uppercases `type`, coerces missing `TTL` to `null`, and maps `ResourceRecords` to a sorted array of `{ value }` objects so ordering differences do not register as drift - `normalizeRecordSet` on an alias record (no `TTL`, no `ResourceRecords`, has `AliasTarget`) returns `ttl: null`, `resourceRecords: []`, and a populated `aliasTarget` - `recordSetsEqual(a, a)` is `true`; changing a single `ResourceRecords` value makes it `false`; changing only the order of `ResourceRecords` keeps it `true` - `recordSetsEqual` returns `false` when TTL differs - `classifyDrift(null, next)` returns `'create'` - `classifyDrift(prev, null)` returns `'delete'` - `classifyDrift(prev, next)` returns `'update'` when the normalized sets differ - `classifyDrift(prev, next)` returns `null` (no history row) when the normalized sets are equal Create `lib/services/route53-record-key.ts` — a dependency-free module (no `pg`, no AWS SDK client construction; it may import types from `@aws-sdk/client-route-53` and `@/lib/types/route53`) so it is unit-testable without mocking anything.

Export:

  • buildRecordKey(input: { zoneId: string; name: string; type: string; setIdentifier?: string | null }): string producing ${zoneId}:${name}:${type}:${setIdentifier ?? ''}. This is the route53_records.record_key primary key and the recordId URL segment used by plan 24-05's routes.
  • normalizeRecordSet(rs: ResourceRecordSet, zoneId: string): NormalizedRecordSet where NormalizedRecordSet is { recordKey, zoneId, name, type, setIdentifier, ttl, resourceRecords, aliasTarget }. Normalization rules: name lowercased with its trailing dot preserved; type uppercased; ttl is rs.TTL ?? null; resourceRecords is (rs.ResourceRecords ?? []).map(r => ({ value: r.Value })).sort((a,b) => a.value.localeCompare(b.value)); aliasTarget is rs.AliasTarget ?? null; setIdentifier is rs.SetIdentifier ?? null. Sorting matters: AWS does not guarantee value ordering, and unsorted comparison would produce phantom drift history rows on every sync.
  • recordSetsEqual(a: NormalizedRecordSet | null, b: NormalizedRecordSet | null): boolean comparing ttl, the serialized resourceRecords array, and the serialized aliasTarget. Both null returns true; one null returns false.
  • classifyDrift(prev: NormalizedRecordSet | null, next: NormalizedRecordSet | null): 'create' | 'update' | 'delete' | null mapping to route53_record_history.change_action, returning null when there is no material difference so the sync does not write a no-op history row.
  • toHistoryPayload(ns: NormalizedRecordSet | null): unknown returning the JSONB shape stored in before_value/after_value: null for a null input, otherwise { name, type, setIdentifier, ttl, resourceRecords, aliasTarget } — the whole-recordset snapshot (24-RESEARCH.md Anti-Patterns: Route 53 has no partial-value primitive, so the history unit of change is the whole recordset).

Do not implement per-value diffing anywhere.

Create lib/services/route53-record-key.test.ts covering every <behavior> case above. Import describe/it/expect explicitly from vitest (globals: false in vitest.config.ts). npx vitest run lib/services/route53-record-key.test.ts && npx tsc --noEmit --pretty <acceptance_criteria> - npx vitest run lib/services/route53-record-key.test.ts passes with at least 10 assertions covering every <behavior> bullet - grep -c "from 'pg'\|postgres-client\|Route53Client" lib/services/route53-record-key.ts returns 0 — the module has no runtime dependency on a DB pool or an AWS client - classifyDrift returns null for equal recordsets (asserted in the test file), preventing no-op history rows - Re-ordering resourceRecords does not change recordSetsEqual's result (asserted in the test file) - npx tsc --noEmit --pretty exits 0 </acceptance_criteria> Pure helpers exist with full unit coverage; no phantom-drift ordering bug; type-check clean.

Task 2: Route53SyncService — zones and records mirror sync lib/services/route53-sync-service.ts - lib/services/veeam-sync-service.ts (class shape, isSyncInProgress, executeSync, sync_history bookkeeping at lines ~78-90 and ~139-165, step-loop with per-step error isolation at lines ~94-120, upsert-with-FK-safety-set at lines ~219-247) - lib/services/pax8-sync-service.ts (getPax8SyncService() singleton export shape at line ~689) - lib/services/route53-record-key.ts (created in Task 1) - lib/services/route53-factory.ts (created in plan 24-01) - lib/services/postgres-client.ts (query/transaction signatures) - migrations/102_route53_tables.sql (exact column names) Create `lib/services/route53-sync-service.ts` following `lib/services/veeam-sync-service.ts`'s class shape exactly:
  • export class Route53SyncService with a private client: Route53Client (constructor takes an optional client, defaulting to getRoute53Client()) and a private isSyncing = false flag.
  • isSyncInProgress(): boolean
  • fullSync(triggeredBy = 'system'): Promise<Route53SyncResult>executeSync('full', triggeredBy)
  • incrementalSync(triggeredBy = 'system'): Promise<Route53SyncResult>executeSync('incremental', triggeredBy)
  • private executeSync(syncType, triggeredBy) that throws 'A Route 53 sync operation is already in progress' when isSyncing, sets the flag, builds syncId = 'route53-' + Date.now(), inserts the sync_history row (entity_type='route53', sync_type = the literal 'full'/'incremental' — the table's CHECK constraint only allows full/incremental/entity-specific, so do NOT write route53-full there), runs the step loop, updates sync_history on completion, and resets isSyncing in a finally block.
  • Step array in order: { name: 'zones', fn: () => this.syncZones() } then { name: 'records', fn: () => this.syncRecords() }. Zones must run first because route53_records.zone_id has an FK to route53_zones(id).
  • Wrap each step in its own try/catch so one failing step does not abort the other, pushing { entity, success, recordsUpserted, duration, error } into entityResults — same shape as VeeamSyncService.
  • Catastrophic-failure catch block updates sync_history to status='failed' with error_message, matching the Veeam analog's lines ~152-173. Log with a [ROUTE53-SYNC] prefix. Log error.message only — never the full AWS SDK error object, which can carry request headers (T-24-03).

syncZones(): paginate ListHostedZonesCommand using Marker / IsTruncated / NextMarker. For each zone strip the /hostedzone/ prefix from Id. To populate authoritative_name_servers (needed by plan 24-04's D-12 check), call GetHostedZoneCommand({ Id: zoneId }) per zone and store DelegationSet?.NameServers ?? [] as JSONB. Upsert with INSERT INTO route53_zones (...) VALUES (...) ON CONFLICT (id) DO UPDATE SET ... synced_at = NOW(), updated_at = NOW(), is_deleted = false, deleted_at = NULL. After the loop, soft-delete zones no longer returned by AWS: UPDATE route53_zones SET is_deleted = true, deleted_at = NOW(), updated_at = NOW() WHERE is_deleted = false AND id <> ALL($1) using the collected id array. Return the upserted count.

syncRecords(): load the live zone id set with SELECT id FROM route53_zones WHERE is_deleted = false (the FK-safety-set pattern from veeam-sync-service.ts syncBackupServers()). For each zone, paginate ListResourceRecordSetsCommand using StartRecordName / StartRecordType / StartRecordIdentifier from NextRecordName / NextRecordType / NextRecordIdentifier while IsTruncated. Normalize each recordset with normalizeRecordSet() and derive its key with buildRecordKey(). Upsert into route53_records on ON CONFLICT (record_key) DO UPDATE, resetting is_deleted = false, deleted_at = NULL, synced_at = NOW(), updated_at = NOW(). After each zone's pagination completes, soft-delete that zone's records no longer present: UPDATE route53_records SET is_deleted = true, deleted_at = NOW(), updated_at = NOW() WHERE zone_id = $1 AND is_deleted = false AND record_key <> ALL($2). Do NOT hard-delete — the history ledger references record_key.

Both fullSync and incrementalSync run the same two steps. Per D-11 the difference is cadence, not scope: Route 53's list APIs expose no modification cursor, so an "incremental" run is the same full read against AWS with the same diff, just scheduled more frequently. Add a comment in executeSync stating this explicitly so a future reader does not assume a missing incremental optimization is a bug.

Export a getRoute53SyncService(): Route53SyncService module-level singleton following pax8-sync-service.ts's pattern (lazy let instance + accessor). Plans 24-05 and 24-06 import this.

Do NOT add any integration_settings.disabled check in this file — per D-10 the Route 53 disable toggle is display-only and must never gate sync. npx tsc --noEmit --pretty && grep -q "getRoute53SyncService" lib/services/route53-sync-service.ts && grep -qv "integration_settings" lib/services/route53-sync-service.ts && echo PASS <acceptance_criteria> - lib/services/route53-sync-service.ts exports Route53SyncService and getRoute53SyncService - npx tsc --noEmit --pretty exits 0 - grep -c 'integration_settings' lib/services/route53-sync-service.ts returns 0 (D-10 — disable never gates sync) - grep -c "entity_type" lib/services/route53-sync-service.ts >= 1 and the inserted sync_type value is the literal 'full' or 'incremental', satisfying sync_history's CHECK constraint - Both ListHostedZonesCommand and ListResourceRecordSetsCommand pagination loops are present: grep -c 'IsTruncated' lib/services/route53-sync-service.ts >= 2 - Soft-delete statements exist for both tables: grep -c 'is_deleted = true' lib/services/route53-sync-service.ts >= 2 - No console.error call passes a raw error object: every logging site uses error instanceof Error ? error.message : String(error) (T-24-03) </acceptance_criteria> Sync service mirrors zones and records with pagination, soft-delete, and sync_history bookkeeping; no disable gating; type-check clean.

Task 3: Drift detection writes sync_detected_drift history rows lib/services/route53-sync-service.ts, lib/services/route53-sync-service.test.ts - lib/services/route53-sync-service.ts (as written in Task 2) - lib/services/route53-record-key.ts (classifyDrift, toHistoryPayload) - migrations/102_route53_tables.sql (route53_record_history columns and CHECK constraints) - lib/services/pax8-sync-service.test.ts (existing sync-service test style — how this codebase mocks postgresClient and external clients) - Given a mirror row and an AWS recordset with a changed TTL, the drift step produces one history row with `change_action='update'`, `source='sync_detected_drift'`, `changed_by_user_id=null` - Given a mirror row with no matching AWS recordset, the drift step produces a history row with `change_action='delete'` and a non-null `before_value`, null `after_value` - Given an AWS recordset with no matching mirror row, the drift step produces a history row with `change_action='create'`, null `before_value`, non-null `after_value` - Given identical mirror and AWS recordsets, the drift step produces zero history rows - `before_value`/`after_value` payloads contain the whole recordset (`name`, `type`, `setIdentifier`, `ttl`, `resourceRecords`, `aliasTarget`), not a per-field delta - A record changed by Pulse CRUD within the same window is still tagged `sync_detected_drift` by the sync (the sync has no way to know), and the `pulse_crud` row written by the CRUD route is the authoritative one — both rows coexist in the ledger Extract the drift logic into an exported, injectable pure function in `lib/services/route53-sync-service.ts` so it is unit-testable without a database: `export function buildDriftHistoryRows(prevByKey: Map, nextByKey: Map, zoneId: string): DriftHistoryRow[]` where `DriftHistoryRow` is `{ zoneId, recordKey, recordName, recordType, changeAction, beforeValue, afterValue }`. Implementation: union the two key sets, call `classifyDrift(prev, next)` per key, skip keys returning `null`, and build the row using `toHistoryPayload()` for both values.

Wire it into syncRecords(): before upserting a zone's recordsets, load that zone's current mirror rows (SELECT record_key, name, type, set_identifier, ttl, resource_records, alias_target FROM route53_records WHERE zone_id = $1 AND is_deleted = false) and shape them into NormalizedRecordSets. Compute buildDriftHistoryRows(...) BEFORE the upsert (after the upsert the previous state is gone). Then upsert, then insert the drift rows with INSERT INTO route53_record_history (zone_id, record_key, record_name, record_type, change_action, before_value, after_value, source, changed_by_user_id, changed_by_email) VALUES (..., 'sync_detected_drift', NULL, NULL). Insert drift rows in a batch (one multi-row INSERT or a loop inside postgresClient.transaction()), and count them into the step's result so the sync summary reports drift volume.

Guard the very first sync: if the mirror had zero rows for a zone, do NOT emit create history rows for the entire zone (that would produce thousands of meaningless rows on initial import). Detect this with a zone-level check — if prevByKey.size === 0, skip history generation for that zone entirely and log [ROUTE53-SYNC] Initial import for zone <id> — skipping drift history once.

Create lib/services/route53-sync-service.test.ts covering every <behavior> case by calling buildDriftHistoryRows directly with hand-constructed maps. No AWS or Postgres mocking is required for these cases. Import vitest primitives explicitly. npx vitest run lib/services/route53-sync-service.test.ts && npx tsc --noEmit --pretty && npm test <acceptance_criteria> - npx vitest run lib/services/route53-sync-service.test.ts passes with cases for update, delete, create, and no-change - buildDriftHistoryRows is exported from lib/services/route53-sync-service.ts and takes no database handle - grep -c "'sync_detected_drift'" lib/services/route53-sync-service.ts >= 1 - The insert statement sets changed_by_user_id to NULL for drift rows (drift has no Pulse actor) - Initial-import guard present: grep -q "prevByKey.size === 0" lib/services/route53-sync-service.ts - Drift rows are computed before the upsert — the buildDriftHistoryRows call appears earlier in syncRecords() than the INSERT INTO route53_records statement - npm test (full suite) exits 0 </acceptance_criteria> Drift produces correctly tagged history rows, initial import does not flood the ledger, full suite green.

<threat_model>

Trust Boundaries

Boundary Description
AWS Route 53 API → Pulse sync service Untrusted-shape external payloads (zone/record data) enter Postgres
Sync service → application logs AWS SDK errors may carry request metadata

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-24-03 Information Disclosure console.error in Route53SyncService.executeSync and step catch blocks mitigate Log error instanceof Error ? error.message : String(error) only; never pass the AWS SDK error object (which can include $metadata and request headers) to a logger. Asserted in Task 2 acceptance criteria.
T-24-04 Repudiation record changes made outside Pulse (AWS console, IaC) mitigate D-06 drift detection writes a route53_record_history row with source='sync_detected_drift' and whole-recordset before/after for every externally-changed record, making external mutation attributable-in-time even when the actor is unknown to Pulse.
T-24-10 Denial of Service initial import emitting one history row per record across all zones mitigate Zone-level initial-import guard (prevByKey.size === 0 → skip history) prevents an unbounded first-run write amplification into the append-only, never-purged ledger (D-08).
T-24-11 Tampering AWS-supplied record values written directly into Postgres JSONB accept Values are stored as data and rendered as text by the admin UI (plan 24-07 renders through React's default escaping, no dangerouslySetInnerHTML). Route 53 is itself the authoritative source; validating its own output against itself provides no security benefit.
T-24-05 Tampering / Spoofing live DNS record content accept Carried forward from plan 24-01 — D-03 accepts immediate execution with post-hoc audit only.
</threat_model>
- `npx vitest run lib/services/route53-record-key.test.ts lib/services/route53-sync-service.test.ts` green - `npm test` full suite green - `npx tsc --noEmit --pretty` exits 0 - Manual smoke (if AWS credentials are live): `node -e "require('ts-node')"` is not available — instead trigger via plan 24-05's `/api/route53/sync` route once it exists, or confirm at the plan 24-07 checkpoint

<success_criteria>

  • Zones and records mirror into Postgres with pagination and soft-delete
  • Drift produces route53_record_history rows tagged sync_detected_drift with whole-recordset before/after
  • Equal recordsets produce zero history rows; initial import produces zero history rows
  • getRoute53SyncService() exported for plans 24-05 and 24-06
  • No integration_settings gating anywhere in this file (D-10) </success_criteria>
Create `.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-02-SUMMARY.md` when done.