---
phase: 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud
plan: 05
type: execute
wave: 3
depends_on: ["24-02", "24-03"]
files_modified:
  - lib/services/route53-change-submit.ts
  - lib/services/route53-change-submit.test.ts
  - app/api/route53/sync/route.ts
  - app/api/route53/zones/route.ts
  - app/api/route53/zones/[zoneId]/records/route.ts
  - app/api/route53/zones/[zoneId]/records/[recordId]/route.ts
  - app/api/route53/zones/[zoneId]/records/[recordId]/history/route.ts
autonomous: true
requirements: [SC-2, SC-3, SC-4]

must_haves:
  truths:
    - "SC-2: Creating, updating, or deleting a record in Pulse submits a ChangeResourceRecordSetsCommand to AWS Route 53 and the change is accepted by AWS (the response carries a ChangeInfo.Id)"
    - "SC-2/D-03: Update and delete execute immediately on request — there is no staged approval, second confirmation endpoint, or pending-approval state anywhere in the write path"
    - "SC-3: Every write attempt creates a route53_audit_log row with status='pending' before the AWS call, transitioned to 'committed' or 'failed' after, carrying actor email, timestamp, and before/after values"
    - "SC-4: Committed writes append a route53_record_history row tagged source='pulse_crud'; failed writes append an audit row but no history row"
    - "D-01: A request with record type NS or SOA is rejected with HTTP 400 before any AWS command is constructed"
    - "D-04: Every write route is gated by requireAdmin(); every read route is gated by at least requireAuth()"
    - "A DELETE submits the exact current recordset (name, type, TTL, full value set) read from the mirror, because Route 53 rejects or mis-targets a DELETE that does not match exactly"
    - "D-02: No zone create/delete route exists anywhere under app/api/route53/ — this is an intentional, verified omission. app/api/route53/zones/route.ts is GET-only; hosted zones are read-only from Pulse and only records within existing zones are writable. Zone lifecycle stays in the AWS console / infra-as-code."
  artifacts:
    - path: "lib/services/route53-change-submit.ts"
      provides: "ChangeResourceRecordSets construction + bounded GetChange poll, testable without a route"
      exports: ["buildChangeBatch", "submitRecordChange", "pollChangeStatus"]
    - path: "app/api/route53/zones/[zoneId]/records/[recordId]/route.ts"
      provides: "PATCH (update) and DELETE handlers with requireAdmin gating"
      exports: ["PATCH", "DELETE"]
    - path: "app/api/route53/zones/[zoneId]/records/route.ts"
      provides: "GET (list records in zone) and POST (create record)"
      exports: ["GET", "POST"]
    - path: "app/api/route53/zones/[zoneId]/records/[recordId]/history/route.ts"
      provides: "GET record change history (SC-4 queryable ledger)"
      exports: ["GET"]
    - path: "app/api/route53/sync/route.ts"
      provides: "POST manual sync trigger + GET sync status"
      exports: ["GET", "POST"]
  key_links:
    - from: "app/api/route53/zones/[zoneId]/records/[recordId]/route.ts"
      to: "lib/services/route53-record-validation.ts"
      via: "validateRecordWrite() call before any AWS command construction"
      pattern: "validateRecordWrite"
    - from: "app/api/route53/zones/[zoneId]/records/[recordId]/route.ts"
      to: "lib/services/route53-write-persistence.ts"
      via: "createPendingAuditLog before the AWS call, markAuditCommitted/markAuditFailed after"
      pattern: "createPendingAuditLog"
    - from: "app/api/route53/zones/[zoneId]/records/[recordId]/route.ts"
      to: "lib/auth-utils.ts"
      via: "requireAdmin() gate"
      pattern: "requireAdmin"
    - from: "app/api/route53/sync/route.ts"
      to: "lib/services/route53-sync-service.ts"
      via: "getRoute53SyncService().fullSync() fire-and-forget"
      pattern: "getRoute53SyncService"
