Skip to content

A refused domain change or cluster edit explains why on the page, and Studio checks a domain change before sending it - #1846

Draft
dawsontoth wants to merge 1 commit into
claude/1800-bind-only-live-selectionfrom
claude/1686-domain-change-preflight
Draft

dawsontoth wants to merge 1 commit into
claude/1800-bind-only-live-selectionfrom
claude/1686-domain-change-preflight

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Refs #1686

Stacked on #1840: #1801 → #1802 → #1840 → this PR. Its base is claude/1800-bind-only-live-selection, so this diff is only the #1686 change. Review and merge the three below it first.

⊙ Problem

PUT /Cluster/{id} is the endpoint behind Bind, Unbind and the cluster edit page. In RUM it was refused 39 times out of 40 in one day, on one cluster, while three sessions retried across three pages (#1686). Studio gave them nothing to go on: each refusal was a toast that faded, and the domain changes sent a whole domainIds list built from whatever the page last cached, unchecked. That list is a replacement, and central manager unbinds every domain it leaves out, so a stale list also silently unbinds a domain another member bound meanwhile.

RUM has no response bodies, so which refusal #1686 hit isn't provable. The central-manager source narrows it. The only 400 that can block both a domain change and an edit is assertClusterMutable (updateCluster.js:69 → sharedHelpers.js:587-606): it refuses every update unless the cluster is RUNNING and no instance is FAILED or transitioning, and transitioning includes STOPPED. The session in #1686 stopped and restarted its containers mid-way, which fits, but it isn't confirmed. Hence Refs, not Closes: the root cause still needs the central-manager logs.

❓ Your call: is the pre-check worth keeping beside the inline reason? With the reason on the page, a user already sees central manager's own sentence. The pre-check stops the doomed request, names the domain rather than its id, and makes a stale-tick retry work at once. The cost is a hand-kept copy of central manager's instance-status list, written so it can only be narrower than central manager's.

💡 Solution

A refused change now says why, on the page, and Studio only sends a domain change central manager will accept, judged by a fresh read:

For example, a bind on a cluster with a stopped member now reads Instance node-1.example.com is STOPPED. This cluster's domains can change once every instance is running. and sends nothing. A refusal Studio can't foresee reads The domain change didn't go through: Cluster has an instance that is not RUNNING (…). Resolve the instance before the update.

⚖️ Alternatives

❓ Your call: the planning review returned better-alternative-exists, and I adopted its alternative. My plan kept #1801's whole-list contract and checked only the ids it sent against a fresh domain list. The reviewer's counterexample beats it: the list still came from the cached cluster, so a domain another member bound meanwhile passes every check and is silently unbound. The cost is a contract change under #1801: its cache write of the accepted list is gone, since every change now re-reads the cluster, and its test "builds the next change … even when re-reading the cluster fails" now asserts the change is refused. A list built on a cluster Studio couldn't read is the thing this PR stops sending.

  • Fix it in central manager (validate before patching, compute removals from the domains' own clusterId, accept deltas): that is where the invariant belongs, and it is filed as HarperFast/central-manager#937. This task was Studio-only, and Studio has to show whatever central manager refuses with anyway.
  • Only show the server's reason inline, no pre-check: smaller, but it keeps sending requests known to fail, and central manager names a foreign-bound or missing domain by raw id.
  • Pre-check the edit page too: the version form deliberately allows a resubmit for a partial upgrade, and its submit gating spans two footers and the billing step. The inline reason covers a refusal there without a second copy of the gate.

🔧 Changes

✅ Verification

End-to-end route: not observable end-to-end here. It needs a central manager and a cluster with a stopped or transitioning member, and a bind changes real infrastructure. Verified at the request boundary against a mocked apiClient, with the test client wired to the app's own error handlers. The copied status list is only proven against central manager's source (cited in the code), not against a running one; a staging pass over Bind, Unbind and edit on such a cluster would close that. After deploy, @type:resource @resource.method:PUT @resource.url:*/Cluster/* 4xx counts should fall, since a refusal no longer sends the request.

Pre-push review: a planning round (codex; verdict better-alternative-exists, adopted as above) and two full rounds (codex graded, gemini). The Harper-domain adjudicator failed on auth and the Cursor legs failed on the 1Password SSH agent every round, so no round converged. Round 1 found that a failed fresh read went through the query cache's toast handler and was reported twice (fixed: plain fetchers, and the test client now mirrors the app's handler). In round 2 codex ruled every earlier finding resolved and raised only comment nits. I rejected:

  • "activeQueryClient is never declared in useDataTableColumns", a blocker (gemini, round 2): it's declared at tableDefinition.tsx:37, added in Bind on the Domains page sends only the domains still offered for binding #1840 and so outside this diff; tsc -b and the tests that render the table pass;
  • "unbind invalidates the wrong client and key, so the domain stays bound until reload" (gemini, round 1): in production the module singleton is the provider's client, and [orgId] is a prefix of [orgId, 'domains']. I switched the moved lines to the active client anyway, for testability;
  • the remaining comment nits (both legs): the kept comments state constraints the code can't (why the reads bypass the query cache, why the toast is off but RUM is not, why a queued click is refused, why the test router mock keeps one object).

Also filed: HarperFast/central-manager#937 (P2, Bug): updateCluster's domain branch can unbind another cluster's domain, strand a half-bound one, or throw on a deleted id. #1843 (P3, Bug): the Domains table's Remove tooltip never opens, because TooltipTrigger asChild wraps a fragment. It's pre-existing, and it's the React warning in these test runs.

🤖 Generated with Claude Code (Claude Opus 5.5); posted via @dawsontoth.

Related PRs: #1801 overlaps (stack base; this PR replaces its whole-list cache write), #1802 overlaps (stack base), #1840 overlaps (this PR's base), #1842 overlaps (both add ClusterForm.test.tsx; whichever lands second merges the file)
Complexity: complicated

Review-Coverage: authored=claude; ran=codex,gemini; blocked=cursor-grok(no-receipt),cursor-composer(no-receipt),domain(auth),cursor-muse(no-receipt); declined=cursor-kimi; rounds=2; full=1 @ ee2b5ba

Review-Attention: study ~9m (raised: degraded review) @ ee2b5ba

…n the page, and pre-check it

A refused PUT /Cluster/{id} (bind, unbind, or a cluster edit) produced only a toast that faded,
and the domain changes sent whole lists Studio had not checked. RUM showed 39 of 40 such PUTs
refused in a day on one cluster, with users retrying across three pages (#1686).

Domain changes now pass intent (add, remove). The mutation builds the list from a fresh read of
the cluster and the organization's domains rather than from what the page last saw, so a domain
another member bound meanwhile is no longer silently unbound. Before sending, it refuses what
central manager would refuse, mirroring its source:

- the cluster is not RUNNING, or an instance is FAILED or in TRANSITIONING_INSTANCE_STATES
  (assertClusterMutable, sharedHelpers.js:587-606; STOPPED and UPDATED included);
- the cluster is self-managed (updateCluster.js:166);
- a domain the change touches is missing, unverified or bound to another cluster
  (updateCluster.js:173-191), the cluster's existing ones included; those point at support.

It refuses rather than drops: central manager unbinds whatever the new list leaves out without
checking which cluster holds it (updateCluster.js:199-205).

Bind and unbind failures, refusals and central manager's own reasons alike, now stay on the
Domains page; a cluster edit's failure stays beside its submit button. Both mutations skip the
global toast and report real request failures to RUM at mutation level. #1801's cache write of
the accepted list is gone, since every change now re-reads the cluster.

Refs #1686

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the custom domain management flow to build and validate domain changes from a fresh read of the cluster and organization domains rather than relying on stale page state. It introduces client-side pre-validation (describeDomainChangeRefusal) to replicate central manager's mutability and domain checks, allowing Studio to present clear, actionable inline error messages (via SubmitErrorMessage) instead of generic 400 errors or fading toasts. Additionally, it updates the edit cluster forms and billing screens to display inline submission errors and adds comprehensive unit tests covering these refusal scenarios. I have no feedback to provide as there are no review comments to evaluate.

@github-actions

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 68.93% 10364 / 15034
🔵 Statements 69.07% 11054 / 16004
🔵 Functions 62.46% 2686 / 4300
🔵 Branches 63.86% 7893 / 12359
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/cluster/domains/Management.tsx 81.05% 88.13% 85% 82.41% 99, 126, 234-260, 268, 274-275
src/features/cluster/domains/constants/tableDefinition.tsx 92.3% 86.2% 91.66% 94.59% 123, 156, 169
src/features/clusters/hooks/useUpdateCluster.ts 83.33% 100% 100% 83.33% 18
src/features/clusters/mutations/describeDomainChangeRefusal.ts 100% 90.9% 100% 100%
src/features/clusters/mutations/setDomainIdsOnCluster.ts 100% 90.9% 100% 100%
src/features/clusters/upsert/ClusterBilling.tsx 78.57% 52% 100% 78.57% 92-106
src/features/clusters/upsert/ClusterDetails.tsx 84.84% 56.36% 100% 83.87% 74-79, 83-88, 123-124, 195-264
src/features/clusters/upsert/ClusterForm.tsx 52.53% 30.72% 60.86% 53.33% 109-118, 123-127, 134-166, 210, 213-226, 230, 233, 262-271, 280-283, 294-297, 339-372, 382-389, 393-398, 411-441, 461-462, 468, 472
src/features/organization/queries/getOrganizationDomains.ts 100% 100% 100% 100%
Generated in workflow #2139 for commit ee2b5ba by the Vitest Coverage Report Action

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant