Skip to content

Intercity Bus — architecture fit ​

Scope of this page: architecture only. Economics, revenue and market sizing are deliberately excluded at the owner's instruction and are assessed separately. Nothing below argues whether the vertical is commercially worth doing.

The question answered: can intercity bus be introduced as another business vertical inside the existing QRSETU architecture without creating unnecessary complexity or future rework?

Model assessed — the owner's: one Setu Card per operator · operator's own buses and seats · multiple agent seats · one admin who is the business owner · a real-time synchronised booking ecosystem across consumer, admin, agents, card and seat inventory. Initial scale 10-20 operators, 100-200 bookings/day, later ~500/day.

Assessed 2026-08-12 against the v2 baseline as applied on Dev. Supersedes the three pages previously published here. Tracker: QRS-601 · QRS-602 · QRS-607 · QRS-608 · QRS-609

THE ANSWER: YES, WITH ONE GENUINELY NEW CONCEPT AND FOUR THIN TABLES

Every actor in the proposed model maps onto existing decisions with no new concepts. Operator, admin, agents, seats, consumer, card, orders, payments, chat — all of it is expressible today or is already-designed Wave 2 work needed for other verticals anyway.

One thing is genuinely new: a seat is a sub-unit of a resource, allocated exclusively, held temporarily. That is not a bespoke bus concept — it is the extension the Resource primitive already needs for salon chairs and coaching batch caps (QRS-602), which means bus validates a planned extension rather than forcing a private one.

⚠ The real dependencies are not bus-specific and are the honest blockers: the RBAC permission store is unbuilt, and notification transport is broken on every channel. Both are shared with every other multi-user vertical.

1 · Actor and service mapping ​

Proposed elementExisting mechanismStatus
Operator as a businessworkspaces🟢 built
One Setu Card per operatorsetu_cards, unique on workspace_id — the platform already enforces exactly one card per business🟢 built
Admin user = the ownerworkspace_members.role_key = 'owner'🟢 built
Agent usersworkspace_members.role_key = 'agent' — ⚠ agent is already one of the five role keys in the schema. The vocabulary anticipated this🟢 built
Multiple agent seatsSeats license users, not workspaces (ADR-0023, QRS-397)🟠 designed
Agent sees operator's inventory⚠ Plain membership. workspace_id = any(my_workspace_ids()). See §2🟢 built
Admin sees all agents' bookingsSame membership scope — all agents are in one workspace🟢 built
Consumer books anonymouslyAnonymous-first is a hard platform requirement; orders.buyer_user_id is nullable by design🟢 built
Consumer registers laterPure INSERT of a membership; consumer history is never on the business entity🟢 built
Bookingorders + order_items🟢 built
Payment, onlinepayments + payment_events🟡 built but inert until payout_accounts exists
Payment, agent counter cashpayments.provider accepts cash and upi_manual (QRS-484)🟢 built
Consumer ↔ operator chatchat + messages, shipped 2026-08-11🟢 built
CRM / passenger recordsParty primitive🔴 Wave 2, unbuilt
Notifications⛔ push unbuilt, email OTP broken (QRS-285)🔴 broken, and a hard prerequisite
AnalyticsTaxonomy exists; setAnalyticsSink is never called, so every track() is a no-op🟡 wiring gap
Role permissions⚠ role_key is a column with no permission store behind it — ADR-0006's roles table does not exist🔴 Wave 2, unbuilt
Boarding / dropping pointslocations — "if it needs its own card and its own P&L it is a workspace; if it is only an address it is a location"🟢 built
Bus as a bookable thingResource, kind = 'vehicle' — already a named kind🔴 Wave 2, unbuilt (QRS-386)
DepartureSchedule row against a resource🔴 Wave 2, unbuilt
A seat⚠ nothing — see §3🔴 the one new concept

Read the status column, not the fit column. Almost nothing here is a fit problem. A large part of it is unbuilt platform work that other verticals need identically, which is a sequencing fact rather than an architecture fact.

2 · The model is simpler than the case the architecture was designed for ​

⚠ Worth stating explicitly, because it is easy to over-engineer this. ADR-0022 org resource sharing exists for the car dealership: twenty agents who each have their own workspace and their own card, selling from one shared stock pool.

The proposed bus model does not need any of that. Agents are members of the operator's workspace, so they see the operator's routes, departures and seats by plain membership. No sharing flags, no oversight subtree, no union resolution. Admin sees every agent's bookings for the same reason.

One scoping question decides whether that stays true:

Agent shapeFits today?
Agent employed by one operator — a counter clerk, a booking desk🟢 Yes, by plain membership. Nothing new
Independent agent selling for five unrelated operators, with their own card🔴 No. Needs cross-organization peer sharing; ADR-0022 shares down a tree, not sideways between unrelated tenants

