---
phase: 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - 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
autonomous: false
requirements: [SC-3, SC-4, SC-5]
user_setup:
  - service: aws-route53
    why: "Route 53 API access for zone/record sync and CRUD write-back"
    env_vars:
      - name: AWS_ACCESS_KEY_ID
        source: "Bitwarden Secrets Manager project referenced by BWS_PROJECT_ID (injected by docker-entrypoint.sh via `bws run`) — NOT the committed .env file"
      - name: AWS_SECRET_ACCESS_KEY
        source: "Bitwarden Secrets Manager project referenced by BWS_PROJECT_ID"
      - name: AWS_REGION
        source: "Bitwarden Secrets Manager project, or leave unset to default to us-east-1"
    dashboard_config:
      - task: "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)"
        location: "AWS Console -> IAM -> Users/Roles -> Permissions"
      - task: "Confirm the BWS project emits the secrets under the literal key names AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION"
        location: "Bitwarden Secrets Manager -> project referenced by BWS_PROJECT_ID"

must_haves:
  truths:
    - "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."
  artifacts:
    - path: "migrations/102_route53_tables.sql"
      provides: "route53_zones / route53_records / route53_record_history / route53_audit_log + integration_settings seed"
      contains: "CREATE TABLE IF NOT EXISTS route53_audit_log"
    - path: "lib/services/route53-factory.ts"
      provides: "getRoute53Client() + isRoute53Configured() + resetRoute53Client()"
      exports: ["getRoute53Client", "isRoute53Configured", "resetRoute53Client"]
    - path: "lib/types/route53.ts"
      provides: "Route53Zone / Route53Record / Route53RecordHistory / Route53AuditLog / Route53SyncResult types"
    - path: "lib/services/route53-factory.test.ts"
      provides: "isRoute53Configured() branch coverage"
  key_links:
    - from: "lib/services/route53-factory.ts"
      to: "@aws-sdk/client-route-53"
      via: "Route53Client construction with no explicit credentials option"
      pattern: "new Route53Client\\("
    - from: "migrations/102_route53_tables.sql"
      to: "integration_settings"
      via: "seed row for key='route53'"
      pattern: "INSERT INTO integration_settings"
---

<objective>
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/<name>-factory.ts` + `is<Name>Configured()` 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.
</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-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

<interfaces>
<!-- Contracts downstream plans (24-02 through 24-07) will import. Define these exactly. -->

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 ?? ''}`
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Install AWS SDK client and create the Route 53 migration</name>
  <files>package.json, package-lock.json, migrations/102_route53_tables.sql</files>
  <read_first>
    - 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)
  </read_first>
  <action>
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.
  </action>
  <verify>
    <automated>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</automated>
  </verify>
  <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>
  <done>Migration file created with all 4 tables + seed row; AWS SDK installed; no committed migration edited; no AWS secret written to .env.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Add Route 53 shared types and the credential factory</name>
  <files>lib/types/route53.ts, lib/services/route53-factory.ts, lib/services/route53-factory.test.ts, CLAUDE.md</files>
  <read_first>
    - 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)
  </read_first>
  <behavior>
    - `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()`
  </behavior>
  <action>
Create `lib/types/route53.ts` exporting the camelCase interfaces and string-literal unions
listed in this plan's `<interfaces>` 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) |`.
  </action>
  <verify>
    <automated>npx vitest run lib/services/route53-factory.test.ts && npx tsc --noEmit --pretty</automated>
  </verify>
  <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>
  <done>Types and factory exist, factory tests green, type-check clean, no explicit credentials wiring, CLAUDE.md documents the AWS_* exception.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 3: Confirm BWS credential names and outbound DNS egress</name>
  <action>
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.
  </action>
  <what-built>
    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.
  </what-built>
  <how-to-verify>
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.

2. **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.

3. **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.

4. **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.
  </how-to-verify>
  <resume-signal>
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.
  </resume-signal>
</task>

</tasks>

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

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

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

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