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

26 KiB

phase plan type wave depends_on files_modified autonomous requirements user_setup must_haves
24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud 01 execute 1
package.json
package-lock.json
migrations/102_route53_tables.sql
lib/types/route53.ts
lib/services/route53-factory.ts
lib/services/route53-factory.test.ts
CLAUDE.md
false
SC-3
SC-4
SC-5
service why env_vars dashboard_config
aws-route53 Route 53 API access for zone/record sync and CRUD write-back
name source
AWS_ACCESS_KEY_ID Bitwarden Secrets Manager project referenced by BWS_PROJECT_ID (injected by docker-entrypoint.sh via `bws run`) — NOT the committed .env file
name source
AWS_SECRET_ACCESS_KEY Bitwarden Secrets Manager project referenced by BWS_PROJECT_ID
name source
AWS_REGION Bitwarden Secrets Manager project, or leave unset to default to us-east-1
task location
Create/confirm an IAM user or role scoped to route53:ListHostedZones, route53:GetHostedZone, route53:ListResourceRecordSets, route53:ChangeResourceRecordSets, route53:GetChange only (least privilege — T-24-08) AWS Console -> IAM -> Users/Roles -> Permissions
task location
Confirm the BWS project emits the secrets under the literal key names AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION Bitwarden Secrets Manager -> project referenced by BWS_PROJECT_ID
truths artifacts key_links
SC-5: AWS credentials reach the Node process only as env vars injected by `bws run` at the docker entrypoint; no AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY value is added to the committed .env file
SC-5: isRoute53Configured() returns false when AWS credential env vars are absent, and getRoute53Client() throws rather than constructing an unauthenticated client
SC-3/SC-4: The dedicated Route 53 schema — route53_zones / route53_records / route53_record_history / route53_audit_log (D-05: dedicated tables, not the phishing pipeline's shared audit_events) — exists in Postgres with a source tag column distinguishing pulse_crud from sync_detected_drift (D-06) and a status column supporting pending/committed/failed (D-07)
D-08: Retention is unbounded by design — no purge job, no TTL, and no DELETE statement against route53_record_history or route53_audit_log anywhere in this phase, matching existing Pulse convention
D-10: An integration_settings row with key='route53' exists so the /admin/integrations toggle is display-only, with no sync/CRUD-blocking behavior anywhere
D-03 ACCEPTED TRADEOFF: destructive record operations execute immediately with no staged approval gate. Malicious or malformed record values (dangling-CNAME / subdomain-takeover, SPF/DKIM TXT tampering) are NOT blocked pre-write. Mitigation is post-hoc traceability only — route53_audit_log captures actor, timestamp, and before/after for every attempt. This is an intentional, documented acceptance, not an oversight.
path provides contains
migrations/102_route53_tables.sql route53_zones / route53_records / route53_record_history / route53_audit_log + integration_settings seed CREATE TABLE IF NOT EXISTS route53_audit_log
path provides exports
lib/services/route53-factory.ts getRoute53Client() + isRoute53Configured() + resetRoute53Client()
getRoute53Client
isRoute53Configured
resetRoute53Client
path provides
lib/types/route53.ts Route53Zone / Route53Record / Route53RecordHistory / Route53AuditLog / Route53SyncResult types
path provides
lib/services/route53-factory.test.ts isRoute53Configured() branch coverage
from to via pattern
lib/services/route53-factory.ts @aws-sdk/client-route-53 Route53Client construction with no explicit credentials option new Route53Client(
from to via pattern
migrations/102_route53_tables.sql integration_settings seed row for key='route53' INSERT INTO integration_settings
Lay the Route 53 foundation: install the official AWS SDK client, create the dedicated Postgres schema (mirror tables + change-history ledger + audit ledger), define shared TypeScript types, and add the credential factory following the exact `lib/services/-factory.ts` + `isConfigured()` shape every other Pulse integration uses.

Purpose: every downstream plan in this phase (sync service, CRUD routes, health check, admin UI) imports from these four artifacts. Nothing else can start until they exist. Output: @aws-sdk/client-route-53 in package.json, migrations/102_route53_tables.sql, lib/types/route53.ts, lib/services/route53-factory.ts (+ test), CLAUDE.md env-prefix row.

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

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

lib/services/route53-factory.ts exports: isRoute53Configured(): boolean getRoute53Client(): Route53Client // from '@aws-sdk/client-route-53' resetRoute53Client(): void

lib/types/route53.ts exports (camelCase — API-response shape, transformed from snake_case rows): Route53Zone { id, name, comment, privateZone, recordCount, authoritativeNameServers, syncedAt, isDeleted } Route53Record { recordKey, zoneId, name, type, setIdentifier, ttl, resourceRecords, aliasTarget, syncedAt, isDeleted } Route53RecordHistory { id, zoneId, recordKey, recordName, recordType, changeAction, beforeValue, afterValue, source, changedByUserId, changedByEmail, changedAt } Route53AuditLog { id, operation, zoneId, recordKey, recordName, recordType, beforeValue, afterValue, performedByUserId, performedByEmail, performedAt, completedAt, status, awsChangeId, awsChangeStatus, errorMessage } Route53RecordValue { value: string } Route53WritableType 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'SRV' Route53HistorySource 'pulse_crud' | 'sync_detected_drift' Route53AuditStatus 'pending' | 'committed' | 'failed' Route53SyncResult { syncId, syncType, status, startedAt, completedAt, duration, entities, errors }

Postgres primary keys (used by every downstream query): route53_zones.id = AWS hosted zone id with the '/hostedzone/' prefix stripped route53_records.record_key = ${zoneId}:${name}:${type}:${setIdentifier ?? ''}

Task 1: Install AWS SDK client and create the Route 53 migration package.json, package-lock.json, migrations/102_route53_tables.sql - package.json (confirm no existing @aws-sdk dependency, confirm scripts) - migrations/091_pax8_tables.sql (mirror-table conventions: raw_payload JSONB, synced_at/is_deleted/deleted_at, per-table idx_*_is_deleted) - migrations/075_itglue_audit.sql (itglue_writes ledger shape: status CHECK, performed_by_user_id FK to "user"(id) ON DELETE SET NULL, before_value/after_value JSONB, error_message) - migrations/081_integration_settings.sql (integration_settings columns + ON CONFLICT (key) DO NOTHING seed pattern) - migrations/001_initial_schema.sql lines 542-555 (sync_history table — entity_type/sync_type/status/records_* columns the sync service will reuse; do NOT recreate it) - .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-PATTERNS.md (migration section) Run `npm install @aws-sdk/client-route-53`. The package legitimacy audit in 24-RESEARCH.md already recorded a `[OK]` slopcheck verdict (official `aws/aws-sdk-js-v3` repo, ~1.78M weekly downloads) — no additional legitimacy gate is required. Do NOT install `@aws-sdk/credential-provider-node` or any `@smithy/*` package explicitly; they arrive transitively and the default credential chain is used implicitly.

Create migrations/102_route53_tables.sql (next number after the current highest, 101_reschedule_mimecast_sync.sql). Every statement uses IF NOT EXISTS. Open with a header comment block matching migrations/091_pax8_tables.sql's style, stating that this is the Phase 24 AWS Route 53 schema and that retention is unbounded by design (D-08 — no purge job, matching existing Pulse convention).

Table route53_zones: id TEXT PRIMARY KEY (AWS hosted zone id, /hostedzone/ prefix stripped), name TEXT NOT NULL, comment TEXT, private_zone BOOLEAN NOT NULL DEFAULT false, record_count INTEGER NOT NULL DEFAULT 0, authoritative_name_servers JSONB (the DelegationSet.NameServers array — consumed by the D-12 NS-delegation health check in plan 24-04), raw_payload JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), is_deleted BOOLEAN NOT NULL DEFAULT false, deleted_at TIMESTAMPTZ. Indexes: idx_route53_zones_is_deleted on (is_deleted), idx_route53_zones_name on (name).

Table route53_records: record_key TEXT PRIMARY KEY (composite string zoneId:name:type:setIdentifier, empty string for a null set identifier — Route 53 recordsets are uniquely identified by zone+name+type+SetIdentifier, there is no AWS-side record id), zone_id TEXT NOT NULL REFERENCES route53_zones(id) ON DELETE CASCADE, name TEXT NOT NULL, type TEXT NOT NULL, set_identifier TEXT, ttl INTEGER, resource_records JSONB (array of { "value": "..." } objects), alias_target JSONB (Route 53 alias records have no TTL/ResourceRecords), raw_payload JSONB, plus the same five audit columns as route53_zones. Indexes: idx_route53_records_zone on (zone_id), idx_route53_records_is_deleted on (is_deleted), idx_route53_records_name_type on (zone_id, name, type).

Table route53_record_history (D-06 — append-only change ledger, written by BOTH the sync service on detected drift and the CRUD routes): id UUID PRIMARY KEY DEFAULT gen_random_uuid(), zone_id TEXT NOT NULL REFERENCES route53_zones(id) ON DELETE CASCADE, record_key TEXT NOT NULL, record_name TEXT NOT NULL, record_type TEXT NOT NULL, change_action TEXT NOT NULL CHECK (change_action IN ('create','update','delete')), before_value JSONB, after_value JSONB, source TEXT NOT NULL CHECK (source IN ('pulse_crud','sync_detected_drift')), changed_by_user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL, changed_by_email TEXT, audit_log_id UUID (soft ref to route53_audit_log(id) — no hard FK, so a history row survives audit-log changes), changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(). Indexes: idx_route53_record_history_record on (record_key, changed_at DESC), idx_route53_record_history_source on (source), idx_route53_record_history_zone on (zone_id, changed_at DESC).

Table route53_audit_log (D-03/D-07 — every attempted operation including failures): id UUID PRIMARY KEY DEFAULT gen_random_uuid(), operation TEXT NOT NULL CHECK (operation IN ('create','update','delete','sync')), zone_id TEXT, record_key TEXT, record_name TEXT, record_type TEXT, before_value JSONB, after_value JSONB, performed_by_user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL, performed_by_email TEXT, performed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), completed_at TIMESTAMPTZ, status TEXT NOT NULL CHECK (status IN ('pending','committed','failed')), aws_change_id TEXT, aws_change_status TEXT, aws_response JSONB, error_message TEXT. Indexes: ix_route53_audit_log_record on (zone_id, record_key, performed_at DESC), ix_route53_audit_log_status on (status). zone_id is deliberately NOT an FK here — a failed attempt against a zone that was never synced must still be recordable.

