# Phase 24: AWS Route 53 DNS Sync - Research

**Researched:** 2026-08-05
**Domain:** AWS Route 53 API integration (sync + CRUD write-back) inside an existing Next.js/Postgres PSA dashboard
**Confidence:** HIGH

## Summary

Phase 24 adds a ninth external integration to Pulse's existing sync framework: AWS Route 53
DNS zones/records, synced into Postgres and writable back to AWS for the common record
types (A/AAAA/CNAME/MX/TXT/SRV). Every one of this phase's open discretion questions has a
directly analogous, already-shipped pattern in this codebase — the IT Glue write-back
pipeline (`itglue_asset_audits` / `itglue_writes` / `app/api/analyzer/itglue/applications/
[id]/apply/route.ts`) is a near-exact structural precedent for "sync a mirror, allow gated
writes back to the source of truth, log before/after including failures." The Veeam and
Datto RMM factory/sync-service pairs are the precedent for the sync half. Nothing in this
phase requires inventing a new architectural shape — it requires composing two patterns
this codebase already has, pointed at a new AWS API.

The single new npm dependency is `@aws-sdk/client-route-53` (confirmed on the npm registry,
official `aws/aws-sdk-js-v3` monorepo, ~1.8M downloads/week, `slopcheck` verdict `[OK]`).
Its default Node credential provider chain (`@aws-sdk/credential-provider-node`, a
transitive dependency) reads `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` /
`AWS_SESSION_TOKEN` / `AWS_REGION` from `process.env` with zero extra code — which lines up
exactly with how BWS injects secrets at the `docker-entrypoint.sh` layer (plain env vars,
before `node server.js` starts). This means the standard, literal `AWS_*` variable names
should be used (not a custom `ROUTE53_*` prefix) — using a custom prefix would force
hand-written credential wiring that defeats the purpose of relying on the SDK default chain.

