Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions docs/decisions/016-frontend-live-update-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# ADR-016: Frontend Live-Update Transport

**Status:** Accepted
**Date:** 2026-06-16

## Context

CritterCab's three audience-facing frontend surfaces — the rider app, the driver app, and the ops dashboard — require real-time push updates from the backend:

- The driver app receives offer deliveries and trip-state changes as they occur.
- The rider app watches trip progress during an active ride.
- The ops dashboard renders a live map of active drivers and trips.

None of these surfaces are satisfied by request/response polling: the latency is too high for offer delivery, and polling a live map at the required refresh rate is wasteful. A server-push transport is required.

The choice of browser-side push transport is governed by a different set of constraints than the service-to-service choices in ADR-005. ADR-005 covers gRPC (service-to-service calls and streaming), Kafka (high-volume telemetry), and Azure Service Bus (business events). Browser-to-backend push is a distinct fourth category: a client that cannot run a native gRPC stack, connecting over HTTP/HTTPS to a server that must push updates as they occur. ADR-005's "transport split by flow type" principle applies — the question is which transport fits the browser-client push shape — but the options differ.

The [sibling-repo frontend survey](../research/frontend-survey-sibling-repos.md) identified a reusable architectural pattern from CritterBids: a transport-agnostic push→Query-cache-bridge where the component contract (`useListen`, `useConnectionState`, `applyMessage`) is decoupled from the underlying transport. The transport is plugged in at the `createConnection` seam; components do not know whether the bytes arrived over SignalR or any other mechanism. This architecture is the primary transferable asset from the survey — the transport slot is a separate decision.

