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

59 KiB

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:

npm install @aws-sdk/client-route-53

Version verification: Confirmed via npm view @aws-sdk/client-route-53 version3.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()   │
                                        └──────────────────────────────┘
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 + isConfigured()

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:

// 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-v3fromEnv() 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:

// 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:

// 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

// 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)

// 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.tsisRoute53Configured() 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_apireferenceChangeResourceRecordSets 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.