---
phase: 24
slug: aws-route-53-dns-sync-track-changes-crud-operations-full-aud
status: complete
nyquist_compliant: true
wave_0_complete: true
created: 2026-08-05
updated: 2026-08-06
---

# Phase 24 — Validation Strategy

> Per-phase validation contract for feedback sampling during execution.

---

## Test Infrastructure

| Property | Value |
|----------|-------|
| **Framework** | vitest 4.1.5 |
| **Config file** | `vitest.config.ts` (`include: ['lib/**/*.test.ts']`, `environment: 'node'`, `globals: false`) |
| **Quick run command** | `npx vitest run <touched-test-file>` |
| **Full suite command** | `npm test` |
| **Estimated runtime** | ~10 seconds (small existing suite) |

**Structural constraint driving the plan layout:** `vitest.config.ts` includes only
`lib/**/*.test.ts`. Nothing under `app/api/**` can be unit-tested. Every plan therefore
places its testable logic (record-key derivation, drift classification, the D-01 allowlist,
AWS error sanitization, change-batch construction, propagation polling, NS comparison) in a
`lib/services/` module that the route handlers call, rather than inline in a route file.

---

## Sampling Rate

- **After every task commit:** `npx vitest run <touched-test-file>`
- **After every plan wave:** `npm test`
- **Before `/gsd:verify-work`:** full suite green + `npx tsc --noEmit --pretty` clean
- **Max feedback latency:** 15 seconds

---

## Per-Task Verification Map

| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 24-01-T1 | 24-01 | 1 | SC-3, SC-4, SC-5 | T-24-06, T-24-SC | Route 53 schema exists with `source` and `status` CHECK constraints; no AWS secret in committed `.env` | schema/grep gate | `grep -c 'CREATE TABLE IF NOT EXISTS route53_' migrations/102_route53_tables.sql` = 4 and `grep -c '^AWS_' .env` = 0 | ➕ created by task | ⬜ pending |
| 24-01-T2 | 24-01 | 1 | SC-5 | T-24-06 | `isRoute53Configured()` false without creds; `getRoute53Client()` throws; no explicit `credentials:` object | unit | `npx vitest run lib/services/route53-factory.test.ts` | ➕ Wave 0 (created by task) | ⬜ pending |
| 24-01-T3 | 24-01 | 1 | SC-5 | T-24-08, T-24-09 | BWS key names confirmed; creds present in container; DNS egress reachable; IAM least-privilege | manual / checkpoint | none automated — blocking `checkpoint:human-verify` | ❌ manual by necessity | ⬜ pending |
| 24-02-T1 | 24-02 | 2 | SC-1, SC-4 | — | Record-key derivation and recordset normalization are order-insensitive, so equal recordsets never register as drift | unit | `npx vitest run lib/services/route53-record-key.test.ts` | ➕ Wave 0 (created by task) | ⬜ pending |
| 24-02-T2 | 24-02 | 2 | SC-1 | T-24-03 | Pagination + soft-delete; errors logged as `error.message` only, never the raw AWS error object | grep gate + typecheck | `npx tsc --noEmit --pretty && test $(grep -c 'integration_settings' lib/services/route53-sync-service.ts) -eq 0` | ➕ created by task | ⬜ pending |
| 24-02-T3 | 24-02 | 2 | SC-4 | T-24-04, T-24-10 | Drift produces exactly one correctly-tagged `sync_detected_drift` history row per changed record; equal recordsets and initial import produce none | unit | `npx vitest run lib/services/route53-sync-service.test.ts` | ➕ Wave 0 (created by task) | ⬜ pending |
| 24-03-T1 | 24-03 | 2 | SC-3 | **T-24-01**, T-24-03, T-24-12 | Closed six-type allowlist rejects `NS`/`SOA`/`ns`/`CAA` with 400; `sanitizeAwsError` redacts AKIA key ids, `arn:aws:*`, and 12-digit account ids | unit | `npx vitest run lib/services/route53-record-validation.test.ts` | ➕ Wave 0 (created by task) | ⬜ pending |
| 24-03-T2 | 24-03 | 2 | SC-3 | T-24-04, T-24-07 | Audit row inserted `status='pending'` before any AWS call; `markAuditFailed` binds a sanitized message; mirror deletes are soft-only | unit (mocked pg) | `npx vitest run lib/services/route53-write-persistence.test.ts` | ➕ Wave 0 (created by task) | ⬜ pending |
| 24-04-T1 | 24-04 | 2 | SC-6 (D-12) | T-24-13, T-24-15 | NS normalization is case/trailing-dot insensitive; empty authoritative list yields no false alarm; empty live answer yields a mismatch; `setServers` only on a dedicated `Resolver` instance | unit | `npx vitest run lib/services/route53-dns-delegation.test.ts` | ➕ Wave 0 (created by task) | ⬜ pending |
| 24-04-T2 | 24-04 | 2 | SC-6 | T-24-03, T-24-16 | `route53` present in the health aggregate; auth-probe errors sanitized; delegation-step failure cannot abort `Promise.all` | integration (curl) + typecheck | `npx tsc --noEmit --pretty && curl -s localhost:3100/api/admin/integration-health \| grep -q '"key":"route53"'` | ➕ modified by task | ⬜ pending |
| 24-05-T1 | 24-05 | 3 | SC-2 | **T-24-01**, T-24-19, T-24-20 | `buildChangeBatch` throws on NS/SOA (defence in depth); `pollChangeStatus` is bounded and stops after timeout; the 30-minute SDK waiter is absent | unit | `npx vitest run lib/services/route53-change-submit.test.ts` | ➕ Wave 0 (created by task) | ⬜ pending |
| 24-05-T2 | 24-05 | 3 | SC-4, SC-6 | **T-24-02**, T-24-18 | Every read route gated by `requireAuth()`; all SQL parameter-bound; no `integration_settings` gating (D-10) | grep gate + curl | `test $(grep -rl 'requireAuth\|requireAdmin' app/api/route53 \| wc -l) -eq 4` and unauthenticated `curl` on `/api/route53/zones` returns 401 | ➕ created by task | ⬜ pending |
| 24-05-T3 | 24-05 | 3 | SC-2, SC-3 | **T-24-01**, **T-24-02**, T-24-03, T-24-04, T-24-17 | `validateRecordWrite` and `createPendingAuditLog` both precede `submitRecordChange`; history written only on success; failures return 502 with a sanitized message; no staged-approval state | source-order gate + typecheck | `npx tsc --noEmit --pretty && test $(grep -rc 'pending_approval\|requiresConfirmation\|confirmToken' app/api/route53/ \| awk -F: '{s+=$2} END {print s}') -eq 0` | ➕ created by task | ⬜ pending |
| 24-05-T3m | 24-05 | 3 | SC-2 (D-04) | **T-24-02** | `requireAdmin()` returns 403 for a `user`-role session hitting a write route directly | manual / smoke | none automated — see Manual-Only table row 1 | ❌ manual by convention | ⬜ pending |
| 24-06-T1 | 24-06 | 3 | SC-1, SC-6 | T-24-21, T-24-22, T-24-24 | Both sync types dispatch via dynamic import and gate on `isRoute53Configured()` only, never on `integration_settings` (D-10) | grep gate + typecheck | `npx tsc --noEmit --pretty && test $(grep -A12 "config.sync_type === 'route53" lib/services/sync-scheduler.ts \| grep -c integration_settings) -eq 0` | ➕ modified by task | ⬜ pending |
| 24-06-T2 | 24-06 | 3 | SC-6 | T-24-23 | Tile entry uses a valid `COLOR_MAP` key; logo asset contains no `script` or `xlink:href` | grep gate + build | `npm run build && test $(grep -ci 'script\|xlink:href' public/logos/route53.svg) -eq 0` | ➕ created by task | ⬜ pending |
| 24-07-T1 | 24-07 | 4 | SC-4, SC-6 | T-24-25 | Page renders record values and history before/after as escaped text; no `dangerouslySetInnerHTML`; no SWR/react-query | grep gate + build | `npm run build && test $(grep -c 'dangerouslySetInnerHTML' app/admin/sync/route53/page.tsx) -eq 0` | ✅ created by task | ✅ green |
| 24-07-T2 | 24-07 | 4 | SC-2 | **T-24-01**, T-24-20, T-24-26 | Type selector offers only the six writable types; submit disabled while in flight; no approval-workflow state introduced | grep gate + build | `npm run build && test $(grep -c "'NS'\|'SOA'" components/admin/route53/record-editor-dialog.tsx) -eq 0` | ✅ created by task | ✅ green |
| 24-07-T3 | 24-07 | 4 | SC-1..SC-6 | all | Full live round-trip: sync, create/update/delete against real AWS, audit completeness, failure logging, auth gating, drift detection, D-12 health, D-10 display-only disable | manual / checkpoint | none automated — blocking `checkpoint:human-verify`, 9 steps | ❌ manual by necessity | ✅ green (all 9 steps passed against `wulfconsulting.com`; TXT-quoting bug found in step 2, fixed in `4da5664`, retried successfully) |

