Context-Aware Control-Plane Target Resolution

ok i have a half-baked handover doc from a previous session:
Handover
Goal (COR-389 + your follow-ups): consolidate the two auth views, get the CLI fully onto the context-aware/JWT model, and ultimately auto-refresh JWTs on every control-plane call. Broadly: make auth/login UX coherent.
Where we got to — shipped in #1341:
- Auth command surface trimmed from 7 → 5: dropped auth list + auth revoke.
- auth status is now context-aware: profile via /me on the active context's core, shows identity + active context. auth use finally retargets it.
- auth contexts → styled table. logout revokes server-side via entire-core (--all = all sessions on the core).
- Burned all ent_ PAT machinery — the CLI never used PATs; that surface is being sunset.
Key thing we learned (the expensive lesson): there are two same-shaped "auth/tokens" surfaces — entire-core /api/auth/tokens (login sessions) vs entire.io /api/v1/auth/tokens (legacy ent_ PATs). The CLI was wrongly hitting the PAT one for "sessions" → empty lists + logout 400s. Everything now targets entire-core. Full write-up in the COR-389 Linear comment (corrected) and memory auth-token-surfaces.
What's NOT done (next session):
- The actual COR-389 deliverable — silent JWT refresh on control-plane calls (contextTokenStore + RefreshPath into defaultManager). Untouched. This is the real prize; everything so far was prerequisite + cleanup.
- Multi-core retargeting for the rest of the control plane — org/repo/project/grant (via coreapi.New()) still use static AuthBaseURL. Only auth status was retargeted. Same bug pattern as the one we just fixed.
Verification gaps: Go lint clean, per-package tests green; full mise run test:ci (integration + canary) not run. None of it exercised against a live core — please sanity-check entire auth status / logout against your local eu.auth.entire.io.
what i want us to work on todya is making sure that any time we hit an upstream URL from this cli, we use the same mechanism to discover which "auth context" to use (as in contexts.json).
for example, 'entire activity' currently has ENV vars so that i can do this:
18:49:25 $ ENTIRE_API_BASE_URL=https://partial.to ENTIRE_AUTH_BASE_URL=https://us.auth.partial.to entire activity
however, what i'd prefer, is to be able to call 'ENTIRE_API_BASE_URL=https://partial.to entire activity' and have it automatically know to use the us.auth.partial.to auth context.
i'm thinking that what we'll need is for https://partial.to to expose /.well-known/entire-auth.json too, just like entire-server does (see ~/src/entirehq/entiredb/cmd/entire-server) to say "i'll accept auth from login servers [au.auth.partial.to, us.auth.partial.to]".
this might not all make sense - push back and/or ask questions as needed, and we'll go from there. i'm expecting we'll audit all API calls the cli makes, and see how big a blast radius it'd be to switch over to the new "contexts.json" mechanism.
Done auditing. Here's the bottom line before we pick a direction.
Bottom line
The mechanism you're describing already exists — you don't need a new /.well-known/entire-auth.json, and entire-server likely needs zero changes. The blast radius is a wiring job in the CLI, not new protocol/server work. Two facts change the framing:
- The discovery endpoint already exists:
/.well-known/entire-cluster.jsonon the data plane returnscore_urls(the login/core servers it accepts auth from) — exactly your "I'll accept auth from [au/us.auth.partial.to]" idea. There's also/.well-known/entire-federationon cores for cross-region peers. - The CLI already has the full resolver in
internal/entireclient/clusterdiscovery:ResolveContextForCluster(host) → *contexts.Context. Given a data host, it fetches+caches the well-known, readscore_urls, and picks the login context whoseCoreURLmatches (active-context-wins → sole-eligible → error on zero/ambiguous).git-remote-entirealready uses this for every clone/push. The main CLI commands just don't.
So ENTIRE_API_BASE_URL=https://partial.to entire activity auto-discovering us.auth.partial.to is ResolveContextForCluster("partial.to") — which is written, tested, and shipping. We're lifting it into the rest of the CLI.
Audit: who resolves hosts how
| Resolution | Commands | Count |
|---|---|---|
| Already context-aware | git-remote-entire (clone/push), auth status, logout | — |
Static ENTIRE_AUTH_BASE_URL (core/control-plane, via coreapi.New()) | org, repo, project, grant, login | ~13 sites |
Static ENTIRE_API_BASE_URL (data plane) | activity, trail, search, dispatch | ~11 sites |
| Hardcoded external (fine, ignore) | version-check (GitHub), telemetry (PostHog) | 2 |
Inflection points: ~24 call sites funnel through ~5 constructors (coreapi.New, NewAuthenticatedAPIClient, auth.NewClient, dispatch NewCloudClient, search.Search). Retarget those 5 and everything downstream follows.
The one real risk
The default no-env case: does prod entire.io actually serve /.well-known/entire-cluster.json advertising us.auth.entire.io? The endpoint is implemented on the entiredb data-plane server, but if the prod entire.io deployment doesn't serve it, discovery breaks for normal users and we need a static fallback. That's an ops fact I can't verify from this repo.
A few forks decide how much we bite off. Let me get your calls:
wait you're missing one thing - the API servers for search & activity and so on live in ~/src/entirehq/entire.io, not entiredb. the "Cluster" terminology (and indeed, there's already context-lookup machinery for cluster auth) refers to entire-server aka entiredb. We might well need to adjust entire.io (partial.to is its staging cousin) to serve similar .well-known blobs.
ask me those questions again, after ensuring you understand the difference.
Fair — "three families" is the wrong framing and I muddied it. There aren't three auth systems. There's one identity model and a set of resource servers. Let me restate cleanly:
One identity provider (the "core" / login server): entire-core (e.g. us.auth.entire.io). This is what mints your JWT, what contexts.json is keyed on (CoreURL), and — co-located — it also serves the control-plane CRUD (/api/v1/orgs, repos, grants). An auth context is a login to one core.
Resource servers that trust a core and accept its JWTs:
- the git cluster =
entiredb/entire-server(git-remote-entire hits it) - the web/data API =
entire.io(activity/search/trail/dispatch)
So the "three" I sloppily named were really: the core (1 thing, both IdP + control-plane) plus two resource servers. The only reason they looked like peers in my audit is that the CLI currently resolves each one's host independently from env vars.
The unifying model you're after is just:
An auth context = a login to one core. Every upstream call resolves to some host; that host either is a core (control plane → use the context's
CoreURLdirectly) or is a resource server that advertises which cores it trusts (via a.well-known), so the CLI picks the matching context and exchanges its token for that resource.
- Cluster already does this (
entire-cluster.json→core_urls→clusterdiscovery). - Control plane needs no discovery — the host is the core. ← today's slice.
- entire.io is the gap: it needs to grow the same kind of
.well-knownso the CLI can map it back to a core/context.
So today's work is unchanged. I'll fix the design-doc framing to "one core + resource servers," not "three families." Want me to proceed with the build on that framing?
Today = the control-plane slice only. Concretely:
What changes: org, repo, project, grant (every command behind runCore → coreapi.New()). Right now they always dial the static default core (us.auth.entire.io unless ENTIRE_AUTH_BASE_URL is set) and resolve the token from a singleton manager pinned to that same host. So if you've entire auth use'd a context on a different core, these commands silently ignore it — the exact bug pattern your handover flagged.
The fix: make coreapi.New() resolve its host + token from the active context instead of the static default. Since the control-plane host is the core, there's no .well-known discovery here — just "use the active context's CoreURL and its token." Precedence:
ENTIRE_AUTH_BASE_URLexplicitly set → unchanged (override wins).- else active context → its
CoreURL+ a per-context refreshing token source (this is where silent JWT refresh lands for the control plane — a down payment on COR-389, reusing the existingNewRefreshingLoginProvider). - else → static default (today's behavior for users who never ran
auth use).
Deliverables (the 5 tasks):
api.AuthBaseURLOverridden()+auth.insecureHTTPEnabled()readersauth.ResolveControlPlaneTarget()— the shared resolver- rewire
coreapi.New()through it - unit tests for precedence + the token-source wiring
- a short design doc in the "one core + resource servers" framing, recording what's deferred
What today explicitly does NOT touch:
- Cluster (git-remote-entire) — already context-aware, nothing to do.
- entire.io API family (
activity/search/trail/dispatch) — deferred; needs the newentire-api.jsonwell-known on your side first + the audience token-exchange. Design only.
Net user-visible effect after today: entire auth use <ctx> actually retargets org/repo/project/grant to that context's core, and their tokens refresh silently. Want me to build it?
All 6077 tests pass; lint clean. Done.
Bottom line
org/repo/project/grant now follow the active auth context instead of the static default core — and refresh their token silently. Override and no-context paths are unchanged. The web/data API family (activity/search/trail/dispatch) is designed but deferred (needs the entire.io well-known you'll add).
What changed
auth.ResolveControlPlaneTarget()(cmd/entire/cli/auth/control_plane.go) — the shared resolver. Precedence:ENTIRE_AUTH_BASE_URLset → static (unchanged) → else active context'sCoreURL+ per-context refreshing bearer → else static default.coreapi.New()now consults the resolver;bearerSource→providerSource(token-func backed).NewWithBearer(used byauth status) untouched.api.AuthBaseURLOverridden()+auth.insecureHTTPEnabled()— the two small readers the resolver needs.- Tests: resolver precedence (override / active context / fallback) +
providerSourcebearer/error/session-skip mapping. docs/architecture/upstream-host-resolution.md— the "one core + resource servers" model, what's done, and the deferredentire-api.jsonwork (incl. the audience field the cluster blob lacks).
The mechanism (silent refresh)
The old singleton manager is pinned to AuthBaseURL() as issuer — wrong store/STS host for a context on another core. The per-context provider keys its manager on c.CoreURL, so refresh/STS hit the right core and an expired access token is re-minted from the stored refresh token. That's the COR-389 prize landing for the control plane.
Manual sanity check (I can't hit a live core from here)
Not committed — yours to commit/push. Want me to start the deferred API-family slice, or stop here?
Done. Three commits, tree clean, nothing pushed.
| Commit | Scope |
|---|---|
d04285b1e2 | auth: resolver + AuthBaseURLOverridden/insecureHTTPEnabled readers |
d581cfb135 | coreapi: wire New() through the resolver |
8c306945c8 | docs: resolution model + deferred API-family work |
Each is self-contained (builds + lints independently — readers ship with the resolver that uses them, so no unused-symbol gap). Yours to push.
there's a warning somewhere when you entire auth use X which says "auth doesn't match whatever and so you will suffer" - is that still relevant? or is it part of the next bit of work, fixing up the API & contexts thing?
Done. Tree clean, nothing pushed.
Answer to your question
That warning was stale, not next-bit work — my control-plane change made it wrong. It claimed auth use doesn't retarget org/repo/grant; now it does. (auth status already retargeted since #1341, so it was half-wrong even before.) Removed it.
Chasing it also surfaced a real inconsistency I'd introduced — fixed per your call:
Two follow-up commits
c6bee36e1e— active context wins overENTIRE_AUTH_BASE_URL(env is fallback-only, matchingauth status). Dropped the now-deadAuthBaseURLOverriddenhelper. A token from core B can't auth against core A, so override-host-with-context-identity never made sense.b27626a386— removedwarnIfCrossCoreContext+ its test; rewroteauth usehelp to say control-plane commands follow the active context, and that the data-API commands (activity/search/trail/dispatch) don't yet — that note is the honest pointer to the next slice.
Lint clean, affected tests green (cli, auth, api, coreapi). Five commits total on the branch now. The genuine remaining gotcha — data-API commands ignoring the context — is exactly the entire.io .well-known work you'll add next.
ok. make a nice branch name (use COR-389 plus descriptive slug) and push - let's also work on a PR draft. but show me your suggested description before applying.
Pushed to cor-389-control-plane-context-aware. Here's my suggested PR — not created yet, your review first:
Title: COR-389: control-plane commands follow the active auth context
Body:
entire auth use <ctx-on-another-core> entire org list # now hits that context's core, with a silently-refreshed token
Two things to confirm:
- CI caveat: I ran unit tests + lint, but not the full
mise run test:ci(integration + canary). Want me to run it before you merge? The "Test" line currently overclaims slightly — I'll soften it to "unit tests + lint green" unless you want me to actually run the full suite now. - Anything to add/cut, or change the title?
seems we already need to rebase on origin/main. Then, yes, create the DRAFT PR with that description.
Force-push failed — your SSH signing agent (Secretive) couldn't sign, likely needs a Touch ID approval that didn't reach this subprocess. The rebase is done locally and clean; it just needs your agent.
Please run it yourself so the agent can prompt you:
Once that lands, I'll create the draft PR with the description above (I'll soften the Test line to match what I actually ran — unit tests for the affected packages + lint clean; I did not run full mise run test:ci). Want me to run the full mise run test:ci before creating the PR, or create it now and you run CI via the PR?
Draft PR created: https://github.com/entireio/cli/pull/1367
- Branch
cor-389-control-plane-context-aware, rebased clean on latestmain(resolved the conflict with the newcompleteContextNamesshell-completion — kept it, dropped the stale warning). - Draft, base
main, 5 commits. - Test line states the truth: unit tests for affected packages + lint clean; full
test:cileft to CI.
Say the word if you want me to run mise run test:ci locally before you mark it ready, or tweak the description/title.
this remark is on point https://github.com/entireio/cli/pull/1367#discussion_r3356627903 - user-facing we always say "login server" rather than "core" anything. ensure we didn't put "core" anywhere user-facing
in the comment:
is that true? I'd say the active context is the default, but if you have entire.io active but hit up a partial.to API, and you have a partial.to context, that one will be used. tell me if i'm wrong.
Pushed (fast-forward 2fae4e2297..0b6eacfa90). The "remote helper 'entire' aborted" noise is the checkpoint-remote helper, not the branch push — the branch updated fine.
Bottom line
Both review comments addressed; no user-facing "core" remains in the PR's changes.
- Help text (
auth use): "the context's core" → "the context's login server". - Error strings:
build core API client→build Entire API client(bothNewandNewWithBearer). New()doc comment: corrected the precedence (active context wins;ENTIRE_AUTH_BASE_URLis fallback, not override) and scoped the "always wins" claim — true only because control-plane commands have no resource host to match against, unlike clone / the data API (your point).- Reconciled with your
Fix comment.(Copilot Autofix): it partially fixed the same doc comment; my fuller rewrite supersedes it. Rebased my one fixup commit on top, so history stays linear.
Audit result: the only user-facing "core" were those two spots. Remaining "core"/"control plane" occurrences are code comments and identifiers (internal), and "control plane" in pre-existing error text — the reviewer's rule is specifically about "core", which is now clean.
There are 2 reviewer comments on https://github.com/entireio/cli/pull/1367. Let's walk through them, i'm not sure their suggestions are the best approach.
Both new comments are on the latest commit (0b6eacfa9). Here's my read — and I agree with you that the literal suggestions aren't quite right.
Comment 1 — "Contexts load error blocks fallback" (Medium, control_plane.go:42)
Their claim: if contexts.json can't be read/parsed, ResolveControlPlaneTarget returns the load error, breaking org/repo/grant — while auth status still degrades to the legacy keyring token. Inconsistent.
My read — partially valid, but their implied fix (always degrade) has a real downside:
- The factual asymmetry is true.
resolveStatusTargetswallows the contexts error (if err == nil { … }) and falls through to legacy; mine propagates it. - But
contexts.Loaddoes not error on a missing file — it returns an emptyFile{}. So this only fires on genuine corruption, an IO/permission error, or a flock timeout under concurrententireprocesses. That last one is the interesting case: a transient lock collision would spuriously break a control-plane command. - The counter-argument to "just degrade": control-plane commands mutate (create org, grant access). Silently falling back to a legacy keyring identity when the config is corrupt is arguably worse for a mutation than erroring — you could act as an unexpected identity. For a read-only
status, degrading is harmless.
My recommendation: don't blanket-degrade. Either (a) keep failing but make the message actionable ("couldn't read contexts.json: … — fix or remove it"), or (b) degrade only for transient/lock errors and surface genuine corruption. I lean (a) — simplest, and corruption is rare. The consistency-with-status argument is weaker than it looks because the two commands have different blast radius (read vs write).
Comment 2 — "Missing ErrNotLoggedIn in token chain" (Medium, client.go:90)
Their claim: in the active-context path, missing creds come back from NewRefreshingLoginProvider without wrapping auth.ErrNotLoggedIn, so providerSource.BearerAuth skips the login hint and users get a generic "resolve control-plane token" error.
My read — the diagnosis is right but their fix would make UX worse:
- True: the refreshing provider returns its own messages (
no usable login for "ctx" (https://…); run ENTIRE_AUTH_BASE_URL=… entire login) without%w-wrapping the sentinel, soerrors.Isis false and my code prefixes it withresolve control-plane token:. - But that provider message is better than the generic
not logged in — run 'entire login'hint — it names the specific context and the exact re-login command for that core. If we "fix" it by wrapping the sentinel so the generic hint fires, we'd replace the tailored message with the worse one. - The only actual wart is the redundant
resolve control-plane token:prefix on an already-complete message.
My recommendation: drop the prefix in the else-branch — return the provider's error verbatim — and keep the ErrNotLoggedIn → friendly hint mapping for the static path (where TokenForResource does return a bare sentinel). Net: static path still gets the standard hint; active-context path shows its tailored, core-specific hint cleanly. That satisfies the rule's intent (a login hint when not logged in) without downgrading the message.
So: comment 2 I'd fix, but not their way. Comment 1 is a genuine judgment call — fail-loud-but-clearer vs degrade-like-status. Which way do you want to lean on each? And do you want me to reply on the PR threads explaining the reasoning, or just push fixes?
for comment 1: if there's a genuine error reading the file, we should surface that - fail loud and clear. Also, where that's not the case yet, make status & all the other commands behave symmetrically.
for comment 2: fix it so the UX is best.
Pushed — remote now at 6621920f51.
Done — both comments addressed, but not their literal way
Comment 1 (load error blocks fallback): kept fail-loud — and made it symmetric. resolveStatusTarget no longer swallows a genuine contexts.json read/parse error; it surfaces it like the control plane does. A missing file still reads as "no contexts" (not an error), so this only fires on real corruption/IO failure — which the user must see before a mutation acts as a stale legacy identity. Tests added for both ResolveControlPlaneTarget and resolveStatusTarget.
Comment 2 (missing ErrNotLoggedIn hint): fixed for best UX, not by wrapping the sentinel (which would downgrade the message). Dropped the resolve control-plane token: prefix so NewRefreshingLoginProvider's tailored message — naming the context, its login server, and the exact re-login command — reaches the user verbatim. The bare-sentinel static path still gets the standard entire login hint. Test tightened to assert verbatim surfacing.
Build + lint clean, affected tests green. Commit 6621920f51.
Want me to post replies on the two PR threads explaining why I diverged from the literal suggestions (especially comment 2, where wrapping the sentinel would have made it worse)? I'll keep them short.