0021 — Collapse the two parallel dynamo data layers

Context

Today there are two parallel implementations of the same DynamoDB data layer on origin/main:

Path Used by
src/lib/data/dynamo/*.ts Vercel app (@/lib/data./src/lib/* first)
agents/clara/lib/data/dynamo/*.ts Lambda inbound-processor (explicit ../../agents/clara/lib/data)

Of 32 files in src/lib/data/dynamo/, 15 are mirrored in agents/clara/lib/data/dynamo/. The two copies share the same DDB table (propflow-prod), the same access patterns, the same domain types — but they evolve independently. New Phase 3 identity files (persons.ts, occupancies.ts, inquiries.ts, etc.) currently land in src/ only. The agents/clara directory was originally created by the site-architecture refactor (ADR-0017 / PR #600) for the Lambda to bundle a smaller dependency surface, and it has accumulated divergence ever since.

Why now: Sentry issue #7455164928 generated 1,383 events over 4 days because PR #588 fixed the phone-normalization bug in src/ only. The Lambda — which processes every inbound SMS / voice / email / Telegram in production — kept running the un-fixed code at agents/clara/. The Vercel app side was pristine; the Lambda side was corrupting ~40% of new conversations.

This is not a one-off. The same class of "PR patches one of two parallel data layers" failure mode is going to recur every time:

We added two safety nets in the Sentry #7455164928 fix branch:

  1. Behavioral drift guard (src/__tests__/clara-data-drift-guard.test.ts) — runs the same write scenarios against BOTH repository implementations and asserts identical GSI/phone outputs. Pinned for saveTenants and saveConversation today.
  2. Static drift guard (src/__tests__/clara-data-static-drift-guard.test.ts) — for every shared file, asserts the same set of normalization helpers (normalizePhoneE164 today) is used in both copies.

These are bandages. The permanent solve is having one source of truth.

Decision

Collapse agents/clara/lib/data/dynamo/* into a thin re-export of src/lib/data/dynamo/*, eliminating the parallel implementation.

The collapse happens in three phases (each independently shippable):

Phase A — bundle alignment, no behavior change. Rewrite each file in agents/clara/lib/data/dynamo/ as a export * from '../../../../src/lib/data/dynamo/<file>' re-export. Run the Lambda esbuild bundle, and confirm the bundle-size CI gate passes (>15% growth vs the pre-Phase-A baseline fails; 5–15% requires an explanation paragraph in the PR description; <5% is silent — per the 2026-05-17 persona-pass Sub-Q1 decision). The larger src/ directory pulls in a few additional types but Phase 3 identity modules are import type for the Lambda's purposes). Land behavior-identical, then delete the dead exports from agents/clara/lib/data/dynamo/.

Phase B — Phase 3 identity types reach the Lambda. As Phase 3 cutover progresses, the Lambda eventually needs to read TenantOccupancy rows directly. Phase A unblocks this: the Lambda can import from agents/clara/lib/data/dynamo/occupancies (re-export of src) without us first having to mirror the file by hand.

Phase C — delete the directory. Per the 2026-05-17 persona-pass acceptance (sub-Q2): the original "two-week observation window" is superseded by the Phase 5a §3.0 GOLDEN/HARD pattern — CI drift guard (the two clara-data-*-drift-guard.test.ts suites) + 24 hours of normal-traffic canary signal after Phase A merges is sufficient. Once the bundle has been live for 24h with no drift-guard alerts, delete agents/clara/lib/data/dynamo/ entirely. Update the Lambda's relative imports to use @/lib/data directly (the Lambda's tsconfig accepts the alias; esbuild resolves it at bundle time).

The drift-guard tests stay in place for all of Phase A and B; they're deleted with the directory in Phase C.

Consequences

Easier:

Harder:

Follow-up work this implies:

Alternatives considered

1. Keep both directories; rely on the drift-guard tests forever. Rejected. Tests catch known invariants only — phone normalization today; something we haven't named yet tomorrow. The next bug class lives in the unknowns. We've already paid 1,383 Sentry events for one un-named contract; we're not paying that again per contract.

2. Symlink one directory at the other. Rejected. Symlinks survive git but don't survive Vercel build, npm install, or CI tar-extracts. Tried this in 2025 for a different shared-code problem; broke more than it fixed.

3. Generate agents/clara/lib/data/dynamo/ from src/lib/data/dynamo/ at build time. Rejected. Adds a code generator + a "source of truth" ambiguity for editors. Re-exports are simpler and equally safe.

4. Move src/lib/data/dynamo/ into agents/clara/lib/data/dynamo/ instead. Considered. Rejected because src/ has more callers, more tests, more imports — the migration cost is dominated by who has to move. src/ wins on caller count, so we re-export from there.

References