---
phase: 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud
plan: 03
type: execute
wave: 2
depends_on: ["24-01"]
files_modified:
  - lib/services/route53-record-validation.ts
  - lib/services/route53-record-validation.test.ts
  - lib/services/route53-write-persistence.ts
  - lib/services/route53-write-persistence.test.ts
autonomous: true
requirements: [SC-3]

must_haves:
  truths:
    - "D-01: A server-side validator rejects any write targeting record type NS or SOA, and accepts only A, AAAA, CNAME, MX, TXT, SRV — the rejection happens in library code the routes call before any AWS command is constructed, not in the UI"
    - "SC-3/D-07: An audit row is created with status='pending' BEFORE any AWS call is made, and is transitioned to 'committed' or 'failed' after — no write to Route 53 can occur without an audit row already in flight"
    - "SC-3/D-07: A failed AWS attempt leaves a route53_audit_log row with status='failed', a sanitized error_message, and the attempted before/after values — failures are as auditable as successes"
    - "T-24-03: Error messages persisted and returned are sanitized — capped in length and stripped of AWS account ARNs, request ids, and access key ids before storage or client return"
  artifacts:
    - path: "lib/services/route53-record-validation.ts"
      provides: "D-01 writable-type allowlist + record-shape validation, unit-testable without a request"
      exports: ["WRITABLE_RECORD_TYPES", "validateRecordWrite", "sanitizeAwsError"]
    - path: "lib/services/route53-write-persistence.ts"
      provides: "pending/committed/failed audit lifecycle + pulse_crud history rows + mirror refresh"
      exports: ["createPendingAuditLog", "markAuditCommitted", "markAuditFailed", "insertPulseCrudHistory", "upsertMirrorRecord", "softDeleteMirrorRecord"]
    - path: "lib/services/route53-record-validation.test.ts"
      provides: "allowlist and sanitizer coverage"
  key_links:
    - from: "lib/services/route53-record-validation.ts"
      to: "lib/types/route53.ts"
      via: "Route53WritableType import"
      pattern: "Route53WritableType"
    - from: "lib/services/route53-write-persistence.ts"
      to: "route53_audit_log"
      via: "INSERT ... status='pending' / UPDATE ... status='committed'|'failed'"
      pattern: "route53_audit_log"
    - from: "lib/services/route53-write-persistence.ts"
      to: "route53_record_history"
      via: "INSERT with source='pulse_crud'"
      pattern: "pulse_crud"
---

<objective>
Build the two library modules the CRUD routes in plan 24-05 depend on: a server-side record
validator enforcing D-01's writable-type allowlist, and a write-persistence module
implementing the pending → committed/failed audit lifecycle (the single most important
pattern in this phase, lifted from the IT Glue write-back precedent).

Purpose: SC-3 (every CRUD operation logged with actor, timestamp, before/after — including
failures per D-07) and the enforcement half of D-01. These live in `lib/services/` rather
than in route handlers specifically so they are unit-testable — `vitest.config.ts` only
includes `lib/**/*.test.ts`, so validation logic embedded in `app/api/**` route files cannot
be covered by an automated test.
Output: `lib/services/route53-record-validation.ts` and
`lib/services/route53-write-persistence.ts`, both with test 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-01-SUMMARY.md

<interfaces>
<!-- From plan 24-01. Use directly. -->

lib/types/route53.ts:
  Route53WritableType = 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'SRV'
  Route53AuditStatus  = 'pending' | 'committed' | 'failed'
  Route53HistorySource = 'pulse_crud' | 'sync_detected_drift'
  Route53RecordValue  = { value: string }