---

<objective>
Build the `/api/route53/*` surface: read routes for zones, records, and change history; a
manual sync trigger; and the CRUD write routes that propagate creates, updates, and deletes
to AWS Route 53 through the pending → committed/failed audit lifecycle.

Purpose: SC-2 (CRUD propagates to Route 53), SC-3 (every operation logged with actor,
timestamp, before/after), SC-4 (history queryable).
Output: one library module (`route53-change-submit.ts`, so the AWS-command construction is
unit-testable — `vitest.config.ts` only includes `lib/**`) plus five route files.
</objective>

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

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

<interfaces>
<!-- From plans 24-01, 24-02, 24-03. Use directly — no codebase exploration needed. -->

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

lib/services/route53-record-key.ts (plan 24-02):
  buildRecordKey({ zoneId, name, type, setIdentifier }): string   // `${zoneId}:${name}:${type}:${setIdentifier ?? ''}`
  normalizeRecordSet(rs, zoneId): NormalizedRecordSet
  toHistoryPayload(ns): unknown

lib/services/route53-sync-service.ts (plan 24-02):
  getRoute53SyncService(): Route53SyncService
  Route53SyncService#isSyncInProgress(): boolean
  Route53SyncService#fullSync(triggeredBy?): Promise<Route53SyncResult>
  Route53SyncService#incrementalSync(triggeredBy?): Promise<Route53SyncResult>

lib/services/route53-record-validation.ts (plan 24-03):
  WRITABLE_RECORD_TYPES: readonly ['A','AAAA','CNAME','MX','TXT','SRV']
  validateRecordWrite(input): { ok: true; value: ValidatedRecordWrite } | { ok: false; status: 400; reason: string }
  sanitizeAwsError(err: unknown): string

lib/services/route53-write-persistence.ts (plan 24-03):
  createPendingAuditLog(input): Promise<{ id: string }>
  markAuditCommitted(id, awsChangeId, awsChangeStatus, awsResponse): Promise<void>
  markAuditFailed(id, err): Promise<void>
  insertPulseCrudHistory(input): Promise<void>
  upsertMirrorRecord(input): Promise<void>
  softDeleteMirrorRecord(recordKey): Promise<void>
  loadMirrorRecord(recordKey): Promise<MirrorRecordRow | null>

lib/auth-utils.ts:
  requireAuth():  Promise<{ session, error: NextResponse|null }>   // 401 when unauthenticated
  requireAdmin(): Promise<{ session, error: NextResponse|null }>   // 403 unless role is admin|super-admin
  // session.user has { id, email, role }

Postgres (migration 102): route53_zones, route53_records, route53_record_history, route53_audit_log
Pre-existing: sync_history(entity_type='route53', sync_type IN ('full','incremental'), ...)
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Change-batch construction and bounded propagation poll</name>
  <files>lib/services/route53-change-submit.ts, lib/services/route53-change-submit.test.ts</files>
  <read_first>
    - lib/services/route53-record-key.ts (buildRecordKey, normalizeRecordSet — plan 24-02)
    - lib/services/route53-record-validation.ts (ValidatedRecordWrite shape, sanitizeAwsError — plan 24-03)
    - .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md (Code Examples: short-interval GetChange poll; Anti-Patterns: never use waitUntilResourceRecordSetsChanged in a request handler; Pitfall 3: exact-match DELETE; Pitfall 4: PriorRequestNotComplete is retryable; Pitfall 6: do not block on propagation)
  </read_first>
  <behavior>
    - `buildChangeBatch('UPSERT', { name: 'www.example.com.', type: 'A', ttl: 300, resourceRecords: [{ value: '1.2.3.4' }] })` produces `{ Changes: [{ Action: 'UPSERT', ResourceRecordSet: { Name, Type, TTL, ResourceRecords: [{ Value: '1.2.3.4' }] } }] }`
    - `buildChangeBatch('CREATE', ...)` sets `Action: 'CREATE'`; `buildChangeBatch('DELETE', ...)` sets `Action: 'DELETE'`
    - A `setIdentifier` present on the input is emitted as `SetIdentifier` on the `ResourceRecordSet`; absent/null omits the key entirely rather than emitting `undefined`
    - `buildChangeBatch` for a DELETE emits the full `TTL` and complete `ResourceRecords` array from the supplied current state (Route 53 requires an exact match)
    - `buildChangeBatch` throws when given a record type of `NS` or `SOA` — a defence-in-depth backstop independent of `validateRecordWrite`
    - `isRetryableAwsError` returns `true` for an error named `ThrottlingException`, `PriorRequestNotComplete`, or `Throttling`, and `false` for `InvalidChangeBatch`
    - `pollChangeStatus` returns `'INSYNC'` as soon as a supplied client reports `ChangeInfo.Status === 'INSYNC'`
    - `pollChangeStatus` returns `'PENDING'` once its timeout budget elapses without an INSYNC answer, and makes no further calls after returning
  </behavior>
  <action>