*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*

**Sampling continuity check:** no 3 consecutive tasks lack an automated verify. The two
checkpoint tasks (24-01-T3, 24-07-T3) are each adjacent to tasks carrying automated
commands, and both are terminal within their plan.

---

## Wave 0 Requirements

All Wave 0 test files are created by the task that needs them, inside the plan that owns the
module under test — no separate scaffolding plan is required, because every test target is a
new file rather than an extension of untested existing code.

- [ ] `lib/services/route53-factory.test.ts` — plan 24-01 Task 2 (`isRoute53Configured()` branches)
- [ ] `lib/services/route53-record-key.test.ts` — plan 24-02 Task 1 (key derivation, normalization, order-insensitive equality, drift classification)
- [ ] `lib/services/route53-sync-service.test.ts` — plan 24-02 Task 3 (drift history row shape and `source` tagging)
- [ ] `lib/services/route53-record-validation.test.ts` — plan 24-03 Task 1 (D-01 closed allowlist, TTL/value shape, AWS error sanitizer)
- [ ] `lib/services/route53-write-persistence.test.ts` — plan 24-03 Task 2 (pending/committed/failed SQL contract, soft-delete only)
- [ ] `lib/services/route53-dns-delegation.test.ts` — plan 24-04 Task 1 (NS normalization + mismatch detection; first health-check test file in this codebase)
- [ ] `lib/services/route53-change-submit.test.ts` — plan 24-05 Task 1 (change-batch construction, retry classification, bounded poll)
- [ ] No framework install needed — vitest is already configured project-wide

---

## Manual-Only Verifications

