# What the client has to get right

This page is about the part of the work that is not "wrap an endpoint": the
rules Chroma imposes that a naively written client gets wrong, and where in
this library each one lives. Every entry names the code and the test that
holds it in place, and most of them were found by a live server rather than by
reading the API docs.

The through line: **the client's job is to make a wrong call impossible to
write, or to make it fail before it reaches the network.** Chroma answers a
malformed request with a `400` whose message rarely says which record was
wrong; a local `InvalidArgument` names the record, the index and the fix.

## 1. Records are columnar, and the columns must stay aligned

Chroma's `add` / `upsert` / `update` models are not arrays of records. They are
parallel arrays (`ids`, `embeddings`, `documents`, `metadatas`) that the server
zips by index. A record with no metadata therefore needs a `null` in the
`metadatas` position — dropping it shifts every later record by one, which the
server cannot detect because the arrays are still the same length.

```moonbit nocheck
// three records, one without a document and one without metadata
client.add(collection.id, [
  Record::of_embedding("r1", [0.1, 0.2]).with_document("first"),
  Record::of_embedding("r2", [0.3, 0.4]),
  Record::of_embedding("r3", [0.5, 0.6]).with_metadata([("tag", MString("x"))]),
])
```

What goes on the wire (`records_to_columns`, `records.mbt`):

```json
{
  "ids":        ["r1", "r2", "r3"],
  "embeddings": [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
  "documents":  ["first", null, null],
  "metadatas":  [null, null, {"tag": "x"}]
}
```

Two more rules ride on the same body, and both are the client's responsibility
because neither has a representation in the columnar shape:

- a column that carries no data at all is omitted (`documents` / `metadatas`),
  which is not the same as sending an array of `null`s: the server reads the
  absent field as "no document for anyone" and the array of `null`s as a field
  the caller set to empty, and only the first is what a caller means;
- `embeddings` is never omitted: a record with no vector has nowhere to go, so
  `validate_records` refuses the batch locally instead of letting the server
  answer `400` after the payload has been built and sent.

## 2. Three constraints a single record cannot show

`validate_records` checks one record at a time (empty id, no vector, no
document). Three rules are about the batch, and they live in `validate_batch`
(`guard.mbt`):

**A duplicate id inside one request.** `add` cannot satisfy "insert `r1`
twice": the server rejects the whole batch.

```moonbit nocheck
client.add(id, [
  Record::of_embedding("r1", [1.0]),
  Record::of_embedding("r2", [2.0]),
  Record::of_embedding("r1", [3.0]),   // ✗ InvalidArgument
])
// invalid argument: record r1 appears at index 0 and again at index 2;
// Chroma rejects an add naming the same id twice, send one record per id
// (or use upsert)
```

`upsert` is not affected — writing an id twice is well defined when the second
write replaces the first — so the same batch through `upsert` is accepted. The
asymmetry is deliberate and is what the flag on `validate_batch` is for.

**Vector width.** Every embedding in a batch has to have the width the
collection's index was created with. A mismatched width is a `400` with no
pointer to the record, so the client names one:

```moonbit nocheck
// invalid argument: record r2 has a 2-dimensional embedding but record r1
// has 3; a collection's vectors must all share one width
```

**The server's batch size limit.** Chroma refuses a batch larger than its
`max_batch_size`, which differs per deployment and is advertised (by servers
that implement the endpoint) at `GET /pre-flight-checks`. The client does not
guess the number and does not silently default to something small; it takes it
from the caller, and `WriteOptions::from_preflight` is what turns the server's
answer into the chunk size:

```moonbit nocheck
// synchronous client: ask the server, then write with what it said.
// `preflight` is synchronous, so it belongs to a `Transport` client.
let options = WriteOptions::from_preflight(client.preflight()) // 0 when unknown
client.add(collection.id, records, options~, on_chunk=(sent, total) => {
  println("\{sent}/\{total} written")
})

// async client: `preflight` is a synchronous call and `add_async` is not, so a
// client over an `AsyncTransport` cannot make both. Pass the size you know, or
// keep a second client over a synchronous `Transport` just for the preflight.
let options = WriteOptions::new(max_batch_size=500)
client.add_async(collection.id, records, options~, on_chunk=(sent, total) => {
  println("\{sent}/\{total} written")
})
```

A batch of 5,000 with `max_batch_size = 500` becomes ten requests, sent in
order. `on_chunk(500, 5000)`, `on_chunk(1000, 5000)`, … is called only after
the server accepts a chunk. `0` means "send it as one request", which is the
pre-`WriteOptions` behaviour.

**A failed chunk reports what landed.** When chunk six is refused, chunk seven
is never sent, so the collection holds a stored prefix and nothing else. That
prefix is not something the caller has to reconstruct from `on_chunk` — the
error carries it:

```moonbit nocheck
match try_add_chunked(client, records, max_batch_size=500) {
  Ok(_) => ()
  Err(err) =>
    match err {
      @chroma-client.PartialFailure(partial) => {
        // partial.sent == 2500: records [0, 2500) are stored
        // partial.cause is the server's answer, so its status and
        // `is_retryable()` are reachable through the outer error too
        let rest = records[partial.sent:]
        if partial.cause.is_retryable() {
          // replaying the tail is safe: upsert on the same ids is idempotent
          client.upsert(collection.id, rest)
        }
      }
      // nothing landed: a validation error, or the first chunk itself failed
      other => println(other.message())
    }
}
```

