> ## Documentation Index
> Fetch the complete documentation index at: https://docs.contracts.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Ask

> Ask your contracts a question in English. The answer is its citations: contract id, block id, char span, and the quoted words.

<Warning>
  **Draft — ahead of the backend; nothing here is live yet.** This surface is
  written against the design, not against a running server. There is no
  `api.contracts.io` to call, no key to issue, and every response below is an
  illustration rather than a recording. It is published so the shape can be argued
  with before it is built.

  It also predates **CONTRACT-SCHEMA v0** and still uses the older nouns — *ask* for
  Proposal, *receipt* for Version, *relationship* for Room. The
  [glossary](/glossary) maps them.
</Warning>

One endpoint, two chairs. The Agent home calls it with `scope: "org"`. In-room, the agent calls it with
`scope: "contract"` and widening is a visible scope chip, not a different stack.

<Note>
  If the two ever diverge, they will disagree, and the room's answer will be the wrong one. It is
  deliberately the same HTTP call.
</Note>

## Ask a question

<ResponseField name="POST /ask" type="endpoint">
  Scopes: `ask:read`
</ResponseField>

<Note>
  **`ask:read` is its own scope** (reconciled 2026-08-24). `contracts:read` alone no longer buys the ask.
  Corpus-wide retrieval crosses every contract the seat can open, and a key granted to read one contract
  should not silently read across the org. Retrieval is still capped by the acting person's row-level
  access regardless of scope. The scope decides whether you may ask, the seat decides what can answer.
</Note>

<ParamField body="question" type="string" required>
  Plain English. There is no filter DSL and there will not be one.
</ParamField>

<ParamField body="scope" type="string" required>
  `org` · `relationship` · `contract`
</ParamField>

<ParamField body="scope_id" type="string">
  A `rel_…` or `con_…`. Required when `scope` is not `org`; `null` otherwise.
</ParamField>

<ParamField body="stream" type="boolean" default="false">
  When `true`, the response is `text/event-stream`. **Defaults to `false`.** A caller who does not ask
  for a socket does not get one.
</ParamField>

## Without streaming

```json Request theme={null}
{
  "question": "Which MSAs cap liability at six months?",
  "scope": "org",
  "scope_id": null,
  "stream": false
}
```

```json 200 application/json theme={null}
{
  "answer": "Three of your main agreements limit what the other side could owe you to six months of fees.",
  "citations": [
    {
      "contract": "con_31",
      "title": "Northwind MSA",
      "state": "filed",
      "origin": "import",
      "block": "blk_9",
      "span": [412, 486],
      "words_hash": "sha256:be71…",
      "quote": "…capped at six (6) months' fees…"
    }
  ],
  "matched":  { "contracts": 3, "citations": 3 },
  "examined": { "blocks": 12, "contracts": 7, "truncated": true },
  "searched": { "contracts": 41, "scope": "org" }
}
```

<Note>
  **Both forms are first-class** (reconciled 2026-08-24). The non-streaming body was previously shown
  only under a `stream: true` request, which read as an SSE frame and left every non-streaming caller
  guessing. Anything that cannot hold a socket open (a cron job, a webhook handler, a server-side ATS
  enrichment) asks with `stream: false` and gets the whole answer in one body.
</Note>

## Streaming

With `stream: true` the response is `text/event-stream` carrying four event types:

| Event          | Carries                                                          |
| -------------- | ---------------------------------------------------------------- |
| `citation`     | One citation object. **Streams ahead of the prose.**             |
| `answer.delta` | A chunk of the answer text.                                      |
| `done`         | Terminal. The final `matched`, `examined` and `searched` counts. |
| `error`        | Terminal. The same error envelope the JSON form returns.         |

Citations arrive first by design, so the list of doors is standing before the answer finishes writing.

Streaming changes **when** the fields arrive, never which ones: concatenate the `answer.delta` chunks,
collect the `citation` events, take the counts from `done`, and you have the non-streaming body back
byte for byte.

### Exactly one of `done` or `error` ends every stream

The stream opens with a `200`, so a retrieval or generation failure after the first byte needs an event
of its own. Without one, a half-written answer and a finished one look identical apart from a missing
`done`, which is also what a dropped socket looks like.

```
event: error
data: { "error": { "type":"api_error", "code":"generation_failed",
                   "message":"The answer stopped partway. Nothing was saved. Ask again.",
                   "doc_url":"https://docs.contracts.io/errors#generation-failed" } }
```

<Warning>
  **A stream that ends with neither `done` nor `error` is a transport failure.** Treat it as incomplete,
  never as an empty answer. Show the reader that it broke, and let them ask again.
</Warning>

## Three counts, three meanings

The response reports three numbers, and they answer three different questions. Reporting only one of them
claims something the retrieval never promised.

| Field      | What it counts                                                                         |
| ---------- | -------------------------------------------------------------------------------------- |
| `searched` | The seat's retrievable universe. How many contracts this person could have been shown. |
| `examined` | What actually reached the model: `blocks`, `contracts`, `truncated`.                   |
| `matched`  | Computed from the citation set: `contracts`, `citations`.                              |

<ResponseField name="examined" type="object">
  `truncated: true` says the ranking was cut, which is the honest signal that a longer answer existed.
</ResponseField>

