Hard-won, project-specific gotchas. Skim the relevant section before touching related code — each of these has cost a debugging session.
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.
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.
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.
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.
@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.enableColumnFilteretc. only typecheck once their feature is instudioTableFeatures. 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 setsmanualSorting: true. Without it the client would re-sort the page behind the query —TableView.test.tsxguards 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).
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.
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.)
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.
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.
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.
.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.
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.)
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.
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 definewindow.PointerEvent = class extends MouseEvent {}(jsdom lacksPointerEvent;MouseEventcarries thebuttonfield Radix checks). - OPEN the menu with
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false })— a plainfireEvent.clickdoes NOT open it (Radix opens on pointerdown). - SELECT a menu item with
fireEvent.click(item)— RadixMenuItem.onSelectfires from the click handler. - Items expose roles
menuitem/menuitemcheckbox(witharia-checked); the container ismenu. Disabled items havearia-disabled="true"and/ordata-disabled.
Working example: src/features/instance/databases/components/PickColumnsDropdown.test.tsx.
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
nullwhile closed gets both in one comparison (seeEditTableRowModal's render-time reset). - Or give the modal a
keythat changes per target and render it only while open, which unmounts the component with the dialog (AddTableRowModalinDatabaseActionModals).
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.
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.
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
putsends 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_idhas norefetchIntervaland 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
putroute, and only the records that made them.functions/removedRecordAttributes.tsreports removals per record, not as one flat list. An edit that merely changes values stays anupdate; 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 throughputwould clobber a concurrent writer's change to an attribute that record's edit never touched. UseObject.hasOwn, neverin, to test presence — an attribute namedconstructor/toString/valueOfreads as present through the prototype chain and its removal silently takes the merge path. puthas 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 theupdateoperation allowlist, which Harper does not require forput: it authorizes from the raw table insert+update flags plus aputallowlist entry, so a role withoperations: ['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 staytruefor such a role, so a gate built from them offers a save that 403s. Check bothattribute_permissions(v5) andattribute_restrictions(translated v4). Onlysuper_usershort-circuits;structure_usercovers DDL, not DML.- Version-gated, but an unreadable version does NOT block the save. Harper denies
registration_infoto any role whoseoperationsallowlist does not list it (and before 5.3 to one that does —gate: 'from-5.3'inconfig/roles/operations/operationsCatalog.ts), which is exactly the shape of rolecheckTablePutPermissionexists 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:putis 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. NotesupportsPutOperationrefuses5.3.0prereleases while the role catalog floorsputat 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.tsrefuses 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:updaterequires 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,updatepatches 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, whichupdateskips andputcannot 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
putwrites the lie back. The operations-API attribute projection reports every registered attribute, filling innullfor one the record doesn't have — verified by inserting a record that never had it.SELECT *reflects real storage. Browse reads throughsearch_by_conditions/search_by_idwithget_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 inventednulls are in the editor buffer, so aputstores them as explicit nulls — turning "absent" into "null" for attributes the user never touched. Studio cannot fix that from the client: a projectednulland a storednullare 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 inSELECT *, 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.tsupdateAndFreeze), so deleting a property inside an object already works through a plainupdate.
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.
@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 thattype/elementsnames a sibling table.@computedattrs are omitted unless describe is sentinclude_computed: true(4.7.40, Oct 2026), and then carrycomputed: true. - Harper 5.1: relationship attrs are omitted from describe entirely;
@computedattrs appear only withinclude_computed: true, and carrycomputed: 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).
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
verifyPermsreturns early forsuper_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 besidesuper_userwould be enforced, butvalidateNoSUPerms(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+operationscannot even be saved.validateNoSUPermsrejects 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_userDDL skips the list only before 5.3. Through 5.2.xcreate_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 (listingdrop_tablecannot reach another one), and its flag never covers create/drop database.structureUserDdlScopereads 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, andget_backup,read_transaction_logandcatchupnamenullon purpose, so no allowlist reaches them. The catalog records this per entry (gate), and callers resolve the instance version into aGrantBandonce. sqlignores the list before 5.3 and needs it from v5.3.0-alpha.1. Through 5.2.xchooseOperationsends it straight tocheckASTPermissions, which never consults the allowlist, so listing or omitting it changes nothing. From alpha.1 it callsverifyOperationsAllowlistonsqlfirst, so a list withoutsql(or a group carrying it, likeread_only) denies every SQL statement. Table permissions apply on both. The catalog marks itoutOfGate: '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 canonicalapi_name. - A non-iterable value takes the instance down.
listUsersexpands every assigned role's allowlist behind a truthiness-only guard, so a record/true/number reachesexpandOperationsPermsand itsfor…ofthrows — 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 namedoperations. Filed as HarperFast/harper#2194. - A database named
operationsbreaks allowlists instance-wide.translateRolePermissionsloops the instance schema, soperms.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.
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
useInstanceManagePermissionis deliberately NOT gated.useInstanceBrowseManagePermissionisn'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 inSTRUCTURE_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_usershort-circuits a specific operation. For DML,structure_userandcluster_userare gated on every version, socheckTableOperationsAllowedexempts 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.
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: [].
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.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. UntilisDeployedBuildlanded, a barevite buildreported into the same app with noenvtag at all: its mode isproduction, so Vite reads nothing from.github/deploy-public-envand neitherDEVnorVITE_LOCAL_STUDIOis set. On 2026-08-26 that was 192 of 227 error events — 85%, all from one developer's127.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 localpnpm build --mode prodtoo. 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, nogtm.jsURL in the emitted assets). If you ever need telemetry from a local build, set that flag explicitly and know you are writing into realenv:data. view.time_spenton aninitial_loadview is measured fromclocksOrigin()— page origin, not SDK start — so a ~1stime_spentdoes 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 880initial_loadviews 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.
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.
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.
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).
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.