Published note
When AI Lets You Hit The Technical Debt Wall Faster
AI agents made JobDone move quickly, but the useful lesson was not speed. The bugs showed where the product truth and architecture were drifting, and that became the signal to stop patching symptoms and design a cleaner Local Replica boundary.
The hard part was not making code appear.
That is the strange thing about building with AI agents. The first wave feels like magic: features that used to take days start arriving in hours. A mobile app gets a working capture flow. Then it gets contacts, locations, recall, feedback reports, teams, invites, approvals, deployment scripts, QA notes, and a database behind it.
Then the bugs start to feel different.
Not “this button is broken” bugs. Those are easy. The worrying ones are the bugs that appear at the boundaries:
- a login flow seems to make local work disappear
- imported contacts vanish after auth or sync
- one screen has fresh data while another screen is stale
- backend rows and local rows use slightly different shapes
- code paths talk about the same real-world thing with different IDs
- fixing one surface risks changing another surface by accident
That was the point where JobDone hit its first technical debt wall.
JobDone is still pre-users, which matters. There is no customer data contract yet. The MVP rules explicitly allow destructive schema rewrites if they make the system cleaner. That does not make a rewrite automatically wise, but it changes the economics. Before users, the cheapest time to tell the truth about the architecture is now.
The tempting move was to keep patching symptoms. Fix login persistence. Patch contact import. Add another sync retry. Add another mapping function between frontend camelCase and backend snake_case. Keep moving.
But the bug pattern was saying something more useful: the architecture was not matching the product truth.
The Review
The review started from the product docs rather than the code.
JobDone already had a clear domain model:
- Captures stay local until Confirmation.
- Confirmed Entries are immutable.
- IndexedDB is the device source of truth.
- Supabase is the cross-device replica.
- Contacts and Locations are first-class structure on Entries.
- Foreground sync is the canonical recovery loop.
The code mostly worked, but it did not make those truths easy enough.
Sync was scattered. Some screens knew too much about persistence. Backend rows and frontend rows did not quite match. The database used snake_case while the app used camelCase. The app carried both local IDs and remote IDs, which was already starting to smell like future bugs. A database-open failure path could delete local IndexedDB data, which is exactly the kind of silent data loss a local-first app must treat as a serious bug.
At that point we talked about a rewrite.
The useful answer was: do not create frontend-v2 and backend-v2 folders and start again from scratch. That looks clean for a day, then doubles the number of things to keep in your head.
Instead, use a strangler move inside the current app. Find the deepest missing interface, build it as a thin vertical slice, then migrate one real collection through it.
For JobDone, that interface is now called Local Replica.
Local Replica
Local Replica is the sync boundary between device IndexedDB and the backend copy used for cross-device continuity.
It has one job: make local-first data safe and boring.
The core idea is deliberately small:
- every local-first row has a stable Client ID created on the device
- the backend may have its own server ID, but that is replica metadata
- sync compares lightweight manifests before transferring payloads
- repeated sync is idempotent
- absence never means delete
- deletes and merges are explicit records
- collection-specific adapters handle real-world mess where needed
That last point matters. A generic sync engine is useful, but a generic table copier would be wrong. Contacts are not just rows. Contacts need merge rules because real people import the same person twice, spell names differently, or get the same phone number from two devices. Locations can also collapse when the actual normalized content is identical. Entries must not collapse just because two entries have the same text; two identical notes at different times can be two real events.
This led to another important decision: Client ID Aliases.
If two Contacts or Locations are discovered to be the same thing, the app should not rewrite every historical reference immediately. It should create a one-way, immutable alias:
oldClientId -> canonicalClientId
Reads, searches, manifests, and Entry structure resolution can resolve aliases at the boundary. That makes automatic duplicate collapse and future manual merge use the same primitive.
The Now-Or-Never Decision
The database naming question looked small, but it was not.
The conventional Postgres answer is snake_case. The app uses camelCase. You can solve that with adapters. Many systems do.
But JobDone had already shown the cost of tiny mapping mismatches. This is a local-first sync-heavy app. If the frontend, API, local database, and backend database all describe the same object with slightly different shapes, that is a permanent bug farm.
So we made the uncomfortable now-or-never decision:
JobDone-owned Postgres identifiers in the jobdone schema will use camelCase.
That means checked-in SQL has to double-quote mixed-case identifiers:
select "displayName"
from jobdone."contacts";
That is less conventional Postgres. It is also one less translation layer across the app’s most fragile boundary. External provider data can still have adapters. JobDone-owned data should not need one.
This is the kind of decision that belongs in an ADR because a future engineer, or a future agent, will be tempted to “fix” it back to snake_case.
Why Not Just Rewrite?
The strongest case for a rewrite was simple:
There are no users yet. The current architecture has already produced data-loss shaped bugs. The database is disposable. AI agents make rebuilding cheap.
The strongest case against a rewrite was stronger:
The product understanding was inside the existing app, docs, bugs, and tests. Throwing that away would lose evidence. A new codebase would feel clean partly because it had not yet met reality.
The better compromise is to rewrite the boundary that is causing the damage.
That means:
- keep the current product surface
- keep the domain docs
- keep the useful tests
- add the missing Local Replica seam
- migrate Contacts first
- prove the shape with property/idempotence tests
- then widen to Locations and Entries
The first slice is intentionally narrow: Contacts through Local Replica. It must prove local-only, remote-only, both-sides-present, duplicate content hash, repeated sync, and no-delete-by-absence.
If that slice is cleaner, the architecture review worked.
If it is not cleaner, the rewrite itch was probably aesthetic rather than evidence-based.
The Lesson
AI-assisted development did not create JobDone’s technical debt by itself.
It made the debt arrive sooner.
That can be useful. Bugs are evidence. They show where the product language is not encoded in the architecture, where boundaries are missing, where local decisions leak across the system, and where the code makes the wrong thing easy.
The danger is treating every bug as an isolated prompt:
fix this
fix this
fix this
The better question is:
what bug pattern is the system trying to show us?
For JobDone, the pattern pointed at local-first sync identity. So the next move is not a grand rewrite. It is a smaller, sharper architectural correction:
build Local Replica, prove it with Contacts, and make the correct thing the easy thing.
The most dangerous thing about AI coding agents may not be bad code.
It may be good-enough code arriving so quickly that you skip the slower work of deciding what the product really is.
Used badly, agents let you outrun your own understanding.
Used well, they give you enough working software, bug reports, tests, and architecture pressure to discover where your understanding is still weak.
That is the real feedback loop.
Evidence Notes
This post is based on public-safe patterns from the JobDone work:
- Local data-loss shaped bugs appeared around login, imported Contacts, and in-progress Captures.
- Sync behaviour was scattered between app startup, API services, IndexedDB helpers, and screen code.
- Frontend/backend data shapes diverged enough to make mapping bugs plausible.
- Local ID and remote ID were both visible in app code, creating identity smell.
- Contacts exposed the real-world merge problem: same person, multiple devices, multiple spellings, multiple import paths.
- The architecture review produced new domain terms: Local Replica, Client ID, and Client ID Alias.
- ADR 0008 captured the now-or-never camelCase Postgres identifier decision.
- Issue #132 became the tracer-bullet implementation slice.
Raw private development logs, device details, diagnostics, email addresses, and database URLs are not part of this public version.