0110 — Continuous anonymized prod→stage mirror (stream replicator)
- Status: Proposed
- Date: 2026-07-23
- Deciders: Gera
Context
Preview testing keeps hitting stale, gappy data. ADR-0097's anonymized prod-subset backfill made stage realistic, but as a manual, windowed batch it drifts the moment it finishes: a call that lands in prod right now has no transcript in preview until someone re-runs six CLIs. The goal raised 2026-07-23: prod and preview should look the same every day — a call comes in, the transcript is visible in both — so anything validated in preview transfers to prod.
The obvious-sounding alternative — dual-writing from the app layer — is not
viable here. There is no universal writer seam: the spine covers ~8
identity-bearing entities, conversation-writer covers conversations
(ADR-0079), and several Lambdas (org-drift-detector, agent-runtime
jobsRepo, concession-watcher) use their own DDB clients. An app-layer hook
would miss writers, add latency/failure coupling to the prod hot path, and
risk double-firing side effects (Temporal, SQS, Twilio, SendGrid, AppFolio
L4). The table stream is the only choke point that sees 100% of writes,
and prod already runs Streams (NEW_AND_OLD_IMAGES) with live consumers
(tour-schedule CDC, conversation-events bridge). Stream consumers fire no
app side effects, so replication at this layer is effect-free and adds zero
latency to prod writes.
PII stance (decided in-session): anonymization stays in-flight and mandatory — the ADR-0097 transform runs per record in the replicator. Raw-copy-now-PII-later was considered and rejected: it reverses the documented SOC2 decision, and mirrored real contacts in stage mean one careless preview test can SMS/email a real tenant. Deterministic pseudonyms with entity IDs preserved keep preview structurally identical to prod.
Decision
Two lanes, both built on the ADR-0097 transform (extracted to
src/lib/anonymize/):
Nightly batch (
scripts/anon-backfill/run-nightly.ts) — unattended orchestration of the existing five stages for the incremental window[latest-manifest-day, today UTC). Runs on the trusted ops box (raw export PII never enters CI); run dir scrubbed in afinally; Slack alert on failure. Bounds staleness at ≤24h on its own and remains the independent verifier (scan-stage-pii) + disaster-recovery reload path after the stream lane ships.Stream replicator (
lambda/stage-replicator/) — an ESM on thepropflow-prodstream mirrors every classifiable change intopropflow-stagewithin seconds:- Deny list =
classify(), the same policy table the batch uses — never ESM filter patterns.skipkinds drop; unknown kinds drop and alarm (UnclassifiedKind— the streaming census). Windowing collapses: a record arriving now is in-window by definition. - In-memory sweep dictionary (Person/Claim/legacy Tenant/Prospect rows): cold-start/TTL (15 min) prod spine scan + same-batch incremental fold. Never persisted — no key material at rest.
- Scrub-then-write (amended 2026-07-24 after the first live hour —
PR #4472): after the dictionary sweep,
scrubResidualContactsrewrites every remaining non-sanctioned contact shape with a fake seeded by the contact VALUE (deterministic, same+1000/@example.testnamespaces) — covering contacts no spine entity can ever declare (the platform's own TFNs, vendor/staff contacts inside AppFolio enrichment, office numbers). The original park-on-violation contract dead-ended exactly those records in the DLQ (~800 parks/15min; retries can never succeed when the contact can never enter the dictionary) and the core kinds never mirrored.verifyRecordContacts(the dictionary-independent "no non-sanctioned contact literal survives" assertion) still runs LAST as the gate; park remains as defense-in-depth only — NACK → bisect/backoff → metadata-only DLQ + first-message alarm, keys only in logs, never row bodies. Trade-off, accepted: during the new-person race a value-seeded fake differs from the person-seeded fake the dictionary would mint (self-heals on the row's next touch; mirror-wins). Residual, documented: the value-seeded pseudonym is an unkeyed 32-bit hash — unreachability is the guarantee, not unlinkability; thread a shared salt through both lanes if that ever changes. - LWW put conditional on
mirroredFromProdAt <= :ts(redrive-safe); provenance stampanonBackfillRunId: 'stream-replicator'. REMOVEs propagate conditional on the stamp — structurally unable to delete stage-native fixture/eval rows — with PII-in-key strings swept first. - Prod safety: the IAM role has stream-read + spine-scan read on
prod and write on stage ONLY (no prod write permission exists); code
belts assert the target table is never
*prod*and the source ARN is the prod table.
- Deny list =
Row-level policy on stage (three provenance populations): synthetic bench fixtures and eval-only objects are untouched (disjoint ID spaces); mirrored rows are read-mostly scenery where the mirror wins — a preview test's mutation of a mirrored row can be overwritten at any moment. Tests needing durable mutations use bench fixtures or self-created entities.
Cutover (no stage wipe): merge → deploy replicator (ESM created DISABLED by the provisioning script) → enable ESM → run the batch pipeline once with a widened window for full history (its wipe stage clears pre-pipeline prod remnants; overlap with the live stream is benign because both paths emit byte-identical deterministic output). Enable the stream BEFORE the bootstrap export so the 24h stream retention covers the gap.
Consequences
- Preview reflects prod within seconds, permanently; testing in preview tests against today's reality, not last month's.
- A new partition kind does not replicate until classified in
src/lib/anonymize/lib.ts— the alarm makes this a conscious, small chore instead of a silent leak (fail-safe inherited from ADR-0097). - Known residual gaps, documented honestly: Temporal workflow pointers reference prod-namespace workflows (viewing mirrored in-flight sagas works; acting on them errors); external media (Twilio/ElevenLabs/S3) points at prod resources; AppFolio has no stage mirror, so L4 actions on mirrored entities no-op; free-text name stragglers remain best-effort (contacts are hard-guaranteed by the residual scrub + the belt verify; park is the defense-in-depth backstop behind both).
prune --all-runsexempts the replicator stamp; wipe already skips any stamped row. Batch and stream lanes are mutually safe.- Blast radius if the replicator misbehaves: stage corruption (recoverable
via wipe+reload) or a stage PII miss (bounded by the per-record
scrub→verify gate, the defense-in-depth park behind it, + the independent
scan-stage-pii). Prod is untouchable by construction. - Cost: < $10/month at current volume (ESM reads free, Lambda in free tier, stage writes ≈ prod write volume, spine rescans cents).
- The nightly batch is a PERMANENT backstop, not a transition aid
(sharpened after the 2026-07-23 #agent-smith thread review): streams
retain only 24h, so a consumer dead past that is a silent gap — and the
nightly's
[latest-manifest-day, today)window heals exactly that gap on its next run, whilescan-stage-piistays the independent leak detector over BOTH lanes. AnIteratorAgealarm (6h threshold, provisioned byprovision.sh) pages long before the retention cliff. - Open follow-up: revisit letting preview writes "claim" a mirrored row only if real test pain appears.
Alternatives considered
- App-layer dual-write at the DDB helpers chokepoint — misses the Lambdas with their own clients, couples prod write latency/failures to stage, double-fires side effects on any replay. Rejected.
- Raw clone now, PII later — reverses ADR-0097's SOC2 stance and makes "preview test reaches a real tenant" possible until "later" arrives. Rejected in favor of in-flight anonymization that costs nothing extra (the transform already exists and is deterministic).
- Scheduled batch only (no stream) — ≤24h staleness fails the "call now, see it in preview now" goal; kept as the bootstrap, verifier, and DR lane rather than the primary.
- Per-branch preview databases — orthogonal to freshness and
operationally heavy (every preview Lambda/ESM/table per branch);
unchanged: all previews share
propflow-stage(ADR-0007).