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

# Market Data

> Commercial intelligence over Mexican public-sector suppliers, opportunities, awards, risk marks, and permits — a governed, read-only warehouse with typed refusals and keyset pagination.

Market data is a separate, read-only warehouse of Mexican commercial intelligence — suppliers, public opportunities, awards, adverse risk marks, and permits — layered under a **semantic contract**, not a SQL surface. It answers governed questions ("who could supply this?", "what has this RFC won?", "does this party carry a published risk mark?"); it does not expose a query language, and every response states what it actually saw so an empty page is never confused with a market that doesn't exist.

The semantic operations share one service layer across HTTP, the typed MCP tools, `driftless market`, and the internal agent tool belt used by [chat and Investigations](#chat-and-due-diligence-screening). The internal belt may batch already-selected detail for synthesis efficiency; public MCP deliberately does not. None of these surfaces exposes SQL or warehouse schema.

<Note>
  Every route is workspace-scoped and requires authentication like the rest of the API — there is no public/unauthenticated market-data route. `market_capabilities` is free discovery; the other twelve market operations are metered, attributable analysis on a shared warehouse.
</Note>

## Base path

```text theme={"theme":"github-light"}
/workspaces/:slug/market-data
```

## The thirteen market operations

Every path below is relative to the base path above. `example` is a minimal, valid request body straight from the route table the API itself serves at `GET capabilities` — every operation's `method`/`path`/`example` is discoverable at runtime, not just documented here.

| Operation              | Method & path                       | Paginates              | Example                                                                                                                                                     |
| ---------------------- | ----------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `market_capabilities`  | `GET capabilities`                  | no                     | —                                                                                                                                                           |
| `search_suppliers`     | `POST suppliers/search`             | yes                    | `{ "query": "tornillos industriales", "state": "Nuevo León", "limit": 5 }`                                                                                  |
| `get_supplier`         | `GET suppliers/~ref/:recordRef`     | no                     | opaque `record_ref` from search                                                                                                                             |
| `count_suppliers`      | `POST suppliers/count`              | no (`page: null`)      | `{ "state": "Nuevo León" }`                                                                                                                                 |
| `compare_segments`     | `POST suppliers/compare-segments`   | no (bounded matrix)    | `{ "segments": [{ "id": "dental", "query": "clínica dental" }], "geographies": [{ "id": "nl", "state": "Nuevo León" }], "observed_kind": "establishment" }` |
| `search_opportunities` | `POST opportunities/search`         | yes                    | `{ "query": "mantenimiento de bombas", "limit": 5 }`                                                                                                        |
| `get_opportunity`      | `GET opportunities/~ref/:recordRef` | no                     | opaque `record_ref` from search                                                                                                                             |
| `search_awards`        | `POST awards/search`                | yes                    | `{ "supplier_rfc": "XAXX010101000", "limit": 5 }`                                                                                                           |
| `get_supplier_history` | `POST awards/history`               | yes                    | `{ "supplier_rfc": "XAXX010101000", "currency": "MXN", "amount_scope": "supplier_contract" }`                                                               |
| `aggregate_awards`     | `POST awards/aggregate`             | yes (groups)           | `{ "currency": "MXN", "amount_scope": "supplier_contract", "group_by": ["buyer_name"] }`                                                                    |
| `search_risks`         | `POST risks/search`                 | yes                    | `{ "rfc": "XAXX010101000" }`                                                                                                                                |
| `screen_risks`         | `POST risks/screen`                 | no (batch, not a page) | `{ "rfcs": ["XAXX010101000"] }`                                                                                                                             |
| `search_permits`       | `POST permits/search`               | yes                    | `{ "holder_name": "Constructora Ejemplo" }`                                                                                                                 |

`GET coverage` and `GET capabilities/compact` sit alongside these as platform-owned discovery routes (see [Discoverability](#discoverability) below).

### Suppliers

`search_suppliers` needs at least one narrowing dimension (`query`, `state`, `scian_codes`, `source_slugs`, or `rfc`) — an unbounded scan of the whole directory is refused with `query_too_broad` rather than served. Each result is **one published observation**, never a consolidated company or a demonstration of capability; it carries contact *presence* and counts, not contact values. HTTP, OAuth, MCP and CLI responses are always abstracted and expose only contact availability; canonical coordinates remain inside the service/audit boundary.

`count_suppliers` shares the same narrowing rule and returns one exact `{ "count": number }`, computed with `SELECT count(*)`, never sampled or estimated. If the count itself can't finish inside the statement timeout, it fails with `market_data_timeout` rather than substituting an approximation.

The internal research belt may open up to 50 already-selected observations in one
in-process call for synthesis efficiency. That batch primitive is not an HTTP,
OAuth, MCP or CLI operation; it returns only the same redacted observations used
by the research runtime. Invalid, unavailable, and unlicensed references share
one `invalid_or_unavailable` status.

`compare_segments` compares up to five text-defined segments across up to ten Mexican states, with a hard ceiling of 50 cells and one required `observed_kind`. Its counts are published observations, never unique companies, TAM, or evidence of demand. `contact_breakdown: true` adds a like-for-like count of observations carrying a published contact channel.

```bash theme={"theme":"github-light"}
curl -X POST https://api.trybrein.com/api/v1/workspaces/acme/market-data/suppliers/count \
  -H "x-api-key: drift_your_api_key_here" -H "Content-Type: application/json" \
  -d '{ "state": "Nuevo León", "scian_codes": ["811111"] }'
```

### Opportunities and awards

`search_opportunities` returns `actionability` and `actionability_reason` on every row. There is no deadline field: this corpus publishes none, and the layer refuses to invent one — `is_open` is a status recorded at load time, not a live verification. `get_opportunity` returns its awards as a nested `awards` child collection plus `award_count`, never a flat join (one procedure can carry hundreds of awards).

An **award** is a publication — a contract was recorded as awarded — never a payment, a delivery, or an execution. `supplier_rfc` is strong identity; `supplier_name` is approximate and returns candidates. `get_supplier_history` requires `supplier_rfc`, `currency`, and `amount_scope` together (a history keyed on a name is a history of a string), and its response lists every other `(currency, amount_scope)` pair that RFC has rows in, so the total returned is never read as the whole history. `search_awards` also accepts `cog_partidas` — 1–20 five-digit SHCP "partida específica" (Clasificador por Objeto del Gasto) codes the *buyer* assigned at award time, matched by array overlap so a contract carrying several codes matches on any one of them; a code not shaped like five digits is refused as `invalid_field_value` before the corpus is read. Every award row carries `cogPartidas` (`[]` when the publisher recorded none).

`aggregate_awards` requires `currency`, `amount_scope`, and at least one allowlisted `group_by` (`supplier_rfc`, `buyer_name`, `buyer_acronym`, `procedure_type`, `contracting_type`, `supplier_size`, `award_month`, `award_year`, `cog_partida`, `cog_capitulo` — at most three). `supplier_name` is deliberately not a grouping dimension: grouping by approximate identity would merge unrelated organizations. Amounts are never summed across currencies or across amount scopes (`supplier_contract` and `award_group_published_total` are different quantities).

#### `compare_period`

Pass `compare_period: { from_date, to_date }` alongside the request's own `from_date`/`to_date` to run a period-over-period comparison **inside the same grouped statement**, never a second query:

```json theme={"theme":"github-light"}
{
  "currency": "MXN",
  "amount_scope": "supplier_contract",
  "group_by": ["buyer_name"],
  "from_date": "2026-01-01",
  "to_date": "2026-06-30",
  "compare_period": { "from_date": "2025-01-01", "to_date": "2025-06-30" }
}
```

Every returned group carries, in the envelope's own camelCase (`results` is not translated to snake\_case):

```ts theme={"theme":"github-light"}
periodA: { awardCount: number; totalAmount: string }
periodB: { awardCount: number; totalAmount: string }   // identical to the top-level awardCount/totalAmount
deltaAmount: string                                     // periodB.totalAmount − periodA.totalAmount
deltaPct: number | null                                 // as a fraction; null when periodA's total was zero
```

`delta_pct` is computed in Postgres in `numeric` and cast to `float8` only after the exact division already decided whether the denominator was zero — it is never `Infinity` or a fabricated number when period A totalled zero. Ranking and pagination order are always period B's: `compare_period` compares against the ranking, it never changes what is being ranked. A cursor issued without `compare_period` is invalidated (`invalid_cursor`) if resumed with one, and vice versa.

#### `group_by: cog_partida` / `cog_capitulo`

These two dimensions group by the SHCP object-of-expense code(s) a mixed contract carries — `cog_partida` on the raw five-digit code, `cog_capitulo` on its first digit — by unnesting `cog_partidas` before grouping. A contract carrying several codes is **not split** across them: it counts in full under *every* code it carries, so a group's `awardCount`/`totalAmount` can double-count relative to the corpus, and the sum across groups can exceed the corpus total. This is never resolved by prorating — the publisher never declared how to divide one contract across its codes. Grouping by either dimension always attaches the `cog_partida_totals_may_exceed_corpus` semantic warning, and both compose with `compare_period` the same way every other `group_by` does. There is no `cog_capitulo` *filter* on `search_awards` — an unindexed scan of the leading digit over the \~500K rows this relation serves was judged too slow to offer; `aggregate_awards` is the way to ask a capítulo-shaped question.

### Risk marks

`search_risks` finds published adverse marks — a barred-supplier listing (`efos`) or a `sancion`/inhabilitación. `rfc` is strong identity; `entity_name` is approximate and returns candidates with their RFCs. A mark is a **published listing**, never a judicial conviction, and a party can carry several, including published exonerations.

`screen_risks` batches 1–50 RFCs into **one** bounded `rfc = ANY($1)` scan instead of one `search_risks` call per RFC:

```json theme={"theme":"github-light"}
{ "rfcs": ["XAXX010101000", "AAA010101AAA"] }
```

`results` carries one entry per requested RFC, **in the order requested**, including an RFC with zero marks. It is coverage-backed only when the envelope declares an effective risks source (`licensedForDisplay: true` and `visibleRows > 0`), never evidence about any other RFC in the same batch. In that case the entry carries `warnings: ["zero_results_with_coverage"]`; the envelope repeats it only when the entire batch has zero marks. With no effective coverage, neither level carries that warning and the `coverage` declaration is the limitation. More than 50 RFCs is refused with `batch_too_large` rather than silently truncated.

### Permits

`search_permits` finds published permits, concessions, and capex commitments. `holder_rfc` is strong identity where published (rare in this corpus); `holder_name` is approximate and is the join this relation was built around. A permit is a granted right recorded at load time, never a live operational verification — treat `is_active_risk`, `status_text`, `is_expansion`, and capex/capacity fields (`investment_mdd`, `capacity_mw`, `estimated_generation`) as what the publisher printed, not audited or disbursed spend.

## The envelope

Every success response — search, get, aggregate, count, screen alike — is one envelope, and every key in it is **camelCase**, deliberately distinct from the snake\_case the rest of the platform (Knowledge and Collections) uses. This is not an inconsistency to fix: it's the dialect the shipped market-data contract, the MCP tool result, and the ChatGPT plugin already speak.

```ts theme={"theme":"github-light"}
interface MarketDataEnvelope<T> {
  schemaVersion: 'market-data/domain/1'
  requestId: string
  operation: string
  interpretedRequest: object   // the request AFTER normalization
  results: T
  page: PageInfo | null        // null for a single-record read or a count
  coverage: CoverageDeclaration[]
  corpusBasis: CorpusBasis
  semanticWarnings: SemanticWarning[]
  provenance: Provenance[]
  diagnostics: { elapsedMs: number; rowsExamined: number; truncated: boolean }
}
```

* **`interpretedRequest`** is what the layer actually ran after normalizing the caller's input (a state name resolved to its code, a fuzzy municipality resolved to its canonical spelling) — `normalizations` says what changed and why.
* **`results`** is the operation's own payload: a row array for a search, one object for a `get_*`/`count_*` read, a group array for an aggregate.
* **`page`** is `null` whenever the operation doesn't paginate (`get_supplier`, `get_opportunity`, `market_capabilities`, `count_suppliers`, `compare_segments`).
* **`coverage`** and **`corpusBasis`** are what makes an answer honest: which published sources contributed, and which snapshot of the corpus this read saw. Neither can be authored by a model or a caller.
* **`semanticWarnings`** names a misreading this domain actively invites (an award is not a payment, a risk mark is not a conviction, a name is approximate identity) — always attached by the layer, never decoration.
* **`provenance`** says where each row was read, explicitly not that it is current or verified.

## What a model sees: evidence categories, not publishers

The service keeps a canonical projection for internal audit and support, but
the HTTP API above always returns the abstracted projection: `source_slug`,
`source_record_id`, publisher identity, retrieval internals and contact
coordinates never travel to an authenticated HTTP caller.

A surface a **model** reads — Chat, Research, and every MCP tool — reads the
same envelope through an *abstracted* projection. Publisher identity is replaced
by the kind of evidence the operation returned, derived from the warehouse
relation rather than from any table of publishers:

| Category                            | Operations                                 | What it is                                                                                        |
| ----------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| `supplier_or_organization_evidence` | supplier search, detail, count             | A published observation of an organization or establishment — never a consolidated legal identity |
| `government_contracting`            | opportunities, awards, history, aggregates | A published procedure, or a contract recorded as awarded under one                                |
| `administrative_risk`               | risk search and screening                  | A published administrative mark — never a judicial conviction                                     |
| `authorization_or_permit`           | permit search                              | A permit or concession recorded when granted — never proof of current operation                   |
| `other_public_record`               | —                                          | The most general stable answer, when no relation is known                                         |

Under that projection each row carries `evidence_category`,
`evidence_category_label`, an opaque **`record_ref`** and a
**`record_fingerprint`**; `coverage` groups by category and counts only rows
licensed for display; and the page cursor is sealed. Every semantic warning,
coverage count, freshness value, identity strength and amount scope is unchanged.

`record_ref` is what `get_supplier` takes: copy it verbatim from the search row
you want the detail for. It is a fresh ciphertext per response, so it is a handle
to follow and never a value to compare across calls: within ONE envelope a row
carries the same `record_ref` everywhere it appears (a `results[]` row and its
`provenance[]` entry match), and the next response mints a different one for the
same row. To recognize a row across responses, join on `record_fingerprint` —
never on `record_ref`. A reference the layer did not issue is refused with
`invalid_record_ref`.

`record_fingerprint` is the value two observations of the same row share — 128
bits, keyed and one-way. It deliberately reveals **equality**: a holder can link
the same published record across pages, calls and runs, which is what
deduplication needs. It reveals no **identity**: there is no operation that turns
it back into a `source_slug`, a `source_record_id` or anything else about the
row, and it is refused wherever a `record_ref` is expected. It survives a
primary-key rotation for as long as a reference minted before that rotation still
resolves, so a row already seen stays recognizable as one.

**There is no source filter on model-facing surfaces.** `source_slugs` is
accepted on the canonical route and advertised nowhere else. It has no abstract
replacement, deliberately: a "kind of record" filter would need per-source
metadata this layer does not have, and deriving it from a list of slugs frozen
inside the API would invent a corpus-dependent value outside the corpus. A
membership question ("is X in that specific register?") is therefore answered by
searching, and an answer says what the search found rather than which register it
came from. See `docs/market-data/tool-contract.md` for the warehouse metadata
that would bring the filter back.

The projection is chosen by the **entrypoint**, never by a request payload: it is
not a parameter, and there is no value a caller can send that widens what it sees.

## Pagination — keyset, never offset

Every operation that returns more than one row (`search_*`, `aggregate_awards`, `get_supplier_history`) pages with an opaque keyset cursor, never `OFFSET`:

```ts theme={"theme":"github-light"}
interface PageInfo {
  limit: number
  returned: number
  hasMore: boolean
  nextCursor: string | null
}
```

`page.nextCursor` carries the contract version (so a cursor from an older response shape can't be resumed against a newer one), the corpus snapshot it was issued against (so pagination can never interleave two published snapshots), a digest of the filters — and, for `aggregate_awards`, of `compare_period` — so continuing with a *different* filter set is refused rather than served, and the last row's position in the total order.

`page.hasMore`/`page.returned` are computed from an honest `limit + 1` read, never inferred from `returned === limit`. Two distinct refusals come out of a broken cursor because the correct recovery differs: a **filter change** mid-pagination is `invalid_cursor`; a **corpus change** is `cursor_stale` — nobody's mistake, the published data moved. Both recover the same way: restart without a cursor.

Default page size is 20 (50 for `aggregate_awards`); `limit` caps at **50** for every search-shaped operation and at **200** for `aggregate_awards`.

## Refusals

A refusal is never a bare 400. Every domain-specific rejection carries a `market_data` block alongside the platform's own `code`/`message`/`request_id`:

```json theme={"theme":"github-light"}
{
  "code": "VALIDATION_FAILED",
  "message": "This batch carries 60 rfcs, above the 50 this operation screens in one call.",
  "request_id": "req-1",
  "market_data": {
    "semantic_code": "batch_too_large",
    "why": "A batch screen answers one bounded scan, not an open-ended list. Serving more than 50 would mean either scanning unboundedly or silently dropping the RFCs past the ceiling — both are worse than telling you the ceiling up front.",
    "suggested_correction": "Split \"rfcs\" into batches of at most 50 and call once per batch.",
    "retryable": false,
    "recovery": { "action": "fix_arguments" }
  }
}
```

* **`why`** explains the misunderstanding, not just the rule — most refusals in this domain are a well-formed question about a field or shape that doesn't mean what the caller assumed, not a typo.
* **`suggested_correction`** is prose aimed at an LLM: the concrete next call that would work.
* **`recovery.action`** is the same fact, machine-readable, drawn from a closed vocabulary so a caller can branch without parsing prose. It's derived one-to-one from `semantic_code` — never set independently of it.
* **`retryable`** is `false` for every shape/value/vocabulary refusal (the identical call reproduces the identical refusal), and `true` only for the operational handful — timeout, unavailable, stale projection — where the world, not the request, is expected to change.

| `semantic_code`                                                                                                                                                                               | Fires when                                                                                                                                                                                                                         | `recovery.action`        |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
| `query_too_broad`                                                                                                                                                                             | A search or count carries no narrowing dimension                                                                                                                                                                                   | `narrow_query`           |
| `query_not_selective`                                                                                                                                                                         | A text query is the ONLY narrowing dimension and matches more of the corpus than the layer will read for one page — established by a bounded probe, never guessed. The same words with any structured filter are answered normally | `narrow_query`           |
| `invalid_field_value`, `unknown_state`, `invalid_observed_kind`, `invalid_mark_kind`, `deadline_not_available`, `amount_requires_currency_and_scope`, `unknown_filter_value`, `unknown_facet` | A value is structurally or semantically wrong for its field                                                                                                                                                                        | `fix_arguments`          |
| `batch_too_large`                                                                                                                                                                             | `screen_risks` carries more than 50 RFCs                                                                                                                                                                                           | `fix_arguments`          |
| `invalid_cursor`                                                                                                                                                                              | A cursor's filters don't match the query it's attached to                                                                                                                                                                          | `restart_without_cursor` |
| `cursor_stale`                                                                                                                                                                                | The corpus changed underneath a cursor mid-pagination                                                                                                                                                                              | `restart_without_cursor` |
| `market_data_timeout`, `market_data_unavailable`, `serving_projection_unavailable`, `serving_projection_stale`                                                                                | A bounded statement timeout, an outage, or a serving projection with no active publication yet                                                                                                                                     | `retry_backoff`          |

`suppliers/count` reuses `query_too_broad`; `risks/screen` reuses `invalid_field_value`/`batch_too_large` — no operation invents its own one-off error vocabulary.

<Note>
  A structural DTO violation (a bad enum, an out-of-range `limit`, `forbidNonWhitelisted` rejecting an unknown property) keeps the platform's [error envelope](/api/errors) exactly as it is — `VALIDATION_FAILED`, same `message`, same status. On the market-data routes it now ALSO carries a `market_data` block with `allowed_values` (the whole closed set the decorator enforces) and `recovery.action: fix_arguments`, because the server knows precisely what would have been accepted and a refusal that withholds it teaches nothing. The `semantic_code` is `invalid_field_value`. Elsewhere on the platform a DTO violation is unchanged and carries no such block.
</Note>

## Discoverability

Two things a blind client (an agent with only `curl` and this API's own descriptions) needs and a bare 404 can't give it: what verb *would* have worked, and what paths exist at all.

* **Wrong verb, real path** → `405 Method Not Allowed` with an RFC 9110-compliant `Allow` header naming the methods that do work, plus the same fact in the JSON body (`allowed_methods`).
* **Unknown path under `market-data/*`** → `404` carrying `documentation_url` (the workspace's own `capabilities` route) and `operations` — the complete method/path list — instead of leaving the caller to guess from silence:

```json theme={"theme":"github-light"}
{
  "statusCode": 404,
  "code": "NOT_FOUND",
  "message": "No market-data route matches this path.",
  "request_id": "req-1",
  "endpoint": "GET /api/v1/workspaces/acme/market-data/suplier/search",
  "documentation_url": "/api/v1/workspaces/acme/market-data/capabilities",
  "operations": [
    { "operation": "search_suppliers", "method": "POST", "path": "/workspaces/{slug}/market-data/suppliers/search" }
  ]
}
```

(`operations` lists all thirteen, trimmed above for space.)

`GET capabilities` is the full contract for integrators: every operation's `question`, `requires`, `refuses`, `method`, `path`, and a runnable `example`, plus the corpus-observed filter value lists (state names, SCIAN codes, procedure types, …), each stamped with the `corpusBasis` it was read against — never presented as timeless constants. An optional `?facets=a,b` query param restricts both the computation and the response to named corpus dimensions. `GET capabilities/compact` is the bounded, source-aware projection the agent runtimes (chat, MCP) are injected instead of spending a tool call on the full catalogue. Both carry `openapiUrl`, which points at the machine-readable OpenAPI document — served **root-absolute** at `/openapi.json`, unlike every other market-data path, which is workspace-relative.

`GET coverage` returns the same `coverage` block attached to every other response, standalone: which published sources contributed to each relation, how fresh, and whether it's licensed for display. It is what separates "no results" from "no such market".

## Analysis versus contact activation

Large-scale questions use server-side counts, comparisons, histories and award
aggregates. One aggregate response is one insight credit even when it contains
many groups; the underlying company/contact rows are not exported.

Contact research is separate and operates only on CRM records the user selected:

1. `driftless_contact_quote` reads the exact selection and balance. It is free,
   calls no external provider and returns no contact coordinates.
2. After the user explicitly accepts that quote,
   `driftless_contact_unlock` sends the same ids, `confirm: true`, the approved
   `max_credits`, the opaque server-minted `quote_token` (valid for 10 minutes),
   and the server-minted `idempotency_key` returned by that same quote. The server recomputes the price
   and refuses before spend if it increased or the balance/permission changed.

Already unlocked accounts cost zero. Partial failures are reported per account;
failed work is refunded. Reusing the same idempotency key cannot call the
provider or debit the workspace twice.

## Authentication and scopes

Market-data routes require the same authentication as the rest of the API (API key or OAuth bearer token) — there is no public route. Over OAuth, market analysis maps to a dedicated `market_data:read` scope, OR-listed with `context:read` for back-compat. A free Contact Path quote over selected CRM records uses the separate `commercial:read` scope; paid unlock uses `commercial:activate`:

| Scope                 | Status                                                                                                                                                                                                                                   |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `market_data:read`    | The documented, canonical scope for market-data reads. Dedicated so a client that should only consume commercial intelligence (an external plugin, say) isn't handed read access to the whole Topics/Knowledge vault via `context:read`. |
| `context:read`        | Still satisfies existing market-data routes transitionally for already-issued connectors. New default grants include `market_data:read`.                                                                                                 |
| `commercial:read`     | Reads an exact Contact Path quote for CRM records the caller selected. It returns price, balance and availability only — never contact coordinates.                                                                                      |
| `commercial:activate` | Optional and never implicit. Required only for the paid Contact Path unlock; the consent screen explains the quote-and-confirm boundary.                                                                                                 |

## MCP: typed market-data tools

MCP exposes thirteen typed `driftless_market_*` analysis tools plus the two-step Contact Path tools. It does not expose `driftless_market_data`, `driftless_market_get_supplier_batch`, physical source selectors, or contact coordinates. Reconnect a client after this schema change because connectors may cache tool definitions. See [MCP reference](/mcp/overview) for connecting a client.

All thirteen market tools carry `readOnlyHint: true`: they do not modify business data or produce external side effects. That annotation does **not** mean a call is free. `driftless_market_capabilities` costs zero calls and zero credits; each of the other twelve market tools is metered according to the commercial-usage contract.

```json theme={"theme":"github-light"}
{ "query": "tornillos industriales", "state": "Nuevo León", "limit": 5 }
```

The tool result adds house-standard top-level pagination (`shown`, `has_more`) alongside the envelope's own `page.returned`/`page.hasMore` — additive, never a replacement — plus a `next_action` hint telling the caller what to do next (continue with a cursor, inspect a candidate with `get_supplier`, read `coverage` before concluding "no marks", and so on).

For textual supplier, opportunity, and award operations (including `aggregate_awards`), `query_mode` is optional: `auto` preserves existing behavior, while `exact_phrase`, `all_terms`, and `any_terms` are bounded interpretations of tokenized text. An explicit opportunity mode uses lexical retrieval and cannot be combined with `strategy=hybrid`. The platform-authored `interpretedRequest.query_interpretation` reports requested and applied modes, normalized terms, recognized state aliases and filters, and any warning. It never includes SQL, schema, or a compiled query.

<Note>
  MCP clients (claude.ai, ChatGPT) **cache tool schemas per connector session**. Reconnect the connector (or start a fresh session) to remove retired tools and receive the Contact Path schemas and scopes — see [MCP & OAuth](/mcp/overview) for the general caching note.
</Note>

## CLI: `driftless market`

```bash theme={"theme":"github-light"}
driftless market capabilities
driftless market suppliers search --query "tornillos industriales" --state "Nuevo León" --limit 5
driftless market suppliers get <record-ref>
driftless market suppliers count --state "Nuevo León"
driftless market opportunities search --query "mantenimiento de bombas"
driftless market opportunities get <id> --include-awards true
driftless market awards search --supplier-rfc XAXX010101000
driftless market awards history <supplier-rfc> --currency MXN --amount-scope supplier_contract
driftless market awards aggregate --currency MXN --amount-scope supplier_contract --group-by buyer_name \
  --compare-from-date 2025-01-01 --compare-to-date 2025-06-30 --from-date 2026-01-01 --to-date 2026-06-30
driftless market risks search --rfc XAXX010101000
driftless market risks screen --rfcs XAXX010101000,AAA010101AAA
driftless market permits search --holder-name "Constructora Ejemplo"
```

Every command supports `--json` for the complete semantic envelope; without it, the CLI prints a compact human summary (row count, per-row label, warnings, coverage, next cursor). Repeat an array flag or pass comma-separated values. `--compare-from-date`/`--compare-to-date` are flat CLI flags that nest into one `compare_period` object on the wire — pass both together or neither.

## Chat and due-diligence screening

The dashboard's market-research chat plans and calls these same operations directly (no separate model-facing API) and can run a **due-diligence screening** method that composes `search_risks` and `search_permits`:

1. Resolve identity by RFC first — from a prior `search_suppliers`/`search_awards` result or given directly — and call `search_risks { rfc }` with it. Do not use that award RFC as a permit bridge: the current permit sources publish no holder RFCs. Call `search_permits { holder_name }` only after a verified published name exists; name resolution is approximate and its match caveat must be stated. `holder_rfc` remains a strong filter where a future source publishes it.
2. Never conclude "no risk marks" or "not authorized" from an empty page alone — `coverage` is read first and the search is stated against it.
3. A permit and a risk mark answer different questions and are reported separately — never merged into one verdict of "clean" or "authorized". A risk mark is a published listing, not a conviction; a permit is a granted right recorded at load time, not current operational status.

## Related

* [MCP & OAuth](/mcp/overview) - connecting a client and the schema-caching note.
* [Errors](/api/errors) - the platform-wide error envelope and code catalog.
* [Security](/security/overview) - authentication and API keys.
