Skip to main content
Market data es un warehouse separado y de solo lectura de inteligencia comercial mexicana — suppliers, opportunities públicas, awards, marcas de riesgo adversas y permits — construido sobre un contrato semántico, no una superficie SQL. Responde preguntas gobernadas (“¿quién podría suministrar esto?”, “¿qué se ha adjudicado a este RFC?”, “¿esta parte lleva una marca de riesgo publicada?”); no expone un lenguaje de consulta, y cada respuesta declara qué vio realmente, para que una página vacía nunca se confunda con un mercado que no existe. HTTP, MCP y driftless market comparten los mismos servicios in-process y siempre devuelven la proyección pública abstracta: operaciones semánticas tipadas, sin SQL, detalle masivo de proveedores ni coordenadas de contacto. El tool belt interno puede componer lecturas acotadas y redactadas para síntesis, sin convertirse en una superficie de extracción. Quien llama planea la secuencia y sintetiza la respuesta.
Cada ruta está acotada al workspace y requiere autenticación como el resto de la API — no hay ninguna ruta pública/sin autenticar en market-data. market_capabilities es descubrimiento gratuito; las otras doce operaciones de market data son análisis medidos y atribuibles sobre un warehouse compartido.

Ruta base

Las trece operaciones de market data

Cada ruta de abajo es relativa a la ruta base de arriba. example es un cuerpo de petición mínimo y válido, tomado directamente de la tabla de rutas que la propia API sirve en GET capabilities: el method/path/example de cada operación es descubrible en tiempo de ejecución, no solo documentado acá. GET coverage y GET capabilities/compact se suman a estas como rutas de discovery propiedad de la plataforma (ver Discoverability más abajo).

Suppliers

search_suppliers necesita al menos una dimensión de acotamiento (query, state, scian_codes, source_slugs, o rfc) — un escaneo sin límite de todo el directorio se rechaza con query_too_broad en vez de servirse. Cada resultado es una observación publicada, nunca una empresa consolidada ni una demostración de capacidad; lleva la presencia de contacto y sus conteos, no los valores de contacto. Las respuestas HTTP, OAuth, MCP y CLI siempre usan la proyección abstracta y sólo exponen disponibilidad; las coordenadas canónicas permanecen dentro del servicio/auditoría. count_suppliers comparte la misma regla de acotamiento y devuelve un entero exacto { "count": number }, calculado con SELECT count(*), nunca muestreado ni estimado. Si el conteo mismo no puede terminar dentro del statement timeout, falla con market_data_timeout en vez de sustituir una aproximación. El belt interno de investigación puede abrir hasta 50 observaciones ya seleccionadas en una llamada in-process para síntesis acotada. Ese batch no es una operación HTTP, OAuth, MCP ni CLI y devuelve sólo observaciones ya redactadas. Las referencias inválidas, no disponibles y no licenciadas comparten intencionalmente un solo estado invalid_or_unavailable. compare_segments compara hasta cinco segmentos definidos por texto en hasta diez estados mexicanos, con un límite duro de 50 celdas y un solo observed_kind obligatorio. Sus conteos son observaciones publicadas, nunca empresas únicas, TAM ni evidencia de demanda. contact_breakdown: true añade un conteo comparable de observaciones con un canal de contacto publicado.

Opportunities y awards

