Debug CLI Core and Cell API Endpoints

Both sweeps are done. Here's the full map of what the CLI talks to.
Two planes, two clients
- Core (control plane) —
internal/coreapi— an ogen-generated REST client, bearer-only, everything under<core-origin>/api/v1. Origin resolves from the active login context (orENTIRE_TOKEN'saud, or a named cluster). No gRPC — the whole surface is HTTP/JSON. - Cell (data plane / entire-api) — a generic
api.Clientwhose "cell-ness" is just the per-jurisdiction base URL + identity token it's built with. Paths are string literals at each call site.
Core (control-plane) endpoints in use
All relative to <core>/api/v1.
Identity
GET /me—auth statusliveness/profile probe, mirror-create wizard
Orgs / Projects / Repos
POST|GET /orgs,GET|DELETE /orgs/{org}POST|GET /projects,GET|DELETE /projects/{project},GET /orgs/{org}/projectsPOST /repos,GET|DELETE /repos/{repo},GET|PUT /repos/{repo}/visibility,GET /projects/{project}/repos
Mirrors / clusters
POST|GET|DELETE /mirrors,GET /mirrors/{mirror},GET /mirrors/available,GET /mirrors/collaboratorsGET /clusters— the cluster catalog used for cell routing (also fetched by hand inauth/cell_data_api.goto avoid an import cycle)
Access grants / membership (grant.go)
POST|GET /orgs/{org}/members,DELETE /orgs/{org}/members/{member}POST /projects/{project}/grants,GET /projects/{project}/members,DELETE /projects/{project}/grants/{grant},DELETE …/grants/account/{provider}POST|GET /repos/{repo}/grants,DELETE /repos/{repo}/grants/{grant},DELETE …/grants/account/{provider}
Identity resolution
GET /identity/handles/{provider}/{handle}
Auth stack, on the same core host but outside the ogen client:
- OAuth:
POST /device_authorization,GET /authorize,POST /oauth/token(device/authcode grant + refresh + RFC 8693 exchange, incl. repo-scoped git-push creds and cross-jurisdiction 421 follow) - Login sessions:
GET /api/auth/tokens,DELETE /api/auth/tokens/current,DELETE /api/auth/tokens/{id}(logout,auth status) - Discovery (unauthed
GET):/.well-known/entire-cluster.json,/.well-known/entire-api.json
(Generated but unused: PATCH/DELETE /me, /lookup, /version, /audit, /oidc-providers, GET /repos, /repos/lookup-by-slug, /access/*, and the whole /service-accounts/* family.)
Cell (entire-api data-plane) endpoints in use
Grouped by the three routing shapes from CLAUDE.md:
Repo-scoped → the one cell hosting the repo (experts)
GET /api/v1/repos— page through accessible repos to map owner/repo → ULIDPOST /api/v1/repos/{repoID}/experts
User-scoped /me → caller's home cell (best-effort, falls back to the data API on failure)
GET /api/v1/me/activity,GET /api/v1/me/commits,GET /api/v1/me/sessions—entire activityGET /api/v1/me/recap(optional?repo=<repo_id>filter) —entire recap
Arbitrary escape hatch
entire api --to cell <path>— home cell, user supplies method + path ({repo_id}placeholder expands)
Repo-set fan-out — cell_fanout.go (groupReposByCell / resolveCellBaseURLs / fanOutCells) exists and is tested, but no production command routes a concrete cell path through it yet; it's prebuilt to mirror the BFF's code-search fan-out.
The bits that connect them
Cell routing itself is driven by core lookups: ListClusters (catalog → cell apiUrl + jurisdiction), GetRepo (ULID → cluster host), ListMirrors (owner/repo → active host + repo_id), plus a POST /oauth/token RFC 8693 exchange to mint the per-jurisdiction identity token the cell requires (the control-plane bearer is rejected by cells).
Two footguns worth flagging, both consistent with what CLAUDE.md warns about:
- Endpoints like
GET /api/v1/repositories,/api/v1/trails/...,POST /api/v1/cli/enablelook cell-shaped but are built withNewAuthenticatedAPIClientagainst the data-API BFF, not a cell. /search/v1/searchis a separate search service, not a cell.
Want me to turn this into a doc/diagram in the repo, or drill into any one command's exact call sequence?
can you also check the repo/mirror list commands in public cli, they do strict enum validation in the marshaling
Confirmed — the hazard is real for both entire repo list and entire mirror list, though the failure point is one step off from where "strict enum validation in the marshaling" would suggest.
What actually happens
The ogen Decode methods are lenient — on an unknown enum value they fall through to *s = T(v) and return nil (e.g. MirrorStatus.Decode, oas_json_gen.go:13271). So decoding itself never rejects a new server value.
The hard failure comes from Validate(), which every response decoder calls unconditionally right after Decode:
ListMirrorsOutputBody.Validate → iterates every mirror → Mirror.Validate → MirrorStatus.Validate (oas_validators_gen.go:1864):
The exposed enum fields
mirror list (repo_mirror.go:401) — one enum on Mirror:
status→processing | ready | failed | suspended
(provider, owner, cell, jurisdiction, clusterHost are plain strings — forward-compat safe.)
repo list (repo.go:176 → ListProjectRepos) — three enums on Repo:
objectFormat→sha1 | sha256state→provisioning | active | failedvisibility→public | private
No tolerance anywhere
- No decode-error catch in the CLI — the error flows straight up
fetchAllPages(corecmd.go:195) →renderCoreList(corecmd.go:170) →RunE, so the whole command fails (both table and--json). - No
unknown/unspecifiedenum member, no customUnmarshalJSON, nox-ogenskip-validation option. - One offending item aborts the entire page — pagination isn't partially recoverable.
So: if the backend ever ships a new mirror status (say migrating) or repo state, every existing CLI would break on list with a validate: invalid value: ... error. Absent fields are fine (the fields are Opt*); only an unknown present value trips it.
If you want to harden this, the fix belongs at the Validate() call in the response decoders (or regenerating with lenient validation), not at the Decode methods — those already tolerate the value. Want me to look at what ogen config knobs or a post-decode wrapper would let you drop/relax that validation for list responses?
GET /api/v1/repos runs against a cell (entire-api data plane) — never the control-plane core.
It's the accessible-repo list in the entire experts command (resolveExpertsRepoID → listExpertsAccessibleRepos, experts_cmd.go:411), and the client is built by NewAuthenticatedEntireAPICellClient (experts_cmd.go:33).
Which cell specifically depends on how you named the repo:
entire experts <ulid>— the ULID is passed to the cell client, soresolveRepoCellTarget(cell_target.go:53) resolves the repo's hosting cell and the GET hits that cell.entire experts owner/repo— this is the interesting case. The client is constructed with thefullNamebefore the ULID is known (experts_cmd.go:277-283), thenGET /api/v1/reposis used precisely to discover the ULID. Cell resolution from an owner/repo goes through the mirror lookup; if that resolves, the GET hits the repo's hosting cell, and if resolution fails it falls back to the caller's home-jurisdiction cell (the best-effort → nil-target fallback documented inauth/cell_data_api.go).
That fallback is exactly why the "not found" error at experts_cmd.go:399 is worded around cross-region routing — if the repo is homed in a different region than the cell the GET happened to reach, it simply won't appear in that cell's list.
One thing worth flagging to avoid confusion: there's also a coreapi GET /repos (ListRepos) served under the core's /api/v1 base — but the earlier sweep confirmed the CLI never calls it. So every GET /api/v1/repos the CLI actually issues today goes to a cell.
ok, back to the enum: yeah we should harden (loose?) this that new values are actually allowed especially if they are just use for display.
Here's the picture, and it splits the four enums into two cleanly different cases.
Right mechanism: a normalize.go transform (not hand-editing generated files)
The fix belongs in spec/normalize.go alongside foldErrorResponses, with an entry in UPSTREAM.md. That's the established pattern here — it survives go generate (hand-edits to oas_*_gen.go would be clobbered), and it's spec-scoped so it's precise.
The transform strips the enum keyword from the read-model string fields. Without enum, ogen emits a plain string/OptString with no Validate() method at all — so an unknown server value decodes and displays fine. (The Decode was already lenient; killing Validate is the whole fix.)
The scope decision — two tiers
Tier 1 — pure display, zero churn. Repo.state, Repo.objectFormat, Repo.visibility have no non-generated references anywhere (the visibility get/set commands use a different type, SetRepoVisibilityInputBodyVisibility). Strip enum on these → they become plain strings, list output is unchanged, nothing to fix at call sites. This is unambiguously correct and I'd do it regardless.
Tier 2 — MirrorStatus, branched-on. This one's used in ~15 places (cell_target.go reachability, the create wizard, repo_mirror_probe.go) via coreapi.MirrorStatusReady etc. Notably, every switch on it already has a default branch, and cell_target.go:133 treats an unknown status as reachable — so it's semantically already an open enum; only Validate() on mirror list disagrees. Stripping its enum removes the generated MirrorStatus type + constants, so I'd hand-declare them in client.go:
and adjust the comparison sites (the field becomes OptString, so .Or(...)/== need a small string()/typed tweak). Modest, mechanical churn — but real.
Two guardrails
- Only loosen response enums. Leave request-body enums (
SetRepoVisibilityInputBodyVisibilityand other*InputBody*) strict — we want to reject a bad--visibilitywe're about to send. Scoping the transform to theRepo/Mirrorread-model schemas keeps request validation intact. AvailableMirrorStatus(used by the create wizard, fromListAvailableMirrors) is a separate enum. It's also a read model, so loosening it is consistent, but its allowed set differs — I'd fold it into Tier 2 if we go there.
My recommendation: do Tier 1 now (free, correct), and do Tier 2 for MirrorStatus since you explicitly called out mirror list — that command literally hard-fails today on any new status, and the display-tolerance you want isn't achievable for it without this.
Want me to implement Tier 1 + Tier 2 (MirrorStatus, and fold in AvailableMirrorStatus), or Tier 1 only? Once you pick, I'll write the transform, regenerate, fix any call sites, and run mise run check.
Done. Tier 1 is shipped.
Summary
PR #1663 — https://github.com/entireio/cli/pull/1663 (branch soph/loosen-repo-list-enums)
The fix: added a second transform to internal/coreapi/spec/normalize.go — loosenReadModelEnums, driven by a readModelEnumFields allowlist — that strips the enum constraint from Repo.state, Repo.visibility, and Repo.objectFormat before ogen generates the client. Without an enum, ogen emits plain OptString with no Validate(), so an unknown server value now decodes and displays verbatim instead of aborting the whole repo list/get request.
Guardrails held:
- Only the response read model was loosened. Request-body enums (
SetRepoVisibilityInputBody,CreateRepoInputBody) stay strict, so we still reject a bad value we're about to send. - Used the established
normalize.gomechanism (same pattern asfoldErrorResponses), so it survivesgo generate— no hand-edits to generated files. - Regeneration confirmed the strict
Repo.Validateand the three named enum types are gone; fields are nowOptString.
Two commits:
fix(coreapi): loosen display-only Repo read enums…— normalize transform, regenerated client,UPSTREAM.mdentry, and theTestListProjectRepos_UnknownEnumValuesPassThroughregression test.refactor(repo): adapt call sites to plain-string Repo.State— dropped the redundantstring()conversion and fixed the one test that referenced the removedRepoStatetype.
Verification: full mise run check (fmt + lint + unit + integration + E2E canary) is green.
Whenever you're ready, Tier 2 is the mirror list case — MirrorStatus (plus AvailableMirrorStatus), which is branched on in ~15 places with default-covered switches, so it needs hand-declared constants alongside the loosening. Just say the word.
Addressed the one PR review comment.
Copilot's finding (the only comment on #1663): repoRow rendered an unset STATE as "", but the repoColumns doc and the CLUSTER field both use "-" for unset. A real inconsistency my refactor had carried over.
Fix (commit b032d2365, pushed): changed r.State.Or("") → r.State.Or("-"), which fixes both repo list and repo-get (the detail row delegates to repoRow). Repo tests pass, lint clean, and I replied on the review thread to mark it resolved.
Copilot's review was otherwise approving — it correctly summarized the forward-compat intent and confirmed request-body enums stay strict. No other comments or change requests outstanding.