Resolve Names Server-Side in Control Plane

Claude Code·Opus 4.8[1m]·Unknown author·3mo ago·19hr 5min·2 Checkpoints·28 file changes·+1783/-1263·222.5K tokens

take a look at https://entire.io/gh/entireio/cli/trails/641/control-plane-cli-friendly-names-close-two-api-gaps using entire trail show ... then check out the apporpriate branch, rebase on origin/main, and walk me through the findings. we'll decide whether and what to do

3mo ago·15m

Done. Here's the walkthrough and what I did.

Bottom line

Trail #641 is checked out, rebased cleanly on origin/main, all 5 findings addressed, and the working tree passes CI-style lint + the touched-package tests. Nothing committed — yours to review/commit. One finding turned out to be inverted by the rebase (see below).

What the trail said (5 medium findings, all in resolveref.go + 2 stale)

  • #1 case-sensitivity (conf 0.70): pickProject/pickOrg re-filtered case-sensitively after the server's name filter — case-mismatched query silently fails.
  • #2 empty AccountId (0.65): resolveAccountRef forwarded "" as the owner ULID into project-create.
  • #3 resolvers untested (0.60): the network-branching resolveXRef funcs had no tests.
  • 2 stale: "don't drop the //nolint:ireturn directives or CI breaks."

What I changed (per your "all four" + "normalise case both sides")

  • #1: strings.EqualFold in pickOrg, pickProject, and filterProjectsByName — matches the server's lower(name) uniqueness you described. The CLI side is done; the server-side normalization lives in the entire.io repo, not this worktree — that part is on you/another change.
  • #2: guard returning handle %q resolved to no account on empty AccountId.
  • #3: TestResolveOrgRef/ProjectRef/AccountRef asserting the load-bearing invariant — ULID ref = 0 HTTP calls, name/handle = exactly 1 — plus case-insensitive and empty-account cases, via an httptest server with a request counter.

The twist — the 2 stale findings inverted

After rebasing, origin/main's .golangci.yaml now allowlists checkpoint.CommittedStore/TemporaryStore for ireturn. So the three //nolint:ireturn directives (which branch commit 182b9702c6 had restored) are now redundant, and CI's nolintlint fails on them as "directive unused." I verified this directly: golangci-lint without --fix reports 3 errors with them present, 0 with them removed. So the correct post-rebase state is the opposite of what those findings said — directives removed. They're now removed in the working tree (you'll see open.go and manual_commit.go in the diff).

Heads-up: I caught this because mise run lint runs golangci-lint --fix and silently edited those files — CI doesn't use --fix. Saved that footgun to memory.

Trail state

All 5 findings closed: 3 resolved (with notes), 2 dismissed (notes corrected to explain the inversion). 0 open.

Diff: resolveref.go (+33/-17 incl. tests file), resolveref_test.go (+237), open.go/manual_commit.go (directive removals). Want me to commit?

that sounds good. so we've addressed all findings? if so, commit and push and respond to the findings.

3mo ago·2m

Done. All findings addressed, committed, pushed, and answered on the trail.

Summary

  • Commit 789678d091 pushed to feat/control-plane-friendly-names (force-with-lease, since the rebase rewrote the 4 commits).
  • 0 open findings: 3 resolved (case-insensitivity, empty-AccountId guard, resolver tests) each annotated with the SHA; 2 dismissed (the ireturn ones, with corrected notes explaining the rebase inverted them).
  • CI-style lint clean (golangci-lint without --fix) and touched-package tests pass.

One non-blocking note: the server-side half of the case normalization (lower(name) uniqueness) lives in the entire.io repo, not this one — only the CLI side shipped here.

Heads-up unrelated to this work: the push logged a checkpoint-remote sync failure — your Secretive SSH agent refused to sign (agent refused operation), so entire/checkpoints/v1 didn't sync. Your branch pushed fine; you may want to unlock/check that agent if you rely on checkpoint sync.

oh damn, this trail is stacked on another branch, right? we should rebase --onto it because we just pushed that branch too.

3mo ago·35.7s

There's a sibling branch feat/control-plane-crud-gaps whose tip is literally "drop now-unused //nolint:ireturn directives" — that's almost certainly the parent this trail is stacked on, and the branch you just pushed. Let me confirm the stack relationship and what my earlier rebase-onto-main actually did.