search_opportunities devuelve actionability y actionability_reason en cada fila. No hay campo de fecha límite: este corpus no publica ninguno, y la capa se rehúsa a inventar uno — is_open es un estado grabado al momento de la carga, no una verificación en vivo. get_opportunity devuelve sus awards como una child collection anidada awards más award_count, nunca un join plano (un procedimiento puede llevar cientos de awards). Un award es una publicación — se registró que un contrato fue adjudicado — nunca un pago, una entrega o una ejecución. supplier_rfc es identidad fuerte; supplier_name es aproximado y devuelve candidatos. get_supplier_history requiere supplier_rfc, currency y amount_scope juntos (una historia indexada por nombre es la historia de un string), y su respuesta lista cada otro par (currency, amount_scope) en el que ese RFC tiene filas, para que el total devuelto nunca se lea como la historia completa. search_awards también acepta cog_partidas — de 1 a 20 códigos de cinco dígitos de “partida específica” SHCP (Clasificador por Objeto del Gasto) que el comprador asignó al momento de la adjudicación, comparados por overlap de arreglo, de modo que un contrato que lleva varios códigos hace match con cualquiera de ellos; un código que no tiene forma de cinco dígitos se rehúsa como invalid_field_value antes de leer el corpus. Cada fila de award lleva cogPartidas ([] cuando el publicador no registró ninguno). aggregate_awards requiere currency, amount_scope, y al menos un group_by en la lista permitida (supplier_rfc, buyer_name, buyer_acronym, procedure_type, contracting_type, supplier_size, award_month, award_year, cog_partida, cog_capitulo — como máximo tres). supplier_name deliberadamente no es una dimensión de agrupación: agrupar por identidad aproximada fusionaría organizaciones distintas. Los montos nunca se suman entre currencies ni entre amount scopes (supplier_contract y award_group_published_total son cantidades distintas).

compare_period

Pasa compare_period: { from_date, to_date } junto con el from_date/to_date propio de la petición para correr una comparación periodo contra periodo dentro del mismo statement agrupado, nunca una segunda consulta:
Cada grupo devuelto lleva, en el propio camelCase del envelope (results no se traduce a snake_case):
delta_pct se calcula en Postgres en numeric y se castea a float8 solo después de que la división exacta ya decidió si el denominador era cero — nunca es Infinity ni un número inventado cuando el periodo A totalizó cero. El orden de ranking y de paginación siempre es el del periodo B: compare_period compara contra el ranking, nunca cambia qué se está rankeando. Un cursor emitido sin compare_period se invalida (invalid_cursor) si se reanuda con uno, y viceversa.

group_by: cog_partida / cog_capitulo

Estas dos dimensiones agrupan por el/los código(s) de objeto del gasto SHCP que lleva un contrato mixto — cog_partida sobre el código de cinco dígitos, cog_capitulo sobre su primer dígito — desanidando cog_partidas antes de agrupar. Un contrato que lleva varios códigos no se reparte entre ellos: cuenta íntegro bajo cada código que lleva, así que el awardCount/totalAmount de un grupo puede duplicar respecto al corpus, y la suma entre grupos puede exceder el total del corpus. Esto nunca se resuelve prorrateando — el publicador nunca declaró cómo dividir un contrato entre sus códigos. Agrupar por cualquiera de las dos dimensiones siempre adjunta la advertencia semántica cog_partida_totals_may_exceed_corpus, y ambas componen con compare_period igual que cualquier otro group_by. No existe un filtro cog_capitulo en search_awards — un escaneo sin índice del primer dígito sobre las ~500K filas que sirve esta relación se consideró demasiado lento para ofrecerlo; aggregate_awards es la forma de hacer una pregunta con forma de capítulo.

Marcas de riesgo

search_risks encuentra marcas adversas publicadas — un listado de supplier vetado (efos) o una sancion/inhabilitación. rfc es identidad fuerte; entity_name es aproximado y devuelve candidatos con sus RFCs. Una marca es un listado publicado, nunca una condena judicial, y una parte puede llevar varias, incluyendo exoneraciones publicadas. screen_risks agrupa de 1 a 50 RFCs en un solo escaneo acotado rfc = ANY($1) en vez de una llamada a search_risks por RFC:
results lleva una entrada por cada RFC pedido, en el orden pedido, incluyendo un RFC con cero marcas. Ese cero sólo está respaldado cuando el envelope declara coverage efectiva de riesgos: en ese caso la entrada lleva warnings: ["zero_results_with_coverage"], nunca como evidencia sobre otro RFC del batch. El envelope repite el warning únicamente si todo el batch quedó en cero. Sin coverage licenciada y visible, ninguno de los dos niveles lo emite y la declaración de coverage sin fuentes expresa la limitación. Más de 50 RFCs se rechaza con batch_too_large en vez de truncarse en silencio.

