Skip to main content
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. 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.
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.

Base path

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. GET coverage and GET capabilities/compact sit alongside these as platform-owned discovery routes (see 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.

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:
Every returned group carries, in the envelope’s own camelCase (results is not translated to snake_case):
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:
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.
  • 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: 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:
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:
  • 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.
suppliers/count reuses query_too_broad; risks/screen reuses invalid_field_value/batch_too_large — no operation invents its own one-off error vocabulary.
A structural DTO violation (a bad enum, an out-of-range limit, forbidNonWhitelisted rejecting an unknown property) keeps the platform’s error envelope 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.

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 path405 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:
(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:

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 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.
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.
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 for the general caching note.

CLI: driftless market

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.
  • MCP & OAuth - connecting a client and the schema-caching note.
  • Errors - the platform-wide error envelope and code catalog.
  • Security - authentication and API keys.