⚠ The second shape is structurally identical to real-estate co-broking, already an open gap (real estate discovery). So it is not a new problem — it is a second instance of a known one, which under the ≥2-industry rule actually strengthens the case for building peer sharing properly. Recommendation: scope the first release to employed agents only. That keeps the model inside plain membership and defers the harder mechanism to when two verticals demand it.

3 · The one genuinely new concept, and how it stays a platform extension ​

A seat is a sub-unit of a resource, exclusively allocated against a scheduled departure, holdable for a short window. No current Resource case books a sub-unit — a stylist, a room and a chair are each booked whole.

But this is not a bus-only need. QRS-602 already records that Resource needs sub-units for cases inside R1 scope: a salon booking a specific chair, a coaching institute capping seats in a batch, a driving school with two instructors on one vehicle.

So model a seat as a child resource — parent_resource_id = the bus, plus capacity for the counted cases. The bus vertical then consumes a planned platform extension instead of introducing a private one, and the ≥3-industry test is satisfied by the extension rather than by the bus.

What remains bus-specific is one table: the allocation of a seat to a departure.

The allocation mechanics, which are simpler than they look ​

Exclusive claim is a single atomic statement. Postgres row locking gives it directly:

sql
update bus_seat_allocations
   set status = 'held', hold_expires_at = now() + interval '8 minutes', held_by = :actor
 where departure_id = :dep
   and seat_resource_id = :seat
   and (status = 'available'
        or (status = 'held' and hold_expires_at < now()))
returning id;

Zero rows returned means somebody else has it. No advisory locks, no queue, no external lock service.

⚠ Use LAZY hold expiry, never a reaper job. A hold is expired by the predicate above, not by a background sweeper. That is correct even if pg_cron is down, it has no race between reaper and claimant, and it removes an entire moving part. A sweeper may still run to tidy rows for reporting, but correctness must never depend on it.

Idempotency is mandatory, not optional. Platform convention already requires a required idempotencyKey on every mutation, reused across retries — 6 of 11 rows in the legacy reminders table were double-tap duplicates (QRS-210), and a berth is far less forgiving than a reminder.

4 · Real-time synchronisation — two different answers for two different audiences ​

The requirement is a "real-time synchronised booking ecosystem". It should not be one mechanism, because the authenticated and anonymous sides have different exposure profiles.

AudienceMechanismWhy
Operator admin and agents — the live seat chartSupabase Realtime on bus_seat_allocations, filtered by departureSame backend, no new infrastructure. RLS applies to Realtime, and plain membership already scopes it correctly
Consumer choosing a seatPoll the availability RPC during seat selection, then claim atomically⚠ Avoids opening a websocket subscription to anon, which would be a new public exposure class under ADR-0014. The claim statement in §3 is the real concurrency guard, so polling is sufficient

The claim is what prevents a double-sell, not the subscription. Realtime is a convenience for the operator console; correctness lives in the conditional update. Designing it the other way round — trying to keep clients in sync so they never collide — is the version that does not work.

5 · The cache boundary, which must be declared rather than discovered ​

The public Setu Card is SSR and Cloudflare-cached, invalidated by purge-on-write (ADR-0027); a short TTL is explicitly rejected (QRS-381). Seat availability changes on every booking.

Resolution: availability never renders on the cached card.

  • Cached, purge-on-write: operator identity, routes, timings, boarding points, fare ranges.
  • Uncached, Cache-Control: no-store: the booking route — live availability, seat map, hold, confirm.

That is a clean boundary rather than an exception to ADR-0027, and it costs one thing worth naming: a shared card cannot advertise "3 berths left tonight", because that is precisely the render-time temporal value the caching model excludes.

⚠ QRS-569 applies directly: a React Router loader's headers do not reach a document response without a headers export. A booking route that silently inherits the card's Cache-Control would serve a cached seat map, which is the worst available failure. Assert no-store in a test, do not assume it.

6 · End-to-end flow ​

Reading the diagram: amber is the genuinely new part, red is the broken prerequisite, green is already built and running on Dev.

7 · Data model delta ​

Reusing resources and schedules as platform primitives, the bus-specific surface is four tables, three of which are thin.

TableRows at target scaleNote
bus_routes~20 operators × a few routesThin. Origin, destination, operator
bus_route_stopsRoute × stops⚠ A stop references locations — a boarding point is an address, not a business unit
bus_departures20 operators × 2/day × horizonA Schedule instance bound to a vehicle resource
bus_seat_allocations~500-1,500/day generated⚠ The only novel table. Unique on departure_id + seat_resource_id; status, hold_expires_at, order_id, held_by

Naming follows the feature-scoped rule: bus_routes never routes, bus_departures never departures. QRSETU already plans other transport-adjacent and event-shaped surfaces, and routes or seats would collide immediately — the cards/plans/primitives/templates sweep has already had to be done four times.

Volume is comfortable. At 500 bookings/day the allocation table generates a few hundred thousand rows a year. That is small for Postgres. The load characteristic to design for is not volume, it is contention: many clients competing for the same few rows at the same moment on a festival evening, which the conditional update handles and a read-modify-write would not.

