Skip to content

Latest commit

 

History

History
926 lines (781 loc) · 65.3 KB

AGENTS.md

File metadata and controls

926 lines (781 loc) · 65.3 KB

studio — engineering notes for coding agents

Hard-won, project-specific gotchas. Skim the relevant section before touching related code — each of these has cost a debugging session.

Commits — Conventional Commits are enforced in CI

Every commit message needs a Conventional Commits prefix: type(scope): subject (@commitlint/config-conventional types — feat, fix, docs, chore, test, refactor, perf, ci, build, style, revert; scope optional; subject case is unrestricted, per commitlint.config.cjs). The Verify Commits workflow (.github/workflows/verify-commits.yaml) runs commitlint on every PR and on pushes to dev/stage/prod — a single unprefixed commit fails the check, and history that already landed on a deploy branch has to be rewritten and force-pushed to fix it.

Don't count on the local hook to catch this first. .husky/commit-msg only runs after pnpm install has run the prepare: husky script, which is what points core.hooksPath at .husky. In a fresh clone or worktree that skipped install, core.hooksPath is unset, the hook never fires, and a non-conforming message commits silently — CI is the first thing that objects.

Routing — keep getParentRoute and addChildren in lockstep

In the TanStack Router setup (src/router/rootRouteTree.ts), every route's declared getParentRoute: () => X MUST be mounted under that same X via addChildren. If a route is placed a level above/below its declared parent (so its declared parent becomes a sibling in the children array), TanStack Router 1.170 silently corrupts route matching — static segments get parsed as params (config → param onfig, ssh-keys → sh-keys) and the real params (organizationId, clusterId) are dropped.

This caused the /Organization/undefined bug (Jun 2026): the cluster instance/edit routes declared getParentRoute: () => clusterLayoutRoute but were mounted under clustersLayoutRoute, so deep-linking to /org/clu/logs lost organizationId and orgLayoutRoute.beforeLoad's prefetch hit /Organization/undefined. Router 1.133 tolerated the mismatch; the 1.133→1.170 bump exposed it.

addChildren and getParentRoute are two sources of truth that must agree. Regression coverage lives in src/router/__tests__/orgUndefinedRepro.test.ts, which uses router.matchRoutes() (no loaders/network) to assert deep links parse params correctly.

Routing — the router must be created exactly once (never per render)

useNewRouter memoizes createRouter()/createHashHistory() with a useState initializer — keep it that way. Every createHashHistory() call monkey-patches window.history.pushState/replaceState (wrappers chain and are never removed) and leaks popstate/scroll listeners. Worse, when the current history entry lacks __TSR_key (true after any raw location.hash = navigation), history creation synchronously calls replaceState — the patched chain then notifies the live router's subscriber mid-render, which React logs as "Cannot update a component (Transitioner) while rendering a different component (AppRouted)" (hit Jul 2026: AppRouted re-rendered on every authStore tick and rebuilt the router each time).

Auth updates reach the memoized router via the RouterProvider context prop plus the router.invalidate() effect in src/AppRouted.tsx — that invalidate is what re-runs beforeLoad guards (e.g. the sign-out redirect in dashboardRoute.ts), so don't remove it.

Routing — @tanstack/router-core's preload-eviction _nonReactive TypeError is now fixed upstream

Studio #1387 / upstream TanStack/router#7759: when a hover-intent preload's cached match was evicted mid-flight (user navigates, router.invalidate(), cache GC), router-core re-read the match after an await and threw TypeError: Cannot read properties of undefined (reading '_nonReactive'), which preloadRoute then console.error'd — polluting Datadog RUM on every hover-then-navigate race. We carried the fix from TanStack/router#7003 as a local pnpm patch (patches/@tanstack__router-core@1.171.15.patch, wired via patchedDependencies in pnpm-workspace.yaml) through several router-core bumps.

TanStack/router#7805 — a router-core rewrite of the whole match-loading pipeline (load-matches.js split into load-client.js/load-server.js, a transaction/lane model) — folded in the same fix and shipped in router-core@1.171.16. Once the bump to that version landed (studio#1595), the local patch was redundant, so it and the patchedDependencies entry were removed. Regression tests remain at src/router/__tests__/preloadEvictionRepro.test.ts, now run unpatched against whatever router-core version is current; they were the way we proved the upstream fix covers this before deleting the patch, so keep them green rather than deleting them.

Tables — every TanStack Table feature is registered in src/lib/table.ts

@tanstack/react-table v9 dropped the v8 "every table gets every feature" model. A feature only exists on a table if it was registered with tableFeatures(), and the resulting feature set is the first generic parameter of every table type (ColumnDef<TFeatures, TData, TValue>, Row<TFeatures, TData>, ...). Studio registers one feature set in src/lib/table.ts and re-exports Cell/CellContext/ColumnDef/Header/HeaderGroup/Row/Table and createColumnHelper already bound to it — so import table types from @/lib/table, and @tanstack/react-table only for feature-independent things (flexRender, useTable, SortingState, ColumnSizingState, ColumnVisibilityState, RowData, ...).

Two consequences worth knowing before you touch a table:

  • A missing API is a missing feature, not a removed API. column.toggleSorting, header.getSize(), columnDef.enableColumnFilter etc. only typecheck once their feature is in studioTableFeatures. Add the feature there rather than casting.
  • Row models are shared, so opt out per table. The sorted row model is registered globally; the browse table (src/features/instance/databases/components/TableView.tsx) sorts and pages on the server and therefore sets manualSorting: true. Without it the client would re-sort the page behind the query — TableView.test.tsx guards this.

Also: TanStack builds initial state as { sorting: [], ...initialState }, so passing an explicit sorting: undefined replaces the default and the first header click throws inside toggleSorting. Default optional sorting props before handing them over (see SimpleBrowseDataTable).

Deploys — two composite actions, and shell: bash is not the shell the workflows had

deploy-dev/stage/prod.yaml own only what differs per environment; the job body lives in two composite actions. The split is a security boundary, not a refactor — do not reunify them: studio-verify installs, tests and lints and takes no credentials; studio-deploy holds every credentialed step. That keeps deploy secrets out of the merge-queue path — not out of reach of unmerged code generally: a same-repo pull_request also runs the workflow definition from the PR's own ref with repository secrets available, so anyone who can push a branch can already read any secret a workflow references. Scoping the credentials is the fix for that, and it is tracked on studio#1651.

No deploy workflow may accept merge_group. That event runs the candidate PR's own workflow and action YAML, so it can request contents: write or edit the actions outright — a boundary inside candidate-controlled YAML is not a boundary. src/lib/workflowPrivilege.test.ts asserts it for any workflow that writes contents or receives deploy credentials, because actionlint never evaluates the event set against job permissions.