Permits

search_permits encuentra permits, concesiones y compromisos de capex publicados. holder_rfc es identidad fuerte donde se publica (raro en este corpus); holder_name es aproximado y es el join sobre el que se construyó esta relación. Un permit es un derecho otorgado grabado al momento de la carga, nunca una verificación operacional en vivo — trata is_active_risk, status_text, is_expansion, y los campos de capex/capacidad (investment_mdd, capacity_mw, estimated_generation) como lo que el publisher imprimió, no como gasto auditado o desembolsado.

El envelope

Cada respuesta exitosa — search, get, aggregate, count, screen por igual — es un solo envelope, y cada key en él es camelCase, deliberadamente distinta del snake_case que usa el resto de la plataforma (Knowledge y Collections). Esto no es una inconsistencia por corregir: es el dialecto que ya habla el contrato de market-data en producción, el resultado de la tool de MCP, y el plugin de ChatGPT.
  • interpretedRequest es lo que la capa realmente corrió después de normalizar la entrada de quien llama (un nombre de state resuelto a su código, un municipio impreciso resuelto a su ortografía canónica) — normalizations dice qué cambió y por qué.
  • results es el payload propio de la operación: un array de filas para un search, un objeto para una lectura get_*/count_*, un array de grupos para un aggregate.
  • page es null cuando la operación no pagina (get_supplier, get_opportunity, market_capabilities, count_suppliers, compare_segments).
  • coverage y corpusBasis son lo que hace honesta una respuesta: qué fuentes publicadas contribuyeron, y qué snapshot del corpus vio esta lectura. Ninguno de los dos puede ser autorado por un modelo o por quien llama.
  • semanticWarnings nombra una mala lectura que este dominio invita activamente (un award no es un pago, una marca de riesgo no es una condena, un nombre es identidad aproximada) — siempre adjuntado por la capa, nunca decoración.
  • provenance dice dónde se leyó cada fila, explícitamente no que esté vigente o verificada.

Lo que ve un modelo: categorías de evidencia, no publicadores

El servicio conserva una proyección canónica para auditoría y soporte, pero la API HTTP de arriba siempre devuelve la proyección abstracta: source_slug, source_record_id, identidad del publicador, internals de recuperación y coordenadas de contacto nunca llegan a un caller HTTP autenticado. Una superficie que lee un modelo — Chat, Research y cada tool de MCP — lee el mismo envelope a través de una proyección abstracta. La identidad del publicador se reemplaza por el tipo de evidencia que devolvió la operación, derivado de la relación del warehouse y no de una tabla de publicadores: Bajo esa proyección cada fila lleva evidence_category, evidence_category_label, un record_ref opaco y un record_fingerprint; coverage agrupa por categoría y cuenta solo las filas licenciadas para mostrarse; y el cursor de página va sellado. Cada advertencia semántica, conteo de cobertura, valor de frescura, fuerza de identidad y alcance de monto queda intacto. record_ref es lo que toma get_supplier: cópialo textual de la fila de búsqueda cuyo detalle quieres. Es un ciphertext nuevo por respuesta, así que es un handle para seguir y nunca un valor para comparar entre llamadas: dentro de UN mismo envelope una fila lleva el mismo record_ref en todas partes donde aparece (la fila de results[] y su entrada en provenance[] coinciden), y la siguiente respuesta acuña uno distinto para la misma fila. Para reconocer una fila entre respuestas, únelas por record_fingerprint — nunca por record_ref. Una referencia que esta capa no emitió se rehúsa con invalid_record_ref. record_fingerprint es el valor que comparten dos observaciones de la misma fila — 128 bits, con llave y de una sola vía. Revela deliberadamente igualdad: quien lo tiene puede ligar el mismo registro publicado entre páginas, llamadas y corridas, que es lo que la deduplicación necesita. No revela identidad: no hay operación que lo convierta de vuelta en un source_slug, un source_record_id ni nada más sobre la fila, y se rehúsa donde se espera un record_ref. Sobrevive a una rotación de la llave primaria mientras una referencia emitida antes de esa rotación siga resolviendo, para que una fila ya vista siga siendo reconocible. No hay filtro de fuente en las superficies model-facing. source_slugs se acepta en la ruta canónica y no se anuncia en ninguna otra parte. No tiene reemplazo abstracto, deliberadamente: un filtro por “tipo de registro” necesitaría metadata por fuente que esta capa no tiene, y derivarlo de una lista de slugs congelada dentro de la API inventaría un valor dependiente del corpus fuera del corpus. Una pregunta de membresía (“¿está X en ese padrón específico?”) se responde entonces buscando, y la respuesta dice qué encontró la búsqueda en vez de de qué padrón vino. Ver docs/market-data/tool-contract.md para la metadata del warehouse que devolvería el filtro. La proyección la elige el entrypoint, nunca el payload de una petición: no es un parámetro, y no hay valor que quien llama pueda enviar para ampliar lo que ve.

