Board Web decisions
This page records the ratified design decisions for Board Web, the React Router application in jodapp-web. Read it before you design anything in it.
Each decision states what we decided, why, and where the evidence is. Decisions live only on this page. Other docs must link here instead of repeating the text. When a decision changes, update this page the same day.
The design itself is in the JodApp Web authentication pages. Session decisions on the Rails side live on the Identities domain decisions page.
D1 — Nothing refreshes; a 401 means the session is gone (2026-09-06)
A credential is what the browser holds for a signed-in person: the session cookie. Refreshing it means asking Rails for a new one when the old one stops working. Board Web never does that. Rails owns session expiry: it deletes idle session rows with a scheduled job, and a 401 from any endpoint means the row is gone. The Rails side of this decision is Identities D5 — Server-side sessions replace jwt_sessions.
What we decided:
| Rule | Meaning |
|---|---|
| The API clients have no refresh path | On a 401 the client returns the error to its caller once. It never retries, never navigates and never clears state |
The session middleware forwards no Set-Cookie | Rails never rewrites the cookie on a read, so there is nothing to forward |
| A loader reads the cookie from the request | The cookie cannot change during a request. Router context holds the session, not the cookie |
| The refusal rule stays | The root routes reload the session when an action fails with 401 or 403. A direct API call that is refused tells React Router with revalidate() and does nothing else. Changing a session, what a refused response means |
| Session expiry has one owner | Rails, by deleting the Identities::UserSession. What JodApp Web knows about a session states the rule on the web side |
Why: two runtimes carry one cookie, the browser and the Board Web server. When both may renew it, the API can accept only one, and the loser is signed out or sends a dead cookie. Three defects on main came from that; Identities D5 lists them with their evidence. With nothing to renew, there is nothing to race. About 100 lines of refresh and cookie-forwarding code go: the refresh lock, the server retry and the 401-then-refresh hook in app/api/ky-client.js, and the Set-Cookie forwarding in both session middlewares and in the public root's adapter.
Evidence: jodapp-web at 406701bb, app/api/ky-client.js:25, :77-97, :145-178, :234-275; app/auth/auth-user-session-middleware.js:43, :74; app/auth/auth-admin-session-middleware.js:42, :73; app/roots/public-root.jsx:39-42. The live run of 2026-09-05 in Identities D5.
D2 — Policies check on the server; no clientMiddleware (2026-09-06)
A policy route is a route with no page. It decides whether the routes inside it may open. clientMiddleware is React Router's browser twin of middleware. It runs before a clientLoader on an in-app click.
What we decided: a policy route exports middleware, a loader that returns null, and an Outlet. No route exports clientMiddleware. The policy loader, the page's own fetches can start before the policy answers holds the mechanism.
Why:
| Reason | Detail |
|---|---|
| The cost it would remove is now small | On an in-app click to a denied page, the page's clientLoader may start beside the policy check. Rails refuses it. That costs one refused call. Under token rotation, the refused call also started a wasted refresh. That cost is gone (D1) |
| A browser check needs a session to read | Router context does not cross from the server to the browser, and middleware cannot call a React hook. So clientMiddleware would need its own session read from Rails on every navigation, or a browser cache of the session with rules for stale data. Both cost more than the one refused call they prevent |
| Rails is the boundary either way | A browser check improves the journey. It never adds safety |
Evidence: react-router 8.3.0, lib/dom/ssr/single-fetch.js:131-182 (every matched route's loading starts under one Promise.all) and lib/router/router.js:725-736 (a redirect is handled before loader data). The policy loader page.
D3 — Node reads, the browser writes, Rails decides (2026-09-06)
Node is the Board Web server process. It answers document requests and .data requests. The browser is the app after hydration. Rails is the API, and it authorizes every request.
What we decided:
| Code | Runs in | Route exports |
|---|---|---|
| Server code | Node | middleware, loader |
| Browser code | The browser | clientLoader, clientAction, event handlers |
| Not used | Node | A server action. Only a form that must work without JavaScript needs one, and none does |
Where page data loads:
| Page | Export |
|---|---|
| Public | loader. The HTML carries the data |
| Protected | clientLoader, plus a HydrateFallback on the page |
| Protected entry page: the dashboard home, a bookmarked detail page | A server loader beside the clientLoader, added after a measurement |
| Policy route | middleware and loader = () => null |
Where writes go:
| The write | Shape |
|---|---|
| Can change the session, or the person stays on the page and the page shows the result | A clientAction. The router re-runs the loaders, so the hand-written revalidate() after the write goes |
| Creates or edits a record and then leaves for another page | The handler stays on existing pages. New pages use a clientAction |
| Loads a page's data | Never useEffect. Page data comes from loader or clientLoader |
| Is a form | react-hook-form and zod stay. The form returns submit()'s promise and owns its submitting state. A button with no fields is a <Form>. Server field errors arrive as action data |
The full conventions, with examples, are in jodapp-web's AGENTS.md, app/routes/AGENTS.md and app/domains/AGENTS.md.
Why:
| Reason | Detail |
|---|---|
| Rails authorizes every request | So the browser may call it directly. Moving every read to Node would put one process in the path of every click, to save about 13 ms per call: 11.35 ms on the private host against 24.02 ms on the public one, out of a render of 500 to 976 ms |
| The router sees actions, not handlers | After a route action succeeds, the router re-runs the loaders. A write from a handler is invisible to it, so the page must refresh by hand. 18 files do that today, at 44 call sites |
A clientLoader-only page paints nothing on a document request | 53 pages answer a document request with an empty body until they hydrate. A HydrateFallback gives the document something to paint |
Evidence: the internal API benchmark analysis; React Router, client data under "Skip the Server Hop", and React Router, hydration; react-router 8.3.0, lib/router/router.js:1937-1938 (no revalidation after a failed action) and lib/dom/ssr/routes.js:299-301 (a clientLoader without a loader hydrates); production on 2026-09-04, where GET https://jodapp.com/employers/sign-up returned a document with no <nav>, <form> or <h1>; the counts on main at 406701bb.
D4 — Check the API's authentication before adding a web-framework convention (2026-09-06)
This is a working rule for the team, not a design. A web-framework convention is a rule about how we use React Router. The API's authentication is how Rails decides who a request belongs to.
What we decided: when a problem on the web side keeps coming back, check the premise on the other side of the wire before adding another convention on this side.
How to apply it:
| Step | Question to answer in writing |
|---|---|
| 1 | What is the failure, in one sentence? |
| 2 | Which system causes it: the browser, Node, or Rails? |
| 3 | If Rails causes it, which decision on the Rails side would remove it? Write that decision first |
What happened: for a week, every authentication problem was treated as a React Router problem. How middleware orders loaders. How the .data request works. How to forward cookies from loaders. How two runtimes refresh one token without racing. Eleven handover notes, nine spec pages, a conventions proposal and a mental-model page were written to make the framework work around one fact nobody questioned: the API replaced the access token every hour and killed the old one at once. The question "why does the API rotate tokens at all?" had a short answer. Mobile apps cannot rely on cookies, which is true. "So use JWT" does not follow from it. The fix was one Rails decision, Identities D5, and it deleted the web problem instead of working around it.
Two facts that would have shortened the week: the gem already depended on a store lookup, and mobile needs a header, not rotation. Both are in Identities D5's evidence.
D5 — A policy checks at the door, not on every click (2026-09-07)
A policy route is a route with no page that decides whether the routes inside it may open. It runs on Node as middleware, and it exports a loader that returns null so that React Router sends Node a .data request. "At the door" means the click that enters the policy's area.
What we decided:
| Rule | Meaning |
|---|---|
| A policy runs when a click enters its area, and on every reload | React Router requests the policy's loader when the policy is newly matched, when the URL search changes, after a route action, after revalidate(), and when a person leaves the area and comes back. Node then runs every matched middleware, the policy's included |
| A click between two pages already inside the area does not run the policy | React Router does not request the policy's loader, so nothing reaches Node, unless the destination page has its own server loader |
| Inside the area, a refused fetch corrects the journey | When the session has ended or an access state has changed, Rails refuses the page's own fetch. The caller answers with revalidate(), that reload runs the policy, and the redirect follows one step later |
No policy route exports shouldRevalidate | Returning true on every navigation would run the policy on every click, at the cost of one .data request and one Node-to-Rails session read per click inside every protected area. Returning false would switch the policy off. Neither is used |
Why: Rails decides access on every request, so a policy only shapes the journey. Running it on every click would add a Rails call to every click inside every protected area, to correct the journey one click sooner. The correction path already exists, and the changing-a-session page states it.
Evidence: react-router 8.3.0, lib/router/router.js:1958 (a newly matched loader is always requested) and :1960-1966 (otherwise only when a reload was requested, the URL is unchanged, the search changed, or the route is a new instance); @react-router/dev, vite.js:2301 (the route manifest carries hasLoader and hasClientMiddleware, and nothing about server middleware, so the loader is the only trigger the browser has). Verified on 2026-09-07 by running createMemoryRouter with a pathless parent route standing in for a policy: its loader ran on entering the area, not on a click inside it, and again on a search change, on revalidate(), and on re-entry. The policy loader page holds the table.
D6 — Node calls each API on a private host inside the VPC (2026-09-06)
Node is the server process that runs JodApp Web. A private host is a DNS name that only machines inside our AWS network can resolve. It lives in a private Route 53 hosted zone attached to the VPC.
What we decided:
| Rule | Meaning |
|---|---|
Node reaches the user API at api.internal.jodapp.com and the Team API at api.internal.teamjod.app | Two private hosted zones, internal.jodapp.com and internal.teamjod.app, each with one A record pointing at the production API instance's private address, both attached to the production VPC |
| Two server-only variables set the hosts | API_INTERNAL_JODAPP_URL and API_INTERNAL_TEAMJOD_URL. They have no VITE_ prefix, so Vite never writes them into the browser bundle. When one is unset, Node uses the public host, so a missing variable can never break a deploy |
| QA shares one private host | The QA Team portal already calls api.jodapp.dev, so both variables point at http://api.internal.jodapp.dev in QA, the record in the internal.jodapp.dev zone |
| The browser keeps the public hosts | VITE_API_JODAPP_URL and VITE_API_TEAMJOD_URL, reached through Cloudflare |
Why:
| Reason | Detail |
|---|---|
| Node reads on every signed-in request | The private path costs 11.35 ms against 24.02 ms over the public host, and the call never leaves the VPC. D3 cites the measurement |
| One rule for both identity systems | The Team side is a twin of the user side everywhere else: its own root, middleware, cookie helpers and client. A hostname was the one place the twin broke, and every page had to explain the exception |
| Rails needs no change | Rails routes the Team endpoints by path, config.hosts already accepts *.teamjod.app, and the cookie domain comes from config.x.session_cookie_domains, never from the request host |
Evidence: the Route 53 listing on 2026-09-06 with the read-only ali-claude profile: zones internal.jodapp.dev (Z08441502LWDRS5RXQ24R), internal.jodapp.com (Z09082153RT6Y1F5H6JRD) and internal.teamjod.app (Z10048871W45Z2GL0LIE7), each private, each with one A record; config/environments/production.rb in jodapp-api for config.hosts and config.x.session_cookie_domains; resolveUserApiPrefixUrl in app/api/ky-client.js; the internal API benchmark analysis. One operations fact: the three A records point at an instance's private address, not a load balancer, so all three must be updated when the API instance is replaced.