Viahe
Travel together, not just to the same destination.
- In progress
- Personal project
- Solo, AI-assisted
- Challenge
- Ownership
- Key constraint
- Current result
- Mobile
- Backend
- Data
- Platform
- Operations
01
The problem
Conventional navigation solves for one vehicle reaching one destination. It has no concept of a group, so a convoy traveling together is really several independent navigations that happen to share an endpoint, and the moment one car stops, takes a wrong turn, or falls behind, nothing in the system notices or cares.
Viahe treats the group as the thing being navigated. That changes what the software has to know: not just where you are and where you're going, but who else is on this journey, whether they can still be seen, and what it means when one of them goes quiet.
Almost every interesting problem in the project came from that shift. Once a screen shows other people's positions, the screen is making a promise about them, and most of the hard engineering was in making sure that promise stays true, or is visibly withdrawn when it can't be.
02
The system
A monorepo with eight packages, one application and one API, and a dependency rule that a package manager enforces rather than a code review.
Applications
- apps/mobile
- packages/backend
Orchestration, presentation, infrastructure integration
Shared
- contracts
- validation
- database
- auth
- security
- logging
Transport shapes, schemas, persistence, adapters
Domain
- core
Business models, enums, domain types. No dependencies at all.
The mobile app does not declare database, auth, security or logging as dependencies, so those imports cannot resolve. The package manager enforces the boundary, not a code review.
Eight packages, one direction
`core` is the only package with no dependencies; `backend` is the only one that depends on all the others. No cycles.
The mobile app cannot reach the database
It depends on `contracts`, `core` and `validation`, and not on `database`, `auth`, `security` or `logging`. The import doesn't resolve, so the rule is enforced by the package manager rather than by review.
Strict beyond the default
`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes` and `verbatimModuleSyntax` are on across every package: the settings that catch the errors ordinary strict mode lets through.
Every vendor SDK enters through an adapter
Supabase, Drizzle and Pino are each reached through a port the application layer owns, so the application never depends on a vendor type directly.
03
Navigation & Focus Mode
The guidance engine is a set of pure functions. No React, no React Native, no Expo, no clock, no network. It takes a route and a coordinate and answers questions about them.
That constraint is what makes the navigation logic testable at all. Locating a rider on a route, deciding which turn instruction is due, and detecting that someone has left the route are all decisions that depend only on geometry and the session's own memory of what it has already said. Pushing the clock and the network outside that boundary means the hard parts can be tested without a device, a map, or a running server.
Only one piece is stateful: a single journey's guidance memory (the current step, how long the rider has been off-route, which cues have already been spoken, whether they have arrived). Keeping that in exactly one place is what stops the same instruction being announced twice from two different code paths.
Focus Mode
Focus Mode is a product decision expressed as a lifecycle rule.
Guidance runs only when the rider explicitly opened Focus Mode, the app is foregrounded, the member session is active, and the journey has no outcome yet. All four conditions, or nothing happens. It would have been easier to start guidance whenever a journey was active, and that would mean a phone in someone's pocket announcing turns to nobody, on a journey they had already finished.
04
Voice guidance
Voice guidance lives outside the guidance engine, and the dependency runs one way only.
Application: pure, no device, no clock
Infrastructure: device state lives here
Which instruction is due is a navigation question, answerable from geometry. Whether to speak it right now is a device question. Fusing them would put device state inside the pure engine and make the navigation logic untestable, in exchange for nothing.
The engine decides that an announcement is due. It does not know that speech exists. The speech adapter receives a cue (a plain shape the engine's announcement happens to satisfy) and decides whether and how to utter it. Nothing in the speech layer reaches back into the engine.
The reason for the separation is that these are two different kinds of decision. Which instruction is due is a navigation question, answerable from geometry and the route. Whether to speak it right now is a device question: is the app in the foreground, has this already been said, is something else talking. Fusing them would put device state inside the pure engine and make the navigation logic untestable, in exchange for nothing.
It is also deliberately one-directional: the outbound speech adapter is forbidden from growing a microphone. Speech recognition exists in the project, but only for searching places by voice: a different feature, a different adapter, and no path between them.
05
Sessions & device safety
Two rules pointing in opposite directions: one user to one device, and one device to one traveler. Neither implies the other.
Journey state
Untouched. A device switch never ends a journey, creates a second membership, or changes a role. Only the session moves.
Why not read-only and stale
A traveler glancing at a device showing a live-looking journey will assume the convoy can see them. Signing out is blunt, but it cannot be misread.
Transitions: a refusal works here
- Confirm readiness
- Start journey
- Redeem invite code
- Report position
Is the device free?
no → 409 DEVICE_IN_USE
yes → stamp device on membership
The path a refusal cannot reach
A traveler who is already traveling confirms nothing, starts nothing and redeems nothing. The only gate left is the position write, and its refusal is swallowed by the delivery queue, which drops the batch, reports it, and returns.
So the journey read carries the conflict instead. Collection stops, and the driving surfaces refuse to open.
06
Data, caching & freshness
Caching in this system is a question about authority, not about speed.
A journey's canonical route is an origin, a destination and ordered stops. The polyline drawn on the map is generated from those by a routing provider, and it is never stored, because a stored polyline is a second copy of something reproducible, and it goes stale the instant a stop is edited.
Caches are permitted anywhere they help. The route the planning wizard is working on is cached for the duration of the wizard. Static map images lean on the image cache. What no cache is ever allowed to be is the thing the system believes when it disagrees with the canonical record.
The one place durability genuinely matters is the opposite direction: positions waiting to be sent. Those are queued on disk rather than in memory, because a backgrounded app can be killed by the operating system at any moment, and a position that never arrives is a traveler who disappears from the convoy.
07
Engineering decisions
Taken from the project's architecture decision records. The rejected options are included because the rejected options are the part that shows the reasoning.
Context
Every one-at-a-time rule in the system was keyed on the person: one unfinished journey per profile, one authentication session per user. Two accounts taking turns on a single handset satisfied both, and still produced the thing neither rule was written to allow: one GPS stream reporting as two members of a convoy, permanently in the same place.
Options considered
- Do nothing: Rejected. The failure is silent and permanent: the convoy keeps showing a traveler who is in the roster and absent from the map.
- Enforce on the client only: Rejected. The client cannot know about another account's journey (local state is per-account and cleared at sign-out), so it would need a local this-device-is-busy flag, which goes stale the moment the other traveler finishes from a different phone, and then blocks forever.
- A separate device-lock table: Rejected. The binding has exactly the lifetime of the member session, so a table would be a second lifecycle to keep in sync with the first.
- A hardware identifier (Android SSAID, iOS IDFV): Rejected. The two differ in lifetime and in what resets them, so the rule would behave differently per platform, and it borrows an identifier the traveler never agreed to share, for a value that only needs to be stable for one install.
- Bind at readiness, check server-side: Selected. Reuses the lock that already exists, binds at the one moment the product already treats as a claim about a phone, and needs no new table.
Decision
A device carries at most one traveler's committed member session, enforced server-side at four transition points and disclosed on the journey read.
Reasoning
The client is the wrong place to hold this because the fact it would need to check belongs to a different account. A per-device flag stored locally is a guess about global state, and a stale guess blocks a traveler permanently with nothing able to clear it.
The binding was placed at readiness rather than departure because readiness is the only moment in the lifecycle where a traveler names the handset they are actually leaving on. Binding at departure would have been too late for everyone but the leader, since a member becomes active through the leader's start request, which carries the leader's device and says nothing about theirs.
Cost accepted
Enforcement lands at four points rather than one, and the identifier is client-supplied and therefore forgeable: clearing app data mints a new device. Stated plainly in the record: this is an integrity rule, not a security control.
Context
The rule shipped with four enforcement checks, all working. Then a traveler reached the live convoy screen on a phone bound to somebody else's journey, with no refusal of any kind. It was found on a physical device, and not by any test.
Options considered
- Add a fifth transition check: Doesn't apply. The traveler made no transition: they were already traveling, so there was no request to refuse.
- Show a warning strip above the map: Tried, then rejected as wrong in the same direction as the bug it was fixing.
- Refuse to open the driving surfaces at all: Selected. The journey stays readable; Convoy Mode and Focus Mode do not render, and a dialog is raised over the Overview.
Decision
The journey read carries a flag the client cannot ignore: location collection stops and the driving surfaces refuse to open, rather than opening with a warning attached.
Reasoning
The four checks were not broken. The subject had simply passed every gate already: they did not confirm readiness, start, or redeem anything, because they were already traveling. The only gate left on their path was the position write, and its refusal is swallowed by the delivery queue: a refused batch is dropped, reported, and returned from. The convoy screen went on looking live while the server discarded everything the phone sent.
The first fix was a signal strip above the map. That was wrong, because a strip above a live-looking map reads as one more transient condition (like waiting for GPS), and this one neither is transient nor recovers by waiting. Something a traveler can drive past is not a telling.
A surface that opens is itself a claim that the group can see you, so the surface has to not open.
Cost accepted
The lesson generalizes past this rule: a constraint expressed only as a refusal of transitions cannot reach a subject that has no transition left to make.
Context
The product streams live location while a traveler is in a journey, and position is attributed to a person rather than to a device. The authentication provider's default permits any number of concurrent sessions per user, which meant two devices signed in as the same traveler were both entitled to report position for one person.
Options considered
- Concurrent sessions (the provider default): Rejected. Ambiguous position attribution, duplicate presence, and no way to determine which device speaks for the traveler.
- Concurrent sessions, one designated active device: Rejected. Adds a device concept to authentication, and the non-active device still holds valid credentials, so enforcement has to be repeated at every location-accepting endpoint rather than settled once at the session boundary.
- Leave the old device read-only and visibly stale: Rejected. Fails in the direction that matters.
- One session; signing in revokes the previous one: Selected. Unambiguous, enforced in one place, and comprehensible to the person it happens to.
Decision
A user has exactly one active authentication session. Signing in on a new device revokes the previous device's session server-side; the old client is not trusted to sign itself out.
Reasoning
The tempting middle option (leave the old device showing the journey, clearly marked stale) was rejected because of which way it fails. A traveler glancing at a device showing a live-looking journey will assume the convoy can see them, and they may be relying on that. Ending the session is blunt, but it cannot be misread.
A subtle consequence had to be handled in the client: on detecting revocation it must not attempt a token refresh first. Refreshing preserves the session identifier claim, so the exchange succeeds and returns a token naming the same dead session, so the retry is refused again and the traveler is told nothing.
Cost accepted
A user with a phone and a tablet cannot use both. Accepted deliberately: this is a traveling product, used on the device the traveler carries.
Context
A journey's canonical route is an origin, a destination and ordered stops. A navigation provider turns those into a polyline. Storing that polyline looks obviously convenient: clients could load a route without asking for it again.
Options considered
- Persist the generated polyline: Rejected. Duplicate data, synchronization complexity, and every stored polyline goes stale the instant any canonical route data changes.
- Persist it, regenerate in the background on change: Rejected. This solves a problem introduced by persisting derived data in the first place: background workflows, eventual consistency, retry handling and new failure modes, all to maintain something reproducible.
- Generate on demand: Selected. One source of truth, no synchronization, and the routing provider stays replaceable.
Decision
Navigation geometry is derived data. It may be cached by clients or infrastructure, but it is never part of the authoritative record.
Reasoning
The guiding rule is short: persist business facts, not generated representations. A polyline is not something a user entered. It is calculated from data that already exists, so storing it creates a second copy that can disagree with the first.
This is what makes caching a question about authority rather than speed. A cache is allowed anywhere it helps; what it is never allowed to be is the thing the system believes when the two disagree.
Cost accepted
Extra routing requests and slightly higher route-generation latency, accepted in exchange for data integrity and a replaceable provider.
Context
The system is a monorepo with a mobile app and an API sharing one business domain. Without enforced boundaries, business logic migrates into applications: duplicated models, duplicated validation, duplicated rules, and eventually circular dependencies and infrastructure leaking into the domain.
Options considered
- One application repository: Rejected. Simple to start, but high coupling and duplicated business logic as soon as a second application exists.
- Separate repositories per application: Rejected. The business domain is shared; separate repositories mean duplication, version management overhead, and divergence.
- A monorepo with explicit package boundaries: Selected. Dependencies flow one way, toward the domain.
Decision
Eight packages with a one-way dependency graph. The mobile app depends on contracts, core and validation, and not on the database, auth, security or logging packages.
Reasoning
The boundary is real rather than documentary. The mobile application simply does not declare the database package as a dependency, so the import cannot resolve. The package manager enforces the rule, not a code review.
Worth being precise about the limit of this: the package boundary is machine-enforced, but the layer direction inside the mobile application is enforced by convention, documentation and review. There is no lint rule or CI check policing it. Saying so is more useful than implying a guarantee that isn't there.
08
How AI fits the work
AI accelerates exploration. It doesn't get to decide what's correct. I define the requirements, challenge the assumptions, review what comes back, test the behavior, and approve what enters the project.
- 01Mine
Idea
- 03AI-led
Exploration
- 04Mine
Review
- 07Mine
Verification
- 08Mine
Decision
The constraints are written down before the code is generated
The project carries an engineering standards document whose final section sets what generated code must satisfy: TypeScript-first, no unnecessary `any`, thin controllers, business logic in application services, repository pattern, dependency inversion, structured logging, centralized error handling, and explicitly, avoid premature optimization and over-engineering. Generated code that ignores those is rejected, not adapted to.
The output gets overruled, and the overruling is recorded
The device-conflict rule shipped with four working enforcement checks and still let a traveler reach a live convoy screen they should not have reached. It was found on a physical device, not by a test. The first fix (a warning strip above the map) was then rejected as wrong in the same direction as the bug it was fixing, and replaced with refusing to open the surface at all. That judgment is not something the tooling arrived at.
Documentation drift is exactly the failure mode to watch for
The repository's own agent-facing notes list a real-time voice library in the stack table. Nothing in the project installs or imports it. Voice guidance is platform text-to-speech, and speech recognition is used only for place search. Generated documentation describing an intended architecture rather than the built one is the quiet version of this failure, and it is why the source of truth for a technical claim is `package.json`, not prose.
09
Testing & validation
The parts that are hard to get right are the parts written to be testable.
The guidance engine's purity is what makes its unit tests meaningful: geometry, announcement selection, deviation detection and the Focus Mode lifecycle rule are all covered without a device in the loop. Where a rule could have lived in a React hook, it was deliberately placed in a plain module instead, because a rule that lives in a hook is a rule nothing proves.
Continuous integration runs lint, typecheck, build and tests on every pull request, and the production deployment waits for that check to pass rather than racing it, so a commit that fails tests never reaches the deployed backend.
Stated accurately
Stated accurately rather than favorably: end-to-end tests exist and are driven by Maestro, but they run locally and are not part of continuous integration. Three of the eight packages have no test script at all. And the bug that produced the project's most interesting design decision was found on a physical device, by hand, not by any of it.
10
Current state
Viahe is in progress. What that means, precisely:
Working
Partial
Deferred
11
What I learned
- 01
A rule that only refuses transitions can't reach someone who has none left
Four enforcement checks, all working, and a traveler still reached a screen they shouldn't have, because they had already passed every gate and had nothing left to request. Constraints need to cover states, not only the moves between them.
- 02
A screen that opens is making a claim
Showing a live-looking map is a promise that the group can see you. If that promise isn't true, the answer isn't a warning on the map: it's not opening the map.
- 03
Which way a design fails matters more than whether it fails
Several options here were rejected not because they broke more often, but because they broke in the direction where someone is misled into relying on something that isn't working.
- 04
Write the decision down while the reasoning is still available
The alternatives that were considered and rejected are the part that disappears fastest. A record written afterward remembers the choice and forgets the argument.