Paginación — keyset, nunca offset

Cada operación que devuelve más de una fila (search_*, aggregate_awards, get_supplier_history) pagina con un cursor keyset opaco, nunca OFFSET:
page.nextCursor lleva la versión del contrato (para que un cursor de una forma de respuesta más vieja no se pueda reanudar contra una más nueva), el snapshot del corpus contra el que se emitió (para que la paginación nunca pueda entrelazar dos snapshots publicados), un digest de los filtros — y, para aggregate_awards, de compare_period — para que continuar con un conjunto de filtros distinto se rechace en vez de servirse, y la posición de la última fila en el orden total. page.hasMore/page.returned se calculan con una lectura honesta de limit + 1, nunca se infieren de returned === limit. De un cursor roto salen dos refusals distintos porque la recuperación correcta difiere: un cambio de filtro a mitad de paginación es invalid_cursor; un cambio de corpus es cursor_stale — no es error de nadie, los datos publicados se movieron. Ambos se recuperan igual: reiniciar sin cursor. El tamaño de página por defecto es 20 (50 para aggregate_awards); limit topa en 50 para toda operación con forma de search y en 200 para aggregate_awards.

Refusals

Un refusal nunca es un 400 desnudo. Todo rechazo específico del dominio lleva un bloque market_data junto al code/message/request_id propio de la plataforma:
  • why explica el malentendido, no solo la regla — la mayoría de los refusals en este dominio son una pregunta bien formada sobre un campo o una forma que no significa lo que quien llama asumió, no un error de tipeo.
  • suggested_correction es prosa dirigida a un LLM: la siguiente llamada concreta que funcionaría.
  • recovery.action es el mismo hecho, legible por máquina, tomado de un vocabulario cerrado para que quien llama pueda ramificar sin parsear prosa. Se deriva uno a uno de semantic_code — nunca se define de forma independiente.
  • retryable es false para todo refusal de forma/valor/vocabulario (la misma llamada reproduce el mismo refusal), y true solo para el puñado operacional — timeout, no disponible, proyección stale — donde se espera que el mundo cambie, no la petición.
suppliers/count reutiliza query_too_broad; risks/screen reutiliza invalid_field_value/batch_too_large — ninguna operación inventa su propio vocabulario de error de un solo uso.
Una violación estructural del DTO (un enum inválido, un limit fuera de rango, forbidNonWhitelisted rechazando una propiedad desconocida) conserva el envelope de error de la plataforma tal cual — VALIDATION_FAILED, mismo message, mismo status. En las rutas de market-data ahora TAMBIÉN lleva un bloque market_data con allowed_values (el conjunto cerrado completo que el decorador aplica) y recovery.action: fix_arguments, porque el servidor sabe exactamente qué habría aceptado y un refusal que se lo guarda no enseña nada. El semantic_code es invalid_field_value. En el resto de la plataforma una violación de DTO no cambia y no lleva ese bloque.

