Market data — the tool contract
This is the contract every market-data operation follows, across all four surfaces that expose it (HTTP, typed MCP tools, the CLImarket
command, and the agent tool belt). It does not restate what each operation
searches for — that lives in semantic-api.md and
commercial-intelligence-v1.md. This
document is the shape every one of them shares: what a success response
carries, what a refusal carries, and how pagination works — so a caller
learns the contract once and every operation follows it, rather than
re-discovering the same shape eleven times.
Two projections of one envelope
Everything below describes the canonical envelope used by the internal belt, audit record, licence enforcement and support. HTTP, OAuth, MCP and CLI always receive the abstracted projection; they never opt into canonical provenance. A surface a model reads (Chat, Research, every MCP tool) reads the same envelope through the abstracted projection instead (apps/api/src/market-data/services/model-exposure.ts). Publisher identity —
sourceSlug, sourceRecordId, publisher, sourceUrl, the per-publisher
membershipMeaning — is replaced by an evidence CATEGORY derived from the
warehouse relation, an opaque record_ref for the detail follow-up (one per
row per RESPONSE — a results[] row and its provenance[] entry carry the same
one, and the next response mints a different one, so refs are never compared
across calls), a
record_fingerprint that recognizes a repeated row (keyed, one-way, 128 bits;
it reveals equality by design and never identity, it is the join key ACROSS
responses, and it survives a primary-key rotation for as long as references
minted before it still resolve), and a per-category
membership meaning that keeps every caveat the publisher-specific wording
carried. Coverage regroups by category and counts only licensed rows; the page
cursor is sealed, because a base64url cursor whose sort key carries the
publisher row key is opacity by convention only.
Everything else in this document is IDENTICAL under both: the same operations,
the same refusals, the same pagination contract, the same semantic warnings and
the same coverage semantics. The projection changes who a row says published
it, never what the row means.
The source filter, and what would bring it back
source_slugs is accepted on the canonical route and is absent from every
model-facing surface. There is deliberately no abstract replacement for it.
A “kind of record” filter is the natural one — screening for membership in an
export register, a licence register or an R&D register is a real commercial
question — but the distinction that makes it useful is a property of each
SOURCE, and this layer has no per-source metadata to read it from. Deriving it
from a list of slugs frozen inside Driftless would be a source-dependent value
invented outside the corpus (R3b), which is why the filter is absent here rather
than simulated.
The requirement: a column on market_data.sources classifying each source
into a small, stable set of record kinds — the same table dataset_coverage
already references, and the same place the per-source membership meanings in
services/source-registry.ts want to live. With that column, coverage carries
the kind per source and the filter becomes an ordinary exact filter over corpus
values. Until then, membership screening is a search rather than a filter, and
the runtime skill says so.
Which projection a response uses is decided by the server boundary and is not a
request parameter. HTTP always applies the abstracted projection. The
X-Driftless-Model-Exposure: abstracted header remains a harmless compatibility
marker for MCP and CLI clients; no header value can request canonical provenance.
The envelope
Every success response — search, get, aggregate, count, screen alike — is oneMarketDataEnvelope<T>:
market-data-surface-dialects.
interpretedRequestis 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).normalizationssays what changed and why, so a caller can tell a normalized value from a literal one.resultsis the operation’s own payload — a row array for a search, one object for aget_*/count_*read, a group array for an aggregate.pageisnullwhenever the operation does not paginate (get_supplier,get_opportunity,market_capabilities,count_suppliers) — a count answers one exact integer, not a page of one.coverageandcorpusBasisare what makes an answer honest: which published sources contributed, and which snapshot of the corpus this read saw. They are written by the platform layer and can never be authored by a model or a caller.semanticWarningsnames a misreading this domain actively invites (an award is not a payment, a risk mark is not a conviction, a name is approximate identity) — never decoration, always attached by the layer.provenancesays where each row was read, explicitly not that it is current or verified (PROVENANCE_DISCLAIMERrides every response that carries rows).
Refusals
A refusal is never a bare 400. Every domain-specific rejection is a typedMarketDataException carrying a market_data block alongside the platform’s
own code/message/request_id:
whyexplains the misunderstanding, not just the rule — most refusals in this domain are a well-formed question about a field or shape that does not mean what the caller assumed, not a typo.suggested_correctionis prose aimed at an LLM: the concrete next call that would work.recovery.actionis the same fact, machine-readable, drawn from a closed four-value vocabulary so a caller can branch without parsing prose:narrow_query,fix_arguments,restart_without_cursor,retry_backoff. It is derived one-to-one fromsemantic_code— never set independently of it — so the machine field and the human-readable one can never disagree.retryablesays whether trying again, unmodified, can help. It isfalsefor every shape/value/vocabulary refusal (the identical call reproduces the identical refusal) andtrueonly for the operational handful — timeout, unavailable, stale projection — where the world, not the request, is expected to change.
semantic_code values worth knowing before calling anything:
suppliers/count and risks/screen both reuse query_too_broad and
invalid_field_value/batch_too_large respectively — no operation invents
its own one-off error vocabulary.
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 cannot be resumed against a newer one,
- the corpus snapshot (
corpusBasis) it was issued against, so pagination can never interleave two published snapshots, - a digest of the filters (and, for
aggregate_awards, ofcomparePeriod), so continuing with a different filter set is refused rather than served, - the last row’s (or group’s) position in the total order.
invalid_cursor (the
caller changed something); a corpus change is cursor_stale (nobody’s
mistake — the published data moved). Both recover the same way: restart
without a cursor.
page.hasMore/page.returned are computed from an honest limit + 1 read,
never inferred from returned === limit — a corpus that holds exactly a
multiple of the page size would otherwise produce a phantom empty last page.
The thirteen market operations
market_capabilities is free discovery. The other twelve operations are
metered analysis. All thirteen MCP tools carry readOnlyHint: true because
they do not modify business data or produce external side effects; that hint
does not mean free or exempt from usage accounting.
count_suppliers
Same bounding rule as search_suppliers: at least one of query, state,
scian_codes or rfc is required (the canonical route also accepts
source_slugs), or the call is refused
with query_too_broad — an exact count of the whole 6.1M-row directory is not
a narrower question than a search for it. results is { count: number },
computed with SELECT count(*), never sampled or estimated. If the count
itself cannot finish inside the statement timeout, the existing
market_data_timeout refusal answers — this operation never substitutes an
approximation for a count it could not finish computing.
Internal supplier synthesis
The internal belt accepts 1–50 opaque references returned by supplier search, resolves and deduplicates them server-side, and reads canonical detail in one set-based statement. Output preserves first-seen order. Missing, forged and unavailable references deliberately shareinvalid_or_unavailable; the operation never
reveals which condition applied. Contact values remain subject to the same live
licence boundary as get_supplier.
compare_segments
Accepts 1–5 text-defined segments, 1–10 Mexican states, one required
observed_kind, and at most 50 cells. One parameterized statement returns the
ordered matrix, optionally including observations with a published contact
channel. Every number is an observation count, never unique-company count, TAM
or purchase intent.
screen_risks
A batch of 1–50 RFCs, screened for published adverse marks in one bounded
rfc = ANY($1::text[]) statement instead of one search_risks call per RFC.
results is one entry per requested RFC, in the order requested,
including an RFC with zero marks — that is a valid, coverage-backed answer for
that RFC alone, never evidence about any other RFC in the same batch. A batch
above 50 is refused with batch_too_large (recovery.action: fix_arguments)
rather than silently truncated at the tail; a truncated batch that drops RFCs
without saying so is exactly the confident-wrong-answer failure this contract
exists to avoid.
Each RFC result carries its own warnings code array. A zero-mark RFC has
warnings: ["zero_results_with_coverage"] only when the envelope declares
effective risks coverage (at least one source with licensedForDisplay: true
and visibleRows > 0); an RFC with marks has warnings: []. With no declared
effective coverage, the RFC warning stays empty and the coverage declaration
is the explicit limitation. The envelope repeats zero_results_with_coverage
only when every requested RFC had zero marks and coverage is effective.
RFC dossier protocol (composition, not an operation)
There is noget_party_dossier route or tool. An RFC dossier is a reproducible
caller-side composition over existing operations:
holder_rfc remains an accepted strong-identity filter for a future permit
source that publishes it; it is not a current award-RFC-to-permit bridge.
The caller keeps results separate and reports identity coverage explicitly:
not_observed and not_attempted are coverage states, not negative claims
about the party. Directory rows remain observations; permits found by name are
candidates, not silently consolidated identities.
aggregate_awards with compare_period
compare_period runs a period-over-period comparison inside the same
grouped statement — never a second query. Give compare_period as a second,
closed date range (period A) alongside the request’s own from_date/to_date
(period B, still the only date bound when compare_period is omitted). Every
returned group then additionally carries:
deltaPct is computed in Postgres, in numeric, and cast to float8 only
after the exact division already decided whether the denominator was zero —
never recomputed in JS from the ::text totals, and 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 — the
filters digest a cursor carries includes comparePeriod.
search_awards and aggregate_awards on cog_partida
cog_partidas on search_awards is 1–20 five-digit SHCP “partida específica”
(Clasificador por Objeto del Gasto) codes — the object-of-expense code the
buyer assigned at award time. A code not shaped like five digits is refused
as invalid_field_value before the corpus is read; a well-formed code is
never further validated against a label catalogue, because none exists to
check it against. The match is cog_partidas && $1::text[] — overlap, not
containment — so a contract carrying several codes matches on any one of
them. Every returned award row carries cogPartidas ([] when the publisher
recorded none). There is no cog_capitulo filter on search_awards: an
unindexed capítulo scan (the code’s leading digit) over the ~500K rows this
relation serves was judged too slow to offer as a filter — aggregate_awards
is the way to ask a capítulo-shaped question.
aggregate_awards accepts group_by: cog_partida (the raw code) and
group_by: cog_capitulo (its first digit), both 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
totalAmount/awardCount 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, and inventing a split would be a number this layer never read
anywhere. Grouping by either dimension always attaches the
cog_partida_totals_may_exceed_corpus semantic warning, and both dimensions
compose with compare_period the same way every other group_by does.
What is out of scope here
Per-operation filter vocabularies, source-registry membership meaning, the Web Evidence boundary and the ChatGPT-facing submission live insemantic-api.md and
commercial-intelligence-v1.md. This
document only fixes the shape every operation shares.