The queue is in use on stage, and verify-stage.yaml is its credential-free check — that separation is the point, so never move a deploy step into it. Verify Stage is the check name the queue must require; whether it already does is a repo setting, not something this repo records (studio#1649).

Every workflow declares permissions: explicitly. The repo default is write, so omitting the block hands a job a write-capable token it almost never needs.

They are composite actions, not a reusable workflow. Composite steps run inside the caller's job, so job names stay Deploy to Dev/Stage/Prod rather than becoming <caller> / <called>. This is about keeping check names stable and the run graph flat. Do not reason about which checks are required from the API alone — branches/stage/protection and rules/branches/stage both read as empty here while the queue demonstrably gates on checks, so a partial read will tell you the opposite of the truth. Ask, or look at the settings UI.

Moving a step in there changes its shell. A composite run step must declare shell:, and shell: bash runs as bash --noprofile --norc -eo pipefail, where a workflow's default is a plain bash -e. -o pipefail is the trap: any cmd | grep … that legitimately finds nothing now fails the whole step, because grep exits 1. That is how the version-tag lookup (git tag --points-at HEAD | grep -E '^v?[0-9]+…') came to abort before reaching its own no-tag fallback, which would have failed every dev deploy. Audit each pipeline you move, and tolerate only the status you mean — { grep … || true; }, never || true on the whole pipeline, which would also swallow a real git failure.

Which Harper major a CM runs is a deploy input, not a commit: @fastify/static majors track fastify majors (v7 ↔ fastify 4 ↔ harperdb 4.x, v8 ↔ fastify 5 ↔ Harper 5.x) and pairing them wrong fails silently on the CM — reply.sendFile is never decorated and every asset 404s. See .github/deploying.md for the pairing, both dispatch inputs, and how to retire the split.

Builds — sourcemaps are per mode, and localstudio deliberately has none

build.sourcemap in vite.config.ts is a function (sourcemapFor), not a flag, because the three kinds of build want three different things. Sourcemaps dominate this output — they were 71 MB of a 94 MB build — since each embeds sourcesContent, the full original text of every module it covers.

build sourcemap why
pnpm build:local (localstudio) false ships inside Harper; nothing reads the maps
pnpm build --mode dev|stage|prod 'hidden' uploaded to Datadog, then stripped before deploy
bare pnpm build true a developer inspecting a prod build locally

How Studio ships inside Harper. The localstudio mode is the UI bundled into the Harper distribution. The harper repo drives that build from its own scripts, roughly VITE_STUDIO_VERSION="v$(jq -r '.version' ../package.json)" pnpm run build:local, and packages the resulting web/ as-is. Nothing on that path uploads maps to Datadog or reads them, so emitting them only inflates the published Harper package — which is why the omission is configured here rather than by post-processing over in harper. Keep it that way: turning localstudio sourcemaps back on silently adds ~71 MB to the Harper package with no consumer, and no failing test will catch it.

Deploy modes. Datadog is the only consumer of the deploy maps, and it keeps its own copy, so the workflows delete them after the upload and before mv web deploy/ rather than replicating ~71 MB to every node for nothing. 'hidden' is what makes that safe to do: it writes the maps but omits the trailing //# sourceMappingURL= comment, which under true would dangle onto a 404 once the files are gone. datadog-ci sourcemaps upload pairs each .js.map with its bundle by filename and never needs that comment (verified with --dry-run: 173/173 paired). The strip step now lives once, in .github/actions/studio-deploy, so a new environment workflow inherits it by calling that action rather than repeating it.

(The repo is public, so none of this is about keeping sourcesContent unreadable — it is purely size containment.)

pnpm — dependency overrides go in pnpm-workspace.yaml, not package.json

This repo uses pnpm 12 (packageManager in package.json). overrides (and other settings like minimumReleaseAge, allowBuilds, onlyBuiltDependencies) live in pnpm-workspace.yaml, NOT in a pnpm field in package.json — pnpm stopped reading that field in 11. It only logs [WARN] The "pnpm" field in package.json is no longer read and the install otherwise "succeeds", so the override is silently ignored.

Add a top-level overrides: block to pnpm-workspace.yaml. Version-range keys ('undici@>=7.0.0') and parent-scoped keys ('parent>child') work too. After editing, run pnpm install --lockfile-only and grep the lockfile to confirm the stale versions are gone.

A pkg@range override replaces the range each matching dependent declares, so it can hold a package below the floor that package asks for, and nothing warns. jsdom 30.1.1 declares undici ^8.10.2, but the override turns that into ^8.9.0 and the lockfile keeps it on 8.10.0 (#1847). When a dependency bump raises its floor above an override's, raise the override with it.

vitest/jsdom runs on undici 8 — don't restore the jsdom>undici carve-out

jsdom 29 imported the internal undici/lib/handler/wrap-handler.js, which undici 8 removed, so PR #1358 held it on undici 7 with a scoped 'jsdom>undici': '^7.28.0' override. jsdom 30 declares undici 8 itself and imports only its public exports (getGlobalDispatcher, WebSocket, Dispatcher, DecoratorHandler). aa385532 (merged as #1597) deleted the carve-out: it was holding jsdom on undici 7.28.0, below both jsdom's own declared range and the CVE-2026-13697 (GHSA-4cwx-7wf7-3272) fix. The tree now resolves a single undici 8 for jsdom and CI alike; there is nothing to keep jsdom on 7 for.

OAuth sign-in links belong to the central-manager origin, not Studio's

Every /oauth/* endpoint is served by central-manager's @harperfast/oauth component, and the whole flow is pinned to that origin: it issues the __Host-oauth_browser CSRF cookie, the provider only accepts the redirect URI registered for that origin, and the callback lands the session cookie there. So build these links with getOAuthSignInUrl(), which is absolute against VITE_CENTRAL_MANAGER_API_URL — the same source of truth apiClient uses.

An origin-relative href="/oauth/google/login" looks equivalent and is not. It satisfies the invariant only where the CM happens to serve Studio (the deployed builds); under pnpm dev, or from a Harper instance serving the bundle, the request hits a server with no oauth resource and the user gets Harper core's plain-text Not found 404. That is how the sign-in buttons were written, with a server.proxy entry for /oauth in vite.config.ts papering over dev — and that entry never worked either: Vite appends the request path to the target's own path, so target: 'http://localhost:9926/oauth' forwarded /oauth/google/login as /oauth/oauth/google/login. It's gone; nothing requests /oauth from the dev origin any more.

Telling the two 404s apart when this breaks again: the OAuth component answers JSON ({"error":"OAuth provider not found"}, or a 503 when no provider is configured), so anything else means the request reached a server where the oauth resource was never registered — the wrong origin, or a CM where the plugin failed to load (its initial updateConfiguration() throws into the plugin loader). What "anything else" looks like depends on where you are: a bare Not found in plain text is Harper core, while on a deployed CM a browser now gets Studio's own 404 page and a non-HTML client {"error":"Not found"} — see the next section. Ask for it with curl -H 'accept: application/json' and the two stay easy to tell apart.

Studio's deployed 404 is a GET /* route, never setNotFoundHandler

.github/deploy-template/fastify/static.js is what central-manager runs to serve Studio, and its catch-all route is the only thing standing between a mistyped URL and Harper core's plain-text Not found.

It cannot be a setNotFoundHandler. Harper registers one on that fastify instance already (server/fastifyRoutes.ts — its handler re-emits unhandled so the request cascades back to core), and fastify allows exactly one per prefix: a component that registers a second throws Not found handler already set for Fastify instance with prefix: '/' at load time, which takes the whole component down — Studio stops being served at all, not just its 404 page. The catch-all route sidesteps the not-found path entirely, and a normal route response is returned to the client verbatim by Harper's inject bridge.

The static plugin is registered with serve: false, which decorates reply.sendFile and registers no routes of its own — so /* is free, and, load-bearing, routing is never a snapshot of the files present at boot. Deploys land a new web/ and every deploy workflow passes restart: false, so the per-file route table that wildcard: false produces 404s each newly hashed bundle until something restarts the component. That was caught in review, not in testing, because a fresh-boot check cannot show it.

So the catch-all resolves each path against disk per request: containment-check, stat, then send. Any .html under web/ is a document — frame guards, short max-age — and everything else is a hashed asset served immutable. The extra stat (on top of send's own) is the deliberate price of owning the fallback.

verify-static.mjs next to the template pins all of it — 14 checks against a real build behind Harper's own not-found handler, including the post-boot asset. Run it on both pairings after touching this file; its header carries the two install lines, and a bare npm i fastify @fastify/static pairs the package.json's v7 pin with fastify 5 and fails the plugin's version check.

Scope of the catch-all: Harper mounts these routes as a global fallback after its own resource routing, so it only ever sees paths the native chain declined — it cannot shadow /oauth/* or the REST API. It is GET-only on purpose; an unmatched POST still cascades to core. And because Studio is hash-routed, an unmatched path is never an app route, so it serves a standalone 404.html (from public/, so every build carries it) rather than index.html, which would drop the visitor on the dashboard under a 404 status. Non-HTML clients get {"error":"Not found"} instead of a page.

The dev server has to be told the same thing, or pnpm dev disagrees with production: Vite's default appType: 'spa' answers every path with 200 and index.html, the hash router lands on the dashboard, and a wrong URL reads as a working one — which is also why the deployed 404 page can't be seen locally. vite.config.ts sets appType: 'mpa' and adds serveNotFoundPage() to supply the page. That middleware must skip / by hand rather than simply running last: Vite registers its own index.html middleware after plugin post hooks, so an unconditional catch-all there 404s the app itself.

None of this is exercised by CI — nothing in the repo evaluates .github/ — so the verifier above is the whole safety net on the deployed side; hit a bogus path on pnpm dev for the dev side.

Both sides answer HTML only to a client that actually accepts it, and text/html;q=0 is a refusal, not a request — but they diverge past that: the deployed handler answers everything else with {"error":"Not found"}, while dev falls through to Vite's own 404. The wantsHtml helper is deliberately duplicated (the template ships standalone into deploy/ and cannot import from src/), so a fix to one is a fix owed to the other — the OWS-tolerant media-type parse came from exactly that.

Google sign-in button has no display

src/features/auth/components/GoogleAuthenticationButton.css (.gsi-material-button) declares width: 100% but no display, so the <a> defaults to display: inline and ignores width. It only renders full-width because on the sign-in/sign-up pages it is a direct child of a flex container (which blockifies flex items).

Wrap that button in a plain <div> (e.g. for an overlay/badge) and it reverts to inline and collapses. Fix: make the wrapper flex flex-col (or add block/w-full to the anchor). The GitHub button (.github-signin-btn) sets display: flex explicitly and is not affected. (Hit while building the "Last used" sign-in badge, #1316.)

A mocked hook whose value lands in a dependency array must keep a stable identity

If you vi.mock a hook and the code under test puts its return value in a useEffect dependency array, returning a fresh object per call re-fires that effect on every render — and any assertion counting effect side-effects then passes for the wrong reason. vi.mock('@tanstack/ react-router', () => ({ useRouter: () => ({ ... }) })) did exactly that to datadog.test.tsx: the test that claimed to prove "one view per navigation" would have passed with location.href removed from the deps entirely. The real useRouter returns a stable reference, so the mock was also lying about production.

Instantiate once in the factory and expose changing state through a getter:

vi.mock('@tanstack/react-router', () => {
	const router = {
		get state() {
			return { location: { href: routerState.href } };
		},
	};
	return { useRouter: () => router };
});

The general check, worth running on any effect-counting test: re-render without changing anything and assert nothing happened. That assertion is what distinguishes "the effect re-ran because its input changed" from "the effect re-runs constantly"; it fails immediately against an unstable mock.

Testing Radix menus in jsdom

This repo has NO @testing-library/user-event — only @testing-library/react + fireEvent. Vitest's default env is node, so component tests must start with /** @vitest-environment jsdom */.

To drive a Radix dropdown/menu (e.g. src/components/ui/dropdownMenu.tsx) in jsdom:

  • In beforeAll, polyfill the DOM APIs Radix needs: Element.prototype.hasPointerCapture, setPointerCapture, releasePointerCapture, scrollIntoView, and define window.PointerEvent = class extends MouseEvent {} (jsdom lacks PointerEvent; MouseEvent carries the button field Radix checks).
  • OPEN the menu with fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false }) — a plain fireEvent.click does NOT open it (Radix opens on pointerdown).
  • SELECT a menu item with fireEvent.click(item) — Radix MenuItem.onSelect fires from the click handler.
  • Items expose roles menuitem / menuitemcheckbox (with aria-checked); the container is menu. Disabled items have aria-disabled="true" and/or data-disabled.