Discoverability

Hay dos cosas que un cliente ciego (un agente que solo tiene curl y las descripciones propias de esta API) necesita y que un 404 desnudo no puede darle: qué verbo habría funcionado, y qué rutas existen en absoluto.
  • Verbo equivocado, ruta real405 Method Not Allowed con un header Allow compatible con RFC 9110 que nombra los métodos que sí funcionan, más el mismo hecho en el cuerpo JSON (allowed_methods).
  • Ruta desconocida bajo market-data/*404 con documentation_url (la propia ruta capabilities del workspace) y operations — la lista completa de método/ruta — en vez de dejar que quien llama adivine desde el silencio:
(operations lista las trece, recortado arriba por espacio.) GET capabilities es el contrato completo para integradores: el question, requires, refuses, method, path, y un example ejecutable de cada operación, más las listas de valores de filtro observadas en el corpus (nombres de state, códigos SCIAN, procedure types, …), cada una estampada con el corpusBasis contra el que se leyó — nunca presentada como una constante atemporal. Un query param opcional ?facets=a,b acota tanto el cómputo como la respuesta a las dimensiones del corpus nombradas. GET capabilities/compact es la proyección acotada y consciente de la fuente que se inyecta a los runtimes de agente (chat, MCP) en vez de gastar una llamada a tool en el catálogo completo. Ambas llevan openapiUrl, que apunta al documento OpenAPI legible por máquina — servido root-absolute en /openapi.json, a diferencia de cualquier otra ruta de market-data, que es relativa al workspace. GET coverage devuelve el mismo bloque coverage que se adjunta a cada otra respuesta, de forma independiente: qué fuentes publicadas contribuyeron a cada relación, qué tan fresca está, y si está licenciada para mostrarse. Es lo que separa “sin resultados” de “no existe ese mercado”.

Análisis frente a activación de contactos

Las preguntas a gran escala usan conteos, comparaciones, historiales y agregados de adjudicaciones calculados en el servidor. Una respuesta agregada cuesta un crédito de insight aunque contenga muchos grupos; las filas subyacentes de empresas o contactos no se exportan. La investigación de contactos es una capacidad separada y opera únicamente sobre registros CRM seleccionados por el usuario:
  1. driftless_contact_quote lee la selección exacta y el saldo. Es gratuita, no llama a proveedores externos y no devuelve coordenadas de contacto.
  2. Después de aceptar explícitamente la cotización, driftless_contact_unlock envía los mismos ids, confirm: true, el max_credits aprobado, el quote_token opaco emitido por el servidor (válido durante 10 minutos) y el idempotency_key emitido por esa misma cotización. El servidor recalcula el precio y se niega antes de gastar si aumentó o si cambió el saldo o el permiso.
Las cuentas ya desbloqueadas cuestan cero. Los fallos parciales se reportan por cuenta y el trabajo fallido se reembolsa. Reutilizar la misma clave de idempotencia no puede llamar dos veces al proveedor ni debitar dos veces al workspace.

Autenticación y scopes

Las rutas de market-data requieren la misma autenticación que el resto de la API (API key o token bearer de OAuth) — no hay ninguna ruta pública. Sobre OAuth, el análisis usa el scope dedicado market_data:read, en OR-list con context:read por compatibilidad hacia atrás. La cotización gratuita de Contact Path sobre registros CRM seleccionados usa commercial:read; el desbloqueo pagado usa commercial:activate:

MCP: tools tipadas de market data

MCP expone trece tools tipadas driftless_market_* para análisis más las tools de dos pasos de Contact Path. No expone driftless_market_data, driftless_market_get_supplier_batch, selectores de fuente física ni coordenadas de contacto. Reconecta el cliente después de este cambio de schema porque los conectores pueden guardar definiciones de tools en caché. Ver Referencia MCP para conectar un cliente. Las trece tools de market data llevan readOnlyHint: true: no modifican datos de negocio ni producen efectos externos. Esa anotación no significa que una llamada sea gratuita. driftless_market_capabilities cuesta cero llamadas y cero créditos; cada una de las otras doce tools de market data se mide conforme al contrato de uso comercial.
Un análisis amplio puede devolver conteos, segmentos, patrones y evidencia sin entregar coordenadas de contacto. Para activar contactos sobre registros CRM seleccionados, llama primero a driftless_contact_quote; sólo después de mostrar el costo exacto se permite driftless_contact_unlock con confirm: true, max_credits, el quote_token opaco emitido por el servidor (válido 10 minutos) y el idempotency_key emitido por esa misma cotización. Registros ya desbloqueados cuestan cero y los resultados parciales se reportan por registro. El resultado de la tool agrega la paginación estándar de la casa al nivel superior (shown, has_more) junto al propio page.returned/page.hasMore del envelope — aditivo, nunca un reemplazo — más una pista next_action que le dice a quien llama qué hacer después (continuar con un cursor, inspeccionar un candidato con get_supplier, leer coverage antes de concluir “sin marcas”, etcétera). En operaciones textuales de proveedores, oportunidades y adjudicaciones (incluyendo aggregate_awards), query_mode es opcional: auto preserva el comportamiento existente; exact_phrase, all_terms y any_terms son interpretaciones acotadas del texto tokenizado. Un modo explícito en oportunidades usa recuperación léxica y no puede combinarse con strategy=hybrid. interpretedRequest.query_interpretation, escrito por la plataforma, declara el modo pedido y aplicado, términos normalizados, aliases de estado y filtros reconocidos, y cualquier advertencia. Nunca incluye SQL, schema ni una consulta compilada.
Los clientes MCP (claude.ai, ChatGPT) cachean los schemas de herramientas por sesión de conector. Cuando cambian las tools o sus parámetros, reconecta el conector (o arranca una sesión nueva) para ver la superficie tipada nueva — ver MCP y OAuth para la nota general sobre este caching.

CLI: driftless market

Todo comando soporta --json para el envelope semántico completo; sin él, la CLI imprime un resumen humano compacto (cantidad de filas, etiqueta por fila, warnings, coverage, próximo cursor). Repite un flag de array o pasa valores separados por coma. --compare-from-date/--compare-to-date son flags planos de la CLI que se anidan en un solo objeto compare_period sobre el wire — pasa ambos juntos o ninguno.

Chat y el método de due-diligence

El chat de investigación de mercado del dashboard planea y llama estas mismas operaciones directamente (sin una API separada de cara al modelo) y puede correr un método de due-diligence screening que compone search_risks y search_permits:
  1. Resolver identidad por RFC primero — de un resultado previo de search_suppliers/search_awards o dado directamente — y llamar search_risks { rfc } con él. No usar ese RFC de awards como puente hacia permits: las fuentes actuales de permits no publican RFC del holder. Llamar search_permits { holder_name } sólo después de obtener un nombre publicado y verificado; la resolución por nombre es aproximada y debe declarar la advertencia del match. holder_rfc queda como filtro fuerte si una fuente futura lo publica.
  2. Nunca concluir “sin marcas de riesgo” o “no autorizado” solo a partir de una página vacía — se lee coverage primero y se declara la búsqueda contra él.
  3. Un permit y una marca de riesgo responden preguntas distintas y se reportan por separado — nunca se fusionan en un veredicto de “limpio” o “autorizado”. Una marca de riesgo es un listado publicado, no una condena; un permit es un derecho otorgado grabado al momento de la carga, no un estado operacional vigente.

Relacionado

  • MCP y OAuth - conectar un cliente y la nota sobre el caching de schemas.
  • Errores - el envelope de error de toda la plataforma y el catálogo de códigos.
  • Seguridad - autenticación y API keys.