wait wait wait doesn't entire trail show .. tell us branch and base?

3mo ago·21.5s

You're right — and it answers the question. The trail says Base: main (from entire trail show 641), and I just verified feat/control-plane-crud-gaps is NOT an ancestor of the branch. So it's not actually stacked.

Bottom line

  • No rebase --onto needed. The trail's declared base is main, and my rebase onto origin/main was correct.
  • feat/control-plane-crud-gaps is a sibling/superset, not a parent: it contains its own copies of the same friendly-names commits (e.g. our e918831a49 ≈ its 1f4ec384e5) plus the CRUD-gap + pagination work. Different SHAs, shared content — that's why it looks related.
  • Confirmation: git merge-base --is-ancestor origin/feat/control-plane-crud-gaps <old friendly-names tip> → NO. The branch was created from e3fc6f88c6 (a main merge commit), never from crud-gaps.

So nothing's broken — the branch is correctly based on main, committed, and pushed.

One thing worth flagging: since crud-gaps duplicates this branch's commits and adds more, these two branches will overlap heavily. If crud-gaps is the one heading to merge, this branch may be redundant. Want me to check whether crud-gaps already supersedes everything here (i.e., whether this branch/trail is still needed)?

nah forget about the other branch. all findings addressed & resolved ?

3mo ago·11.9s

All five findings addressed and closed on the trail:

  • #1 case-sensitivity → resolved (EqualFold in pickOrg/pickProject/filterProjectsByName)
  • #2 empty AccountId → resolved (local guard)
  • #3 resolvers untested → resolved (new resolver tests)
  • 2 stale ireturn → dismissed (now redundant; CI would fail with them, so removed)

Trail #641: 0 open, 3 resolved, 2 dismissed. All resolutions annotated with commit 789678d091, which is committed and pushed.

confirm for me that all our lookups are now O(1)? We should not be doing any client-side "get full list and filter" any more, right?

3mo ago·35.7s

Confirmed by the code — and the honest answer is no, not all lookups are O(1). Two paths still fetch a full list and filter client-side, and both are blocked by the server API, not by CLI choice.

LookupPathCostWhy
project by name (resolveProjectRef)ListProjects{Name:}server-filtered ✅endpoint has a Name param
account by handle (resolveAccountRef)ResolveHandlepoint lookup ✅dedicated resolve endpoint
org by name (resolveOrgRef)ListOrgs → pickOrgclient-side filter ❌ListOrgs takes no params — no server-side name filter exists
project list (filterProjectsByName, project list --org --name)org-scoped list → filterclient-side filter ❌org-scoped list endpoint has no Name param

Bottom line

  • ListOrgs(ctx) has no parameters at all (oas_client_gen.go:209), so resolving an org by name must pull every visible org and match locally. The pickOrg doc comment says exactly this ("filters the full ListOrgs result client-side").
  • filterProjectsByName exists specifically because the org-scoped project list has no name filter (its own comment notes this).

These aren't fixable in the CLI — they need the control-plane to add a name query param to GET /orgs (and to the org-scoped project list). Until then, the EqualFold change I made is the right behavior, but it's still O(n) over the returned set.

Worth a quick check before you decide: I haven't verified whether ListOrgs is paginated — if it is and we only read page one, org-by-name resolution could be not just slow but incorrect (miss an org on a later page). Want me to check that?

hm. Take a look in ~/src/entirehq/entiredb (which is where entire-core lives, that's the server we would need to adjust), and make sure:

  • fetch origin/main
  • create new git worktree off origin/main,
  • ensure that the APIs you need provide the O(1) semantics we need.
  • Additionally, ensure that case folding is uniform. I want all these by-name lookups to be case insensitive where we use INDEX UNIQUE on lower(name).
3mo ago·22m

Done implementing in entire-core. Here's the bottom line.

What I changed (worktree: ~/.claude/worktrees/entiredb/control-plane-name-lookups, branch paul/control-plane-name-lookups, off origin/main)

Goal achieved: all by-name lookups are O(1) and case-insensitive via lower(name) unique indexes.

Migrations (goose):

  • 022_orgs_lower_name_unique.sql — drop orgs.name column-UNIQUE, add UNIQUE INDEX orgs_lower_name_key ON orgs(lower(name))
  • 023_projects_lower_name_unique.sql — replace projects_name_key(name) with (lower(name))
  • (repos already had lower(name) indexes from migrations 012/018)

Queries fold case:

  • GetOrgByName → WHERE lower(name)=lower($1)
  • GetProjectByName → new foldProjectName (lowercases instead of rejecting uppercase) + WHERE lower(name)=$1
  • GetRepoForProjectByName + GetRepoRegistrationByProjectAndName → AND lower(name)=lower($2)

New API — closes the CLI's org-scoped gap:

  • GET /api/v1/orgs/{orgId}/projects?name= returns the single org-owned project (case-insensitive) or 404. Reuses global project-name uniqueness + an ownership check; no new index.

Tests added: org/project case-insensitive lookup, case-variant create conflict (409), repo case-insensitive lookup, and an endtoend test for the org-scoped ?name= path (incl. 404 for a foreign-org project).

Status

  • ✅ go build ./core/... ./api/... passes
  • ✅ mise run lint — 0 issues (all sub-linters)
  • ✅ tests compile (go vet clean)
  • ⚠️ tests not run — the local CockroachDB + SpiceDB stack isn't up (the pre-existing TestGetProjectByName_TrimValidateAndMissing fails identically on connection refused), and your CLAUDE.md says you manage Docker.

Key finding worth your attention

The server was already O(1) for org-by-name (GET /orgs?name= → GetOrgByName) and global project-by-name. The CLI just isn't using those params (its generated ListOrgs(ctx) has none). So after this server change ships, the CLI work is: regenerate the coreapi client, pass name to ListOrgs, use the new org-scoped ?name=, and delete resolveOrgRef's client-side filter + filterProjectsByName.

Want me to start the stack and run the tests (need your OK to touch Docker), or leave that to you? And should I commit this branch?

okay open a PR for me.

3mo ago·2m

PR opened: https://github.com/entirehq/entiredb/pull/2159

  • Committed, pushed paul/control-plane-name-lookups, PR against main.
  • Build + lint clean; tests written but not run (need the CockroachDB/SpiceDB stack).
  • PR body flags the create-semantics change (case-variant org names now 409) and the CLI follow-up.

what if there are already case-insensitive conflicts in the DB?

3mo ago·8m

Done. PR #2159 updated: https://github.com/entirehq/entiredb/pull/2159

  • Added the pre-deploy collision check + detection query to migration 022's comment (new commit 29bdbd743d).
  • Updated the PR body with a ⚠️ pre-deploy section so reviewers see it.
  • Org create unchanged — mixed-case names stay allowed; only uniqueness folds case.

Net: projects/repos carry no migration risk; orgs will fail the index build loudly (non-destructively) on a pre-existing collision, with a documented query to find and resolve them first.

okay, assume that has landed. now let's get back to feat/control-plane-friendly-names work. i think this simplifies the doubts we had around O(1).

3mo ago·11m

Committed 415781c661 on feat/control-plane-friendly-names (not pushed — your call).

What changed

You were right — the server work simplifies this and removes the doubts entirely. The CLI now delegates to the server instead of fetching-and-filtering:

  • Regenerated coreapi client from a hand-authored spec bump: ListOrgs gains a name param + singular org; GET /orgs/{orgId}/projects gains name + singular project. (Idempotent when the real curl refresh runs post-deploy.)
  • resolveOrgRef/resolveProjectRef read the single server match and map 404 → friendly "no X named".
  • project list --org --name filters server-side now.
  • Deleted pickOrg, pickProject, filterProjectsByName — all client-side filtering gone.

Bonus: fixed a latent bug

The old name paths read out.Projects, but the server returns a name match under the singular out.Project — so an existing project resolved as "not found" against the real server. Both resolveProjectRef and project list --name were affected. Now fixed.

Verification

  • go build ./..., full cmd/entire/cli tests, and go vet -tags 'integration e2e' all pass.
  • mise run fmt && mise run lint → 0 issues.
  • Resolver tests rewritten to assert the CLI sends ?name= and reads the singular response (filtering is genuinely server-side now).

Net result on your original O(1) question

All four by-name lookups (org, project, org-scoped project, repo) are now O(1) server-side and case-insensitive. No client-side list-and-filter remains.

Note: this depends on entiredb PR #2159 being merged + deployed before the CLI branch ships, since the regenerated client calls the new params. Want me to push, or leave it?