8 · Role and permission considerations ​

RoleShould be able to
owner (operator admin)Routes, departures, fares, seat blocks, agent invite and revoke, all bookings, refunds
agentCreate and cancel bookings, view the seat chart, take cash or UPI, view own bookings. ⛔ Not fares, routes or other agents
manager (optional)All bookings, no configuration
consumerOwn booking only

⚠ This is the single largest genuine dependency in the whole assessment, and it is not bus-specific.workspace_members.role_key is a column with five permitted values and no permission store behind it — ADR-0006's roles table does not exist. "Agents may book but not edit fares" is exactly the RBAC distinction ADR-0024 draws between a feature grant and a permission, and nothing enforces it today.

Any vertical with more than one human per workspace needs this. Bus is simply the first proposed vertical where a second role inside one business is load-bearing rather than incidental — recorded as QRS-609.

9 · Booking and cancellation flow considerations ​

  • Hold window. A hold must be short enough that inventory is not starved and long enough for a UPI round trip. 8-10 minutes is the usual shape 🟠. It is a config value, not a constant in code.
  • Release on abandonment is free with lazy expiry — nothing runs, the seat simply becomes claimable.
  • Cancellation is a state change plus a refund decision, and the two must not be conflated. The seat returns to available immediately; the money follows a separate policy. payment_status already carries partly_paid for the advance case.
  • ⚠ Refunds are constrained by settlement direction. QRSETU must never hold customer funds, so money already split to an operator is not simply reversible by the platform. Decide the refund policy before the schema, not after — it determines whether a cancellation writes a reversal, a credit, or nothing.
  • Agent-cancelled versus consumer-cancelled must be distinguishable in the row, or the operator cannot audit their own counter.
  • Offline capture. An agent at a boarding point may have no signal. Client-generated UUIDs plus the required idempotency key make a deferred write safe — the same property Ledger is already advised to adopt from day one.

10 · Architectural risks worth addressing while greenfield ​

#RiskActionCost
1⚠ City and travel slugs are not reserved, and setu_cards.slug is WRITE-ONCE by trigger. Measured: mumbai, delhi, bangalore are reserved; pune, latur, nagpur, solapur, nanded, beed are not, nor are bus, ticket, route, seat, trip, travelsExtend reserved_slugs and write the policy in the migration commentUnder an hour. Genuinely now-or-never (QRS-607)
2Resource has no sub-unitsAdd parent_resource_id + capacity to the design before appointments ship~0.5 day (QRS-602)
3RBAC store unbuilt, so agent-versus-admin is unenforceableSequence ADR-0006's roles with the first multi-user verticalWave 2 (QRS-609)
4Notification transport broken on every channel⚠ Hard prerequisite. A departure change nobody hears about is a stranded passengerQRS-285
5Availability is a new public access class — every current public read is slug-keyedIts own RPC, its own projection, its own pgTAP exposure test. No policy widened to TO authenticatedWith the feature
6A cached booking route would serve a stale seat mapExplicit headers export + a test asserting no-storeSmall (QRS-569 precedent)
7External channel sync. If an operator also sells on an aggregator, QRSETU's chart is not authoritative⚠ Scoping decision, see belowDecide before build

On risk 7, and a position I am revising ​

With economics excluded, the blocked-allocation model becomes the correct architecture rather than a trap. The operator allocates a fixed block of berths to QRSETU; QRSETU is authoritative for that block and can never oversell it. My earlier objection to this shape was commercial — unsold blocked seats the operator cannot resell — and that argument is out of scope here. Architecturally it is clean, it needs no aggregator integration, and it is the right first release.

The alternative, QRSETU as the operator's master inventory across all channels, is a much larger undertaking and should not be attempted first. Nothing in the blocked-allocation design forecloses it — the allocation table is the same either way; only the source of the block changes.

11 · Summary answer ​

Can it be introduced without unnecessary complexity or future rework? Yes, subject to three conditions.

Fits naturally, no changeOperator as workspace · one card per operator · admin and agents by membership · agent already a role key · consumer anonymous-first · orders · payments incl. cash · chat · locations as boarding points
Minor extension, needed anywayResource sub-units (QRS-602) · Schedule with resource_id (QRS-386) · reserved slugs (QRS-607)
Genuinely newOne table — bus_seat_allocations — plus three thin ones. The concurrency mechanism is a conditional UPDATE, not new infrastructure
Real blockers, not bus-specificRBAC permission store · notification transport · payout_accounts before online payment
Scope to keep it simpleEmployed agents only in the first release; independent multi-operator agents need peer sharing, which is the co-broking gap

⚠ The honest framing of the rework question: there is very little bus-specific rework risk, because almost nothing here is bus-specific. The dependencies are the platform's own unbuilt Wave 2 — roles, resources, schedules, notifications — and every one of them is required by verticals already in scope. If those land, bus is a thin layer on top. If they do not, bus cannot be built regardless of how it is designed.