| Behavior | Requirement | Covered By | Why Manual | Test Instructions |
|----------|-------------|------------|------------|--------------------|
| Write-route auth gating (401/403 for non-admin) | SC-2 / D-04 / T-24-02 | 24-07 Task 3, step 7 | `requireAdmin()`/`requireAuth()` are Better Auth session-dependent; this codebase has no precedent for testing route auth gating in isolation — historically verified by manual click-through (see `22-VERIFICATION.md`) | Sign in as a `user`-role account, `POST /api/route53/zones/<zone>/records` with a valid payload, confirm 403. Confirm `GET /api/route53/zones` still returns 200 for the same session. Repeat the POST as `admin` and confirm success. **Observed: PASS.** `user`-role session (colleen@wulfconsulting.com) got 403 `"Forbidden - Admin access required"` on write; `GET /api/route53/zones` returned 200 for the same session. `super-admin` session (lorentz@wulfconsulting.com) succeeded on the equivalent write. |
| Live AWS write-back round-trip (create/update/delete a real record) | SC-2 / SC-3 / SC-4 | 24-07 Task 3, steps 2-6 | Cannot be safely automated against a real AWS account/hosted zone in CI; requires a real Route 53 zone and live credentials | Using a disposable TXT record in a real hosted zone: create via the Pulse UI, confirm in the AWS console; update; delete. Confirm `route53_audit_log` has 3 `committed` rows with correct before/after and actor, and `route53_record_history` has 3 `pulse_crud` rows. Then force an AWS-side rejection (apex CNAME) and confirm a `failed` row with a sanitized `error_message` and an HTTP 502 response. **Observed: PASS on retry.** First create attempt failed with AWS's `InvalidCharacterString` (TXT values weren't RFC-1035-quoted) — real bug, fixed in `4da5664` (`formatTxtValue()` in `route53-change-submit.ts`), `pulse-app` rebuilt, retried successfully. Create/update/delete against `pulse-phase24-test.wulfconsulting.com.` all confirmed in the AWS console; `route53_audit_log` showed 3 `committed` rows (create/update/delete) attributed to lorentz@wulfconsulting.com. Apex-CNAME rejection confirmed 502 + a `failed` audit row with a clean, sanitized `error_message` (no account id/ARN/key id present). |
| DNS-egress-in-production assumption (D-12 health check) | D-12 / T-24-13 | 24-01 Task 3, step 3 and 24-07 Task 3, step 9 | Whether outbound UDP/53 to public resolvers is permitted from the production container network is unverifiable from the repo (Open Question 3 / Assumption A3 in `24-RESEARCH.md`) | Run the `dns.Resolver().setServers(['1.1.1.1','8.8.8.8']).resolveNs('google.com')` one-liner inside the container (exact command in plan 24-01 Task 3). `EGRESS-OK` means the Node `dns` path is viable; `EGRESS-BLOCKED` requires plan 24-04 to use the DoH-over-HTTPS fallback. **Observed: EGRESS-OK** — confirmed twice (24-01 checkpoint and again during 24-07's live testing via the D-12 delegation check running successfully). Node's `dns` module in use, no DoH fallback needed. |
| D-10 display-only disable behavior | D-10 | 24-07 Task 3, step 9 | The failure mode (disabling the toggle silently stopping sync, replicating PAX8's blocking exception) is only observable end-to-end across the health cache, the scheduler, and the sync route | Toggle `route53` off at `/admin/integrations`, wait for the 5-minute health cache (or let the PATCH clear it), confirm the health row reads `disabled`, then confirm `POST /api/route53/sync` still succeeds and returns `{ ok: true }`. **Observed: PASS.** Toggling off showed `disabled` on the health row while a manual sync still succeeded. Developer noted `/admin/integrations` page load felt slow during this step overall (not specific to Route 53) — investigated, confirmed unrelated to this plan's code (see `deferred-items.md`), logged and not fixed here. |
| BWS secret key names / IAM least privilege | SC-5 / T-24-08 | 24-01 Task 3, steps 1, 2, 4 | BWS project contents live in Bitwarden's cloud, and IAM policy scope lives in the AWS console — neither is inspectable from this repo | See plan 24-01 Task 3's `how-to-verify` steps 1, 2, and 4. Verification commands print only `SET`/`unset` presence markers, never secret values. **Observed: RESOLVED.** BWS key names initially mismatched (`AWS_ACCESS_KEY`/`AWS_SECRET_KEY` vs. the AWS-SDK-required `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`) — developer renamed the secrets directly in Bitwarden; re-verified present and correctly named. Credentials confirmed reaching the actual Node process (`/proc/<pid>/environ`, presence-only check). IAM least-privilege scope left as an open item for the developer to confirm from the AWS console side — not a blocker for this phase's code. |

---

## Validation Sign-Off

- [x] All tasks have an `<automated>` verify or an explicit manual/checkpoint justification
- [x] Sampling continuity: no 3 consecutive tasks without an automated verify
- [x] Wave 0 covers all MISSING references — every test file is created by the task that needs it
- [x] No watch-mode flags in any command
- [x] Feedback latency < 15s (all unit commands target a single file; the full suite is ~10s)
- [x] Every grep gate that counts occurrences targets source constructs, not comment prose
- [x] `nyquist_compliant: true` set in frontmatter

**Approval:** ready for execution