Postgres (migration 102):
  route53_audit_log(id UUID PK DEFAULT gen_random_uuid(),
                    operation CHECK IN ('create','update','delete','sync'),
                    zone_id, record_key, record_name, record_type,
                    before_value JSONB, after_value JSONB,
                    performed_by_user_id TEXT FK->"user"(id) ON DELETE SET NULL,
                    performed_by_email, performed_at, completed_at,
                    status CHECK IN ('pending','committed','failed'),
                    aws_change_id, aws_change_status, aws_response JSONB, error_message)
  route53_record_history(id UUID PK, zone_id, record_key, record_name, record_type,
                         change_action CHECK IN ('create','update','delete'),
                         before_value JSONB, after_value JSONB,
                         source CHECK IN ('pulse_crud','sync_detected_drift'),
                         changed_by_user_id, changed_by_email, audit_log_id, changed_at)
  route53_records(record_key PK, zone_id, name, type, set_identifier, ttl,
                  resource_records JSONB, alias_target JSONB, raw_payload JSONB,
                  created_at, updated_at, synced_at, is_deleted, deleted_at)

lib/services/postgres-client.ts default export `postgresClient`:
  .query<T>(sql, params?) -> { rows: T[] }
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Server-side record validation and AWS error sanitizer</name>
  <files>lib/services/route53-record-validation.ts, lib/services/route53-record-validation.test.ts</files>
  <read_first>
    - lib/types/route53.ts (Route53WritableType — created in plan 24-01)
    - app/api/analyzer/itglue/applications/[id]/apply/route.ts lines 86-94 (the credential-field blocklist — the exact "belt over the UI's braces" defensive-check pattern to mirror)
    - .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md (Security Domain: ASVS V5 note, Known Threat Patterns table)
    - .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-CONTEXT.md (D-01)
  </read_first>
  <behavior>
    - `validateRecordWrite({ type: 'NS', ... })` returns `{ ok: false, status: 400, reason: <message naming NS as a delegation record> }`
    - `validateRecordWrite({ type: 'SOA', ... })` returns `{ ok: false, status: 400 }`
    - `validateRecordWrite({ type: 'ns', ... })` (lowercase) is also rejected — the check is case-insensitive
    - Each of `A`, `AAAA`, `CNAME`, `MX`, `TXT`, `SRV` with a well-formed payload returns `{ ok: true }`
    - An unknown type such as `CAA` or `DS` returns `{ ok: false, status: 400 }` — the allowlist is closed, not a blocklist
    - Missing or empty `name` returns `{ ok: false, status: 400 }`
    - `ttl` outside 0..2147483647, or non-integer, returns `{ ok: false, status: 400 }`
    - An empty `resourceRecords` array returns `{ ok: false, status: 400 }` (Route 53 rejects an empty value set)
    - A `resourceRecords` entry with an empty-string value returns `{ ok: false, status: 400 }`
    - `sanitizeAwsError` strips anything matching an AWS access key id pattern (`AKIA` followed by 16 alphanumerics), any `arn:aws:` substring through the following whitespace, and truncates the result to 500 characters
    - `sanitizeAwsError` on a non-Error input returns a string, never throws
  </behavior>
  <action>
Create `lib/services/route53-record-validation.ts`.

Export `WRITABLE_RECORD_TYPES` as a frozen array of exactly the six D-01 types:
`['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SRV']`. `NS` and `SOA` must not appear anywhere in
this array. This is a closed allowlist, deliberately not a blocklist — an unrecognized type
is rejected rather than passed through.