`sent` counts accepted chunks only — it advances after a chunk's response comes
back, never when the chunk is handed to the transport. So a transport failure
mid-chunk is not counted as written, and `records[sent:]` is exactly the
unsent tail. A failure on the *first* chunk is not partial (nothing landed), so
the plain `Api` / `Transport` / `Decode` error is raised, unchanged from before
chunking existed.

## 3. A filter that would delete a collection has to be refused

`delete` with none of `ids`, `where`, `where_document` is a legal request that
empties the collection. chroma-go refuses it too
(`Delete requires at least one of ids, where, or where_document`); here it
never becomes a request:

```moonbit nocheck
client.delete(collection.id)   // ✗ InvalidArgument, nothing sent
client.delete(collection.id, filter=Filter::new(ids=["r1"]))            // ok
client.delete(collection.id, filter=Filter::new(where_clause=Some(
  where_eq("tag", MString("draft")),
)))                                                                     // ok
```

`limit` on a delete is only legal together with a clause, and only positive:
`limit` alone would be "delete `limit` records, no idea which", which the
server rejects. That combination is refused locally too.

## 4. A `where` clause has exactly one spelling

Chroma reads a flat `where` object as a conjunction: `{"a": .., "b": ..}` means
`a AND b`. It does not read `{"$and": [..], "b": ..}` the same way — depending
on the fields, the server answers `400 Invalid where clause` or silently
ignores the top-level key, and the second outcome is the dangerous one: the
caller gets back records they did not filter for.

`normalize_where` (`guard.mbt`) folds a plain top-level key into the operator's
operand list, so both spellings reach the server as the conjunction the caller
meant:

```
{"$and": [{"tag": {"$eq": "a"}}], "size": {"$gt": 1}}
   ↓
{"$and": [{"tag": {"$eq": "a"}}, {"size": {"$gt": 1}}]}
```

It is applied inside `filter_clauses` (`records.mbt`), which every write-side
`where` / `where_document` this client sends goes through — `get`,
`update` and `delete` share it, and `query_embeddings` normalizes the same two
fields in `append_filter` (`query.mbt`) before it is sent. There is no path
where an unnormalized clause can escape. A clause with no operator at the top
level is returned unchanged; nothing is rewritten that is already unambiguous.

The two write bodies are built separately from the read one on purpose:
`delete` and `update` never carry `include` (it selects which *columns come
back*, and a write returns nothing) and never carry `offset` (no write endpoint
pages).

## 5. A query result is columnar *twice*

`query` answers with one row per query embedding, and each field is again a
column of that row. A row that matched nothing comes back short: `ids` has no
entry, `documents` may have one, `embeddings` (not requested) has none.

The natural way to read a result is to loop over `ids` and index the other
columns; on a short row that is an out-of-bounds read at runtime, which in
MoonBit aborts the program. `pad_query_result` (called from
`QueryResult::from_query_json`) pads every column to `ids.length()`, with
`None` / `[]` for the cells the server did not send:

```moonbit nocheck
// {"ids": [["r1","r2"]], "documents": [["d1"]], "distances": [[0.1,0.2]]}
let result = client.query_embeddings(id, [[1.0, 0.0]])
result.ids        // ["r1", "r2"]
result.documents  // [Some("d1"), None]   ← padded, not []
```

The alternative — leaving the column short — is the bug: the arrays would
describe different numbers of records and only fail later, at the index
expression, far from the response that caused it.

## 6. Endpoints that look alike but are not

| What looks uniform | What is actually the case | Where |
| --- | --- | --- |
| `GET` / `DELETE` / `PUT /collections/{id}` all take a name | only `GET` and `DELETE` do; `PUT` wants a UUIDv4 and answers `400 InvalidArgumentError: Collection ID is not a valid UUIDv4` otherwise | `collections.mbt`, `resolve_collection_id` |
| `POST /collections` creates, and `get_or_create` is a separate call | one path, and the two entry points differ only by the `get_or_create` flag | `collection.mbt`, `create_body` |
| `count` answers `{"count": n}` | `GET /collections/{id}/count` answers a bare integer; the envelope is accepted as a fallback | `client.mbt`, `decode_count` |
| a collection reports its configuration under `configuration` | the server takes `configuration` and reports `configuration_json`; reading the first spelling always yields `{}` | `collection.mbt`, `configuration_of` |
| `modify_collection` sends `metadata` | the field is `new_metadata`; `metadata` is silently ignored | `collections.mbt`, `modify_body` |
| `include` may be omitted | omitting it makes the server fall back to `["documents", "metadatas"]`; an *empty array* is the way to ask for ids only | `query.mbt`, `Include::to_json` |

Each of these fails silently in the "wrong" direction — a `PUT` that reports
success while changing nothing, a `count` that decodes to `0` — which is why
each one has a live-server test in `chroma_wbtest.mbt` in addition to the unit
test that pins the wire format.

## Where the tests for this live

- `guard_test.mbt` — batch rules, chunking, progress, partial-failure reporting,
  `where` normalization, query padding.
- `scripted_async_wbtest.mbt` — the same rules through the `_async` path,
  which is written separately (an `async fn` cannot call a synchronous callback
  that awaits), so the two halves can drift.
- `chroma_wbtest.mbt` — everything above that only a real server can confirm,
  run by `sh scripts/integration.sh`.
- `coverage_test.mbt` / `client_test.mbt` — decode edge cases: a short row, a
  malformed row, a body past `MAX_RESPONSE_BYTES`.
