Renameable Primitives & uid Identity
Status: Draft | Owner: vineeth | Last updated: 2026-06-17
Related: rajat/fix-auth (Honcho), rajat/jwt-fix (Groudon) — both in flight, not blocked by this spec
1. Problem Statement
Honcho emits the immutable name of each primitive (workspace, peer, session) as its external identifier. Names were designed to be immutable precisely so they could serve as stable references. In practice this breaks down:
- Developers name things wrong and want to fix them.
- Names drift in meaning over time and teams want to rename.
- Teams want to merge peers (e.g. two peers that turn out to be the same user).
We cannot support any of this today because name is load-bearing in three places simultaneously:
- External identifier — emitted to clients as the primitive’s “id”.
- Foreign keys —
peer.workspace_name → workspaces.name,UniqueConstraint(name, workspace_name)on peers and sessions, and message/observation rows reference peer/session names. - JWT auth claims — scoped API keys encode
{w, p, s}= workspace/peer/session names (src/security.py:JWTParams).auth()authorizes by string-comparing the claim against the route’s path name.
Because of (3), a naive rename silently breaks every API key scoped to the renamed primitive: the claim no longer matches the resource. Honcho does not track minted keys (the JWT is the record), so there is no server-side list to rotate.
This spec defines how to (a) introduce a stable, exposable identifier (uid), (b) make primitives renameable, and (c) do so without breaking any existing API key — Groudon-issued or direct.
2. Key Insight
The token never has to change. The verifier does.
Existing JWTs are signed and already in customers’ hands; their contents are immutable. But Honcho owns the verifier. Today auth() compares claim-name against route-name by string equality. The migration is to canonicalize both sides to the immutable internal id and compare ids, backed by a name-history (alias) table.
Once both the token’s claim and the route’s path parameter pass through a resolver that knows name history, a rename is just an alias insert:
- the old name baked into a legacy JWT resolves to the same
id, - the new name on the route resolves to the same
id, - they match — no key is reissued, nothing breaks.
This is the backwards-compatibility map, located in the resolver where it serves renames, merges, and legacy tokens at once.
3. Goals / Non-Goals
Goals
- Expose internal
idexternally asuidwithout renaming the internal column or changing the existing name-as-id API contract. - Make workspace / peer / session renameable.
- Support peer merge.
- Zero breakage of existing API keys (Groudon-issued and direct-to-Honcho).
- Move new references toward
uidover time.
Non-Goals (this spec)
- Removing
nameas an accepted identifier. Names remain a valid, mutable lookup key. - Rewriting historical/point-in-time metadata (see §6.3 — treated as snapshots).
- Cross-workspace moves of a primitive.
4. Current State (verified on branch)
JWTParams={t, exp, ad, w, p, s};w/p/shold names (src/security.py:31-63).verify_jwtenforces a token-shape invariant: a peer/session token must carryw(security.py:114-117).auth()authorizes by the token’s narrowest scope, no workspace fallthrough (security.py:196-218). This is the just-fixed privilege-escalation bug.- Keys endpoint (
src/routers/keys.py) takes params named*_idbut stuffs names into the claim. - Groudon stores
jwt+jwt_paramsper external key in itsapi_keystable — Groudon is the system of record for the external→JWT mapping. Direct Honcho keys are untracked.
Implication for this spec: the auth() rewrite landed in rajat/fix-auth is the exact seam the resolver plugs into. Replace each jwt_params.X != X_name with resolve(...) != resolve(...); the narrowest-scope control flow is unchanged.
5. Design
5.1 Expose uid
Add uid to API responses for workspace / peer / session, returning the existing internal id (nanoid). No internal column rename — id stays id; uid is the public alias. Accept uid anywhere a name is accepted in path/query resolution (dual-read).
This gives clients an immutable handle immediately, without touching the name-as-id contract that existing integrations depend on.
5.2 Name-history (alias) table
peer_name_alias / session_name_alias (or a unified primitive_alias)
scope -- workspace id (and parent for peer/session disambiguation)
alias_name -- a name this primitive was previously known by
primitive_id -- canonical internal id
retired_at -- when this name stopped being the live name
PRIMARY KEY (scope, alias_name)
Written on every rename and merge. A live name is also resolvable (either keep live names out of the alias table and check the primary row first, or insert the live name too — implementation detail). Resolution order: live row → alias table → 404.
5.3 Resolver + verifier change
def resolve_peer(workspace_id, identifier) -> str: # returns canonical internal id
# identifier may be: a uid, the live name, or a retired name (alias)
# raises Authentication/NotFound on miss
# auth(), per scope, becomes (sketch against current security.py:196-218):
if jwt_params.p is not None:
if not peer_name:
raise AuthenticationException("JWT not permissioned for this resource")
if resolve_peer(claim_ws, jwt_params.p) != resolve_peer(route_ws, peer_name):
raise AuthenticationException("JWT not permissioned for this resource")
...Cost: auth() gains a DB resolution where today it does pure string compare. auth() is already async. Mitigate with a name→id cache (stable except on rename/merge; invalidate those events). This is the one real performance consideration — call it out in review.
5.4 Versioned claims (forward path)
Mint new JWTs with uid claims and a version flag so the verifier knows the claim semantics without ambiguity (uids and names are both arbitrary strings; a flag avoids collision guessing). Keeps tokens compact — the field-shortening rationale in JWTParams still holds.
class JWTParams(BaseModel):
t: str
exp: str | None = None
ad: bool | None = None
v: int | None = None # claim version: absent/1 = names (legacy), 2 = uids
w: str | None = None # name if v<2, uid if v==2
p: str | None = None
s: str | None = NoneVerifier branches on v:
v == 2: claims are uids → resolve the route’s name → id, compare to the uid claim directly. Renames are invisible to these tokens (they reference the immutable id; no alias lookup needed on the claim side).vabsent /1: legacy name claims → resolve via alias table → id, compare.
5.5 Rename & merge operations
- Rename: update the live name; insert the old name into the alias table; bump cache invalidation. With FKs still on
name, this requiresON UPDATE CASCADE(interim) or the FK→id migration (§6.1). Forbid renaming to a name that exists as a retired alias pointing elsewhere (see §7). - Merge (peer A ← peer B): repoint B’s data to A’s id, insert B’s name(s) as aliases of A, retire B. Old
{p: B}tokens resolve through the alias to A. Merge is the clearest argument for id-based references everywhere.
5.6 Groudon convergence
Groudon stores jwt + jwt_params per external key, so it can re-mint without customer impact:
- Background job re-mints Groudon-issued keys to
v:2uid form. Externalhch-v2-…key unchanged; customer notices nothing. Converges Groudon to uid-only. - Groudon’s
ApiKeyCreatevalidation (peer XOR session, workspace required) is already correct for the uid world —workspace_id/peer_id/session_idsimply carry uids once the dashboard fetches uids instead of names.
Direct-to-Honcho keys can’t be re-minted (untracked) but don’t need to be — the alias resolver keeps their name claims valid indefinitely; they age out as they expire.
6. The Metadata Problem (the tricky part)
Names appear far beyond FKs and JWT claims — denormalized into JSONB metadata across the system (e.g. conclusions/documents metadata: workspace_name, peer_name, session_name, observer, observed, target_name). These split into three cases:
6.1 Foreign keys — must resolve correctly
peer.workspace_name, UniqueConstraint(name, workspace_name), message/observation references. Long-term: migrate these to reference id. Interim: ON UPDATE CASCADE + alias. This is the heaviest lift and should be its own migration; size it by tracing every FK/column on a *_name value first.
6.2 Filterable metadata — dual-write uid
Anywhere metadata names are used to filter/query (not just display), dual-write uid going forward and filter on uid, keeping the name for display. New writes only; do not rewrite history inline.
6.3 Snapshot metadata — leave alone
Audit/trace metadata recording what a peer was called at the time is arguably correct to leave stale after a rename. Don’t backfill these.
6.4 Reconciler task for existing state
For records already written with names (and for anyone currently in the broken/desynced key state), introduce a temporary background reconciler rather than a big-bang migration:
- Walks records in batches, resolves each name → canonical id via the alias/live tables, and dual-writes
uidinto filterable metadata (§6.2). - For Groudon: reconciles desynced external→JWT mappings and re-mints to
v:2. - Idempotent and claim-guarded: the reconciler must mark/lease rows it’s working (an
update/status field or lease column) so multiple reconciler instances don’t race to fix the same row. This mirrors the health-service pattern and the stuck-tenant reconcile concern — concurrency safety is a hard requirement, not a nicety. - Self-retiring: runs until the backlog drains, then is removed. Provide a
--dry-runmode and exact batch-size/rate flags for ops.
7. The One Invariant You Cannot Violate
Once a name is retired by a rename, it must not be silently reused for a different resource — otherwise a legacy JWT’s alias resolves to the wrong entity, which is a privilege escalation (exactly the class rajat/fix-auth just closed). Either forbid reuse of retired names within a scope, or expire/scope aliases explicitly with a deliberate policy. Add a regression test alongside tests/test_security.py.
8. Migration Sequence
- Merge
rajat/fix-auth+rajat/jwt-fix. Correct scope semantics; independent of everything below. - Expose
uidin responses; acceptuidin path/query resolution (dual-read names and uids). - Add the alias table + switch
auth()to resolve-and-compare-by-id. Verifier-only change; no reissue. Add name→id cache + invalidation. - Mint
v:2uid claims; Groudon background-reprovisions its keys. - Migrate FKs (§6.1) and filterable metadata (§6.2) to uid; run the reconciler (§6.4) for existing rows; leave snapshot metadata (§6.3) alone.
- Ship rename + merge operations once 3–4 are live.
Steps 1–3 unblock “renameable without breaking keys.” 4–6 are convergence and the user-facing feature.
9. Open Questions
- Unified
primitive_aliastable vs per-type alias tables? - Do we ever expose the alias history to customers (audit), or keep it internal?
- Cache layer for name→id resolution: in-process vs Redis (Groudon already invalidates the key cache via Redis)?
- FK→id migration: big-bang vs expand/contract with
ON UPDATE CASCADEas the bridge? - Should
v:2minting be gated behind a workspace feature flag during rollout?