Create `lib/services/route53-change-submit.ts` so AWS-command construction and the polling
loop live under `lib/**` where vitest can reach them (route files under `app/api/**` are
outside `vitest.config.ts`'s `include` glob).

Export `buildChangeBatch(action: 'CREATE' | 'UPSERT' | 'DELETE', recordSet: { name: string; type: string; ttl: number; resourceRecords: Array<{ value: string }>; setIdentifier?: string | null }): ChangeBatch`
producing the `@aws-sdk/client-route-53` `ChangeBatch` shape. Omit `SetIdentifier` from the
emitted object when null/undefined rather than setting it to `undefined`. Throw an `Error`
naming the type when `type.toUpperCase()` is `NS` or `SOA` — a second, independent D-01
enforcement point so no future caller can bypass `validateRecordWrite` (T-24-01,
defence in depth).

Export `isRetryableAwsError(err: unknown): boolean` returning true for AWS error `name`
values `ThrottlingException`, `Throttling`, `PriorRequestNotComplete`, and
`ServiceUnavailable`. Per 24-RESEARCH.md Pitfall 4, `PriorRequestNotComplete` is a per-zone
serialization constraint (two writes to the same hosted zone close together), not a hard
failure.

Export `submitRecordChange(input: { zoneId: string; action: 'CREATE'|'UPSERT'|'DELETE'; recordSet: ...; client?: Route53Client }): Promise<{ changeId: string | null; awsResponse: unknown }>`
— constructs `ChangeResourceRecordSetsCommand({ HostedZoneId: zoneId, ChangeBatch: buildChangeBatch(...) })`
and sends it. On an error where `isRetryableAwsError` is true, retry up to 2 additional
times with 750ms then 1500ms backoff; any other error rethrows immediately. Do not add a
general backoff wrapper around every AWS call — the SDK's built-in retry strategy already
handles transport-level retries (24-RESEARCH.md "Don't Hand-Roll").

Export `pollChangeStatus(changeId: string, opts?: { client?: Route53Client; timeoutMs?: number; intervalMs?: number }): Promise<'INSYNC' | 'PENDING'>`
— default `timeoutMs: 15000`, `intervalMs: 2000`. Loop sending `GetChangeCommand({ Id: changeId })`
until `ChangeInfo.Status === 'INSYNC'` or the budget elapses, then return `'PENDING'`.
Swallow per-attempt errors (a transient GetChange failure is not a write failure — the write
was already accepted) and keep polling until the budget elapses.
CRITICAL (24-RESEARCH.md Anti-Patterns): do NOT use the SDK's
`waitUntilResourceRecordSetsChanged` waiter — its default config is a 30-second interval
with 60 attempts, i.e. up to 30 minutes inside an HTTP request handler.

Create `lib/services/route53-change-submit.test.ts` covering every `<behavior>` case. Test
`pollChangeStatus` with a hand-rolled fake client object exposing a `send()` that returns
canned `ChangeInfo` values and counts invocations — no AWS mocking library, no network. Use
a short `timeoutMs`/`intervalMs` (e.g. 50/10) so the timeout case runs in milliseconds.
Import vitest primitives explicitly (`globals: false`).
  </action>
  <verify>
    <automated>npx vitest run lib/services/route53-change-submit.test.ts && npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `npx vitest run lib/services/route53-change-submit.test.ts` passes with at least 8 assertions covering every `<behavior>` bullet, completing in under 5 seconds
    - `grep -c 'waitUntilResourceRecordSetsChanged' lib/services/route53-change-submit.ts` returns 0
    - `buildChangeBatch` throws for `NS` and for `SOA` — asserted in the test file
    - `pollChangeStatus` timeout case is asserted to return `'PENDING'` and to have stopped calling `send()` after returning (invocation counter does not increase afterwards)
    - `npx tsc --noEmit --pretty` exits 0
  </acceptance_criteria>
  <done>Change construction, retry classification, and bounded polling are tested library code with no 30-minute waiter.</done>
</task>

<task type="auto">
  <name>Task 2: Read routes — zones, records, history, and sync status</name>
  <files>app/api/route53/sync/route.ts, app/api/route53/zones/route.ts, app/api/route53/zones/[zoneId]/records/route.ts, app/api/route53/zones/[zoneId]/records/[recordId]/history/route.ts</files>
  <read_first>
    - app/api/pax8/sync/route.ts (full file — fire-and-forget POST + status GET shape to copy; note it has NO auth gate, an existing gap this plan must not replicate)
    - app/api/pax8/companies/route.ts (list-read route shape, snake_case to camelCase transform)
    - app/api/analyzer/itglue/applications/[id]/audit/route.ts (append-only ledger read route shape)
    - lib/auth-utils.ts lines 31-45 and 79-103 (requireAuth / requireAdmin return shape)
    - lib/services/route53-sync-service.ts (getRoute53SyncService, isSyncInProgress — plan 24-02)
    - migrations/102_route53_tables.sql (exact column names for the SELECT statements)
    - middleware.ts (confirm /api/route53/* is NOT added to the public-route list)
  </read_first>
  <action>
Create four read surfaces. Every handler follows CLAUDE.md's route conventions: `try/catch`,
`NextResponse.json({ error, message }, { status })`, manual snake_case→camelCase transform,
no Zod, no `'use server'`.

`app/api/route53/sync/route.ts` — modelled on `app/api/pax8/sync/route.ts` but WITH auth:
- `POST`: `const { session, error } = await requireAdmin(); if (error) return error;` then
  read `{ syncType }` from the body (default `'full'`). If `!isRoute53Configured()` return
  503 with a message naming the missing AWS env vars. If
  `getRoute53SyncService().isSyncInProgress()` return 409 `{ error: 'Sync already in progress' }`.
  Otherwise fire-and-forget `fullSync(session.user.email)` or `incrementalSync(...)`, catching
  in a `.catch(err => console.error('[ROUTE53-SYNC] Background sync error:', sanitizeAwsError(err)))`,
  and return `{ ok: true, message: 'Route 53 sync started' }` immediately.
- `GET`: `requireAuth()` gate. Return `{ inProgress, counts, history }` where `counts` comes
  from a single query selecting `(SELECT COUNT(*) FROM route53_zones WHERE is_deleted = false) AS zones`,
  the equivalent for `route53_records`, and `(SELECT COUNT(*) FROM route53_record_history) AS historyRows`;
  and `history` from
  `SELECT id, sync_type, status, started_at, completed_at, records_added, records_updated, records_deleted, error_message, triggered_by FROM sync_history WHERE entity_type = 'route53' ORDER BY started_at DESC LIMIT 10`.
Do NOT gate either handler on `integration_settings.disabled` — that is the PAX8-only
exception and D-10 explicitly excludes Route 53 from it.

`app/api/route53/zones/route.ts` — `GET` with `requireAuth()`. Return
`SELECT id, name, comment, private_zone, record_count, authoritative_name_servers, synced_at FROM route53_zones WHERE is_deleted = false ORDER BY name`
transformed to the `Route53Zone` camelCase shape from `lib/types/route53.ts`.

`app/api/route53/zones/[zoneId]/records/route.ts` — `GET` with `requireAuth()`. Params are a
Promise in Next 16 (`{ params }: { params: Promise<{ zoneId: string }> }`, awaited). Return
`SELECT record_key, zone_id, name, type, set_identifier, ttl, resource_records, alias_target, synced_at FROM route53_records WHERE zone_id = $1 AND is_deleted = false ORDER BY name, type`
transformed to `Route53Record`. Support optional `?type=` and `?search=` query params applied
as parameterized SQL predicates — never string-interpolated into the SQL. (The `POST` create
handler is added in Task 3 in this same file.)

`app/api/route53/zones/[zoneId]/records/[recordId]/history/route.ts` — `GET` with
`requireAuth()`. `recordId` is the URL-encoded `record_key`; decode it with
`decodeURIComponent`. Return
`SELECT id, zone_id, record_key, record_name, record_type, change_action, before_value, after_value, source, changed_by_user_id, changed_by_email, changed_at FROM route53_record_history WHERE record_key = $1 ORDER BY changed_at DESC LIMIT $2`
with `limit` from `?limit=` clamped to 1..200, default 50. Transformed to
`Route53RecordHistory`. This route is SC-4's "history is queryable, not just current state"
proof.

Confirm `middleware.ts` does not list `/api/route53` among its public routes — these
endpoints must stay behind the session-cookie check, with role enforcement in the handlers.
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty && test $(grep -rl "requireAuth\|requireAdmin" app/api/route53 | wc -l) -eq 4 && ! grep -rq "integration_settings" app/api/route53 && echo PASS</automated>
  </verify>
  <acceptance_criteria>
    - All four read route files exist and every exported handler begins with a `requireAuth()` or `requireAdmin()` call whose `error` is returned early
    - `grep -rc 'integration_settings' app/api/route53/` returns 0 across all files (D-10)
    - `POST /api/route53/sync` uses `requireAdmin()`; `GET /api/route53/sync` uses `requireAuth()`
    - `grep -rn 'route53' middleware.ts` returns no match (routes stay non-public)
    - No SQL string interpolation of user input: `grep -rn '\${' app/api/route53/*/route.ts app/api/route53/**/route.ts` shows no template literal inside a SQL string containing a request-derived value
    - `npx tsc --noEmit --pretty` exits 0
    - `curl -s -o /dev/null -w '%{http_code}' localhost:3100/api/route53/zones` returns 401 when unauthenticated
  </acceptance_criteria>
  <done>Four read surfaces exist, all auth-gated, all parameterized, none gated on the disable toggle.</done>
</task>

<task type="auto">
  <name>Task 3: CRUD write routes — create, update, delete with the audit lifecycle</name>
  <files>app/api/route53/zones/[zoneId]/records/route.ts, app/api/route53/zones/[zoneId]/records/[recordId]/route.ts</files>
  <read_first>
    - app/api/analyzer/itglue/applications/[id]/apply/route.ts (full file — the canonical pending-row-before-external-call pattern, the pre-write guardrail at lines 86-94, and the 502 failure-response convention at lines 188-200)
    - lib/services/route53-write-persistence.ts (plan 24-03 — exact function signatures)
    - lib/services/route53-record-validation.ts (plan 24-03 — validateRecordWrite, sanitizeAwsError)
    - lib/services/route53-change-submit.ts (Task 1 of this plan)
    - lib/services/route53-record-key.ts (buildRecordKey, toHistoryPayload — plan 24-02)
    - app/api/route53/zones/[zoneId]/records/route.ts (the GET handler written in Task 2 — POST goes in this same file)
    - lib/auth-utils.ts lines 79-103 (requireAdmin)
  </read_first>
  <action>
Add `POST` to `app/api/route53/zones/[zoneId]/records/route.ts` and create
`app/api/route53/zones/[zoneId]/records/[recordId]/route.ts` exporting `PATCH` and `DELETE`.
All three follow the identical eight-step sequence — factor the shared body into a local
helper in the `[recordId]` file only if it does not obscure the flow; duplication across two
files is acceptable here.

Sequence for every write handler:
1. `const { session, error } = await requireAdmin(); if (error) return error;` (D-04). Never
   rely on the UI hiding a control (T-24-02).
2. `if (!isRoute53Configured()) return NextResponse.json({ error: 'Route 53 not configured', message: '...' }, { status: 503 });`
3. Await `params`, parse the JSON body with `.catch(() => ({}))`.
4. Call `validateRecordWrite(...)`. On `{ ok: false }` return
   `NextResponse.json({ error: 'Invalid record', message: result.reason }, { status: 400 })`.
   This runs BEFORE any `@aws-sdk/client-route-53` command object is constructed (T-24-01).
   For DELETE, validate the type of the record being deleted the same way — an NS/SOA delete
   is as destructive as an NS/SOA write.
5. Establish `beforeValue`:
   - POST (create): `loadMirrorRecord(recordKey)` must return null; if a record already
     exists return 409 `{ error: 'Record already exists' }`. `beforeValue` is `null`.
   - PATCH (update) / DELETE: `loadMirrorRecord(decodeURIComponent(recordId))`; a null result
     returns 404. For DELETE, the loaded row's exact `name`, `type`, `ttl`, and full
     `resourceRecords` set are what gets submitted to AWS — Route 53 rejects or mis-targets
     a DELETE whose recordset does not match exactly (24-RESEARCH.md Pitfall 3). Never build
     a DELETE from only `{ name, type }` supplied by the client.
6. `const audit = await createPendingAuditLog({ operation, zoneId, recordKey, recordName, recordType, beforeValue, afterValue, performedByUserId: session.user.id, performedByEmail: session.user.email })`.
   This must complete before step 7. Never call AWS without a pending audit row in flight.
7. In a `try`: `const { changeId, awsResponse } = await submitRecordChange({ zoneId, action, recordSet })`
   with `action` = `'CREATE'` for POST, `'UPSERT'` for PATCH, `'DELETE'` for DELETE. Then
   `const propagationStatus = changeId ? await pollChangeStatus(changeId) : 'PENDING';`
   Then `await markAuditCommitted(audit.id, changeId, propagationStatus, awsResponse);`
   Then `await insertPulseCrudHistory({ ..., changeAction: 'create'|'update'|'delete', beforeValue, afterValue, changedByUserId: session.user.id, changedByEmail: session.user.email, auditLogId: audit.id });`
   Then refresh the mirror: `upsertMirrorRecord(...)` for POST/PATCH,
   `softDeleteMirrorRecord(recordKey)` for DELETE.
   Return `NextResponse.json({ auditId: audit.id, status: 'committed', propagationStatus, record })`
   with HTTP 200 (or 201 for POST).
8. In the `catch`: `const message = sanitizeAwsError(err); await markAuditFailed(audit.id, err);`
   then `return NextResponse.json({ auditId: audit.id, status: 'failed', error: 'Route 53 write failed', message }, { status: 502 });`
   Use 502 for AWS-side failures, matching the IT Glue write route's existing convention for
   "upstream integration rejected the write" (24-PATTERNS.md), not 500. Do NOT call
   `insertPulseCrudHistory` on this path — nothing changed on AWS's side (24-RESEARCH.md
   Pattern 3).

D-03 compliance: these handlers execute the mutation on the first request. Do not add a
`confirm` body flag, a two-phase endpoint, a `pending_approval` status, or any gate that
requires a second call. The audit trail is the control, not a pre-write block.

`recordKey` derivation: for POST, compute it with
`buildRecordKey({ zoneId, name: validated.name, type: validated.type, setIdentifier })`. For
PATCH/DELETE it is `decodeURIComponent(recordId)`; verify the decoded key's `zoneId` prefix
matches the `zoneId` path param and return 400 on mismatch (prevents a caller from mutating
a record in a different zone through a mismatched path — T-24-17).

Log with a `[ROUTE53-WRITE]` prefix and `sanitizeAwsError(err)` only. Never
`console.error(err)` with the raw AWS error object.
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty && npm test && test $(grep -rc "requireAdmin" app/api/route53/zones/\[zoneId\]/records/route.ts app/api/route53/zones/\[zoneId\]/records/\[recordId\]/route.ts | awk -F: '{s+=$2} END {print s}') -ge 3 && echo PASS</automated>
  </verify>
  <acceptance_criteria>
    - `app/api/route53/zones/[zoneId]/records/route.ts` exports `GET` and `POST`; `app/api/route53/zones/[zoneId]/records/[recordId]/route.ts` exports `PATCH` and `DELETE`
    - All three write handlers call `requireAdmin()` as their first statement and return `error` early (D-04)
    - In each write handler, the `validateRecordWrite` call appears at a lower line number than any `submitRecordChange` / `ChangeResourceRecordSetsCommand` reference (D-01 enforced before command construction)
    - In each write handler, `createPendingAuditLog` appears at a lower line number than `submitRecordChange` (audit row in flight before the AWS call)
    - `insertPulseCrudHistory` appears only inside a `try` success path, never inside a `catch`: `grep -A20 'catch' <file> | grep -c insertPulseCrudHistory` returns 0
    - Failure responses use status 502 and a `sanitizeAwsError` message: `grep -c 'status: 502' app/api/route53/zones/\[zoneId\]/records/\[recordId\]/route.ts` >= 2
    - `grep -rc 'pending_approval\|requiresConfirmation\|confirmToken' app/api/route53/` returns 0 (D-03 — no staged approval)
    - DELETE builds its recordset from `loadMirrorRecord` output, not from the request body: `grep -B5 -A5 "'DELETE'" app/api/route53/zones/\[zoneId\]/records/\[recordId\]/route.ts` shows the mirror row's ttl/resourceRecords being passed
    - `npm test` full suite exits 0; `npx tsc --noEmit --pretty` exits 0
  </acceptance_criteria>
  <done>Create/update/delete propagate to Route 53 with the audit row created first, history written only on success, failures logged with sanitized messages and returned as 502.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Browser / any HTTP client → `/api/route53/*` | Untrusted request bodies and path params reach code that mutates live DNS |
| Pulse API route → AWS Route 53 | Authenticated mutation of a production DNS zone |
| AWS error → HTTP response body | Upstream error text returned to an authenticated caller |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-24-01 | Tampering | record `type` in POST/PATCH/DELETE bodies | mitigate | `validateRecordWrite` (closed six-type allowlist) runs before any AWS command is constructed and returns 400 for `NS`/`SOA`; `buildChangeBatch` throws on the same types as an independent second gate. Line-order asserted in acceptance criteria. |
| T-24-02 | Elevation of Privilege | a `user`-role session calling a write route directly, bypassing UI gating | mitigate | `requireAdmin()` is the first statement of every write handler (D-04), returning 403. Read routes use `requireAuth()` (401). `middleware.ts` is not modified — `/api/route53/*` stays outside the public-route list. Verified manually per 24-VALIDATION.md's Manual-Only table. |
| T-24-03 | Information Disclosure | AWS error text in the 502 response body and in `route53_audit_log.error_message` | mitigate | Every error path returns `sanitizeAwsError(err)`, which redacts AKIA key ids, `arn:aws:*` strings, and 12-digit account ids and truncates to 500 chars. Raw error objects are never logged. |
| T-24-04 | Repudiation | a DNS mutation with no attributable actor | mitigate | `createPendingAuditLog` runs before the AWS call with `performed_by_user_id` and `performed_by_email` from the Better Auth session; `insertPulseCrudHistory` records the same actor on success. A crashed request leaves a `pending` row as evidence of the attempt. |
| T-24-17 | Tampering | `recordId` path param decoding to a record in a different hosted zone than `zoneId` | mitigate | The decoded `record_key`'s zone prefix is compared against the `zoneId` path param; a mismatch returns 400 before any audit row or AWS call. |
| T-24-18 | Tampering | SQL injection via `?type=` / `?search=` / `recordId` query and path params | mitigate | Every SELECT uses `postgresClient.query(sql, params)` parameter binding; no request-derived value is interpolated into a SQL template literal. Asserted by grep in Task 2 acceptance criteria. |
| T-24-19 | Denial of Service | an HTTP handler blocked for up to 30 minutes on DNS propagation | mitigate | `pollChangeStatus` is bounded at 15s / 2s intervals and returns `propagationStatus: 'PENDING'` on timeout; the SDK's 30s/60-attempt `waitUntilResourceRecordSetsChanged` waiter is explicitly not used (grep-asserted). The next incremental sync reconciles final state. |
| T-24-20 | Denial of Service | concurrent writes to one hosted zone producing `PriorRequestNotComplete` | mitigate | `isRetryableAwsError` classifies `PriorRequestNotComplete` and throttling as retryable with bounded backoff (2 retries); plan 24-07's UI disables the save control while a request for that zone is in flight. |
| T-24-05 | Tampering / Spoofing | semantically malicious record values (dangling CNAME → subdomain takeover, SPF/DKIM TXT tampering) | accept | D-03 locks immediate execution with no pre-write approval gate. No semantic threat analysis is performed on record values. The compensating control is entirely post-hoc: `route53_audit_log` records actor, timestamp, and before/after for every attempt including failures, and `route53_record_history` makes the change queryable. Documented as an intentional acceptance in plan 24-01's `must_haves`. |
</threat_model>

<verification>
- `npx vitest run lib/services/route53-change-submit.test.ts` green
- `npm test` full suite green; `npx tsc --noEmit --pretty` exits 0
- Unauthenticated `curl localhost:3100/api/route53/zones` returns 401
- Manual (per 24-VALIDATION.md): signed in as a `user`-role account,
  `curl -X POST localhost:3100/api/route53/zones/<zone>/records` returns 403; as `admin` it succeeds
- Manual (per 24-VALIDATION.md): a live create/update/delete round-trip against a disposable
  test record produces 3 `route53_audit_log` rows with correct before/after and 3
  `route53_record_history` rows tagged `pulse_crud`
- Manual: `curl -X POST .../records -d '{"name":"x.example.com","type":"NS",...}'` as admin returns 400
</verification>

<success_criteria>
- POST/PATCH/DELETE propagate to Route 53 and return the AWS change id plus a propagation status
- Every write attempt produces exactly one `route53_audit_log` row, transitioned to committed or failed
- Committed writes produce exactly one `route53_record_history` row tagged `pulse_crud`; failed writes produce none
- NS/SOA writes are rejected with 400 before any AWS command is constructed
- All write routes gated by `requireAdmin()`, all read routes by at least `requireAuth()`
- History is queryable via `GET /api/route53/zones/{zoneId}/records/{recordId}/history`
- No staged-approval mechanism anywhere in the write path (D-03)
</success_criteria>

<output>
Create `.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-05-SUMMARY.md` when done.
</output>
