0021 — Collapse the two parallel dynamo data layers
- Status: Accepted
- Date: 2026-05-06 (drafted), 2026-05-17 (accepted)
- Deciders: Jose (= Gera) — author; sane (Sean) and feed (Fede) lenses applied via solo persona pass per
feedback_personas_replace_workshops. - Acceptance note: Status flipped to
Acceptedon 2026-05-17 via the persona-pass decisions memo atdocs/data-model-migration/decisions/2026-05-17-adr-0021-persona-pass.md. Six sub-questions surfaced and resolved: (Q1) hard CI bundle-size gate on Phase A (>15% fail / 5–15% explain / <5% silent); (Q2) replace Phase C's 2-week observation with drift-guard-CI + 24h canary signal per Phase 5a §3.0 GOLDEN/HARD pivot; (Q3) defer messaging/email tree audits to Phase D follow-up; (Q4) retirelambda/lib/esbuild-clara-fallback.ts+ both drift-guard tests in the Phase C PR; (Q5) Better Auth adapters are a sanctioned carve-out and survive Phase C, drift-guarded by the test shipping in the Phase 6a code PR; (Q6) add monorepo-posture rule toCLAUDE.md§"Project Structure" in Phase C's docs slice. Timing: Phase A lands during Phase 6b's shadow-read bake window (passive calendar time on the 6a side).
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:
- A bug fix lands in one directory, the author doesn't notice the other.
- A new field/contract is added to one type, the other drifts.
- A query optimization is implemented in one repo, the other still scans linearly.
We added two safety nets in the Sentry #7455164928 fix branch:
- 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 forsaveTenantsandsaveConversationtoday. - Static drift guard (
src/__tests__/clara-data-static-drift-guard.test.ts) — for every shared file, asserts the same set of normalization helpers (normalizePhoneE164today) 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:
- Bug fixes apply once. No "did I patch the other copy?" review item.
- Phase 3 identity migration ships to the Lambda automatically, in lockstep with src — no per-file mirror PRs.
- The two drift-guard tests retire when the canonical fix lands.
- Code review surface area drops by ~40% for any change touching the data layer (one file to read, not two).
Harder:
- Lambda bundle size grows by whatever Phase 3 identity types add to
the dependency tree. Mitigated by
import typefor types we don't emit; verified at Phase A by comparing bundle sizes before/after. - The Lambda's bundling has to round-trip through the longer relative path. esbuild handles this but adds ~5ms to bundle time (negligible).
- A small set of files in
agents/clara/lib/data/dynamo/carry comments specific to the Lambda's perspective (e.g.identity-dual-write.ts's bundled-Lambda counter discussion). These move with the file tosrc/.
Follow-up work this implies:
- The same parallel-layer problem may exist elsewhere —
agents/clara/lib/messaging,agents/clara/lib/email. Audit. Apply the same collapse if the divergence is unintentional. lambda/lib/esbuild-clara-fallback.ts(the esbuild plugin that resolves agents/clara aliases) becomes simpler or unnecessary.
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
- Sentry #7455164928 — the incident that surfaced this.
- Trello card
wYzAY8WN. - ADR-0017 (route-groups layout) — the original site refactor that forked the data layer.
- ADR-0018 (Person identity model) and
ADR-0019 (Organization model) — the Phase 3 work that lives in
src/lib/data/dynamo/only and accelerates the divergence the longer this duplication persists. - ADR-0020 (Person as the universal human spine) — the broader identity arc this collapse serves.
src/__tests__/clara-data-drift-guard.test.tssrc/__tests__/clara-data-static-drift-guard.test.tsdocs/runbooks/sentry-conversation-tenant-unknown.md