Finally append the D-10 seed row: INSERT INTO integration_settings (key, disabled) VALUES ('route53', false) ON CONFLICT (key) DO NOTHING;

Do NOT edit migrations/081_integration_settings.sql — it is committed.

Apply the migration to the running database manually (Postgres init only applies migrations/*.sql on first volume boot — per CLAUDE.md and the MEMORY caveat confirmed by migration 090): docker exec -i pulse-postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" < migrations/102_route53_tables.sql. If the container is not running, note this in the SUMMARY as a deployment follow-up rather than skipping the file. grep -c 'CREATE TABLE IF NOT EXISTS route53_' migrations/102_route53_tables.sql | grep -qx 4 && grep -q "source IN ('pulse_crud','sync_detected_drift')" migrations/102_route53_tables.sql && grep -q "status IN ('pending','committed','failed')" migrations/102_route53_tables.sql && grep -q "INSERT INTO integration_settings" migrations/102_route53_tables.sql && node -e "const p=require('./package.json');if(!p.dependencies['@aws-sdk/client-route-53'])process.exit(1)" && echo PASS <acceptance_criteria> - package.json dependencies contains a @aws-sdk/client-route-53 entry; package-lock.json is updated in the same commit - migrations/102_route53_tables.sql exists and contains exactly 4 CREATE TABLE IF NOT EXISTS route53_* statements - route53_record_history has a source TEXT NOT NULL CHECK (source IN ('pulse_crud','sync_detected_drift')) column (D-06) - route53_audit_log has status TEXT NOT NULL CHECK (status IN ('pending','committed','failed')) and an error_message TEXT column (D-07) - route53_zones has an authoritative_name_servers JSONB column (D-12 input) - The file ends with INSERT INTO integration_settings (key, disabled) VALUES ('route53', false) ON CONFLICT (key) DO NOTHING; (D-10) - git diff --name-only does NOT list migrations/081_integration_settings.sql or any other pre-existing migration - No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY line appears in the committed .env file: grep -c '^AWS_' .env returns 0 </acceptance_criteria> Migration file created with all 4 tables + seed row; AWS SDK installed; no committed migration edited; no AWS secret written to .env.

Task 2: Add Route 53 shared types and the credential factory lib/types/route53.ts, lib/services/route53-factory.ts, lib/services/route53-factory.test.ts, CLAUDE.md - lib/services/veeam-factory.ts (canonical factory shape — singleton, isXConfigured, throw-on-missing, resetXClient) - lib/services/datto-rmm-factory.ts (multi-var config check variant) - lib/services/pax8-factory.test.ts (existing factory test conventions in this codebase — env var save/restore pattern) - lib/types/veeam.ts (domain type barrel conventions) - migrations/102_route53_tables.sql (column names the types must mirror in camelCase) - .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md (Pattern 1 and Pitfall 1) - `isRoute53Configured()` returns `false` when `AWS_ACCESS_KEY_ID` is unset - `isRoute53Configured()` returns `false` when `AWS_SECRET_ACCESS_KEY` is unset - `isRoute53Configured()` returns `false` when both are set to empty strings - `isRoute53Configured()` returns `true` when both are set to non-empty values - `getRoute53Client()` throws an Error mentioning `AWS_ACCESS_KEY_ID` when credentials are absent - `getRoute53Client()` returns the same instance on a second call (singleton), and a different instance after `resetRoute53Client()` Create `lib/types/route53.ts` exporting the camelCase interfaces and string-literal unions listed in this plan's `` block. These are the API-response shapes — route handlers transform `snake_case` rows into them manually (no ORM, per CLAUDE.md). Include `Route53WritableType` as `'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'SRV'` (D-01) and `Route53HistorySource` / `Route53AuditStatus` matching the migration's CHECK constraints exactly. `Route53SyncResult` mirrors the shape `VeeamSyncResult` uses in `lib/services/veeam-sync-service.ts` (`syncId`, `syncType`, `status`, `startedAt`, `completedAt`, `duration`, `entities`, `errors`).

Create lib/services/route53-factory.ts following lib/services/veeam-factory.ts line for line, adapted per 24-PATTERNS.md's "Adaptation for Route 53" snippet: module-level let route53ClientInstance: Route53Client | null = null; isRoute53Configured() returning !!(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY); getRoute53Client() that throws with the message 'AWS credentials missing. Please set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (and AWS_REGION) environment variables.' when unconfigured, otherwise constructs new Route53Client({ region: process.env.AWS_REGION || 'us-east-1' }); and resetRoute53Client() setting the singleton back to null.

CRITICAL (24-RESEARCH.md Pitfall 1): do NOT pass an explicit credentials: option to Route53Client. Omitting it lets @aws-sdk/credential-provider-node's default chain read AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN from process.env, which is exactly how BWS injects them at the docker-entrypoint.sh layer. Add an inline comment stating this so a future reader does not "fix" it by adding explicit credentials. Do NOT introduce a ROUTE53_* env prefix — the SDK hardcodes the AWS_* names.

Create lib/services/route53-factory.test.ts covering the <behavior> cases above. Follow lib/services/pax8-factory.test.ts's env-var save/restore discipline (snapshot process.env values in beforeEach, restore in afterEach) and call resetRoute53Client() between cases so the singleton does not leak across tests. Import describe/it/expect/beforeEach/afterEach explicitly from vitest (this project sets globals: false in vitest.config.ts).

Add a row to CLAUDE.md's integration env-prefix table: | AWS Route 53 | AWS_* (literal AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION — intentional exception to the per-service prefix convention; the AWS SDK's default credential chain hardcodes these names. Injected by BWS at the container entrypoint, never in .env) |. npx vitest run lib/services/route53-factory.test.ts && npx tsc --noEmit --pretty <acceptance_criteria> - npx vitest run lib/services/route53-factory.test.ts passes with at least 5 assertions covering the <behavior> list - npx tsc --noEmit --pretty exits 0 - grep -n "credentials" lib/services/route53-factory.ts shows only comment lines, never a credentials: object literal passed to Route53Client - grep -c 'ROUTE53_ACCESS\|ROUTE53_SECRET' lib/services/route53-factory.ts returns 0 - lib/types/route53.ts exports Route53WritableType with exactly the six D-01 types and no NS or SOA member: grep -q "'SRV'" lib/types/route53.ts && ! grep -q "'NS'" lib/types/route53.ts - CLAUDE.md's integration table contains a row matching grep -c 'AWS Route 53' CLAUDE.md >= 1 </acceptance_criteria> Types and factory exist, factory tests green, type-check clean, no explicit credentials wiring, CLAUDE.md documents the AWS_* exception.

Task 3: Confirm BWS credential names and outbound DNS egress Pause execution and present the four verification steps below to the developer verbatim. Run any command the developer asks you to run on their behalf, but do not proceed to plan 24-02 until they respond. Record every answer in the SUMMARY — plan 24-04's resolver implementation branches on the DNS-egress result, and plan 24-01 Task 2's factory may need a one-line env var name change if the BWS key names differ. The AWS SDK is installed, the Route 53 schema exists in Postgres, and `lib/services/route53-factory.ts` reads credentials from the literal `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_REGION` env var names via the AWS SDK's default credential provider chain. Three assumptions from 24-RESEARCH.md's Open Questions cannot be verified from the repository and must be confirmed before plans 24-02 through 24-07 build on them. 1. **BWS secret key names (Open Question 1, Assumption A2).** In Bitwarden Secrets Manager, open the project referenced by `BWS_PROJECT_ID` and confirm the AWS credentials are stored under keys named exactly `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and (optionally) `AWS_REGION`. `bws run` exports each secret under its own key name, so a different key name means the factory will not see the credentials. If the names differ, report the actual names — the factory in Task 2 needs a one-line change.
  1. Credentials reach the container (Open Question 2). With the app running, run: docker exec pulse-app sh -lc 'echo "id=${AWS_ACCESS_KEY_ID:+SET} secret=${AWS_SECRET_ACCESS_KEY:+SET} region=${AWS_REGION:-unset}"' Expected: id=SET secret=SET region=us-east-1 (or another explicit region). This prints only presence markers, never the secret values.

  2. Outbound DNS egress to public resolvers (Open Question 3, Assumption A3). The D-12 NS-delegation health check in plan 24-04 depends on reaching 1.1.1.1/8.8.8.8 on UDP/53 from inside the container. Run: docker exec pulse-app node -e "const{Resolver}=require('dns');const r=new Resolver();r.setServers(['1.1.1.1','8.8.8.8']);r.resolveNs('google.com',(e,a)=>console.log(e?'EGRESS-BLOCKED: '+e.code:'EGRESS-OK: '+a.join(',')))" Expected: a line starting EGRESS-OK:. If it prints EGRESS-BLOCKED, plan 24-04 must use the DoH-over-HTTPS fallback described in 24-RESEARCH.md's Alternatives Considered instead of Node's dns module.

  3. IAM scope (T-24-08, operational). Confirm the IAM principal behind these credentials is scoped to Route 53 actions only (route53:ListHostedZones, route53:GetHostedZone, route53:ListResourceRecordSets, route53:ChangeResourceRecordSets, route53:GetChange). This is an AWS-console concern Pulse cannot enforce; report the answer either way. Reply with the four answers, e.g. "1: names match / 2: SET SET us-east-1 / 3: EGRESS-OK / 4: scoped to route53 only", or describe any deviation. Type "approved" if all four match expectations.

<threat_model>

Trust Boundaries

Boundary Description
BWS/Bitwarden cloud → container env AWS credentials cross into the process at docker-entrypoint.sh before Node starts
Node process → AWS Route 53 API SigV4-signed HTTPS calls carrying long-lived IAM credentials
npm registry → repo dependency tree New third-party package added to production runtime

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-24-06 Information Disclosure committed .env file mitigate AWS credentials are NEVER written to .env (which is committed to git per CLAUDE.md). Task 1 acceptance criterion asserts grep -c '^AWS_' .env returns 0. Credentials arrive only via bws run env injection.
T-24-08 Elevation of Privilege IAM principal behind AWS_ACCESS_KEY_ID transfer Least-privilege IAM policy scoped to the five Route 53 actions. Enforced in the AWS console, not in Pulse code — surfaced as checkpoint question 4 and recorded in user_setup.
T-24-09 Information Disclosure checkpoint verification commands mitigate Verification steps print only SET/unset presence markers (${VAR:+SET}), never secret values.
T-24-SC Tampering npm install @aws-sdk/client-route-53 mitigate 24-RESEARCH.md ## Package Legitimacy Audit records a [OK] slopcheck verdict for the single new package (official aws/aws-sdk-js-v3 repo, ~6 years old, ~1.78M weekly downloads). No [ASSUMED]/[SUS] packages in this phase, so no blocking legitimacy checkpoint is required. Version is recorded in package-lock.json.
T-24-05 Tampering / Spoofing record values written to live DNS (dangling CNAME, SPF/DKIM TXT tampering) accept D-03 explicitly accepts immediate execution with no pre-write approval gate. Compensating control is post-hoc only: route53_audit_log captures actor, timestamp, and before/after for every attempt including failures. Recorded verbatim in this plan's must_haves.truths as an intentional acceptance.
</threat_model>
- `npx vitest run lib/services/route53-factory.test.ts` green - `npx tsc --noEmit --pretty` exits 0 - `npm test` (full suite) still green — no regression from the new dependency - `psql -c "\d route53_audit_log"` (or `docker exec pulse-postgres psql ... -c '\dt route53_*'`) lists all four tables - Checkpoint answers recorded in the SUMMARY, including whether DNS egress is available (drives plan 24-04's implementation choice)

<success_criteria>

  • @aws-sdk/client-route-53 present in package.json dependencies
  • migrations/102_route53_tables.sql defines route53_zones, route53_records, route53_record_history, route53_audit_log and seeds integration_settings('route53')
  • lib/types/route53.ts and lib/services/route53-factory.ts export the contracts listed in <interfaces>
  • Factory tests pass; type-check clean
  • No AWS secret value written to .env or any committed file
  • Checkpoint answered: BWS key names confirmed, credentials present in container, DNS egress result known </success_criteria>
Create `.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-01-SUMMARY.md` when done. Record in the SUMMARY: the confirmed BWS secret key names, the DNS-egress result (EGRESS-OK vs EGRESS-BLOCKED — plan 24-04 depends on this), and whether the migration was applied to the live database or is pending deployment.