Working example: src/features/instance/databases/components/PickColumnsDropdown.test.tsx.

An always-rendered modal keeps its state; only its contents unmount

Several modals are rendered unconditionally and toggled with open (e.g. EditTableRowModal in DatabaseTableView). Radix's DialogContent is not force-mounted, so closing the dialog unmounts everything inside it — inputs, editors, their DOM — while the modal component's own useState survives, because that component never unmounted.

The two halves then disagree: the re-mounted contents render from props (fresh), and the surviving state still describes the session the user abandoned. In #1600 that combination disabled Save Changes forever — a isValidJSON=false from a malformed draft outlived the editor that held it, so re-opening the row showed valid JSON above a dead button.

Reach for whichever fits:

  • Reset the state when the modal opens, not only when its data changes. Tracking the data snapshot as null while closed gets both in one comparison (see EditTableRowModal's render-time reset).
  • Or give the modal a key that changes per target and render it only while open, which unmounts the component with the dialog (AddTableRowModal in DatabaseActionModals).

Related @monaco-editor/react behaviour, which is what makes this invisible rather than merely wrong: it applies a changed value prop through executeEdits behind an internal preventTriggerChangeEvent flag, so programmatic value updates do not fire onChange. An editor re-mounted (or re-valued) from props therefore never tells the component that the buffer it is validating has been replaced.

Live central-manager data — WebSocket works, the load balancer never flushes SSE

Concretely, "the edge" in front of central-manager is a Linode NodeBalancer: stage.studio.harperfabric.com is a CNAME to studio.harperfabricnlfstage.akadns.net — Akamai GTM, which only steers DNS and never touches the bytes — and that resolves to 172.232.30.8 (172-232-30-8.ip.linodeusercontent.com). The NodeBalancer is the only proxy in the data path, so it's the layer to suspect when a long-lived response never arrives.

Harper serves every table for subscription over SSE and WebSocket, but through that path a GET /SystemStatus/ carrying Accept: text/event-stream produces no response at all — 0 bytes and not even a status line after 75s (time_starttransfer never leaves 0), so an EventSource never fires an event and the feature looks broken with nothing in the console. Chunked encoding isn't the problem: the same URL without that header returns Transfer-Encoding: chunked normally. What breaks is a response that stays open, which the proxy holds waiting for an end it never gets. The WebSocket upgrade to wss://<cm-host>/<Table> passes straight through — HTTP 101 immediately — to Harper's table subscription and delivers deltas normally. (Re-verified against stage, Jul 2026. Probing from outside can't separate a NodeBalancer buffer from a stall in Harper's own SSE handler; that needs origin-side access. The proxy is the better first suspect only because a socket to the same host streams fine.)

So reach for WebSocket, not EventSource, for any live central-manager data. Public-read tables upgrade anonymously, which is what lets a global notice render for signed-out users. Reference implementation: src/features/notifications/NotificationsSubscriptionManager.tsx, whose file comment carries the reconnect/backoff reasoning. Copy its slow poll backstop too — that lives on the query rather than the component (refetchInterval: 60_000 in src/features/notifications/queries.ts) — because an edge that accepts-then-drops the upgrade otherwise leaves the tab with no data path at all.

Browse — removing an attribute needs put, and only on Harper 5.3+

update and upsert are both a merge. Both land on the same bridge path — dataLayer/harperBridge/ResourceBridge.ts updateRecords → upsertRecords, which calls Table.patch for an existing record — so an attribute left out of the payload keeps its stored value, and null stores a null rather than dropping the key. That is why deleting a property in the row editor silently did nothing (studio#1643).

The fix is Harper's put operation (create-or-replace, HarperFast/harper#2347, 5.3.0): the stored record becomes exactly what you send, so an omitted attribute is removed. It is the same Table.put REST's PUT /Table/id performs, so one atomic write — the record is never absent, __createdtime__ survives, and subscribers see a single write rather than a delete followed by an insert. Studio previously emulated this with delete-then-insert; don't go back to that.

Four things worth knowing before changing the record editor:

  • The target record's own concurrent writes are clobbered, deliberately. The put sends the buffer captured when the modal opened, so it reverts any change another writer (an app resource, a replicated peer write) committed to an attribute this edit never touched. search_by_id has no refetchInterval and is invalidated only after a write, so the modal's stale-buffer guard never fires during the editing window. This is the accepted cost of replace-based removal until field-scoped __unset__ (harper#2350) lands — note the asymmetry: a mixed batch is refused to protect other records from exactly this, while the target record takes it silently.
  • Only removals take the put route, and only the records that made them. functions/removedRecordAttributes.ts reports removals per record, not as one flat list. An edit that merely changes values stays an update; a pasted batch where only some records drop an attribute is refused outright. A replace is last-writer-wins over the whole record, so routing a co-edited record through put would clobber a concurrent writer's change to an attribute that record's edit never touched. Use Object.hasOwn, never in, to test presence — an attribute named constructor/toString/valueOf reads as present through the prototype chain and its removal silently takes the merge path.
  • put has its own authorization shape — do not assemble it from the per-action checks. checkTablePutPermission (hooks/checkSchemaTablePermission.ts) is the only correct gate. checkSchemaTablePermission(…, 'update') ANDs in the update operation allowlist, which Harper does not require for put: it authorizes from the raw table insert+update flags plus a put allowlist entry, so a role with operations: ['put'] and both flags is valid server-side and must not be blocked. And an attribute-scoped role is denied outright (utility/operation_authorization.ts, PUT_WITH_ATTRIBUTE_PERMS) — a replace removes every attribute the request omits, which the attribute check cannot police since it only sees what a request supplies. Those table flags stay true for such a role, so a gate built from them offers a save that 403s. Check both attribute_permissions (v5) and attribute_restrictions (translated v4). Only super_user short-circuits; structure_user covers DDL, not DML.
  • Version-gated, but an unreadable version does NOT block the save. Harper denies registration_info to any role whose operations allowlist does not list it (and before 5.3 to one that does — gate: 'from-5.3' in config/roles/operations/operationsCatalog.ts), which is exactly the shape of role checkTablePutPermission exists to accept. Blocking on an unread version therefore made the feature permanently unavailable for those roles, behind a "reload and try again" that never helps. Allowing it is safe because the fallback isn't silent: put is a distinct operation name, so a pre-5.3 instance rejects the request outright rather than merging and keeping the attribute. A version that IS readable and predates the operation still blocks, for the clearer message, and grants are checked first so a missing grant never reads as a version problem. Note supportsPutOperation refuses 5.3.0 prereleases while the role catalog floors put at the earliest one; the divergence is deliberate and explained at both sites.
  • The primary key can't be edited, and silence there is the same bug again. functions/primaryKeyMismatch.ts refuses a save that lost a loaded record's key, or that names one the editor never loaded. Removing or changing it isn't a rename: update requires an existing record, so it skips the write and the modal reported success — #1643 all over again — and if something IS stored under the new key, update patches a record the user never opened. Key the check on the loaded records' keys, not on every edited record having one: a stored row with no value for the declared key is #1199, and flagging it refuses a whole batch over a row that was never addressable. Keyless records are still counted — a surplus over what was loaded means the payload adds records, which update skips and put cannot address, so success would be a lie. Both failure modes are pinned by tests; the rule has been wrong in each direction once.
  • On a legacy open table the read projection lies, and put writes the lie back. The operations-API attribute projection reports every registered attribute, filling in null for one the record doesn't have — verified by inserting a record that never had it. SELECT * reflects real storage. Browse reads through search_by_conditions/search_by_id with get_attributes, so two things follow. On the read side, a removed attribute keeps rendering as an empty cell: storage is correct, the read lies. On the write side, those invented nulls are in the editor buffer, so a put stores them as explicit nulls — turning "absent" into "null" for attributes the user never touched. Studio cannot fix that from the client: a projected null and a stored null are indistinguishable in the response, which is the same ambiguity that made #1643 confusing. The removal the user asked for still happens, and the record still reads the same; the difference is visible in SELECT *, storage size, and replication.
  • Only top-level attributes need any of this. A patch replaces a nested object wholesale rather than merging into it (resources/tracked.ts updateAndFreeze), so deleting a property inside an object already works through a plain update.

Both write paths check their answer, not just put — update skips a record it can't address and names it in skipped_hashes, so the merge path had the same silent-success hole.

But the update check lives at the call site, not in the writer. describeIncompleteUpdate returns a description; updateTableRecords stays a transport. That is deliberate: the writer is shared with the chat agent tool (features/instance/applications/components/Chat/tools/updateTableRecords/execute.ts), which needs the response — skipped_hashes included — and its own invalidateQueries to still run on a partially committed batch. Asserting inside the writer turned a 9-of-10 durable write into a reported total failure and skipped the invalidation, leaving the grid serving stale data for the 9 records that did change. A rule about what a person is told does not belong in a shared writer.

It reads an absent field as complete, since update runs against every version Studio manages back to 4.7 and an unrecognized legacy response isn't evidence of failure — but a field that is present and not an array is incomplete: that responder does answer this operation, so an unreadable answer can't be read as success. put follows the same shape — describeIncompletePut, interpreted at the same call site — for the same reason: a throw skips the caller's invalidation, so a replace that landed but answered unreadably would leave the grid and the open editor showing the attribute the user just removed. Neither writer asserts; integrations/api/instance/database/incompleteWrite.ts holds the shared shape.

One policy, in onWriteSettled, for both paths — every asymmetry between them so far came from changing one and not the other, so they share the handler rather than mirroring it.

Two invalidations, not one. refreshTable keys on [entityId, databaseName, tableName], which does not reach the open record: getSearchById keys on [entityId, 'search_by_id', databaseName, tableName, ids], so 'search_by_id' sits where that prefix expects the database name and partial matching fails. Use searchByIdInvalidationKey, never a hand-built key; the relationship is pinned by a test asserting it against the live query key, because the original bug was exactly this mismatch — a landed removal left the row editor's cached record showing the attribute again on reopen, the very symptom #1643 is about.

Refresh both unless the answer says plainly that nothing was written; in that one case refresh neither and leave the editor open, since nothing is stale and the draft is what the user needs in order to act on the message. Otherwise refresh and close.

describeIncompletePut treats anything short of put_hashes naming every record sent as incomplete, including a missing or malformed list: put only ever goes to a 5.3+ instance and dataLayer/insert.ts always sets the field, so a 200 without one didn't come from a healthy Harper answering this operation. The message can't help — insert.ts builds it as put N of N from written_hashes.length for both halves (skipped is always [] for put), so it can never report a partial write.

__unset__ — field-scoped removal that keeps merge semantics for everything else — is HarperFast/harper#2350, not yet built. When it lands it is the better tool for "drop one field, leave the rest alone", because it does not clobber concurrent writes the way a replace does.

Browse — relationship/computed attributes vary by Harper version

@relationship and @computed attributes are read-only: Harper rejects any insert/update whose record merely CONTAINS the key ("Computed property X may not be directly assigned a value", even for null). Strip them from record-editor JSON (see functions/relationshipAttributes.ts).

What describe_table reports differs by server version (verified empirically, Jul 2026):

  • Harper 4.7: relationship attrs ARE listed — to-one as {attribute, type: '<RelatedTable>', properties: [...]}, to-many as {type: 'array', elements: '<RelatedTable>'} — but there is NO explicit relationship flag: the only signal is that type/elements names a sibling table. @computed attrs are omitted unless describe is sent include_computed: true (4.7.40, Oct 2026), and then carry computed: true.
  • Harper 5.1: relationship attrs are omitted from describe entirely; @computed attrs appear only with include_computed: true, and carry computed: true.

Browse sends include_computed (describe_table, and its describe_all) and names each computed attribute ahead of the '*' in get_attributes. A ['*'] search omits them on 4.7.40, and Harper 5 strips them from an expanded '*' (dataLayer/harperBridge/ResourceBridge.ts getSelect), so without both the column is missing or blank (#1256).

The 5.1 omission is an unintentional regression, not a deliberate change (traced in the Harper source, Jul 2026). describe's pushAtt never filtered relationships and hasn't changed; what changed is the v5.0 "big lift" rewrite made describe read table.attributes rebuilt from the persisted attribute registry (attributesDbi), and relationships are runtime-only — table()'s persistence loop continues past them so they're never written to the registry (same in 4.7, but 4.7's describe read the schema-applied in-memory list that still held them). Harper commit 3017e097c (RE-7 / #1183) treats this as a bug but only partially fixes it, and there is NO include_relationships-style flag to re-surface them.

Because of that, browse ALSO reads relationships from the component schema.graphql files (functions/schemaRelationships.ts → get_components + get_component_file, parsed with the applications schema parser). This is the ONLY source on 5.1, and it's authoritative for the exact @relationship(from:/to:) key mappings even on 4.7. A schema-only relationship (not in describe) renders as a synthesized column: to-one reads the stored foreign key (from:) directly from the row; to-many links to the related table filtered by the reverse key (to:). getRelationshipInfoMap merges the describe-detected and schema-declared sources.

Search wire contract (works on 4.7; describe-resolvable there, FK-based on 5.1): get_attributes accepts nested selects {name, select: [...]} to resolve relationships, but a LEADING '*' makes the server return raw records and ignore the rest of the list — relationship selects must come before the '*'. search_by_conditions accepts search_attribute: ['rel', 'subProp'] as a join into the related table; when the sub-property IS the related primary key AND we know the local foreign key, we query the FK directly instead (indexed, and works on 5.1's ops API which can't execute relationship joins).

Role permission.operations — the semantics are not what the shape suggests

Harper 5.0+ roles accept an operations allowlist. Almost every intuitive reading of it is wrong, and each of these cost a review round on #1628 — verify against the Harper source before changing src/features/instance/config/roles/operations/** or src/integrations/api/localRolePermission.ts, at the tag you mean. Harper changed gate 1 twice in 5.3, and resolveGrantBand turns a version into one of three bands (pre-5.3, 5.3-early, 5.3) so callers don't parse SemVer per operation: from v5.3.0-alpha.1 (harper f8a5aa90a) verifyPerms runs the gate ahead of every privilege early-return and chooseOperation runs it on sql; from v5.3.0-beta.3 every entry names its grant. allowlistGatesEveryPath(band) is the first change.

  • The list never narrows a stored super user, for a different reason on each side. Through 5.2.x verifyPerms returns early for super_user ("admins can do (almost) anything") before the gate. From v5.3.0-alpha.1 the gate comes first ("must run before EVERY privilege early-return"), and a list beside super_user would be enforced, but validateNoSUPerms (next bullet) refuses to store one. Either way the allowlist scopes a NON-super role (gate 2 then lets it hold otherwise-SU operations).
  • super_user/cluster_user + operations cannot even be saved. validateNoSUPerms rejects any permission with more than one key that sets either flag, so the combination 400s. Strip the allowlist for those roles rather than sending it.
  • structure_user DDL skips the list only before 5.3. Through 5.2.x create_table/ create_attribute/drop_table/drop_attribute (plus create/drop database for the boolean form) return before the gate. From v5.3.0-alpha.1 the gate runs first, so an allowlisted structure user runs that DDL only when it is listed. On both, the array form keeps the DDL to its listed databases (listing drop_table cannot reach another one), and its flag never covers create/drop database. structureUserDdlScope reads the shape only; the editor's copy takes the band.
  • Which names the gate matches depends on the version. Before v5.3.0-beta.3, 20 entries omit api_name, so the gate compares a camelCase handler name against a snake_case grant and never matches (deploy_component, get_status, registration_info, …; HarperFast/harper#2175). From v5.3.0-beta.3 every entry names its grant, and get_backup, read_transaction_log and catchup name null on purpose, so no allowlist reaches them. The catalog records this per entry (gate), and callers resolve the instance version into a GrantBand once.
  • sql ignores the list before 5.3 and needs it from v5.3.0-alpha.1. Through 5.2.x chooseOperation sends it straight to checkASTPermissions, which never consults the allowlist, so listing or omitting it changes nothing. From alpha.1 it calls verifyOperationsAllowlist on sql first, so a list without sql (or a group carrying it, like read_only) denies every SQL statement. Table permissions apply on both. The catalog marks it outOfGate: 'pre-5.3'.
  • Alias spellings are inert too (describe_database, search_by_id, create_schema, …): both spellings dispatch to one handler whose entry carries only the canonical api_name.
  • A non-iterable value takes the instance down. listUsers expands every assigned role's allowlist behind a truthiness-only guard, so a record/true/number reaches expandOperationsPerms and its for…of throws — rejecting the user-cache load and failing authentication for EVERY user. A bare string or an array with non-strings iterates fine (a string per character), so those are invalid but not fatal. Reachable via a v4 upgrade of a role granting a database named operations. Filed as HarperFast/harper#2194.
  • A database named operations breaks allowlists instance-wide. translateRolePermissions loops the instance schema, so perms.operations.tables[t] runs against the allowlist array and throws for any role carrying one.

Practical consequence for the UI: never describe a restriction the server does not enforce, and never advise "fixing" an operations value in place — both mistakes shipped and were caught in review. classifyOperationsValue needs the instance version because the same shape means different things on either side of the 5.0.0-alpha.8 floor (the feature's first tagged build, not 5.0.0). The editor's structure-user and SQL sentences need the band for the same reason: each is true on one side of v5.3.0-alpha.1 only.

Which of those the UI gating turns on

The two facts that decide Studio's permission hooks (src/hooks/checkOperationPermission.ts):

  • Gate admin chrome on the allowlist and you hide UI that works. No stored super user fails the gate on any version (see above), so useInstanceManagePermission is deliberately NOT gated. useInstanceBrowseManagePermission isn't either, but only pre-5.3 makes that exact: there its DDL half returns before the gate for a structure user. From v5.3.0-alpha.1 nothing does, so an allowlisted structure user is offered DDL the server refuses unless the list names it. The hooks can't see the version, and gating them on the list would hide DDL that works on 5.2, so they stay open and the server's 403 names the operation (#1894 tracks per-operation checks). The application-editor operations behind browse-manage (set_component_file, drop_component, deploy_component, set_env_value, restart_service) are not in STRUCTURE_USER_OPS, so for a non-super role they reach the gate on every version — where an allowlist listing them grants them via gate 2. Denying that half on the allowlist would hide UI a scoped role can legitimately use.
  • Only super_user short-circuits a specific operation. For DML, structure_user and cluster_user are gated on every version, so checkTableOperationsAllowed exempts the first flag alone. Asking whether a role carries an elevating flag is the editor's question, not this one.

Also two different expansions are correct for two different questions, and sharing one silently broke the second once: expandEffectiveOperations folds alias spellings together to describe a role's effective reach (display); simulating the gate must expand verbatim and canonicalize only the operation being checked, or a lone alias grant wrongly reads as working.

Settling one of these empirically

Reading verifyPerms is not enough — the super-user and structure-user verdicts above were confirmed by running it. In a Harper checkout, drop a throwaway spec in unitTests/utility/, require('#src/utility/operation_authorization'), build a hdb_user.role.permission literal, and call verifyPerms(requestJson, handler.name): null means allowed, a PermissionResponseObject means denied. testUtils.preTestPrep() + testUtils.setGlobalSchema(...) provide the schema globals, and rewire reaches internals — requiredPermissions (to audit which names carry an api_name) and verifyPermsAST (to show SQL skipping the gate). Run with npx mocha unitTests/utility/<file>, read the verdicts, delete the file. Measured this way: super_user + operations: [] still ALLOWS insert, search, restart and get_configuration, while super_user: false + operations: [] DENIES insert. Those are 5.2 verdicts. A built dist answers too, without touching a checkout — a checkout's, or an installed harper package's, which is how to compare versions: require its utility/operation_authorization.js, set global.hdb_schema, and call verifyPerms (or verifyOperationsAllowlist(requestJson, 'sql') for the SQL gate). Run that way on 5.2.3, 5.3.0-beta.2 and 5.3.1, structure_user: true + operations: ['read_only'] ALLOWS create_table on the first and DENIES it on the other two, and so does super_user: true + operations: [].

Datadog RUM — beforeSend fails open, so nothing in it may throw

The SDK wraps our beforeSend in catchUserErrors: a throw is swallowed, the hook returns undefined, and shouldSend drops the event only on an explicit false — and a view event can't be dropped from beforeSend at all (assembly.js warns "Can't dismiss view events using beforeSend!"). So a throw part-way through ships the event with only the mutations made so far. For a hook whose job is redaction, that is the one failure mode that matters, and it is why beforeSend.ts type-checks every field it touches instead of testing it for truthiness.

Same reason the URL redaction runs before shouldKeepEvent and the error-text redaction after it. The filter attributes errors on the raw stack, so that can't be rewritten ahead of it — but it reads no URL field, and it can itself throw on a malformed error field, which would ship the event with its URLs untouched.

error.stack is not the engine's stack string. The SDK parses it and re-serializes every stack it records, so what reaches beforeSend is always this shape, whatever the engine produced — no V8 at fn (url) or Safari fn@url variants get through:

<Name>: <message>
  at <func>[(args)] @ <url>[:<line>[:<column>]]

(toStackTraceString in @datadog/browser-core/cjs/tools/stackTrace/handlingStack.js, reached from domain/error/error.js.) Note the two-space indent and the space-padded @ separator: the frame's URL is reliably the trailing token, which is what lets shouldKeepEvent attribute a frame by parsing its URL rather than substring-matching the whole line.

Only the frames are normalized — the message is not, and that distinction is load-bearing. formatErrorMessage is just `${name}: ${message}`, so any newlines the thrower put in the message land in error.stack ahead of the real frames. Monaco's DI errors do exactly this: their message embeds a whole V8-native stack, which looks like frames but is text:

Error: [createInstance] Fm depends on UNKNOWN service ICodeLensCache.
    at e._createInstance (https://fabric.harper.fast/assets/editor.api-OBQnf1nL.js:817:2045)
  at <anonymous>

The four-space parenthesised lines are the message; only the last, two-space @-separated line is a frame the SDK emitted. Attribution therefore has to match the SDK's frame shape rather than hunt for a URL anywhere on the line — otherwise those message lines read as first-party frames. Roughly a third of Studio's RUM error volume is this family, so it is not an edge case.

Matching the frame shape is necessary but not sufficient: a multi-line message can put a line in the SDK's own frame shape above the real frames. Harper composes relayed text by interpolating customer input (redactRelayedMessage.ts), so at x @ <anonymous>:1:1 in an import field reaches the stack verbatim, and shouldKeepEvent runs on the raw stack, before any redaction. Skip the header span instead of trusting the shape — messageHeaderLength(type, message, stack) in redactRelayedMessage.ts computes it, and both the redaction and the attribution use it (#1659).

Two traps when re-checking this after an SDK bump. The pnpm store can hold several browser-core versions at once, so resolve the one browser-rum actually uses (require.resolve from the browser-rum entry) instead of globbing .pnpm — a stale sibling copy reads as authoritative. And a frame that fails to parse is treated as unlocatable and silently skipped, not flagged, so a regex anchored to end-of-line must tolerate a trailing carriage return from a CRLF stack; one stray character otherwise disables attribution for the whole stack without any symptom.

Only fields on the SDK's modifiable-field allowlist can be mutated here. view.name, view.url, view.referrer, context, service and version are shared by every event type; errors add error.message, error.stack, error.handling_stack, error.resource.url and error.fingerprint; resource events add resource.url (plus GraphQL/header/websocket fields). Grep the installed bundle for "view.referrer":"string" to re-check after an SDK bump.

Datadog RUM — exactly one startView per page load, or Core Web Vitals vanish

datadog.ts sets trackViewsManually: true, and the SDK's contract for that flag is narrower than it looks. It stays stopped until the first startView, adopts that call's options as its single initial_load view, then turns every later call into a route_change view (preStartRum.ts tryStartRum, trackViews.ts startView). Only an initial_load view runs trackInitialViewMetrics, so it is the only view that can ever carry LCP or FCP.

A second startView on boot therefore ends the one view that collects paint metrics. That is what zeroed Studio's vitals for a month (#1570): useDatadog and useOnRouteLoadTracker both called it, 0.3ms apart, and every initial_load event shipped with dom_complete but no lcp and no fcp. Ownership of that first call now lives in useOnRouteLoadTracker alone — it mounts on the root route (rootRoute.ts), so it runs on every cloud route.

Three traps when reading this from RUM data:

  • Scope every query to env:prod. Until isDeployedBuild landed, a bare vite build reported into the same app with no env tag at all: its mode is production, so Vite reads nothing from .github/deploy-public-env and neither DEV nor VITE_LOCAL_STUDIO is set. On 2026-08-26 that was 192 of 227 error events — 85%, all from one developer's 127.0.0.2:9926 — and it manufactured a 135-event "Monaco DI regression" that had zero production occurrences. An unscoped count is not a production count, and historical data from before that fix still contains this traffic.
  • Telemetry is now gated on VITE_TELEMETRY_ENABLED, which only the deploy action's build step sets — deliberately not any file in .github/deploy-public-env, because those are read by a local pnpm build --mode prod too. So a hand-built deploy-mode bundle stays silent, and both the RUM and GTM branches are constant-folded out of it entirely (verified: no client token, no gtm.js URL in the emitted assets). If you ever need telemetry from a local build, set that flag explicitly and know you are writing into real env: data.
  • view.time_spent on an initial_load view is measured from clocksOrigin() — page origin, not SDK start — so a ~1s time_spent does not mean the view was alive and observing for a second. It says nothing about the observation window.
  • Never name a view from window.location.pathname. Studio uses hash routing, so that is permanently / — which is why all 880 initial_load views in one week were named / whatever route actually loaded, and why #1405's "/ view regression" was really every deep-link entry conflated into one bucket.

A boot-time redirect does not cost you that first view, and the deciding factor is whether getAllConnections() can answer synchronously. It synthesizes { user: null, isLoading: false } for OverallAppSignIn only when no Studio:PotentiallyAuthenticated:OverallAppSignIn marker is stored; with one it returns the record untouched, so the key is simply absent. dashboardLayout.beforeLoad guards on auth && !auth.isLoading && !auth.user, so those are two different outcomes: a signed-out deep link redirects during the router's initial load and the root component never commits the deep-link location — one startView, named /sign-in/ — while an expired session short-circuits on the missing key, renders the deep link, and only redirects once auth resolves and AppRouted calls router.invalidate(). Two views there, a network round trip apart, the second a genuine navigation.

Verifying a change here needs a visible browser on a production build: a headless or background tab reports visibilityState: 'hidden', emits zero paint and LCP entries, and trackFirstHidden discards them anyway — so vitals always read as absent, and a broken fix looks identical to a working one.

Never put an address or a credential in a URL

Studio uses hash routing, so a query param lives in the fragment: for https://fabric.harper.fast/#/sign-in?me=…, new URL(url).search is empty and anything built on URLSearchParams silently does nothing. Match the raw string.

The RUM SDK reads view.url/view.referrer from window.location, and every third-party pixel we load reads the page URL too — so a param is not a private channel. As of 2026-08-18 the auth screens' ?me=/?email= form persistence and the /config/users/<address> route were putting addresses there, and ?token= on /reset-password was putting a live password-reset credential there.

redactSensitiveParams keeps both out of Datadog — credential params by name, addresses by matching the address token anywhere in the URL (a param value ending at a delimiter cannot cover a path segment or an unencoded JSON value, and picking a delimiter set trades one leak for another). It applies to URL fields only: user@host.tld is also an scp-style git remote, and redactErrorText deliberately keeps the host of git@github.com:<redacted> in free text, so error text gets redactCredentialParams instead.

None of that helps anywhere else. Every third-party pixel we load reads the page URL, so those addresses also reach HubSpot, Meta, LinkedIn and GA — beforeSend cannot touch what they send. Carry the value in router state or sessionStorage, and key routes by an opaque id.

Tailwind — --spacing(N) in an arbitrary value is a function, not a variable

h-[calc(100vh-(--spacing(32)))] (used by the databases, log, and API-explorer sidebars) is valid Tailwind v4: --spacing(N) is the framework's spacing function, compiled to calc(var(--spacing) * N) = 8rem here. It is not var(--spacing-32) — the default theme defines only the base --spacing: 0.25rem, so a var(--spacing-32) "fix" resolves to nothing and collapses the calc() (the container height drops to auto). Multiple code-review models flag the function form as invalid CSS and suggest the var() form; don't take the bait. Verify by measuring, not by reading: the container computes to exactly innerHeight − 128px when the function resolved.

Dropping errorHandler from an onError also drops the RUM report

The Datadog RUM SDK instruments console.error and reports each call as an error with error.source: "console" — no datadogLogs init involved, and we have none. errorHandler (src/react-query/queryClient.ts) opens with console.error(rawErr), so a handled query/mutation rejection reaches RUM today purely as a side effect of showing its toast — subject to shouldKeepEvent, which then drops timeouts, 401s and third-party stacks in beforeSend. Reaching RUM is not the same as reaching Error Tracking.

That matters when replacing a toast with inline form copy: drop the errorHandler call and the failure silently stops being reported. The auth forms that render their own failure keep an explicit console.error(error) (reportAuthFailure, which logs an offline failure with console.debug instead) for exactly this reason — at mutation level, in the useMutation({ onError }), not in the caller's mutate(…, { onError }) callback. React Query skips the latter when the component unmounted mid-flight, which is precisely when someone gave up on a slow sign-in and navigated away. All three auth forms (sign-in, sign-up, forgot-password) report this way. Exclude control flow and expected answers from it — SignIn skips the unverified-email rejection there, because submitForm redirects into the verification flow on it and reporting it would file every unverified sign-in; sign-up and forgot-password skip a CAPTCHA rejection, the check's expected verdict, which they state inline (#1688). Reported, it arrives only as a generic Request failed with status code 403; count CAPTCHA refusals from @type:resource 403s on POST /User/ and /ForgotPassword/ instead.

A related trap when reasoning about these forms: a minimal useForm probe does not reproduce them. react-hook-form clears a root error on a resolver-rejected resubmit in isolation, but SignUp demonstrably does not (#1677), and handleSubmit(fn, () => clearErrors('root')) does not change that. Reproduce against the real component, and assert the request count so a "stale" alert cannot actually be a second identical rejection. Keeping the failure in component state, outside react-hook-form, is the model that clears correctly — sign-in has always done this and forgot-password moved to it; sign-up is the remaining root user. The inverse trap is real too — a .catch() written to reduce RUM noise must not console.error, which is what #1658 was. console.debug is not collected, and is the channel for a swallowed failure you still want in devtools.

Error boundaries are the same trap from the other side. React's root onCaughtError already reports every error any boundary catches, through reportReactError, so a componentDidCatch that console.errors files a second, console-sourced copy of each one. Log there with console.warn (not collected either, but visible in devtools by default). That reporter also collapses repeats: a TanStack route re-throws its stored error on every match update and every reload throws a fresh identical one, so one failed load used to reach Error Tracking up to 14 times (#1695).

A 5xx body never reaches an auth form

describeError renders whatever the server sent, and before #1676 that reached the sign-in and sign-up forms as a toast. Those pages are anonymous and the inline alert this PR added persists, so describeAuthFailure substitutes our own copy for every 5xx (and 429) rather than deferring: a 5xx body is our infrastructure talking — Harper's "exceeded request queue limit for resolving cache record", or an upstream connect ECONNREFUSED 10.0.3.x:9925 — none of it actionable by a signed-out visitor, and some of it our topology. 4xx still defers, because that is where an authored, actionable reason lives.

Which 5xx copy is shown turns on whether the request could already have been processed, not on curryRetryGatewayErrors's retry list — that interceptor is installed on instance clients only (getInstanceClient.ts), never on apiClient, so nothing auto-retries an auth call and the retry the copy invites is the user's own. Only 503 promises a plain retry, because a declining server very likely never processed the request. 502, 504, any timeout, and ERR_NETWORK get a third message instead: all three auth submits are non-idempotent POSTs, and each of those means the request may already have been applied — axios reports a CORS rejection and a connection dropped after the POST both as ERR_NETWORK, so none of them can claim the server was never reached. RFC 9110 §9.2.2: a retry is only safe once you know the request was not applied.

The one ERR_NETWORK that gets "check your connection, then try again" instead is a request sent while onlineManager reported offline, to a host that isn't loopback. Every auth form's mutation (sign-in, sign-up, forgot and reset password, email verification, cluster/instance sign-in) spreads failFastOffline, which sets networkMode: 'always' and rethrows that failure as an OfflineSubmitError. React Query's default 'online' mode pauses a mutation while offline rather than failing it, so the form spun with nothing to show, and the request went out whenever the connection came back, carrying a CAPTCHA token that had expired by then (#1930). An offline failure is logged with console.debug, never console.error. Sign-out, Finish Setup and every non-auth mutation still pause (#1933).

That message states the uncertainty and stops — the remediation is the caller's, because only the form knows what recovery means for its endpoint ("check your inbox before requesting another link", "check your email for a verification link", "try signing in again"). Do not fold a generic "reload and try again" back into the shared copy: reloading an anonymous form performs no status check, so following it just repeats the side effect this branch exists to avoid (#1668). Everything else 5xx gets copy that does not promise waiting helps, plus a way to escalate — and SubmitErrorMessage decides that from the message itself rather than from a prop each caller must remember, which is what kept two of three forms from shipping without it.

Do not "improve" this by rendering the server's 5xx sentence when it looks presentable. Two earlier passes tried gating on whether the body held a usable sentence (truthiness, then a length and leading-character heuristic); both leaked, and both duplicated describeError's extraction where it could drift. Gate on status, which cannot.

Worth knowing for the telemetry half: with no body describeError falls back to errorText(err.message), so the pre-#1676 user saw the bare Request failed with status code 503. "We had some trouble!" is the next fallback down and needs message absent too, which real AxiosErrors never are — it shows up only in tests whose fixture omits it, so don't read a test expectation as production behavior here.