Skip to content
View as .md

Record refs

How every arbe command turns a user-typed reference (id, prefix, list index, 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. List index — 1, 2, … into the same order arbe <noun> list prints.
  4. Exact name (case-insensitive).
  5. Name prefix (case-insensitive).

Threads resolve by id (step 3). Entry creation is broader: arbe thread entries create <ref> "hi" accepts a thread or agent ref; an agent ref maps to a DM chat thread and creates it when missing.

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

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

Architecture

Five 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)
byListIndex // /^\d+$/ → records[N-1] (1-based)
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. Ordering matters: byListIndex runs before name matchers so a house literally named "1" doesn’t beat list-index 1.

const houseName = (h: HouseRecord) => h.name
export const resolveHouse = (input: string, houses: HouseRecord[]) =>
resolveRef({
label: 'house',
input, records: houses,
matchers: [
byId,
byIdPrefix(1),
byListIndex,
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), byListIndex],
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.

List-index canonical order. byListIndex returns records[N-1]. For the index a user types to match what they saw, the list passed to the resolver must be in the same order arbe <noun> list prints. Wrappers fetch via the same call (fetchHouses, listThreads, …) the list verb uses; the order is owned by the fetch, not the resolver.

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 keep their (input, client) => Promise<id> signatures and reshape to call the resolver internally.

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.

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 they never collide with byListIndex digits. 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, scope.ts, task.ts — per-entity wiring: matchers, format, and the *Ref fetch wrapper. parent.ts resolves the thread-parent union, scope.ts the config scope, task.ts namespaced task ids.
  • record-ref/*.test.ts — pure matcher and resolver tests, no fixtures.