Pooling riders into shared trips under constraints (pickup within 1.5 km, destination within 3 km, vehicle capacity) while several actors change state at once, with payments and an auditable history.
Home / Work / Ride-pooling lifecycle with guarded transitions
Ride-pooling lifecycle with guarded transitions
A full-stack ride-pooling MVP for a fixed Tesla fleet in Dhaka, with a guarded request lifecycle, shared pools formed by route compatibility and an audit event for every transition.
Solo project, backend. TypeScript, Next.js, Express, PostgreSQL.
- My roleSole contributor in the commit history. All 125 commits and 15 merged pull requests, with iterative work on the core ride service (26 September to 1 October 2026).
- EvidenceChecked by me 71 written test cases exist in the repository. They were counted, not run for this page.
Playable system flow
Watch an accept meet the capacity guard
Pick a scenario and press Play. The sequential steps are recorded from the project test run. The concurrent case is an observation under a database mock.
The controls need JavaScript. The whole flow is written out below, step by step.
The flow in text
Request B fits A sequential accept, recorded.
Request: Request B asks for 2 seats Recorded project run
- What came in
- A passenger request for 2 seats, with the vehicle at 1 of 3 committed.
- What acted
- The passenger creates the request; the server computes an integer-paisa fare.
- What it decided
- Nothing yet.
- What changed
- B exists with status REQUESTED.
- What happens next
- A driver accepts it.
Recorded in the project's own test run: request B, 2 seats.
Pool matching: Pool matching: a new pool Recorded project run
- What came in
- The accepted request and the vehicle's current pools.
- What acted
- Pool matching.
- What it decided
- Join an existing pool when the routes are compatible, or found a new one.
- What changed
- In the recorded run, B ends up in Pool 2.
- What happens next
- The status moves through the transition table.
Recorded in the project's own test run: request B's pool.
State transition: The conditional accept Modelled from the code
- What came in
- A driver's accept.
- What acted
- The transition table and a conditional (compare-and-set) update.
- What it decided
- Move B from REQUESTED to MATCHED only if it is still REQUESTED.
- What changed
- The status can change, if the guard allows it.
- What happens next
- The capacity guard sums the vehicle's seats.
Described from the implementation: every status change goes through one transition table.
Capacity check: The guard counts: 1 + 2 = 3 Runs in browser
- What came in
- 1 seats already committed across the vehicle's active pools, and 2 requested.
- What acted
- The capacity guard, inside the accept transaction.
- What it decided
- 3 fits within 3, so the accept may proceed.
- What changed
- Nothing is blocked.
- What happens next
- The accept is written.
The guard's arithmetic, run in your browser: 1 + 2 ≤ 3.
Accept or refuse: Accepted: HTTP 200 Recorded project run
- What came in
- The guard's verdict.
- What acted
- The accept endpoint.
- What it decided
- Commit the accept.
- What changed
- B is MATCHED. The vehicle is at 3 of 3.
- What happens next
- A status event is recorded.
Recorded in the project's own test run: request B returned HTTP 200.
Status event: Event: SUCCESS Recorded project run
- What came in
- The outcome of the accept.
- What acted
- The status-event writer.
- What it decided
- Every transition leaves an audit event.
- What changed
- A SUCCESS event is committed with the state change.
- What happens next
- End of the trace.
Recorded in the project's own test run: events SUCCESS, SUCCESS.
Request C is refused A sequential accept the guard refuses, recorded.
Request: Request C asks for 2 seats Recorded project run
- What came in
- A passenger request for 2 seats, with the vehicle at 3 of 3 committed.
- What acted
- The passenger creates the request; the server computes an integer-paisa fare.
- What it decided
- Nothing yet.
- What changed
- C exists with status REQUESTED.
- What happens next
- A driver accepts it.
Recorded in the project's own test run: request C, 2 seats.
Pool matching: Pool matching Recorded project run
- What came in
- The accepted request and the vehicle's current pools.
- What acted
- Pool matching.
- What it decided
- Join an existing pool when the routes are compatible, or found a new one.
- What changed
- In the recorded run, C is placed in a pool before the capacity check refuses it.
- What happens next
- The status moves through the transition table.
Recorded in the project's own test run: request C's pool.
State transition: The conditional accept Modelled from the code
- What came in
- A driver's accept.
- What acted
- The transition table and a conditional (compare-and-set) update.
- What it decided
- Move C from REQUESTED to MATCHED only if it is still REQUESTED.
- What changed
- The status can change, if the guard allows it.
- What happens next
- The capacity guard sums the vehicle's seats.
Described from the implementation: every status change goes through one transition table.
Capacity check: The guard counts: 3 + 2 = 5 Runs in browser
- What came in
- 3 seats already committed across the vehicle's active pools, and 2 requested.
- What acted
- The capacity guard, inside the accept transaction.
- What it decided
- 5 is more than 3, so the accept is refused.
- What changed
- The transaction rolls back.
- What happens next
- A conflict is returned.
The guard's arithmetic, run in your browser: 3 + 2 > 3.
Accept or refuse: Refused: HTTP 409 Recorded project run
- What came in
- The guard's verdict.
- What acted
- The accept endpoint.
- What it decided
- Refuse the accept.
- What changed
- The car stays at 3 of 3. The project's message reads: "This vehicle is already carrying too many committed seats across its current trips". It refers to the accept that would exceed capacity.
- What happens next
- A status event is recorded.
Recorded in the project's own test run: request C returned HTTP 409.
Status event: Event: CONFLICT, written after the rollback Recorded project run
- What came in
- The outcome of the accept.
- What acted
- The status-event writer.
- What it decided
- Every transition leaves an audit event.
- What changed
- A CONFLICT event is written after the rollback, so the refusal is audited.
- What happens next
- End of the trace.
Recorded in the project's own test run: events SUCCESS, SUCCESS, CONFLICT.
Two accepts at once The documented limit: both pass the guard.
Request: Two accepts at the same moment Recorded project run
- What came in
- A is already accepted (1 of 3). B and C each ask for 2 seats, and two drivers accept at the same time.
- What acted
- Two accept requests run concurrently.
- What it decided
- Nothing yet.
- What changed
- Two transactions are open at once.
- What happens next
- Each reads the vehicle's pools.
Recorded: an observation under the project's in-memory database mock, consistent with the code's read-then-write structure. PostgreSQL was not run.
Capacity check: Both read the pools, and both see 1 of 3 Recorded project run
- What came in
- Each accept's read of the vehicle's active pools.
- What acted
- The capacity guard, in each transaction, reading before either has written.
- What it decided
- Each computes the committed seats from what it read: 1.
- What changed
- Neither transaction can see the other's pending change.
- What happens next
- Each checks its own total.
Recorded: an observation under the project's in-memory database mock, consistent with the code's read-then-write structure. PostgreSQL was not run.
Capacity check: Both pass: 1 + 2 = 3 each Runs in browser
- What came in
- 1 committed seat and 2 requested, in each transaction.
- What acted
- The guard's arithmetic, once per transaction.
- What it decided
- Each total, 3, fits within 3. Both pass.
- What changed
- Both accepts are cleared to write.
- What happens next
- Both write.
The guard's arithmetic, run in your browser: 1 + 2 ≤ 3, for each transaction separately.
Accept or refuse: Both write: HTTP 200 twice Recorded project run
- What came in
- Two cleared accepts.
- What acted
- The accept endpoint, twice.
- What it decided
- Each commits.
- What changed
- B and C are both MATCHED.
- What happens next
- The vehicle is over capacity.
Recorded: an observation under the project's in-memory database mock, consistent with the code's read-then-write structure. PostgreSQL was not run.
Status event: 5 of 3: the boundary is crossed Recorded project run
- What came in
- The committed seats after both writes.
- What acted
- Nothing: no component notices.
- What it decided
- None. The guard ran twice and passed twice.
- What changed
- Five seats are committed on a three-seat vehicle, and both responses said 200.
- What happens next
- What would prevent this?
- Failure mode
- The guard reads, sums, then writes. Two transactions can read before either writes, so each check passes against a stale total.
- Caveat
- Observed under the mock's interleaving. Real PostgreSQL behavior was not run, so the exact interleaving there is unverified.
Recorded: an observation under the project's in-memory database mock, consistent with the code's read-then-write structure. PostgreSQL was not run.
A stronger version: A design note, not implemented: what a stronger version would need Modelled from the code
- What came in
- The failure above.
- What acted
- A design change that is not in the project.
- What it decided
- Make check-and-claim one indivisible step: serialize accepts per vehicle (lock the vehicle row or use a serializable transaction), or claim seats with a single conditional update on a committed-seat counter.
- What changed
- The second accept would see the first one's seats, or wait, and then be refused with a 409.
- What happens next
- Then test it against a real PostgreSQL with true concurrency, not a mock.
A design note, not an implementation: nothing here is in the repository and nothing here was run.
Problem
What I built
A Next.js front end, an Express API and a PostgreSQL schema accessed through Prisma. A ride request moves from REQUESTED to MATCHED, DRIVER_ARRIVED, STARTED and COMPLETED, and can be cancelled before it starts. Drivers form shared pools when a request is route-compatible, and every transition leaves a status event. Completion settles a simulated wallet or cash payment.
My contribution
- Role
- Sole contributor in the commit history.
- Personal
- All 125 commits and 15 merged pull requests, with iterative work on the core ride service (26 September to 1 October 2026).Basis: commit history.
- Team
- None.
- Upstream
- Framework scaffolding and standard libraries.
- Development process
- AI-assisted development: Claude Code was used for scaffolding, backend and frontend implementation, tests and git workflow, and a design plugin guided the visual direction. I made the architecture and design decisions, reviewed and corrected the output, and checked behavior by running the code. The repository keeps a log of what was accepted, rejected and fixed.
- Provenance
- Built in an assessment context; the assessment brief is not in the repository. The repository documents the AI tools it used in docs/ai-usage-notes.md.
Architecture
- A passenger creates a request, and the server computes an integer-paisa fare.
- A driver accepts it. A conditional update succeeds only if the request is still REQUESTED.
- The service joins an existing pool when routes are compatible, or founds a new pool.
- Inside one transaction it sums the seats already committed across the vehicle’s active pools and refuses the accept if the total would exceed capacity.
- Status transitions, including a conflict outcome, are written as audit events.
Request
A passenger creates a request with a seat count, and the server computes an integer-paisa fare.
- Decision
- Integer paisa for money.
Transition table
Every status change goes through one table: REQUESTED, MATCHED, DRIVER_ARRIVED, STARTED, COMPLETED, with cancellation allowed before the ride starts.
- Why it exists
- So no route can skip a state.
- Decision
- One transition table for every status change.
- Evidence
- Verified 71 written test cases exist in the repository. They were counted, not run for this page.
- Source
- apps/api/src/common/rideLifecycle.ts
Conditional accept
A driver accepts a request. A conditional update succeeds only if the request is still REQUESTED.
- Decision
- Conditional (compare-and-set) updates for single-row races.
- Limitation
- It covers a single row, not the vehicle's total across pools.
Pool matching
The service joins an existing pool when routes are compatible, or founds a new pool.
- Why it exists
- Pools form by route compatibility, within pickup and destination distance limits.
Capacity guard
Adds up the seats committed across every active pool of the vehicle (open or locked) and compares that total plus the new request with the vehicle's capacity.
- Input
- Requested seats (1 to 6) and the vehicle's active pools.
- Output
- Accept, or HTTP 409 and a conflict status event.
- Why it exists
- A per-pool check passed while the vehicle as a whole was over capacity, so the guard was moved to the vehicle level.
- Decision
- Guard the vehicle's total, not each pool.
- Control
- The accept transaction refuses when the total plus the request exceeds capacity.
- Failure mode
- Two accepts at the same moment can both read a total under capacity before either writes.
- Limitation
- Concurrent accepts across pools are not covered by this guard.
- Evidence
- Verified Recorded before and after the guard commit: 5 of 3 seats without it, 3 of 3 with it. In-memory database mock, not PostgreSQL.
- Evidence
- Partially verified Two accepts at the same moment both passed the guard under the mock, reaching 5 of 3. PostgreSQL behavior was not run.
- Source
- apps/api/src/modules/rides/rides.service.ts, accept transaction
Status events
Every transition leaves a status event, and a conflict outcome is written as an event after the rollback.
- Decision
- Success events committed with state changes, and conflict events written after a rollback.
Engineering decisions
- One transition table for every status change, so no route can skip a state.
- Conditional (compare-and-set) updates for single-row races.
- Identity from the verified token, with ownership checks.
- Success events committed with state changes, and conflict events written after a rollback.
- An aggregate guard across pools, added after an overbooking was reported, because guarding one pool does not protect the vehicle.
- Integer paisa for money, and one vehicle per driver by a unique constraint.
Evaluation
Seven backend test files define 71 test cases (52 for rides). There are no frontend, end-to-end or database-backed tests, and no executed results are retained. The recorded behavior shown in the trace below was produced by running the project’s own tests at two commits.
Results
VerifiedA lifecycle transition table and 71 test cases across seven backend test files are defined in the repository.Read the source and counted the test cases; passing is not claimed.
VerifiedThe third accept gives different results before and after the capacity guard commit. Without the guard it returns HTTP 200 and the vehicle holds 5 of 3 seats; with it, HTTP 409 and 3 of 3 seats.Ran the project's own test harness on both commits and recorded the states. Uses the project's in-memory database mock, not PostgreSQL.
Partially verifiedTwo accepts made at the same moment can both pass the capacity guard.Ran two simultaneous accepts under the harness. Observed under the mock's interleaving and consistent with the code's read-then-write structure; real PostgreSQL behavior was not run.
VerifiedCompleting one rider marks the whole pool completed, and cancellation does not release seats.Read the source.
VerifiedEvery data-touching test mocks the database; the largest suite implements an in-memory database with artificial interleaving.Read the test setup.
Not claimedThe README states that race conditions were checked against a live database and that tests pass. No results are retained, so neither is claimed here.Looked for retained results and found none.
Limitations
- The project is a local-only MVP with fixed seeded zones, straight-line distances and one vehicle per driver.
- The capacity guard reads, sums, then writes, and concurrent behavior against a real database was not run.
- Completing one rider completes the whole pool, and cancellation never releases seats.
- Tests mock the database, there are no end-to-end or database-backed tests, and there is no CI or deployment.
Not claimed Production readiness, scale and performance are not claimed for this project.
Engineering Trace
A vehicle has 3 seats. Three requests of 1, 2 and 2 seats are accepted one after another, each into its own pool. What stops the vehicle from carrying 5?
Recorded project run
Partially verifiedStates were recorded by running the project's own jest tests and service code at the commit before the capacity guard and at the commit that added it; this site did not generate or simulate the states. The first state condenses the creation of the three requests into one step. Database: In-memory mock (not PostgreSQL).
Also known: Two accepts at the same momentPartially verified
The guard reads, sums, then writes. In the project's test harness, accepting B and C at the same moment (A already accepted) let both through: 5 of 3 seats, both HTTP 200.
An observation under the mock's interleaving, consistent with the code's read-then-write structure. Real PostgreSQL behavior was not run.
The trace in text
- Capability. The service accepts ride requests into shared pools and is designed not to commit more seats to one vehicle than the vehicle has.
- Expected behavior. Requests A (1 seat) and B (2 seats) fill the 3-seat vehicle. Each pool is individually under capacity, and a third request of 2 seats must not be admitted.
- Trigger. Request C (2 seats, a pickup too far from A and B to share a pool) is accepted after A and B.
- Recorded states.
- The vehicle has 3 seats. Requests A (1 seat), B (2 seats) and C (2 seats) are waiting. Their pickups are 4.5 km or more apart, so none can share a pool. Recorded: events none yet; requests A REQUESTED, B REQUESTED, C REQUESTED.
- Request A is accepted. A founds its own pool. Committed seats: 1 of 3. Recorded: HTTP 200; events SUCCESS; requests A MATCHED in Pool 1, B REQUESTED, C REQUESTED.
- Request B is accepted. B also founds its own pool. Committed seats: 3 of 3. Each pool is individually under capacity. Recorded: HTTP 200; events SUCCESS, SUCCESS; requests A MATCHED in Pool 1, B MATCHED in Pool 2, C REQUESTED.
With the guard
- Request C meets the guard. The accept transaction sums the vehicle's active pools (3) and finds that 3 + 2 is more than 3. HTTP 409. C stays REQUESTED and no pool is created. Recorded: HTTP 409 (This vehicle is already carrying too many committed seats across its current trips); events SUCCESS, SUCCESS, CONFLICT; requests A MATCHED in Pool 1, B MATCHED in Pool 2, C REQUESTED.
Without the guard
- Request C is accepted. C founds a third pool. Committed seats: 5 of 3, more than the vehicle has. Recorded at the commit before the guard: HTTP 200. Recorded: HTTP 200; events SUCCESS, SUCCESS, SUCCESS; requests A MATCHED in Pool 1, B MATCHED in Pool 2, C MATCHED in Pool 3.
- Control. The accept transaction sums the seats already committed across all of the vehicle's active pools before it commits, and refuses the accept when the total would exceed capacity.
- Measurement. Committed seats on the vehicle after the third accept, the HTTP status returned, and the audit events written.
- Outcome. With the guard: HTTP 409, C stays REQUESTED, a conflict event is recorded and the vehicle holds 3 of 3 seats. Without it: HTTP 200 and the vehicle holds 5 of 3 seats.
- Limitation. The guard reads the pools, sums the seats, then writes. Two accepts at the same moment can both pass the check; this was observed under the test harness and has not been run against a real database.
Evidence
- Verified The third accept gives different results before and after the guard commit: HTTP 200 and 5 of 3 seats before, HTTP 409 and 3 of 3 seats after. Recorded by running the project's own test harness on the project's own service code at both commits, with the project's in-memory database mock.
- Partially verified Two accepts made at the same moment can both pass the guard. Observed under the mock's interleaving (5 of 3 seats, both HTTP 200). Real PostgreSQL transaction behavior was not run.
Commits: 3af09ba (before the guard), 54de79d (adds the guard). Test: "caps total committed seats across ALL of a Tesla's active pools, not just the one pool being joined or founded".
Takeaways
Guarding one row does not guard an invariant that spans rows. The aggregate guard fixed the sequential overbooking and also showed what remains: a check that reads and then writes still needs a stronger mechanism under concurrency.
Tests that mock the database validate logic under chosen interleavings, not database isolation.
Repository
- Repository
- No hosted demo.