Export `validateRecordWrite(input: { name: unknown; type: unknown; ttl?: unknown; resourceRecords?: unknown }): { ok: true; value: ValidatedRecordWrite } | { ok: false; status: 400; reason: string }`
where `ValidatedRecordWrite` is `{ name: string; type: Route53WritableType; ttl: number; resourceRecords: Route53RecordValue[] }`.
Rules, in order, each returning a distinct `reason` string:
1. `name` must be a non-empty string after trimming; normalize by lowercasing and appending
   a trailing dot if absent (Route 53's canonical form).
2. `type` must be a string; uppercase it, then it must be a member of
   `WRITABLE_RECORD_TYPES`. When the uppercased type is `NS` or `SOA`, use an explicit
   reason naming zone delegation, e.g.
   `'Record type NS is not writable from Pulse — NS and SOA are zone-delegation records (D-01)'`,
   so an operator understands the refusal rather than seeing a generic type error.
3. `ttl` must be an integer between 0 and 2147483647 inclusive; default to 300 when omitted.
4. `resourceRecords` must be a non-empty array whose entries each have a non-empty string
   `value`. Cap the array at 100 entries.
Do not introduce Zod — CLAUDE.md says route handlers do not use it and this module is
plain validation. Do not construct any AWS command here; this module has no AWS or DB
dependency.

Export `sanitizeAwsError(err: unknown): string` (T-24-03). Take
`err instanceof Error ? err.message : String(err)`, then redact with regex replacements:
`/AKIA[0-9A-Z]{16}/g` → `'[redacted-key-id]'`, `/arn:aws:[^\s"']+/g` → `'[redacted-arn]'`,
and `/\b[0-9]{12}\b/g` → `'[redacted-account-id]'` (12-digit AWS account ids). Then truncate
to 500 characters with a trailing ellipsis. This is the only string that may be written to
`route53_audit_log.error_message` or returned in an API response body.

Create `lib/services/route53-record-validation.test.ts` covering every `<behavior>` case.
Import vitest primitives explicitly (`globals: false`).
  </action>
  <verify>
    <automated>npx vitest run lib/services/route53-record-validation.test.ts && npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `npx vitest run lib/services/route53-record-validation.test.ts` passes with at least 11 assertions covering every `<behavior>` bullet
    - `grep -c "'NS'\|'SOA'" lib/services/route53-record-validation.ts` shows NS/SOA appearing only inside the rejection branch and its reason message, never inside `WRITABLE_RECORD_TYPES`
    - The test file asserts rejection for both `'NS'` and `'ns'` (case-insensitivity) and for an unlisted type such as `'CAA'`
    - `sanitizeAwsError` test asserts an input containing `AKIAIOSFODNN7EXAMPLE` and `arn:aws:route53:::hostedzone/Z123` produces a string containing neither substring
    - `grep -c "@aws-sdk\|postgres-client" lib/services/route53-record-validation.ts` returns 0 — no AWS or DB dependency
    - `npx tsc --noEmit --pretty` exits 0
  </acceptance_criteria>
  <done>D-01 allowlist is enforced by tested library code with a closed allowlist; AWS errors have a tested sanitizer.</done>
</task>

<task type="auto">
  <name>Task 2: Audit lifecycle and pulse_crud history persistence</name>
  <files>lib/services/route53-write-persistence.ts, lib/services/route53-write-persistence.test.ts</files>
  <read_first>
    - lib/services/analyzer/asset-audit/persistence.ts lines 321-379 (createPendingWrite / markWriteCommitted / markWriteFailed — the exact three-function shape to mirror)
    - migrations/075_itglue_audit.sql lines 60-88 (itglue_writes precedent)
    - migrations/102_route53_tables.sql (route53_audit_log and route53_record_history columns)
    - lib/services/route53-record-validation.ts (sanitizeAwsError — created in Task 1)
    - lib/services/postgres-client.ts (query signature and parameter binding style)
    - lib/services/route53-record-key.ts (buildRecordKey — created in plan 24-02 Task 1; if plan 24-02 has not landed, import path is still `@/lib/services/route53-record-key`)
  </read_first>
  <action>
Create `lib/services/route53-write-persistence.ts` following
`lib/services/analyzer/asset-audit/persistence.ts`'s three-function shape exactly.

`createPendingAuditLog(input: { operation: 'create'|'update'|'delete'; zoneId: string; recordKey: string; recordName: string; recordType: string; beforeValue: unknown; afterValue: unknown; performedByUserId: string | null; performedByEmail: string | null }): Promise<{ id: string }>`
— `INSERT INTO route53_audit_log (operation, zone_id, record_key, record_name, record_type, before_value, after_value, performed_by_user_id, performed_by_email, status) VALUES ($1,...,$6::jsonb,$7::jsonb,...,'pending') RETURNING id::text AS id`.
This must be callable and complete BEFORE any `ChangeResourceRecordSetsCommand` is
constructed — that discipline is the whole point of the pattern (24-RESEARCH.md Pattern 3:
"never write to the external system without an audit row already in flight").

`markAuditCommitted(id: string, awsChangeId: string | null, awsChangeStatus: string | null, awsResponse: unknown): Promise<void>`
— `UPDATE route53_audit_log SET status = 'committed', completed_at = NOW(), aws_change_id = $2, aws_change_status = $3, aws_response = $4::jsonb WHERE id = $1`.

`markAuditFailed(id: string, err: unknown): Promise<void>`
— `UPDATE route53_audit_log SET status = 'failed', completed_at = NOW(), error_message = $2 WHERE id = $1`, passing `sanitizeAwsError(err)` as `$2` (D-07 + T-24-03). Never pass a raw
error object or `JSON.stringify(err)`.

`insertPulseCrudHistory(input: { zoneId: string; recordKey: string; recordName: string; recordType: string; changeAction: 'create'|'update'|'delete'; beforeValue: unknown; afterValue: unknown; changedByUserId: string | null; changedByEmail: string | null; auditLogId: string }): Promise<void>`
— `INSERT INTO route53_record_history (...) VALUES (..., 'pulse_crud', ...)`. Callers must
invoke this ONLY after `markAuditCommitted` — a failed AWS call changed nothing on AWS's
side, so it gets an audit row but no history row (24-RESEARCH.md Pattern 3, explicit).
Document that rule in a comment above the function.

`upsertMirrorRecord(input: { recordKey, zoneId, name, type, setIdentifier, ttl, resourceRecords, aliasTarget, rawPayload }): Promise<void>`
— best-effort refresh of `route53_records` after a committed write so the admin UI reflects
the change before the next scheduled sync. `INSERT ... ON CONFLICT (record_key) DO UPDATE SET ... synced_at = NOW(), updated_at = NOW(), is_deleted = false, deleted_at = NULL`.

`softDeleteMirrorRecord(recordKey: string): Promise<void>`
— `UPDATE route53_records SET is_deleted = true, deleted_at = NOW(), updated_at = NOW() WHERE record_key = $1`. Never hard-delete: `route53_record_history` references `record_key`
and the ledger is unbounded by design (D-08).

`loadMirrorRecord(recordKey: string): Promise<MirrorRecordRow | null>`
— `SELECT record_key, zone_id, name, type, set_identifier, ttl, resource_records, alias_target FROM route53_records WHERE record_key = $1 AND is_deleted = false`, returning a camelCase
object (manual snake_case→camelCase transform per CLAUDE.md, no ORM). This supplies the
`before_value` and, critically, the exact TTL and value set that a Route 53 `DELETE` action
requires to match (24-RESEARCH.md Pitfall 3 — a DELETE with a mismatched TTL or value set
fails or targets the wrong thing).

Wrap the mirror-refresh helpers so a failure there is logged (`[ROUTE53-WRITE]` prefix,
`sanitizeAwsError`) but does not throw — the AWS write already succeeded and the next
incremental sync reconciles the mirror regardless. The audit/history writes must NOT be
best-effort; let them throw.

Create `lib/services/route53-write-persistence.test.ts` verifying the SQL contract with a
mocked `postgresClient`: use `vi.mock('@/lib/services/postgres-client', ...)` (follow
whichever mocking style `lib/services/pax8-sync-service.test.ts` already uses in this repo)
and assert (a) `createPendingAuditLog` issues an INSERT whose SQL contains `'pending'`,
(b) `markAuditFailed` passes a sanitized string (an input containing `AKIAIOSFODNN7EXAMPLE`
does not appear in the bound parameters), (c) `insertPulseCrudHistory` binds the literal
`'pulse_crud'`, (d) `softDeleteMirrorRecord` issues an UPDATE and never a `DELETE FROM`.
  </action>
  <verify>
    <automated>npx vitest run lib/services/route53-write-persistence.test.ts && npx tsc --noEmit --pretty && npm test</automated>
  </verify>
  <acceptance_criteria>
    - `lib/services/route53-write-persistence.ts` exports `createPendingAuditLog`, `markAuditCommitted`, `markAuditFailed`, `insertPulseCrudHistory`, `upsertMirrorRecord`, `softDeleteMirrorRecord`, `loadMirrorRecord`
    - `npx vitest run lib/services/route53-write-persistence.test.ts` passes with the four assertions listed in the action
    - `grep -c 'DELETE FROM route53_records' lib/services/route53-write-persistence.ts` returns 0 (soft-delete only, D-08)
    - `markAuditFailed` calls `sanitizeAwsError`: `grep -q 'sanitizeAwsError' lib/services/route53-write-persistence.ts`
    - `grep -c "'pulse_crud'" lib/services/route53-write-persistence.ts` >= 1
    - A comment above `insertPulseCrudHistory` states that it must not be called on a failed AWS write
    - `npm test` full suite exits 0
    - `npx tsc --noEmit --pretty` exits 0
  </acceptance_criteria>
  <done>Audit lifecycle and history persistence exist with tested SQL contracts; failures are sanitized; no hard deletes.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| HTTP request body → validation module | Untrusted operator-supplied record payload crosses into logic that will mutate live DNS |
| AWS SDK error → Postgres / HTTP response | Error text may carry account ids, ARNs, or key ids |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-24-01 | Tampering | record `type` field in a write request | mitigate | `validateRecordWrite` enforces a closed allowlist of the six D-01 types in `lib/services/route53-record-validation.ts`, rejecting `NS`/`SOA` (case-insensitively) with status 400. Enforced in library code the routes call before constructing any AWS command — never relying on the UI hiding the option. Unit-tested. |
| T-24-03 | Information Disclosure | `route53_audit_log.error_message` and API error responses | mitigate | `sanitizeAwsError` redacts AKIA-prefixed key ids, `arn:aws:*` strings, and 12-digit account ids, then truncates to 500 chars. It is the only permitted source of `error_message` values, asserted by a unit test. |
| T-24-04 | Repudiation | Pulse-initiated record writes | mitigate | `createPendingAuditLog` runs before any AWS call, capturing actor (`performed_by_user_id`/`performed_by_email`), timestamp, and before/after. A crashed process leaves a `pending` row, which is itself evidence an attempt occurred (SC-3). |
| T-24-07 | Tampering | audit/history rows treated as best-effort | mitigate | Audit and history writes intentionally throw on failure; only mirror-refresh helpers are best-effort. A DB failure must fail the request rather than silently produce an unlogged DNS mutation. |
| T-24-12 | Denial of Service | oversized `resourceRecords` array in a write request | mitigate | Validation caps `resourceRecords` at 100 entries and rejects empty-string values before any AWS call. |
| T-24-05 | Tampering / Spoofing | semantic content of record values (dangling CNAME, SPF/DKIM TXT) | accept | Carried forward from plan 24-01 — D-03 accepts immediate execution with no pre-write approval gate; `route53_audit_log` before/after + actor is the compensating post-hoc control. Shape validation here explicitly does NOT attempt semantic threat detection. |
</threat_model>

<verification>
- `npx vitest run lib/services/route53-record-validation.test.ts lib/services/route53-write-persistence.test.ts` green
- `npm test` full suite green
- `npx tsc --noEmit --pretty` exits 0
- `grep -rn "NS\b" lib/services/route53-record-validation.ts` confirms NS appears only in the rejection path
</verification>

<success_criteria>
- D-01 allowlist enforced by tested library code, closed (unknown types rejected)
- AWS errors sanitized before storage or client return
- pending → committed/failed lifecycle implemented with the audit row created before any AWS call
- `pulse_crud` history rows written only after a committed write
- Mirror updates are soft-delete only
</success_criteria>

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