The survey also named a tension: CritterBids uses SignalR (proven), while CritterCab's primary goal of showcasing Wolverine's gRPC feature set (vision §Goals Primary #1) raised gRPC-web streaming as the on-thesis alternative. Both were evaluated.

## Options Considered

### Option A — SignalR (`@microsoft/signalr` v10)

SignalR provides a managed WebSocket hub abstraction over ASP.NET Core. It handles connection negotiation, reconnect lifecycle, and hub method invocation. For authenticated surfaces, it accepts a bearer token via `accessTokenFactory`, which maps to CritterCab's Entra-issued token acquisition (ADR-006) without additional ceremony.

CritterBids already ships a generic `createSignalRProvider<TMessage>` that returns a `{ Provider, useHub, useListen, useConnectionState }` quad shared across three SPAs. The pattern is proven, actively maintained, and part of the existing muscle memory across multiple repositories.

The cost: SignalR routes the most visible real-time frontend surfaces through a Microsoft-specific WebSocket framework rather than through gRPC. For the browser-client category, this is a deliberate pragmatic departure from the on-thesis path.

### Option B — gRPC-web streaming

gRPC-web is a browser-compatible variant of gRPC using HTTP/1.1 framing or HTTP/2 without server push. ASP.NET Core supports it natively via `UseGrpcWeb()`. Choosing gRPC-web would align the browser transport with the backend gRPC surfaces.

The cost: gRPC-web is niche and experimental in the browser context. Neither sibling repository has shipped it. Ergonomics — tooling maturity, streaming reconnect semantics, React DX with generated TypeScript clients — are unproven in this stack. The on-thesis argument is real but the gRPC thesis is demonstrated convincingly by the backend service mesh; the browser transport does not need to carry the same demonstration burden.

### Option C — Raw WebSockets or Server-Sent Events

Direct WebSocket connections or SSE streams without a framework. Lowest ceremony to establish, but requires hand-rolling connection management, reconnect logic, and message dispatch. The ops live map's fan-out requirements and the driver app's reconnect-after-gap semantics make the missing infrastructure costs real, not theoretical.

## Decision

**Option A.** CritterCab adopts **SignalR** (`@microsoft/signalr` v10) as the browser-client live-update transport for the rider app, driver app, and ops dashboard.

The architectural pattern adopted is the transport-agnostic push→Query-cache-bridge from CritterBids' `createSignalRProvider<TMessage>`:

- The `Provider` is parameterised by behavior — `createConnection`, `parseMessage(payload) → TMessage | null`, and `applyMessage(queryClient, message)` — not by protocol.
- On each inbound message, the bridge writes the result into the TanStack Query cache via `applyMessage`, then fans out to imperative listeners.
- `useConnectionState` surfaces `HubConnectionState` + `lastError` for UI resilience.
- The `onReconnected(connection, queryClient)` seam handles cache invalidation after a reconnect gap — the ride-sharing analogue of state-digest reconciliation when a driver or rider app reconnects after a network drop.

Connection construction follows CritterBids' anonymous/token split: an anonymous connection (auto-reconnect, default negotiation) for any public surfaces; a token connection (`WebSockets` + `skipNegotiation` + `accessTokenFactory` acquiring Entra-issued tokens per ADR-006) for authenticated rider, driver, and ops surfaces.

The gRPC thesis (vision §Goals Primary #1) is demonstrated by the backend service mesh — service-to-service calls, server-streaming offer delivery between services, client-streaming GPS ingest. ADR-005's three-transport commitment governs those flows. SignalR governs the distinct browser-client push category without conflicting with ADR-005's scope.

Consistency with the proven house stack (shared with CritterBids) is an explicit factor: the pattern and its tooling are already exercised across multiple active projects, reducing the marginal integration risk to near zero.

## Consequences

The component contract (`useListen`, `useConnectionState`, `applyMessage`) is transport-agnostic. Components in the rider, driver, and ops SPAs do not couple to SignalR directly; they read from the TanStack Query cache and call the hook surface. The `createConnection` seam is explicitly left open: the transport is swappable at that seam without touching component code.

SignalR hub endpoints live on the ASP.NET Core host of the relevant backend service (or a dedicated BFF layer). They are standard ASP.NET Core SignalR hubs, not Wolverine endpoints. Backend-to-hub fan-out uses Wolverine message handlers or background services publishing to the hub context, keeping the Wolverine handler graph intact on the server side.

The ops dashboard's live map requires higher-frequency driver location updates than the rider or driver apps. SignalR's WebSocket transport handles this at expected ops-dashboard concurrency (tens of simultaneous ops users).

The shared contracts package shape — one `@crittercab/shared` or per-BC packages — is explicitly deferred to the first frontend implementation session. See §Open Questions in the vision doc.

Cross-references: [ADR-005](./005-transport-selection-by-flow-type.md) (transport precedent; browser-client push is the fourth category not covered by ADR-005), [ADR-006](./006-identity-provider-as-swappable-anti-corruption-layer.md) (token acquisition for `accessTokenFactory` on authenticated hub connections).
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ For the template and guidelines on when to write an ADR, see [ADR-001](./001-rec
| [013](./013-shared-cross-bc-identifier.md) | Shared Cross-BC Identifier | Accepted |
| [014](./014-asb-topic-naming-convention.md) | Azure Service Bus Topic Naming Convention | Accepted |
| [015](./015-driver-app-projection-timing-budget.md) | Driver-App Projection Timing Budget | Accepted |
| [016](./016-frontend-live-update-transport.md) | Frontend Live-Update Transport | Accepted |
1 change: 1 addition & 0 deletions docs/retrospectives/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ Subsequent sections are session-specific. Existing retros in this directory serv
- [`skills-tidy-ai-skills-sync.md`](./skills-tidy-ai-skills-sync.md) — **First ai-skills upstream-sync session**; lighter-cadence successor to Phase 5's full reconciliation. Sync target was ai-skills @ `b0d0f7d` (HEAD as of 2026-05-22). Eighteen-commit upstream delta categorized as 4 content commits (HTTP strengthen ×2, projections `IncludeType<T>` clarification, migration-skills first cut) and 14 infrastructure / catalog-growth commits (sidebar reconciliation, Buy CTAs, install URL fixes, version bumps, new `added_in` frontmatter convention, three new upstream skills). Verified zero content propagation to both Cab counterparts (`wolverine-http-handlers/SKILL.md` and `marten-projections/SKILL.md`) via re-greps + end-to-end reads — upstream-changed surface lives in fundamentals layers Cab already cross-references rather than duplicates, so Phase 5's trim discipline carried the sync passively. **Closed out Phase 5 retro's metadata gap** (`Status: In progress` → `Complete`; `Outcome` synthesized from the existing `## Phase 5 deliverable summary`; new `## Document history` section) as natural continuation of the reconciliation thread. Introduces a **methodology shift** captured for future syncs: per-upstream-commit lens (this session) is correct for incremental syncs; per-skill audit (Phase 5) is correct for full reconciliations. Recorded sync floor SHA + upstream catalog inventory (75 skills, 7% growth) so the next sync becomes a `git log b0d0f7d..HEAD` content-delta query. **Second prompt-and-retro pair to exercise the spec-delta null-edge case** (first was `housekeeping-delete-may-15-handoff` on 2026-05-19). Triggered by [`prompts/skills-tidy-ai-skills-sync.md`](../prompts/skills-tidy-ai-skills-sync.md). Status: complete (2026-05-23).
- [`aspire-reconcile-and-port-band.md`](./aspire-reconcile-and-port-band.md) — **First `tidy: aspire` session.** Reconciled Aspire to `13.4.3` across code and docs and pinned CritterCab's `53xx` local-dev port band (cross-project band registry + `+5` per-service slot convention documented in the `aspire`/`adding-a-service` skills, whose AppHost-location contradiction was also fixed). **Surfaced and fixed a latent build break:** the file-based AppHost did not restore under the repo's Central Package Management (NU1008/NU1009); `#:property ManagePackageVersionsCentrally=false` fixes it and corrects the prompt's "entries are inert" premise (a file-based `dotnet run` generates a synthetic csproj at the repo root that inherits `Directory.Packages.props`). Proved empirically that a file-based program honors an adjacent `Properties/launchSettings.json`. Compile-verified via `dotnet build apphost.cs`; full `aspire run` deferred to a manual check. Six follow-ups queued (Wolverine/Marten/Polecat doc-drift, Grpc/Polecat 5.38-vs-6.8, ServiceDefaults, Swagger title, MessagePack NU1903, JasperFx feed 403). Triggered by [`prompts/aspire-reconcile-and-port-band.md`](../prompts/aspire-reconcile-and-port-band.md). Status: complete (2026-06-13).
- [`fix-marten9-projection-source-gen.md`](./fix-marten9-projection-source-gen.md) — **First `fix:` PR.** Declared the three conventional projection subclasses `partial` so Marten 9's source generator emits dispatchers (runtime codegen removed in v9 / JasperFx 2.0, no fallback); fixed pre-existing `main` breakage from the `2f83e66` bump that PR #31's CI surfaced. `dotnet test CritterCab.slnx` 7/8-failing → 8/8-green; analyzer flows transitively (no `.csproj` change); `RideRequest` self-aggregating type left untouched. Confirmed the diagnosis by checking `main`'s CI before assuming the PR caused it. Triggered by [`prompts/fix-marten9-projection-source-gen.md`](../prompts/fix-marten9-projection-source-gen.md). Status: complete (2026-06-13).
- [`frontend-architecture-unpark.md`](./frontend-architecture-unpark.md) — **First frontend design-return.** Unparked the frontend decision parked since vision v0.1, after the [sibling-repo frontend survey](../research/frontend-survey-sibling-repos.md) discharged the "CritterBids lands on a stable live-update pattern" trigger. Five decisions walked: transport recording (Accepted, SignalR — gRPC-web ruled out as niche/experimental before grill reached question 2); stack/monorepo (vision doc only, no ADR); map library (re-filed to open question); gRPC-web spike (moot); contracts-package shape (open question). ADR-016 (Frontend Live-Update Transport) authored and Accepted: SignalR with transport-agnostic push→Query-cache-bridge architecture; component contract decoupled from transport at the `createConnection` seam. Vision bumped to v0.6: frontend stack + audience-SPA monorepo direction adopted; two parked items retired; three new open questions filed (map library, contracts-package shape, monorepo vs. separate repos). Two successor sessions named: map-library spike (after Telemetry geospatial representation is settled); contracts/monorepo session (at first frontend implementation). Null narrative/workshop spec delta — fifth honest-null instance. Triggered by [`prompts/frontend-architecture-unpark.md`](../prompts/frontend-architecture-unpark.md). Status: complete (2026-06-16).

Phase 1–3 retrospectives were not authored at the time those phases ran (the retrospective convention solidified during Phase 4). They may be reconstructed from the working transcripts and skill artifacts if needed; otherwise they remain a known gap in the project record.

Expand Down
70 changes: 70 additions & 0 deletions docs/retrospectives/frontend-architecture-unpark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Retrospective — Frontend Architecture Unpark (Vision v0.6 + ADR-016)

| Field | Value |
|---|---|
| **Triggering prompt** | [`docs/prompts/frontend-architecture-unpark.md`](../prompts/frontend-architecture-unpark.md) |
| **Status** | Complete |
| **Date** | 2026-06-16 |
| **Output artifacts** | [`docs/decisions/016-frontend-live-update-transport.md`](../decisions/016-frontend-live-update-transport.md) (new); [`docs/decisions/README.md`](../decisions/README.md) (ADR-016 row); [`docs/vision/README.md`](../vision/README.md) (v0.6 — stack/monorepo direction, two parked items re-filed, three open questions added, Document History entry); [`docs/retrospectives/README.md`](../retrospectives/README.md) (this retro's index entry) |
| **Outcome** | ADR-016 Accepted (SignalR). Vision bumped to v0.6. Two parked frontend items retired. Three new open questions filed. Two successor sessions named (map-library spike; contracts-package session). |

---

## Framing

Design-return session per ADR-004, triggered by the sibling-repo frontend survey discharging the vision's explicit "CritterBids lands on a stable live-update pattern" unpark condition. The session's job was judgment, not research: decide what the vision adopts as working direction and record the transport decision in an ADR.

---

## Outcome summary

| Deliverable | Result |
|---|---|
| ADR-016 — Frontend Live-Update Transport | Authored and Accepted. SignalR (`@microsoft/signalr` v10) with transport-agnostic push→Query-cache-bridge architecture. |
| Vision v0.6 | Bumped. Frontend stack + audience-SPA monorepo adopted as working direction in §Tentative Technology Stack. |
| "Frontend architecture" — re-filed | Removed from §Explicitly Parked. Discharge cited in v0.6 Document History. |
| "Map library for frontend" — re-filed | Removed from §Explicitly Parked. Added to §Open Questions with candidates (MapLibre GL, Leaflet, deck.gl) and backend-coupling note. |
| §Open Questions — three new entries | Map library; contracts-package shape (one `@crittercab/shared` vs. per-BC); monorepo vs. separate repos. |
| Successor sessions named | Map-library spike; contracts-package/monorepo-shape session at first frontend implementation. |

---

## What worked

**grill-with-docs surfaced the load-bearing question early.** The first grill question — "does ADR-005 already place browser-client streaming in the gRPC column?" — immediately exposed the most important distinction the ADR had to make: ADR-005 governs service-to-service transport; browser-to-backend push is a distinct fourth category. Without that clarification, ADR-016 would have been written with the wrong framing.

**The transport decision resolved cleanly and fast.** The prompt's lean was "Accepted with gRPC-web as default, SignalR as fallback, pending spike." The user rejected gRPC-web as too niche and experimental before the grill reached question 2, collapsing four grilling questions to one answer: SignalR, Accepted, no spike, no gate. The decision is cleaner than the lean anticipated.

**Decision-at-a-time sign-off kept each call crisp.** Five decisions, each pre-authored with a lean and a citation, walked sequentially. No batching, no ambiguity about what was being decided. The two moot decisions (gRPC-web spike; fallback alignment with ADR-005) were dropped cleanly rather than argued through.

**The transport-agnostic bridge architecture survived the transport flip.** The core architectural pattern from the survey (push→Query-cache-bridge with the transport parameterised at `createConnection`) transferred intact regardless of which transport filled the slot. Committing to the architecture separately from the transport was the right structure.

---

## What was harder than expected

**The prompt's lean on flag #1 was overthought.** The "Accepted-with-default-gRPC-web-pending-spike" shape never came up in practice — the user resolved the transport question categorically before the grill reached it. The lean anticipated a tension that the user had already resolved. Useful to surface, but the sign-off pattern worked better than the multi-step fallback structure suggested.

**The vision doc header was stale.** The version header said "v0.4" when v0.5 had already been applied (a missed header update from the W003 session). Fixed incidentally during the v0.6 bump.

---

## Methodology refinements that emerged

**The grill-with-docs "first question" carries disproportionate leverage.** In this session the first question — scope of ADR-005 — collapsed the rest of the grilling tree. Investing in identifying the most foundational dependency-breaking question before starting the grill is worth explicit effort.

**Prompt leans should acknowledge that the user may have already resolved the tension.** The flag #1 lean spent significant prose on a three-option fallback structure for a decision the user had already made. A shorter lean — "our read is SignalR-or-gRPC-web; here's the tension; your call" — would have been more efficient.

---

## Outstanding items / next-session inputs

- **Map-library spike** — named successor session. Trigger: when Telemetry BC's geospatial representation (H3 cells vs. GeoJSON) is decided; spike is a throwaway implementation, not a research doc.
- **Contracts-package + monorepo shape** — named successor session at first frontend implementation. Trigger: when the first SPA package is bootstrapped. Two open questions (contracts-package shape; monorepo vs. separate repos) are filed in the vision doc and will be decided in that session.
- **SignalR hub design** — not started. Which services own hub endpoints, how Wolverine message handlers fan out to hub contexts, and whether a BFF layer is needed are all deferred to the first frontend implementation session.

---

## Spec delta — landed?

Null narrative/workshop spec delta, as planned and honestly named in the prompt. This is a vision-level design-return; no narrative or workshop spec was amended. Deltas land at the vision/decision layer: vision doc gains §Tentative Technology Stack frontend section and loses two §Explicitly Parked entries; decision record gains ADR-016. The null-delta pattern is established precedent (fifth instance: housekeeping-delete-may-15-handoff; skills-tidy-ai-skills-sync; workshops/003 retro; aspire-reconcile-and-port-band; fix-marten9-projection-source-gen).
Loading
Loading