Route 53's write API (`ChangeResourceRecordSets`) models an "update" as a whole-recordset
replace (`UPSERT`), not a per-value patch — so `route53_record_history` should store
whole-recordset before/after snapshots, matching the API's actual unit of change, not
attempt finer-grained value diffing that AWS itself doesn't expose. Route 53's throttling
model was overhauled by AWS at some point before this research date (the current official
docs literally say "Amazon Route 53 updated its API throttling behavior... this page
describes the updated limits") to a token-bucket model far more generous than the commonly
cited "5 requests/second" figure still repeated on Stack Overflow and blog posts — current
limits are a 50-burst/10-per-second default per-action bucket plus a separate 1500-burst/
100-per-second change-throughput bucket. This comfortably supports D-11's incremental +
daily-full cadence.

**Primary recommendation:** Add `lib/services/route53-factory.ts` +
`lib/services/route53-sync-service.ts` following the Veeam/Datto RMM factory+sync-service
shape exactly; add `route53_zones` / `route53_records` / `route53_record_history` /
`route53_audit_log` in migration `102_route53_tables.sql`; implement CRUD write routes
under `app/api/route53/*` following `app/api/analyzer/itglue/applications/[id]/apply/
route.ts`'s pending→committed/failed pattern with `requireAdmin()`; use
`@aws-sdk/client-route-53`'s default credential chain against literal `AWS_*` env vars; use
a custom short-interval poll (not the SDK's 30s/30min-default waiter) for the CRUD route's
synchronous response, backed by the next incremental sync for eventual-consistency
reconciliation; and implement the D-12 NS-delegation health check with Node's built-in
`dns` module (already used elsewhere in this codebase) against a dedicated `dns.Resolver()`
pointed at a public resolver, not the container's default resolver.

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| AWS Route 53 API client (list/change/get-change) | API/Backend (`lib/services/`) | — | Credentials + AWS SDK calls must never reach the browser; factory pattern matches every other integration |
| Zone/record mirror sync | API/Backend (scheduler + service layer) | Database | `node-cron` singleton triggers; Postgres is the mirror |
| CRUD write-back to Route 53 | API/Backend (`app/api/route53/*` routes) | — | Immediate-execution writes (D-03) must be server-side, auth-gated |
| Record-level change history | Database | API/Backend (read routes) | Append-only ledger; queried, not computed live |
| Audit log (ops + failures) | Database | API/Backend | Same shape as `itglue_writes`/`audit_log` — persisted, not derived |
| NS-delegation health check | API/Backend (`integration-health.ts`) | External (live public DNS lookup via `dns` module) | Must compare Route 53's authoritative NS against a live third-party resolver — inherently crosses a network boundary from the backend |
| `/admin/sync/route53` UI | Browser/Client (`'use client'` page) | API/Backend (data source) | Matches every other `/admin/sync/<x>` detail page in this codebase |
| Admin disable toggle | Database (`integration_settings`) | API/Backend (health check overlay) | Existing D-10-compliant mechanism, no new code pattern needed |

## User Constraints (from CONTEXT.md)

### Locked Decisions

- **D-01:** Writable record types are the common set only — A, AAAA, CNAME, MX, TXT, SRV. NS
  and SOA are excluded from the write path (zone-delegation records; editing them risks
  breaking the zone).
- **D-02:** Records only, not zones. Pulse can create/update/delete records within hosted
  zones that already exist in Route 53. Hosted zone creation/deletion (domain onboarding/
  decommissioning) stays outside Pulse (AWS console or infra-as-code).
- **D-03:** Destructive record operations (update/delete) execute immediately — no
  phishing-style staged/two-step approval gate. Every change is logged with actor/timestamp/
  before/after so mistakes are traceable after the fact, not blocked beforehand.
- **D-04:** CRUD is gated at `requireAdmin()` (admin + super-admin) — the same bar as other
  write-capable admin surfaces in Pulse, not a stricter super-admin-only gate.
- **D-05:** Dedicated Route 53 tables, not a reuse of the phishing pipeline's `audit_events`
  table. New migration introduces `route53_zones` / `route53_records` /
  `route53_record_history` / `route53_audit_log` (naming for planner/researcher to
  finalize) — mirrors how Veeam and Datto RMM each own their tables rather than sharing a
  cross-domain audit schema.
- **D-06:** Change history is written both for Pulse-initiated CRUD and for sync-detected
  drift (a record changed outside Pulse, e.g. directly in the AWS console). Each history
  row is tagged with a `source` field: `pulse_crud` | `sync_detected_drift`, so the query
  "did someone change this outside Pulse?" is answerable.
- **D-07:** Failed AWS API attempts (rate-limited, invalid record, AWS-side error) are also
  logged in the audit trail — attempted before/after + error message + `status: failed` —
  not just successful writes.
- **D-08:** Retention is unbounded — no purge job. Matches existing Pulse convention; no
  audit/history table in this codebase currently has an automatic retention/purge
  mechanism.
- **D-09:** New tile on `/admin/sync` (same list as Veeam/Datto RMM/PAX8) plus a dedicated
  `/admin/sync/route53` detail page for zones, records, and history — the existing
  per-integration pattern, not folded into an existing page.
- **D-10:** `/admin/integrations` disable toggle for `route53` is display-only (suppresses
  health-check display; scheduler/sync/CRUD keep working underneath) — the default behavior
  per CLAUDE.md. Route 53 is **not** a second PAX8-style exception that blocks sync/writes
  when disabled.
- **D-11:** Sync cadence is incremental + periodic full — more frequent incremental checks
  plus a daily full reconciliation, rather than a single daily full sync. Trade-off (more
  API calls against Route 53 rate limits for better real-time drift detection) accepted
  knowingly.
- **D-12:** The health-check row for Route 53 goes beyond the generic auth-check +
  last-sync-age pattern used by other integrations — it also includes a DNS-specific
  delegation check: compare each hosted zone's Route-53-authoritative NS records against a
  **live public DNS lookup** (e.g. Node's `dns` module or a DoH resolver) for that domain,
  flagging a mismatch as degraded health. No manually-maintained "expected NS" field — the
  live lookup is itself the source of truth to diff against.

### Claude's Discretion

- **Credentials & AWS account scope** — not discussed interactively (user deliberately
  skipped this topic, treating it as already settled). Codebase scouting found uncommitted
  infrastructure already in place: `docker-entrypoint.sh` (new, untracked) plus diffs to
  `Dockerfile` and `docker-compose.yml` that install the `bws` CLI and wrap the app's start
  command as `bws run --project-id "$BWS_PROJECT_ID" -- node server.js` when
  `BWS_ACCESS_TOKEN` is set, falling back to a plain `node server.js` otherwise. This means
  Bitwarden secret injection happens at the container-entrypoint layer, before the Node
  process starts — the app itself never calls a BWS SDK; AWS credentials simply appear as
  normal `process.env` values by the time `getRoute53Client()`-style code runs.
  Researcher/planner should: (1) follow the exact existing `lib/services/<name>-factory.ts`
  + `is<Name>Configured()` pattern used by every other integration, reading credentials from
  `process.env`; (2) confirm the actual env var names with the user (e.g.
  `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_REGION`, vs a `ROUTE53_*`-prefixed
  variant) before finalizing the factory — this wasn't locked in discussion; (3) add a
  `ROUTE53_*` (or `AWS_*`) row to CLAUDE.md's integration env-prefix table once confirmed.
- **AWS account scope** — not discussed. Default assumption for planning purposes is a
  single AWS account holding all client hosted zones (the common MSP pattern), not
  per-client AWS accounts/cross-account IAM roles. Flag during research if this assumption
  looks wrong once the actual AWS setup is inspected.
- **Exact record-change diff granularity** (whole-recordset replace vs individual value
  diffing) — left to researcher/planner, informed by how the AWS SDK's
  `ChangeResourceRecordSets` API actually models a record update.
- **Table/column naming inside the dedicated Route 53 schema** — D-05 locks "dedicated
  tables," not literal names; researcher/planner should follow existing migration
  conventions (`snake_case`, audit columns `created_at`/`updated_at`/`synced_at`/
  `is_deleted`/`deleted_at`).

### Deferred Ideas (OUT OF SCOPE)

None — discussion stayed within phase scope. The "Credentials & AWS scope" gray area was
deliberately not discussed interactively (user judged it already settled by the existing
BWS/docker-entrypoint infrastructure) — not treated as out-of-scope or deferred to a future
phase.

## Phase Requirements

No requirement IDs are mapped to Phase 24 in `.planning/REQUIREMENTS.md` (file does not
exist at all in this project — this project tracks scope via ROADMAP.md phase success
criteria, not a separate REQUIREMENTS.md). The six numbered Success Criteria in
ROADMAP.md's Phase 24 section function as the requirement list; each is addressed below.

| Success Criterion | Research Support |
|----|-------------|------------------|
| SC-1: Hosted zones/records sync into Postgres on a schedule, matching AWS as source of truth | Sync-scheduler + sync-service pattern (see Architecture Patterns, Code Examples) |
| SC-2: CRUD from Pulse propagates to Route 53 via AWS API | `ChangeResourceRecordSets` write-back pattern (see Code Examples, IT Glue write precedent) |
| SC-3: Every sync/CRUD operation logged with actor, timestamp, before/after | `route53_audit_log` design (see Migration section) |
| SC-4: Record-level change history queryable, not just current state | `route53_record_history` design, `source` column (D-06) |
| SC-5: AWS credentials resolved via BWS at runtime, never persisted plaintext | Env var + credential provider chain research (see Standard Stack, Pitfalls) |
| SC-6: Integration appears in existing sync admin UI/scheduler alongside others | `sync-scheduler.ts` / `integration-health.ts` / `app/admin/sync/page.tsx` extension points (see Architecture Patterns) |

## Project Constraints (from CLAUDE.md)

- No ORMs — use `postgresClient` singleton (`query`, `transaction`, `upsert`, `bulkUpsert`).
- Factory + `is<Name>Configured()` pattern for every integration client; credentials from
  `process.env` only, client throws if missing.
- New numbered migration; never edit a committed one. Use `IF NOT EXISTS` +
  `ON CONFLICT DO NOTHING` for seed data. Current highest is `101_reschedule_mimecast_
  sync.sql` — next is **102**.
- snake_case DB columns, camelCase API responses, manual transform in route handlers (no
  ORM auto-mapping).
- Audit columns convention: `created_at`, `updated_at`, `synced_at`, `is_deleted`,
  `deleted_at`.
- API routes: `try/catch`, `NextResponse.json({ error, message }, { status })`. 503 for
  missing/bad config, 401/403 from auth helpers, 500 runtime.
- Auth in API routes via `lib/auth-utils.ts` (`requireAuth()`, `requireAdmin()`,
  `requirePermission()`); middleware only checks a session cookie exists.
- No `'use server'` actions — API routes called via client-side `fetch()`.
- No SWR/react-query — match local `useState`/`useEffect`/`fetch()` pattern.
- Icons: `lucide-react`. Toasts: `sonner`. Tables: `@tanstack/react-table` via
  `components/admin/DataTable.tsx`. Details: `components/admin/DetailModal.tsx`.
- Sync scheduler, analyzer worker, RMM worker auto-start as side effects of being imported
  — don't eager-import a new Route 53 worker (there isn't one needed; sync is scheduler-
  driven, not a polling worker) from a hot path or shared utility.
- `.env` is committed to the repo — treat secrets as potentially real, don't log/echo them.
  **New AWS credentials must not land in `.env`** — they arrive via BWS at the
  container-entrypoint layer (see Pitfalls). Flag this explicitly if a plan step suggests
  adding `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` to `.env`.

## Standard Stack

### Core

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `@aws-sdk/client-route-53` | ^3.1104.0 (verified current on npm registry, 2026-08-05) | Route 53 API client (list/get/change zones and records) | Official AWS SDK v3 modular client; the only non-hand-rolled way to sign and call the Route 53 API [VERIFIED: npm registry + Context7 `/aws/aws-sdk-js-v3`] |

No other new runtime dependency is required. `@aws-sdk/client-route-53` transitively pulls
in `@aws-sdk/credential-provider-node` (default credential chain) and `@smithy/*` (HTTP
handler, retry logic) — these do not need to be installed or imported directly for the
default (env-var) credential path.

### Supporting

| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| Node built-in `dns` module (`dns.promises`, `dns.Resolver`) | Node 18+ (bundled) | D-12 live NS-delegation health check | Already used elsewhere in this codebase (`lib/services/pipeline-steps/ping-flap-suppress.ts` imports `promises as dns`) — no new dependency, `dns.Resolver().setServers([...])` gives a dedicated instance that doesn't affect the app's default resolver [VERIFIED: codebase grep + Node core docs] |

### Alternatives Considered

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Node built-in `dns` module for NS lookup | A DNS-over-HTTPS (DoH) client library (e.g. hitting Cloudflare's `1.1.1.1/dns-query` or Google's `dns.google/resolve` over `fetch()`) | DoH avoids depending on the container's configured resolver reaching the public internet correctly, and works even if outbound UDP/53 is firewalled in the hosting environment. But it's a new external dependency (an HTTP call to a third party) for a check `dns.Resolver` with explicit public nameservers already solves without new code. Recommend `dns` module first; fall back to DoH only if `Resolver.setServers()` proves unreliable in the actual deployment network (e.g., Docker network blocks outbound UDP/53) — this is a genuine unknown, flagged in Open Questions. |
| `@aws-sdk/client-route-53`'s aggregated `Route53` class | The modular `Route53Client` + individual `*Command` classes (`ListHostedZonesCommand`, `ChangeResourceRecordSetsCommand`, etc.) | The aggregated `Route53` class (`new Route53({ region })`) offers direct method calls (`client.listHostedZones()`) but pulls in every command's code even if unused, increasing bundle size in a serverless/edge context. This codebase runs `output: 'standalone'` in a long-lived Docker container (not edge functions), so bundle size is a non-issue either way — either style works; the modular `*Command` + `.send()` style is what Context7's official examples default to and is slightly more idiomatic for SDK v3, so prefer it for consistency with AWS's own current documentation. |

**Installation:**
```bash
npm install @aws-sdk/client-route-53
```

**Version verification:** Confirmed via `npm view @aws-sdk/client-route-53 version` →
`3.1104.0`, package first published 2020-01-14, ~1.78M downloads in the trailing week (npm
registry download API, checked 2026-08-05). Cross-referenced against Context7's
`/aws/aws-sdk-js-v3` official docs, which describe the same modular client shape.

## Package Legitimacy Audit

| Package | Registry | Age | Downloads | Source Repo | slopcheck | Disposition |
|---------|----------|-----|-----------|--------------|-----------|--------------|
| `@aws-sdk/client-route-53` | npm | ~6 years (published 2020-01-14) | ~1.78M/week | `github.com/aws/aws-sdk-js-v3` | `[OK]` | Approved |

**Packages removed due to slopcheck [SLOP] verdict:** none
**Packages flagged as suspicious [SUS]:** none

Note on method: `slopcheck install <pkg>` in this environment performs a *real*
`npm install` as its verification step (not a registry-metadata-only check). This research
session ran it once, confirmed the `[OK]` verdict, and immediately reverted the resulting
`package.json`/`package-lock.json` changes via `git checkout` so this research task leaves
no unintended repo modification. **The planner should be aware `slopcheck install` is not
side-effect-free** — if re-run during planning/execution, expect it to actually install the
package (which may be desired at that point, but should not be run speculatively against a
clean tree without intending to keep the change).

## Architecture Patterns

### System Architecture Diagram

```
┌─────────────────────────────┐        ┌──────────────────────────────┐
│  node-cron scheduler        │        │  Admin browser                │
│  (lib/services/             │        │  /admin/sync/route53          │
│   sync-scheduler.ts)        │        │  /admin/sync (tile)           │
│                              │        └───────────┬────────────────┘
│  route53-incremental (15m)  │                    │ fetch()
│  route53-full (daily)       │                    ▼
└───────────┬──────────────────┘        ┌──────────────────────────────┐
            │ calls                     │  app/api/route53/*            │
            ▼                           │  GET  zones/records/history    │
┌─────────────────────────────┐        │  POST/PATCH/DELETE records/[id]│
│ Route53SyncService           │        │  requireAdmin() gate           │
│ (lib/services/               │        └───────────┬────────────────┘
│  route53-sync-service.ts)    │                    │ calls
│                              │                    ▼
│ 1. ListHostedZones           │        ┌──────────────────────────────┐
│ 2. ListResourceRecordSets    │◄───────┤ Route53 write path             │
│    (per zone, paginated)     │ shares │ 1. Read current recordset      │
│ 3. Diff vs Postgres mirror   │ client │    (before_value)               │
│ 4. bulkUpsert route53_records│        │ 2. INSERT route53_audit_log     │
│ 5. On diff: INSERT           │        │    (status=pending)             │
│    route53_record_history    │        │ 3. ChangeResourceRecordSets     │
│    (source=sync_detected_    │        │ 4. Short-interval poll          │
│    drift)                    │        │    GetChange (≤ ~20s)           │
└───────────┬──────────────────┘        │ 5. Mark audit_log committed/    │
            │ getRoute53Client()        │    failed; INSERT record_       │
            ▼                           │    history (source=pulse_crud) │
┌─────────────────────────────┐        └───────────┬────────────────┘
│ lib/services/                │                    │ getRoute53Client()
│ route53-factory.ts           │◄───────────────────┘
│ isRoute53Configured()        │
│ getRoute53Client()           │
│                              │
│ new Route53Client({ region })│
│ credentials: default chain   │
│ (fromEnv() reads AWS_*)      │
└───────────┬──────────────────┘
            │ signed HTTPS calls
            ▼
┌─────────────────────────────┐
│ AWS Route 53 API              │
│ (global service, us-east-1     │
│  signing region)               │
└─────────────────────────────┘

Separately, on the same 5-minute cache cadence as every other integration:

┌─────────────────────────────┐        ┌──────────────────────────────┐
│ integration-health.ts        │───────►│ checkRoute53()                 │
│ checkIntegrationHealth()     │        │ 1. Auth check (ListHostedZones │
│                              │        │    with MaxItems=1)             │
└─────────────────────────────┘        │ 2. Last-sync age (route53_      │
                                        │    audit_log / sync_schedules)  │
                                        │ 3. D-12: per-zone NS compare —  │
                                        │    route53_zones.authoritative_ │
                                        │    name_servers  vs live        │
                                        │    dns.Resolver().resolveNs()   │
                                        └──────────────────────────────┘
```

### Recommended Project Structure
```
lib/services/
├── route53-factory.ts          # getRoute53Client() + isRoute53Configured()
├── route53-sync-service.ts     # incrementalSync() / fullSync(), mirrors veeam-sync-service.ts
└── route53-client-helpers.ts   # (optional) pagination + record-set key helpers shared by sync + CRUD routes

lib/types/
└── route53.ts                  # Route53Zone, Route53Record, Route53RecordHistory, Route53AuditLog types

migrations/
└── 102_route53_tables.sql      # route53_zones / route53_records / route53_record_history / route53_audit_log

app/api/route53/
├── sync/route.ts                        # POST trigger manual sync, GET last-sync status (mirrors /api/pax8/sync)
├── zones/route.ts                       # GET list zones
├── zones/[zoneId]/records/route.ts      # GET list records in a zone, POST create
├── zones/[zoneId]/records/[recordId]/route.ts  # PATCH update, DELETE
└── zones/[zoneId]/records/[recordId]/history/route.ts  # GET record_history rows

app/admin/sync/route53/
└── page.tsx                     # zones/records/history detail page, DataTable + DetailModal
```

### Pattern 1: Factory + is<Name>Configured()

**What:** Lazy singleton client, credential-presence check separate from client
construction, matching every existing integration in this codebase.
**When to use:** Any new AWS SDK client added to `lib/services/`.
**Example:**
```typescript
// lib/services/route53-factory.ts
// Source: pattern from lib/services/veeam-factory.ts + lib/services/datto-rmm-factory.ts
import { Route53Client } from '@aws-sdk/client-route-53';

let route53ClientInstance: Route53Client | null = null;

export function isRoute53Configured(): boolean {
  return !!(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY);
}

export function getRoute53Client(): Route53Client {
  if (!route53ClientInstance) {
    if (!isRoute53Configured()) {
      throw new Error(
        'AWS credentials missing. Please set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (and AWS_REGION) environment variables.'
      );
    }
    // No explicit `credentials` option: @aws-sdk/credential-provider-node's default
    // chain (a transitive dependency) reads AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY /
    // AWS_SESSION_TOKEN from process.env automatically via fromEnv(), which is first in
    // the chain. Region defaults to us-east-1 — Route 53 is a global service but the
    // SDK still requires a signing region.
    route53ClientInstance = new Route53Client({
      region: process.env.AWS_REGION || 'us-east-1',
    });
  }
  return route53ClientInstance;
}

export function resetRoute53Client(): void {
  route53ClientInstance = null;
}
```
*[VERIFIED: Context7 `/aws/aws-sdk-js-v3` — `fromEnv()` reads `AWS_ACCESS_KEY_ID`/
`AWS_SECRET_ACCESS_KEY`/`AWS_SESSION_TOKEN`; `fromNodeProviderChain` (used implicitly by
`credential-provider-node`'s default) tries `fromEnv()` first among its provider chain]*

### Pattern 2: Sync service — incremental + full, upsert-based

**What:** Fetch all zones/records from AWS, diff against the Postgres mirror, `bulkUpsert`,
and write a `route53_record_history` row (`source='sync_detected_drift'`) for anything that
changed since the mirror was last read — without an operator having touched Pulse.
**When to use:** Both `route53-incremental` and `route53-full` scheduled sync types.
**Example:**
```typescript
// lib/services/route53-sync-service.ts
// Source: pattern from lib/services/veeam-sync-service.ts (executeSync/step loop)
import { ListHostedZonesCommand, ListResourceRecordSetsCommand } from '@aws-sdk/client-route-53';
import { getRoute53Client } from './route53-factory';
import postgresClient from './postgres-client';

async function syncZones(client = getRoute53Client()) {
  let marker: string | undefined;
  let count = 0;
  do {
    const page = await client.send(new ListHostedZonesCommand({ Marker: marker }));
    for (const z of page.HostedZones ?? []) {
      const zoneId = (z.Id ?? '').replace('/hostedzone/', '');
      await postgresClient.query(
        `INSERT INTO route53_zones (id, name, comment, private_zone, record_count, raw_payload, synced_at)
         VALUES ($1,$2,$3,$4,$5,$6,NOW())
         ON CONFLICT (id) DO UPDATE SET
           name=EXCLUDED.name, comment=EXCLUDED.comment, private_zone=EXCLUDED.private_zone,
           record_count=EXCLUDED.record_count, raw_payload=EXCLUDED.raw_payload,
           synced_at=NOW(), updated_at=NOW()`,
        [zoneId, z.Name, z.Config?.Comment ?? null, !!z.Config?.PrivateZone,
         z.ResourceRecordSetCount ?? 0, JSON.stringify(z)]
      );
      count++;
    }
    marker = page.IsTruncated ? page.NextMarker : undefined;
  } while (marker);
  return count;
}
```
*[VERIFIED: Context7 `/aws/aws-sdk-js-v3` for `ListHostedZonesCommand` pagination shape
(`Marker`/`IsTruncated`/`NextMarker`); upsert SQL pattern matches
`veeam-sync-service.ts`'s `syncOrganizations()`]*

### Pattern 3: CRUD write-back with pending → committed/failed audit row

**What:** Insert an audit row *before* calling the external API, then flip it to
committed/failed after the call resolves — never write to the external system without an
audit row already in flight. This is the single most important pattern to reuse verbatim;
it already exists in this codebase for a structurally identical problem (IT Glue
write-back).
**When to use:** All `app/api/route53/zones/[zoneId]/records/*` POST/PATCH/DELETE routes.
**Example:**
```typescript
// Source: pattern lifted directly from
// app/api/analyzer/itglue/applications/[id]/apply/route.ts
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ zoneId: string; recordId: string }> }) {
  const { session, error } = await requireAdmin(); // D-04
  if (error) return error;

  // 1. Read current recordset from the mirror (before_value).
  // 2. INSERT route53_audit_log (status='pending', before_value, after_value=requested).
  // 3. Call ChangeResourceRecordSetsCommand with Action: 'UPSERT'.
  // 4. On success: short-interval GetChange poll (see Pitfall 6), mark committed,
  //    INSERT route53_record_history (source='pulse_crud'), refresh mirror row.
  // 5. On failure (any thrown error, including ThrottlingException): mark audit_log
  //    status='failed' with the error message — D-07. Do NOT write record_history
  //    (nothing on the AWS side actually changed).
}
```

### Anti-Patterns to Avoid

- **Per-value diffing before writing to AWS:** `ChangeResourceRecordSets` has no
  "append one value to an existing multi-value record" primitive — every write replaces
  the entire `ResourceRecordSet` (`Name`+`Type`+`SetIdentifier`). Don't build app-side logic
  that tries to submit partial-value patches; always construct and submit the full
  intended `ResourceRecords` array.
- **Using the SDK's built-in `waitUntilResourceRecordSetsChanged` waiter inside a
  synchronous HTTP request handler:** its default poll interval is 30 seconds with up to 60
  attempts (30 minutes) — this is the botocore-shared waiter config the JS SDK mirrors. A
  user clicking "save" in `/admin/sync/route53` should not wait 30+ seconds for a first
  status check. Poll `GetChangeCommand` manually on a short interval instead (Pitfall 6).
- **Writing `.env` entries for AWS credentials:** this repo commits `.env` to git (per
  CLAUDE.md's "Watch out for" section). AWS credentials must arrive via the BWS
  `docker-entrypoint.sh` path, never as literal values added to the committed `.env` file.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| AWS request signing (SigV4) | A custom HMAC-SHA256 signer for Route 53's REST/XML API | `@aws-sdk/client-route-53` | Signing, retry, and pagination logic is exactly what the official SDK exists to own — Route 53's request signing is non-trivial and version-sensitive |
| Retry/backoff for Route 53 throttling | Custom exponential-backoff wrapper around every AWS call | The AWS SDK v3 client's built-in default retry strategy (3 retries, retryable-error-aware) | Already ships in every `*Client` by default; only add app-level backoff for the CRUD route's `GetChange` polling loop, which is a business-logic concern (bounded wait for UX), not a transport-retry concern |
| Live DNS resolution | A hand-rolled DNS packet parser over raw UDP sockets | Node's built-in `dns` module (`dns.promises`, `dns.Resolver`) | Already a dependency of the Node runtime, already used elsewhere in this codebase (`ping-flap-suppress.ts`) |
| Change-history diffing UI | A generic JSON-diff library for `before_value`/`after_value` | Simple field-by-field comparison in the detail-page component (record sets have a small, fixed field set: `Name`/`Type`/`TTL`/`ResourceRecords`/`SetIdentifier`) | The itglue write pattern doesn't use a diff library either — before/after are just rendered side-by-side in the existing formatted/raw `DetailModal.tsx` tabs |

**Key insight:** Every "how do I build X" question in this phase already has a "we already
built something structurally identical" answer elsewhere in this codebase. The primary risk
in this phase isn't technical novelty — it's *not noticing* the IT Glue write-back precedent
and re-deriving a worse version of it from scratch.

## Common Pitfalls

### Pitfall 1: Using a custom `ROUTE53_*` env var prefix breaks the free credential chain

**What goes wrong:** If credentials are read from custom names (`ROUTE53_ACCESS_KEY_ID`
etc.), `getRoute53Client()` must manually construct a `credentials: { accessKeyId, secretAccessKey }` object, and BWS's secret names inside the Bitwarden project must then be
configured to emit those custom names — an extra manual mapping step with no upside.
**Why it happens:** Copying the `<NAME>_*`-prefix convention from every *other* integration
in CLAUDE.md's table without noticing AWS's SDK hard-codes its own env var names.
**How to avoid:** Use the literal `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` /
`AWS_REGION` / (optional) `AWS_SESSION_TOKEN` names. Add a CLAUDE.md table row noting this
is an intentional exception to the per-service-prefix convention, with the rationale
(SDK-hardcoded names, not configurable).
**Warning signs:** A factory that constructs an explicit `credentials:` object instead of
omitting the option entirely.

### Pitfall 2: Trusting the "5 requests/second" figure still repeated online

**What goes wrong:** Designing the incremental+full sync cadence (D-11) around an outdated,
overly conservative rate limit, leading to unnecessarily long sync intervals or unnecessary
custom rate-limiting code.
**Why it happens:** AWS changed Route 53's throttling model from a flat rate limit to a
token-bucket model at some point; many blog posts, Stack Overflow answers, and even some
AWS re:Post threads still cite the old flat "5 req/s" number.
**How to avoid:** Use the current official numbers (verified 2026-08-05, `docs.aws.amazon.
com/Route53/latest/DeveloperGuide/throttling-api-requests.html` — the page explicitly
states it describes "updated limits"): 50-burst/10-per-second default request-rate bucket
per action AND account-wide combined, plus a separate 1500-burst/100-per-second
change-throughput bucket (only for zone/record-mutating actions; `ChangeResourceRecordSets`
consumes 1 token per CREATE/DELETE, 2 per UPSERT). `ListHostedZones`/
`ListResourceRecordSets`/`GetChange` are not in the special-limits table, so they use the
50-burst/10-per-second default action bucket.
**Warning signs:** Sync code with hard sleeps between every single API call, or a sync
interval longer than necessary "just to be safe."

### Pitfall 3: `ChangeResourceRecordSets` requires an *exact* match to delete

**What goes wrong:** A `DELETE` action fails (or worse, silently deletes/replaces the wrong
thing) if the submitted `ResourceRecordSet` doesn't exactly match what AWS currently has —
same `Name`, `Type`, `TTL`, and full `ResourceRecords` array (values must match verbatim,
same order not required but same set).
**Why it happens:** Assuming `DELETE` only needs `Name`+`Type` (like a typical REST DELETE
by ID).
**How to avoid:** Always read the current recordset from the Postgres mirror (or, to be
safe, re-fetch fresh via `ListResourceRecordSetsCommand` scoped to that name/type
immediately before deleting) and submit that exact shape back with `Action: 'DELETE'`.
**Warning signs:** A delete route that only accepts `{ zoneId, name, type }` in its request
body without reading current TTL/values first.

### Pitfall 4: Same-hosted-zone concurrent changes cause `PriorRequestNotComplete`

**What goes wrong:** Two writes to the same hosted zone submitted close together (e.g., an
admin double-clicking "save," or a CRUD write racing an in-progress sync's own read) can
get rejected with `PriorRequestNotComplete` — this is a separate, per-zone serialization
constraint, distinct from account-level throttling.
**Why it happens:** Route 53 processes one `ChangeResourceRecordSets` request per hosted
zone at a time internally.
**How to avoid:** Treat this as a retryable error (short backoff + retry, same as
throttling) rather than a hard failure; disable the save button while a request for that
zone is in flight in the UI.
**Warning signs:** Intermittent `400 PriorRequestNotComplete` errors correlating with rapid
UI double-submits or sync-vs-CRUD timing overlaps.

### Pitfall 5: `dns.resolveNs()` may reflect a cached/local resolver, not a live public lookup

**What goes wrong:** D-12 explicitly wants a **live public DNS lookup**, but Node's default
`dns` module resolves through whatever nameserver the container's `/etc/resolv.conf` points
at (often a local caching resolver, e.g. Docker's embedded DNS or the host's resolver) —
this can return stale or non-authoritative-chain results, defeating the purpose of the
check.
**Why it happens:** `dns.promises.resolveNs()` (the global default resolver) is the
lowest-friction API, so it's tempting to reach for it directly.
**How to avoid:** Create a dedicated `new dns.Resolver()` instance and call
`.setServers(['1.1.1.1', '8.8.8.8'])` on it before calling `.resolveNs(domain)` — this
scopes the override to that one lookup without touching the app's default resolver
(important: don't call the global `dns.setServers()`, which would affect *all* DNS
resolution in the process, including internal service hostnames).
**Warning signs:** NS health checks that never flag drift even when a domain's registrar-
level NS records are known to be wrong.

### Pitfall 6: Don't block the CRUD HTTP response on full DNS propagation

**What goes wrong:** `ChangeResourceRecordSets` returns immediately with `Status: PENDING`;
full "INSYNC" (propagated to all Route 53 name-server edge locations) can take anywhere
from a few seconds to (rarely) a couple of minutes. Blocking the API route until `INSYNC`
using the SDK's built-in waiter risks a 30-second-to-30-minute wait (default waiter config).
**Why it happens:** Assuming `ChangeResourceRecordSets` behaves like a synchronous write.
**How to avoid:** Submit the change, write the audit/history rows immediately with the AWS
`ChangeInfo.Id`, then poll `GetChangeCommand` manually every ~2 seconds for a short bounded
window (~15-20s) purely for UI feedback ("Submitted" vs "Propagated"). If still `PENDING`
when the window elapses, return success with a `propagationStatus: 'PENDING'` flag — the
next `route53-incremental` scheduled sync (15 min later, per D-11) will reconcile the final
state regardless. This satisfies D-03's "immediate execution" (the mutation IS submitted
and accepted immediately; only global propagation is eventually consistent, which is
normal, expected AWS behavior, not a staged-approval gate).
**Warning signs:** A CRUD route that takes 30+ seconds to respond, or one that reports
success/failure based on `INSYNC` status rather than the initial `ChangeResourceRecordSets`
API acceptance.

## Code Examples

### Reading a hosted zone's authoritative NS records for the D-12 health check

```typescript
// Source: pattern combining Context7 /aws/aws-sdk-js-v3 GetHostedZoneCommand shape
// with this codebase's existing dns module usage (lib/services/pipeline-steps/ping-flap-suppress.ts)
import { GetHostedZoneCommand } from '@aws-sdk/client-route-53';
import { Resolver } from 'dns';
import { promisify } from 'util';

async function checkNsDelegation(zoneId: string, zoneName: string, client = getRoute53Client()) {
  const { DelegationSet } = await client.send(new GetHostedZoneCommand({ Id: zoneId }));
  const authoritativeNs = (DelegationSet?.NameServers ?? []).map(ns => ns.toLowerCase().replace(/\.$/, ''));

  const resolver = new Resolver();
  resolver.setServers(['1.1.1.1', '8.8.8.8']); // dedicated instance — does not affect global dns resolution
  const resolveNs = promisify(resolver.resolveNs.bind(resolver));

  try {
    const liveNs = (await resolveNs(zoneName.replace(/\.$/, ''))).map((ns: string) => ns.toLowerCase().replace(/\.$/, ''));
    const mismatch = authoritativeNs.some(ns => !liveNs.includes(ns));
    return { zoneName, authoritativeNs, liveNs, mismatch };
  } catch (err) {
    return { zoneName, authoritativeNs, liveNs: null, mismatch: true, error: err instanceof Error ? err.message : String(err) };
  }
}
```

### Short-interval GetChange poll for the CRUD route (not the SDK's default waiter)

```typescript
// Source: pattern derived from AWS-documented ChangeInfo.Status values (PENDING|INSYNC),
// custom interval chosen for HTTP-response UX rather than using
// waitUntilResourceRecordSetsChanged's 30s/60-attempt default.
import { GetChangeCommand } from '@aws-sdk/client-route-53';

async function pollChangeStatus(changeId: string, client = getRoute53Client(), timeoutMs = 15000, intervalMs = 2000) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    const { ChangeInfo } = await client.send(new GetChangeCommand({ Id: changeId }));
    if (ChangeInfo?.Status === 'INSYNC') return 'INSYNC';
    await new Promise(r => setTimeout(r, intervalMs));
  }
  return 'PENDING'; // still propagating — next incremental sync reconciles
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Route 53 flat "5 requests/second per account" throttle (still widely cited online) | Token-bucket model: 50-burst/10-per-second default per-action + account-wide request-rate bucket, plus a separate 1500-burst/100-per-second change-throughput bucket for mutating actions | Unspecified exact date — AWS's own current docs describe this as an update ("Amazon Route 53 updated its API throttling behavior... increasing the requests per second limit and introducing change-based throttling") without citing when; treat any source repeating "5 req/s" as stale | D-11's incremental+full cadence has far more headroom than the old figure would suggest — no need for aggressive client-side rate limiting beyond the SDK's built-in retry/backoff |
| AWS SDK for JavaScript v2 (`aws-sdk` monolithic package, `new AWS.Route53()`) | AWS SDK for JavaScript v3 (modular `@aws-sdk/client-route-53`, `new Route53Client()` + `*Command` + `.send()`) | v3 GA ~2020, v2 in maintenance mode | Use v3 exclusively — v2 is not the standard for any new integration and would introduce a second AWS SDK major version into the dependency tree unnecessarily |

**Deprecated/outdated:**
- The commonly-cited flat Route 53 rate limit ("5 requests per second") — superseded by the
  token-bucket model described above. Any research or Stack Overflow answer using this
  number as a design constraint should be treated as outdated.

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | A single AWS account holds all client hosted zones (no per-client AWS accounts / cross-account IAM role assumption needed) | User Constraints, Standard Stack | If wrong, `getRoute53Client()` needs to support assuming a role per company/zone (`fromTemporaryCredentials` / `sts:AssumeRole`) rather than a single static credential pair — a materially larger scope change to the factory and sync-service design |
| A2 | The literal `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_REGION` env var names (rather than a `ROUTE53_*` prefix) is the right call, and the BWS Bitwarden Secrets Manager project can be configured to emit exactly these names | Standard Stack, Architecture Patterns, Pitfall 1 | If the BWS project is already configured with different secret key names for an unrelated reason, either the BWS project needs a naming change or the factory needs custom credential wiring — low risk, but genuinely unconfirmed since no `AWS_*`/`ROUTE53_*` keys currently exist in this repo's `.env` |
| A3 | `dns.Resolver().setServers(['1.1.1.1','8.8.8.8'])` will succeed from inside the production Docker container (i.e., outbound DNS/UDP-53 to public resolvers is not firewalled in the actual hosting environment) | Pitfall 5, Code Examples | If outbound UDP/53 to arbitrary public IPs is blocked by the hosting network/firewall, the D-12 health check will always report `unreachable`/error rather than a genuine NS mismatch — a DoH-over-HTTPS fallback (Alternatives Considered) would be needed instead |
| A4 | AWS_REGION should default to `us-east-1` when unset, since Route 53 is a global service but the SDK still requires a signing region | Architecture Patterns (Pattern 1) | Low risk — `us-east-1` is the conventional/documented choice AWS's own CLI/console uses for Route 53; if wrong, easily corrected by setting `AWS_REGION` explicitly, no data-model impact |

## Open Questions

1. **What are the exact BWS (Bitwarden Secrets Manager) secret key names for AWS
   credentials in the actual Bitwarden project referenced by `BWS_PROJECT_ID`?**
   - What we know: `docker-entrypoint.sh` runs `bws run --project-id "$BWS_PROJECT_ID" --
     node server.js`, which injects every secret in that BWS project as an env var named
     after the secret's own key (that's how `bws run` works generally).
   - What's unclear: Whether the project already contains AWS-related secrets under some
     name, and if so, what that name is — this cannot be inspected from the repo (BWS state
     lives in Bitwarden's cloud, not in this codebase).
   - Recommendation: Confirm with the user (or whoever set up the BWS project) before
     finalizing the factory — plan should include this as an explicit early
     `checkpoint:human-verify` step, not an assumption baked into code.

2. **Is `AWS_REGION` (or any AWS credentials) already present in any non-`.env`
   configuration** (e.g., a `.env.local` not checked into git, or already set in the actual
   running container)?
   - What we know: No `AWS_*`/`ROUTE53_*`/`BWS_*` keys currently exist in the committed
     `.env` file in this repo.
   - What's unclear: Whether the production deployment already has these set outside the
     committed file (a normal and expected setup for secrets, per CLAUDE.md's warning that
     `.env` is committed and secrets should not live there for anything real).
   - Recommendation: Ask the user to confirm before assuming the factory needs to handle a
     "not yet provisioned" state gracefully vs. credentials already existing in the live
     environment.

3. **Can outbound DNS to arbitrary public resolvers (1.1.1.1/8.8.8.8 on UDP/53) actually
   reach the internet from the production container's network** (see Assumption A3)?
   - What we know: Nothing in this repo's `docker-compose.yml`/`Dockerfile` restricts
     outbound networking explicitly (no explicit firewall rules visible), so it's likely
     fine, but this is inferred, not verified against the live deployment.
   - What's unclear: Actual runtime network policy of the hosting environment (Traefik/
     reverse-proxy setup visible in `docker-compose.yml` governs inbound routing, not
     outbound).
   - Recommendation: Planner should include a smoke-test task early (a one-off script or
     admin debug endpoint calling `dns.Resolver().resolveNs('google.com')`) to confirm this
     before building the full D-12 health check around it.

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| `@aws-sdk/client-route-53` (npm package) | Route 53 client, all sync/CRUD | ✗ (not yet installed in `package.json`) | 3.1104.0 (target, confirmed on npm registry) | none needed — trivial `npm install` |
| AWS Route 53 hosted zones + IAM credentials | All sync/CRUD functionality | Unknown — cannot be verified from this repo; depends on the BWS project contents (Open Question 1) | — | If credentials are genuinely not yet provisioned, `isRoute53Configured()` returns `false` and every route/health-check/scheduler branch degrades gracefully (`not_configured` status), matching every other integration's pattern |
| `bws` CLI in the production container | Credential injection at container start | ✓ (per uncommitted `Dockerfile`/`docker-compose.yml` diff already reviewed) | 2.1.0 (pinned `ARG BWS_VERSION` in `Dockerfile`) | Falls back to a plain `node server.js` start (no BWS) if `BWS_ACCESS_TOKEN` unset — already implemented in `docker-entrypoint.sh` |
| Outbound DNS to public resolvers (1.1.1.1/8.8.8.8, UDP/53) | D-12 health check | Unknown — see Open Question 3 | — | DoH over HTTPS (443, already open for any web app) if UDP/53 egress is blocked |
| Node built-in `dns` module | D-12 health check | ✓ (Node core, bundled) | Node 18+ (matches project's stated Node requirement) | — |

**Missing dependencies with no fallback:**
- AWS credentials/hosted zones being genuinely configured in the target AWS account — this
  is out of this phase's control (an external prerequisite), but the code should degrade
  gracefully (matching every other `is<Name>Configured()`-gated integration) rather than
  crash when absent.

**Missing dependencies with fallback:**
- `@aws-sdk/client-route-53` — trivial `npm install`, no risk.
- Outbound public DNS — DoH fallback available if needed (Alternatives Considered).

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | vitest 4.1.5 |
| Config file | `vitest.config.ts` (`include: ['lib/**/*.test.ts']`, `environment: 'node'`) |
| Quick run command | `npx vitest run lib/services/route53-sync-service.test.ts` (once created) |
| Full suite command | `npm test` (runs all `lib/**/*.test.ts`) |

This codebase's test coverage today is limited to `lib/services/analyzer/**`,
`lib/services/rmm/**`, `lib/services/b2/**` (per CLAUDE.md — "other parts of the codebase
have no tests"). No existing tests touch any sync-service or factory file (Veeam, Datto
RMM, PAX8 have zero `.test.ts` files). This phase can follow that same convention (type-
check as the primary safety net) or introduce the *first* sync-service tests in this
codebase — recommend at minimum testing the pure/non-network logic (record-set key
derivation, before/after diff shaping, NS-comparison normalization) since those are
easily unit-testable without mocking AWS, following the existing `target-resolver.test.ts`
style (`_INTERNALS` export pattern for testing private helpers).

### Phase Requirement → Test Map

| Success Criterion | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| SC-3/SC-4 (audit + history correctness) | `route53_record_history` gets a row with correct `source` tag for CRUD vs. drift | unit (pure logic: given before/after recordsets, does the diff function classify correctly) | `npx vitest run lib/services/route53-sync-service.test.ts` | ❌ Wave 0 |
| D-12 (NS delegation check) | NS-list normalization (case, trailing dot) and mismatch detection | unit | `npx vitest run lib/services/integration-health.test.ts` | ❌ Wave 0 (no existing `integration-health.test.ts` for any integration today) |
| SC-2 (CRUD write-back) | Route handler auth-gating (`requireAdmin()` returns 401/403 for non-admin) | manual / smoke (this codebase has no existing precedent for testing API route auth gating in isolation — `requirePermission`/`requireAdmin` are Better Auth session-dependent, historically verified by manual click-through per `22-VERIFICATION.md`'s precedent) | none automated | ❌ — matches existing project convention of manual auth verification |

### Sampling Rate

- **Per task commit:** `npx vitest run <touched-test-file>`
- **Per wave merge:** `npm test` (full suite — cheap, this project's suite is small)
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps

- [ ] `lib/services/route53-sync-service.test.ts` — record-set key derivation, before/after
      diff classification (CREATE/UPSERT/DELETE → history row shape), NS-list normalization
- [ ] `lib/services/route53-factory.test.ts` — `isRoute53Configured()` true/false branches
      (mirrors `veeam-factory.ts`'s lack of a test file today — optional, low priority, but
      this is the first AWS-credential-shaped factory in the codebase and a cheap unit test)
- [ ] No framework install needed — vitest is already configured project-wide

## Security Domain

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-------------------|
| V2 Authentication | No (new surface) | N/A — reuses existing Better Auth session, no new auth surface introduced |
| V3 Session Management | No | N/A — no session changes |
| V4 Access Control | Yes | `requireAdmin()` gate on every write route (D-04); read routes should also require at minimum `requireAuth()` (admin-area page, but the API itself has no built-in scoping — mirror the itglue/veeam pattern of gating reads at `requireAuth()` and writes at `requireAdmin()`) |
| V5 Input Validation | Yes | Record `Type` must be validated against the writable allowlist (A/AAAA/CNAME/MX/TXT/SRV — D-01) *before* constructing the AWS API call, not relying on AWS to reject NS/SOA writes. `Name`/`TTL`/`ResourceRecords` values should be validated for basic shape (no client-side Zod is mandated by CLAUDE.md, but explicit validation here matters more than most routes since a malformed `ResourceRecords` value is a *live DNS* mutation, not just a bad DB row) |
| V6 Cryptography | No | Credentials handled entirely by the AWS SDK's SigV4 signing — never hand-roll |
| V7 Error Handling & Logging | Yes | D-07 requires failed attempts logged with error message — `route53_audit_log.status='failed'` + `error_message`, matching `itglue_writes`'s pattern; never let a caught AWS SDK error leak raw stack traces to the client response body (`message` field should be a sanitized string, matching `app/api/analyzer/itglue/applications/[id]/apply/route.ts`'s `err instanceof Error ? err.message : String(err)` pattern, which itself is already somewhat permissive — recommend capping/sanitizing before returning to the client if AWS error messages ever include account-identifying ARNs) |

### Known Threat Patterns for AWS Route 53 DNS management

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|----------------------|
| Writing to NS/SOA records, breaking zone delegation | Tampering | D-01's write-type allowlist enforced server-side (not just hidden in the UI) — reject any request targeting `Type: 'NS'` or `Type: 'SOA'` at the API route layer with a 400, before ever constructing a `ChangeResourceRecordSetsCommand` |
| Privilege escalation via a non-admin session hitting the API route directly (bypassing UI gating) | Elevation of Privilege | `requireAdmin()` server-side check (D-04) — never rely on the UI hiding buttons as the only gate |
| Credential leakage via error messages or logs | Information Disclosure | Never `console.log` the full AWS SDK error object (which can include request headers); log `error.message` only, matching existing `catch (error) { console.error(...) }` conventions in this codebase which already avoid dumping full request/response objects |
| Malicious/malformed record values (e.g., a TXT record used for SPF/DKIM policy bypass, or a CNAME pointed at an attacker-controlled domain — "dangling DNS"/subdomain takeover risk) | Tampering / Spoofing | Out of scope for input-shape validation alone — D-03 explicitly accepts immediate execution without a pre-write approval gate, relying on `route53_audit_log`'s before/after + actor for post-hoc traceability rather than pre-write blocking. Flag this tradeoff explicitly in the plan's `must_haves` so it's a documented, intentional acceptance rather than an overlooked gap |
| Route 53 API credential reuse across regions/services beyond DNS (if the same IAM user/role is later granted broader AWS permissions) | Elevation of Privilege | Recommend (as an operational note, not a code change) that the IAM policy attached to the credentials used here be scoped to Route 53 actions only (`route53:ListHostedZones`, `route53:ListResourceRecordSets`, `route53:ChangeResourceRecordSets`, `route53:GetChange`, `route53:GetHostedZone`) — least privilege. This is an AWS-console-side IAM concern, not something Pulse's code can enforce, but worth flagging for whoever provisions the credentials |

## Sources

### Primary (HIGH confidence)
- Context7 `/aws/aws-sdk-js-v3` — credential provider chain (`fromEnv`, `fromNodeProviderChain`), `ChangeResourceRecordSetsCommand` input/output shape, `Route53Client`/aggregated `Route53` client usage
- Context7 `/websites/aws_amazon_route53_apireference` — `ChangeResourceRecordSets` API reference, `ThrottlingException` shape
- `docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html` (fetched 2026-08-05) — quotas overview, pointer to updated throttling page
- `docs.aws.amazon.com/Route53/latest/DeveloperGuide/throttling-api-requests.html` (fetched 2026-08-05) — current token-bucket throttling model, exact burst/refill numbers, change-throughput token costs
- npm registry (`npm view @aws-sdk/client-route-53`) — version 3.1104.0, publish date, dependency list including `@aws-sdk/credential-provider-node`
- `slopcheck install @aws-sdk/client-route-53` — `[OK]` verdict (package legitimacy)
- Direct source reads: `lib/services/veeam-factory.ts`, `lib/services/veeam-sync-service.ts`, `lib/services/datto-rmm-factory.ts`, `lib/services/sync-scheduler.ts`, `lib/services/integration-health.ts`, `app/admin/sync/page.tsx`, `migrations/081_integration_settings.sql`, `migrations/091_pax8_tables.sql`, `migrations/075_itglue_audit.sql`, `app/api/analyzer/itglue/applications/[id]/apply/route.ts`, `lib/permissions.ts`, `lib/auth-utils.ts`, `lib/services/audit.ts`, `lib/services/postgres-client.ts`, `lib/services/pipeline-steps/ping-flap-suppress.ts`, `docker-entrypoint.sh`, `git diff Dockerfile docker-compose.yml`

### Secondary (MEDIUM confidence)
- WebSearch cross-referenced with official botocore docs — `ResourceRecordSetsChanged` waiter default config (30s delay / 60 attempts), consistent across the JS SDK v3 GitHub issue tracker and AWS's Ruby/Python SDK docs (shared underlying waiter spec)

### Tertiary (LOW confidence)
- None retained — the initial WebSearch result citing a flat "5 requests/second" Route 53 rate limit was superseded by the official, dated-current throttling documentation fetched directly and is called out explicitly in State of the Art as outdated, not used as a design input

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — official AWS SDK docs via Context7, npm registry version check, slopcheck legitimacy pass
- Architecture: HIGH — every pattern has a direct, working precedent already in this exact codebase (Veeam/Datto RMM sync pattern, IT Glue write-back pattern)
- Pitfalls: HIGH for AWS API behavior (official docs), MEDIUM for the DNS-egress-in-production-network assumption (Open Question 3, Assumption A3 — genuinely unverifiable from the repo alone)

**Research date:** 2026-08-05
**Valid until:** 30 days for the architecture/pattern guidance (stable); the throttling
numbers should be re-checked if this phase's execution is delayed more than ~90 days, since
AWS has changed this model at least once already and the docs give no version/date anchor
to detect future changes automatically.
