05
Product
18
Backend
10
Auth
12
iOS
08
Infra
02
Real-Time

Extract apply_and_rebuild for cross-transport modification parity

ADR-0065 ACCEPTED · 2026-04-11
Extract apply_and_rebuild for cross-transport modification parity

Context

Every mutation that changes a trip follows the same four steps: store a modification in the append-only log (ADR-0033), fetch the full modification chain, rebuild the trip from that chain, and update the denormalized purpose field. The Redis subscription signal (ADR-0041) fires inside store_modification, after the transaction commits.

When we added the MCP server as a second transport alongside GraphQL, the MCP tool handlers duplicated this sequence. Seven GraphQL resolver files and three MCP tool files all contained the same store-fetch-rebuild-update pipeline inlined. The duplication was subtle because each call site used slightly different variable names and orderings, but the logic was identical.

I caught this during code review of PR #211. The Rust agent and its reviewer agent both missed it across multiple passes — the duplication was spread across ten files in different domains, so no single file looked wrong.

Decision

Extract ModificationService::apply_and_rebuild(trip_id, modification, origin_device_id) -> Trip as the single entry point for applying a modification to a trip. It calls store_modification (which handles the transaction and publishes the Redis event), fetches the modification chain, rebuilds the trip, and updates the purpose field.

All GraphQL resolvers and MCP tool handlers call apply_and_rebuild instead of orchestrating the steps themselves. Both transports produce the same database writes, the same event, the same rebuild. You can't store a modification without rebuilding, and you can't forget the event publish.

Consequences

One method owns the entire modify-a-trip path. Adding a third transport (REST, CLI, whatever) means calling the same method. The refactoring also fixed a subtle concurrency bug: some resolvers were returning an in-memory trip computed before the store, rather than rebuilding from the database after commit. Under concurrent modification, the returned trip could be stale.

Net result was -85 lines across the GraphQL resolvers alone. New mutation resolvers no longer need to know the steps — they build a Modification and hand it off.

This slipped past two AI review passes. The agents were good at catching issues within a single file but didn't flag cross-file duplication between domains. Manual code review still catches structural patterns that automated review misses, at least for now.