Skip to content
View as .md

Record refs

How every arbe command turns a user-typed reference (id, prefix, name, name prefix) into a record. The thing the user types is a record ref; resolving it lives in apps/cli/src/record-ref/.

The user model

Every CLI verb that takes a house, agent, or thread accepts the same shapes, in the same priority:

  1. Full id — the canonical form. A 12-char [k-z] short id for houses and threads; a UUID for agents.
  2. Id prefix — any leading slice that resolves to a single record, like jj’s change-id prefixes.
  3. Exact name (case-insensitive).
  4. Name prefix (case-insensitive).

There is no list index. A bare number is a name, or nothing. arbe thread view 2 used to mean “row 2”, counted into a listing the resolving process never saw — a different limit, or a --status filter, and you opened a real thread that was not yours. A ref has to mean the same thing wherever it is typed, and a row number cannot (arbe-6b95). The bolded id prefix is the replacement and costs the same two keystrokes: arbe thread view rx. A number that names nothing says so, and says why.

Threads resolve by id and prefix only, and that is all entry creation takes — arbe send <who…> "hi" is how you reach a person, so nothing has to resolve a ref that might be either noun.

Commands whose argument can point at more than one noun still use the same engine over a typed union. arbe thread create <parent> resolves across houses, agents, and threads before POSTing the canonical id, and fails on cross-noun ambiguity instead of guessing.

The user learns this once. Every verb that takes a scope honors it.

Architecture

Four primitive matchers, one combinator. Each matcher is pure: (input, records) → records. A matcher that doesn’t apply (input doesn’t fit the id shape, or the records don’t have a name field, or the input is too short) returns the empty list and falls through.

type Match<T> = (input: string, records: T[]) => T[]
byId // r.id === input
byIdPrefix(min) // input.length >= min && r.id.startsWith(input)
byName(getName) // case-insensitive equality
byNamePrefix(getName, min) // input.length >= min && case-insensitive startsWith

byId and byIdPrefix are deliberately format-agnostic — they don’t validate UUID or short-id shape, they just compare. So one matcher resolves a UUID-backed agent and a [k-z] short-id house with no per-format code. The min-length gate on byIdPrefix is the only knob that tunes per format: 1 for short-id houses, 2 for threads, 4 for UUID agents.

byNamePrefix carries the same min-length knob (default 2) so arbe house view a doesn’t match every house starting with a. A future byNameSubstring slots in the same way; it’s not wired today because nothing needs it.

The combinator is a left-fold-with-short-circuit:

function resolveRef<T>(opts: {
label: string // 'house', 'thread', 'agent'
input: string
records: T[]
matchers: Match<T>[] // tried in order
format?: (r: T) => string // candidate formatter for ambiguous-list error
}): T

Rules:

  1. For each matcher in order, run it.
  2. 1 hit → return it.
  3. More than 1 hit → fail with multiple ${label}s match "${input}": and the formatted candidate list. Don’t fall through — ambiguity inside a strategy is real ambiguity, not a hint that the next strategy might disambiguate. Falling through would silently pick a different match and confuse the user.
  4. 0 hits → continue to the next matcher.
  5. After all matchers: ${label} "${input}" not found. Run \arbe ${label} list` to see available ${label}s.`

That’s the whole engine. Pure, sync, testable without mocks.

Per-entity wiring

Each entity declares only the strategies that make sense for it, in the priority that disambiguates correctly. Id matchers run before name matchers, so a house literally named "aaaa" never shadows an id prefix that resolves.

const houseName = (h: HouseRecord) => h.name
export const resolveHouse = (input: string, houses: HouseRecord[]) =>
resolveRef({
label: 'house',
input, records: houses,
matchers: [byId, byIdPrefix(1), byName(houseName), byNamePrefix(houseName)],
format: h => `${h.name} ${h.id}`,
})
export const resolveThread = (input: string, threads: ThreadOnList[]) =>
resolveRef({
label: 'thread',
input, records: threads,
matchers: [byId, byIdPrefix(2)],
format: t => `${t.id} ${t.kind} ${t.status}`,
})

Adding a new entity is ~6 lines + a format. Adding a new matcher (say byTagSubstring for threads with tags) is one function with no engine change.

Order no longer changes what a ref means — that was the list index’s job, and it is gone. Wrappers still fetch via the same call (fetchHouses, listThreads, …) the list verb uses, because which records are candidates still matters (see below).

Return type

Resolvers return the full record, not just the id. Callers that only need the id pay .id for it; callers that need the name (e.g. arbe house select echoing the active-house line) avoid a second fetch.

The outer wrappers in apps/cli/src/record-ref.ts reshape to call the resolver internally. Most are (input, client) => Promise<id>; a wrapper takes more when the ref needs more (resolveAgentRef takes a scope — see below) or returns more when the caller needs it (resolveThreadParentRef returns the parent plus the scope inside it).

Async sources and the no-fetch fast-path

