# 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.

```ts
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:

```ts
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.

```ts
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:

```ts
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`:

```ts
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.
