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

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

```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. Ordering matters: `byListIndex` runs before name matchers so a house literally named `"1"` doesn't beat list-index `1`.

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

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

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