<ResponseField name="matched" type="object">
  Derived from the citations that came back, so it can never disagree with the doors on screen.
</ResponseField>

<Warning>
  **Any count you render comes from `matched`, or from counting the citations yourself. Never parse a
  number out of the answer prose.** The prose carries its own generated count, and the two can disagree
  inside one response. The generator is handed `matched.contracts` so the prose does not drift, and
  `matched` is still the only number to print.
</Warning>

**An ask is a ranked retrieval, not a guaranteed exhaustive scan.** Say so in your client. "Three of the
41 agreements you can open" is honest. "We checked all 41" is not.

## The answer *is* its citations

<Warning>
  Every claim carries **contract id + block id + char span + the quoted words.** That is what lets a client
  render *"3 MSAs cap at 6 months, open each"* as three real doors instead of a paragraph the reader
  must trust. **A sentence that cannot cite says so.**
</Warning>

Do not render the prose without the citation list. The prose is the summary; the citations are the
product.

## Retrieval, and the one thing it must never do

Per org, three corpora are indexed side by side:

<CardGroup cols={3}>
  <Card title="Clause blocks" icon="file-text">
    The current-text fold, so the index can never quote words that lost.
  </Card>

  <Card title="Facts" icon="tag">
    Name, value, occurrence. What turns "six months" from a fuzzy match into an exact one.
  </Card>

  <Card title="Letters" icon="mail">
    Notes, asks and `why`s. Where the reason a term moved actually lives.
  </Card>
</CardGroup>

Each row carries both a lexical index and an embedding, keyed to the `words_hash` it was built from and
swept when that hash moves. A question runs both rankings, fuses them by reciprocal rank, reranks the
top slice, and hands ≤12 blocks to the model with their contract ids attached.

Hybrid is not hedging. It is the two halves of a contract question. **The number is lexical; the
concept is not.**

### How fresh the index is

Every index row is keyed to the `words_hash` it was built from, and swept when that hash moves. The two
halves of the index are not equally fresh, and the difference decides whether words that landed a moment
ago can be quoted as the words that stand now.

1. **The keyword half is transactional.** Its row is written in the same transaction as the words
   themselves, so it is never stale.
2. **The embedding half is asynchronous**, usually less than a second behind.
3. **A row whose `words_hash` is not the contract's current hash is left out of the ranking.** It is not
   served with its old text, and it is not quietly re-read at citation time either. A re-read would quote
   today's words under a ranking earned by words that lost.
4. **Every citation carries the `words_hash` it was built from**, so a client can compare it against the
   contract it opens and tell the reader the words have moved since.

The practical consequence is small and worth saying out loud. For about a second after a counter lands, a
question about a concept can miss the new words while a question about an exact phrase already finds
them. Nothing ever quotes the old ones.

### Retrieval runs on your seat

<Warning>
  A contract the asker cannot open **cannot be retrieved, cannot be cited, and cannot be counted.**

  There is no service-role index reader.
</Warning>

`searched.contracts` counts only contracts **this seat can open**, and your UI should say so in those words.

There is deliberately no `withheld: 2` field, and there never will be: *a count of what you cannot see is
itself a leak.*

<Warning>
  **Unsent replies, staged `why`s and internal notes are never retrievable across parties.** Both sides
  can open a shared contract, so "can you open this contract" is one level too coarse a question here. Clause
  blocks are shared by construction. Letters and notes are not: a sent letter is shared, a round you have
  staged and not sent is yours, and an internal note never leaves your side. An ask is a summary, and a
  summary of the other side's private draft would be the hardest kind of leak to notice, because the
  answer reads just as fluently either way.
</Warning>

### Asking about a contract you cannot open

<Warning>
  `POST /ask` with `scope: "contract"` on a contract this seat cannot open returns **`404 not_found`**, exactly
  what a contract that never existed returns.
</Warning>

The two answers are identical on purpose. A `403` would confirm the contract is real, which lets somebody
outside guess ids and sort the real ones from the invented ones.

## Errors

| Code                 | Status                       | Means                                                                                                                                                                               |
| -------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `unauthorized`       | `401`                        | No key, a malformed key, a revoked key, or a guest link that expired or was revoked.                                                                                                |
| `insufficient_scope` | `403`                        | The key works and `ask:read` was never granted. `param` names the missing scope.                                                                                                    |
| `not_found`          | `404`                        | The contract in `scope_id` does not exist, or this seat cannot open it. Deliberately the same answer for both.                                                                      |
| `invalid_request`    | `400`                        | `scope` and `scope_id` do not go together: a `scope` outside `org`, `relationship` and `contract`, or a missing `scope_id` when `scope` is one of the last two. `param` says which. |
| `generation_failed`  | `200`, then an `error` event | The answer stopped partway. Nothing was saved. Ask again.                                                                                                                           |
| `rate_limited`       | `429`                        | Over the limit. Carries `Retry-After`.                                                                                                                                              |

The envelope, the status of every code, and the rest of the list are on [Errors](/api/errors).

## What died here

* A separate analytics or BI query language over contracts: saved queries, a filter DSL, a reporting
  endpoint. A second query language would be a second permission surface and a second thing to keep in
  sync with the words.
* A search tool taking a filter DSL instead of a question.

The question is asked in English, answered with citations, and narrowed with the index's existing facets.
