Skip to main content

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:

RuleMeaning
The API clients have no refresh pathOn 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-CookieRails never rewrites the cookie on a read, so there is nothing to forward
A loader reads the cookie from the requestThe cookie cannot change during a request. Router context holds the session, not the cookie
The refusal rule staysThe 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 ownerRails, 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:

ReasonDetail
The cost it would remove is now smallOn 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 readRouter 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 wayA 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:

CodeRuns inRoute exports
Server codeNodemiddleware, loader
Browser codeThe browserclientLoader, clientAction, event handlers
Not usedNodeA server action. Only a form that must work without JavaScript needs one, and none does

Where page data loads:

PageExport
Publicloader. The HTML carries the data
ProtectedclientLoader, plus a HydrateFallback on the page
Protected entry page: the dashboard home, a bookmarked detail pageA server loader beside the clientLoader, added after a measurement
Policy routemiddleware and loader = () => null

Where writes go:

The writeShape
Can change the session, or the person stays on the page and the page shows the resultA 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 pageThe handler stays on existing pages. New pages use a clientAction
Loads a page's dataNever useEffect. Page data comes from loader or clientLoader
Is a formreact-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:

ReasonDetail
Rails authorizes every requestSo 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 handlersAfter 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 request53 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:

StepQuestion to answer in writing
1What is the failure, in one sentence?
2Which system causes it: the browser, Node, or Rails?
3If 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:

RuleMeaning
A policy runs when a click enters its area, and on every reloadReact 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 policyReact 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 journeyWhen 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 shouldRevalidateReturning 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:

RuleMeaning
Node reaches the user API at api.internal.jodapp.com and the Team API at api.internal.teamjod.appTwo 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 hostsAPI_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 hostThe 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 hostsVITE_API_JODAPP_URL and VITE_API_TEAMJOD_URL, reached through Cloudflare

Why:

ReasonDetail
Node reads on every signed-in requestThe 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 systemsThe 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 changeRails 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.