The resolver itself is sync. Each command-level wrapper fetches the list first, then calls the resolver — except when the input is unambiguously a full id, in which case it short-circuits before the fetch:

async function resolveHouseRef(input: string, records?: HouseRecord[]): Promise<string> {
if (isFullId(input)) return input // no fetch
records ??= await fetchHouses() // single roundtrip, reusable
return resolveHouse(input, records).id
}

isFullId recognizes either canonical shape — a 36-char UUID or a 12-char [k-z] short id — so a full id short-circuits the list fetch. Same intent as byIdPrefix(min), at the wrapper level.

Wrappers accept an optional pre-fetched list so callers that already paged through arbe <noun> list can hand it in and avoid a redundant roundtrip.

Pure-layer tests pass records directly; wrapper tests stub the fetch. No client mocking.

Who owns the candidate set

A ref resolves against the list the user was shown. The matchers are only as right as the records handed to them: hand them the wrong candidate set and a name matches the wrong record.

So when a noun’s candidates depend on a scope, the scope is a required argument, not an optional one. resolveAgentRef(input, client, scope) takes an AgentRefScope:

export type AgentRefScope =
| {kind: 'enclosingMembers'; id: string; of?: 'house' | 'thread'; label?: string}
| {kind: 'global'}
| {kind: 'records'; agents: AgentResult[]}

Every call site states its answer, and the compiler catches the next one. houseScope(id, typed) / threadScope(id, typed) build the scoped forms; GLOBAL_AGENTS is the fleet-wide one. ?scope= resolves a house or thread id to the enclosing house’s members — hence the kind’s name, and why one fetch serves both. of only sharpens the error; a full id whose noun is not yet known (the parent fast-path) leaves it unset.

global is a real answer, not a fallback. Some refs are fleet-wide by nature: arbe agent view <ref> on an agent you share no house with, and arbe member add <house> <agent>, where the agent is not a member yet — scoping that one to the house would make it unresolvable. A resolver never falls back from a scope to global; that is how a name silently reaches the wrong record.

The unscoped list endpoint returns the 50 newest agents, so global unions that window with a server-side ?q= name search, which is the only way an older agent comes back. An id prefix can only be answered by the window — ?q= is an ilike on name.

A miss names the scope it searched and what to run next: agent "ada" not found in house Radio4000. Run `arbe agent list --house Radio4000` to see its members. That is ResolveRefOpts.where; a resolver with no scope keeps the plain “run arbe <noun> list” message.

resolveThreadParentRef returns {id, agentScope} — the parent id plus the scope refs inside it resolve against, so arbe thread create <house> --participant ada means the ada in that house. An agent parent has no single enclosing house, so its refs stay global.

Windows

Where the scope is a window rather than a set — the N most recent records — the window has to be at least as wide as what the list verb can print. arbe wf runs defaults to 50 rows but takes -n, so resolveRunRef fetches RUN_REF_WINDOW (200) regardless: a prefix copied off arbe wf runs -n 200 resolves, and a prefix that also matches an older run reports ambiguity instead of quietly picking the newer one. For prefixes a wider window is strictly safer — it can only turn a silent wrong pick into an error.

Widening only works because refs are position-independent. It is the same property that killed the list index: a wider candidate set can never change what an id prefix or a name means, only whether it is unique — and non-uniqueness is a loud error.

One seam remains: arbe thread list bolds the shortest prefix unique among the ~30 rows it printed, while resolveThreadRef searches 200. So a bolded prefix can come back ambiguous. That is the safe direction — it lists the candidates instead of picking one — but it is why bolding passes minPrefix: THREAD_ID_PREFIX_MIN, never less than the resolver’s gate.

Relation to jj

The prefix contract — return all hits, succeed only on uniqueness, list candidates on collision — is the same one jj exposes for change-id prefixes. We arrived at it independently; it’s the right answer when an opaque id has a canonical form and a usable prefix form.

Short ids make the prefix practical: 12 characters on a 16-letter [k-z] alphabet resolve uniquely in 1–2 chars for a normal-sized fleet, and the [k-z] alphabet has no digits, so an id prefix can never be mistaken for a number. arbe <noun> list bolds each id’s shortest unambiguous prefix — the same affordance jj gives change ids.

Why not extend to web

Web URLs use UUIDs. The browser doesn’t type ids by hand. Record-ref resolution is a CLI ergonomics layer, not a platform abstraction.

Where it lives

apps/cli/src/record-ref.ts is a barrel re-exporting the matchers, resolveRef, and every per-entity resolver. The folder beside it holds:

  • record-ref/match.ts — the five matchers, Match<T>, isFullId.
  • record-ref/resolve.ts — the resolveRef combinator.
  • record-ref/house.ts, agent.ts, thread.ts, parent.ts, run.ts, task.ts — per-entity wiring: matchers, format, and the *Ref fetch wrapper. parent.ts resolves the thread-parent union, run.ts workflow runs (id and prefix only), task.ts namespaced task ids.
  • record-ref/*.test.ts — pure matcher and resolver tests, no fixtures.