API¶
One FastAPI app (services/app.py), three surfaces on one port: the data plane
(/v1, caller keys — asking questions), the control plane (/mgmt, admin auth —
managing the install), and the governed MCP door (/mcp). A running server serves
its own interactive OpenAPI at /docs.
Authentication¶
Bearer tokens in the Authorization: Bearer <token> header. The local dashboard rides
a dst_session cookie instead; a header always wins over the cookie
(services/auth/deps.py).
| Prefix | What | Where it works |
|---|---|---|
dstadm_ |
admin token (dst bootstrap) |
/mgmt; also /v1 as a superuser identity. Rejected at /mcp with a 403 naming the fix |
dst_ |
caller API key (dst keys create) |
/v1 and /mcp — the governed identity, one per person |
dsto_ |
OAuth access token | same plane as caller keys, differs only by expiry |
dstsess_ |
local dashboard session | cookie-carried |
Local sessions: POST /auth/login (email + password → dstsess_, in the body and as
an httpOnly cookie), POST /auth/logout, GET /auth/me; accounts are managed via
POST/GET /mgmt/users. Token sanity: GET /mgmt/ping (does this admin token
resolve?) and GET /mgmt/whoami (to which org?).
Prefixes: services/auth/tokens.py. MCP authentication happens at the ASGI edge,
before routing (services/app.py): no bearer → 401 with a WWW-Authenticate challenge
pointing at the OAuth protected-resource metadata (the hook a client's native OAuth
flow hangs on); a bad key fails at connect time rather than yielding a
working-looking session that fails on every tool call.
Access to lenses is deny-by-default per lens; every tenant table sits behind Postgres
row-level security whose failure mode is no rows, never cross-tenant
(services/db/session.py).
Meta¶
| Route | Description |
|---|---|
GET /health |
Liveness — the process is up |
GET /ready |
Readiness — DB, MCP session manager, embedding-config status, and models: what this install actually resolves (fast=/smart= provider/model + embedder). A lens that names no model runs on the smart= entry, so this is where "which model does my lens run on?" is answered |
Data plane (/v1)¶
Caller keys (or an admin token). Routes in services/api/query.py,
services/api/route.py, services/api/reviews.py, services/api/openai_compat.py.
| Route | Description |
|---|---|
GET /v1/lenses |
The lenses this key may query |
GET /v1/lenses/{name} |
One lens's schema, definitions, and certified count |
POST /v1/lenses/{name}/query |
Ask: body {"q": "..."} → a governed answer |
POST /v1/lenses/{name}/metrics |
Ask with a structured intent (metrics/dimensions/filters) instead of prose — same governance and receipts |
GET /v1/definitions |
Look a governed term up across every lens this key may use — the approved meaning verbatim, no SQL, no warehouse (dst define) |
POST /v1/sql |
Guarded read-only SQL — against a connection (admin token) or within a lens's boundary (caller key); row-capped and logged |
GET /v1/lenses/{name}/certified |
The lens's certified library; with ?q= results are similarity-ranked (top 5, scored) |
POST /v1/lenses/{name}/certified/{cert_id}/run |
Run an approved question→SQL pair exactly as certified — zero AI SQL generation |
POST /v1/query |
Routed ask: dst picks the lens, or declines with an uncovered envelope |
POST /v1/verify-receipt |
Check an answer's signed receipt — signature + field-by-field cross-check against the logged trace |
POST /v1/reviews |
Open a review ticket on a traced request (optionally with a correction delta) |
GET /v1/reviews |
The tickets raised on this caller's own requests — the reporter's half of the queue |
GET /v1/reviews/{ticket_id} |
Ticket status + tracking URL |
POST /v1/chat/completions |
OpenAI-compatible: model: "dst/<lens>" selects the lens; streaming supported; unknown fields ignored |
GET /v1/models |
This key's lenses as OpenAI model objects |
Every ask route takes "format" (both | structured | prose, default both).
structured returns the rows without the written answer — and skips the LLM call that
writes it, which is most of the wait: on a certified serve the difference is roughly
two orders of magnitude. Everything else is unchanged (same SQL, citations, confidence,
trace, receipt), so it is the right shape whenever the caller phrases the reply itself.
A prose caller still receives the envelope-level truncated block — dropping the
data payload cannot drop the fact that it was incomplete.
The query response (services/contracts/response.py) carries the answer plus its
receipts: the sql, citations, a graded confidence
(verified / partial / unverified — derived from named checks, never asserted),
certification (certified / assisted / none), data_as_of, a request_id
that ties it to the audit trail, and a signed receipt — a portable block anyone in
the org can POST back to /v1/verify-receipt later to confirm these exact claims were
really served. A clarification field set means it is not a data
answer; its kind says why. ambiguous_term: a governed term needs the caller to pick
a meaning first. unknown_value: a filter value the question used was proven absent
from the column it filters (checked against the committed value dictionary, or by a
governed probe of the warehouse) — term names the column, options are the values it
actually holds; re-ask with the stored value you mean, or read the absence as the
answer (Clarify & refusal).
Row caps and truncation¶
Three caps, deliberately separate:
| Cap | Default | What it bounds |
|---|---|---|
max_rows_to_compose (per lens) |
200 | rows the composer's prompt sees — a prompt budget |
max_rows_to_return (per lens) |
1000 | the data payload the caller gets back |
| fetch cap (fixed) | 5000 | rows dst fetches from the warehouse at all — past it row_count is a floor |
Either cap biting is stated, never inferred: truncated is a block on the
response envelope, not only inside data, so a format: "prose" caller —
whose data block is dropped — still holds the fact deterministically. It carries
returned and total, and total is null when the engine-side fetch cap
bit: the true count is then unknown and must never be stated as a number
(services/contracts/response.py, services/runtime/pipeline.py).
Verifying a receipt¶
POST /v1/verify-receipt runs two independent checks, both deterministic, no
LLM (services/api/receipts.py, services/runtime/receipt.py):
- Signature — recompute the HMAC-SHA256 over the receipt's canonical JSON,
keyed by
DST_SECRET_KEY— the same key list and rotation contract as stored-secret encryption: the first key signs, every key verifies. - Trace cross-check — read the
request_logrow serving already wrote and compare the receipt's claims (lens,confidence,certification,sql_sha256) field by field; every disagreement is listed.
Verification is stateless: nothing new is persisted. ok means both held —
valid signature, trace found, zero mismatches.
The request body is the receipt object itself — not wrapped in a
{"receipt": …} envelope. A wrapped body returns 422 naming request_id,
lens and served_at as missing, which is the first thing everyone gets wrong.
$ curl -sX POST $DST_URL/v1/verify-receipt -H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' -d "$RECEIPT_JSON"
{"ok": true, "signature": "valid", "trace_found": true, "mismatches": [],
"question": "How many customers are repeat customers?",
"lens": "customer_value", "caller": "admin"}
Edit any field and resubmit, and the failure is two-part — the signature and the specific field that no longer matches the logged trace:
{"ok": false, "signature": "invalid", "trace_found": true,
"mismatches": ["confidence"], "question": "…", "caller": "admin"}
It returns the original question and caller too, which is what makes it
useful to a skeptic holding only a pasted number: they learn what was actually
asked, by whom, and whether anything was tampered with. signature is one of
valid / invalid / unsigned (the receipt carries no digest — the serving
server had no signing key, and says so rather than faking one) / unkeyed
(the receipt is signed but this server holds no key to check with — a config
gap named as itself, never reported as forgery). The receipt carries the SQL's
hash, not the SQL — receipts travel further than SQL should, and the hash still
pins the receipt to the exact query. Refusals and clarifications carry no
receipt: they make no data claim to attest.
MCP (/mcp)¶
A remote, stateless streamable-HTTP MCP server (services/mcp/server.py); connect with
a URL and a dst_ key, or via the built-in OAuth flow. Twelve tools:
| Tool | Description |
|---|---|
list_lenses |
What this key can query |
describe_lens |
Fields, definitions, certified count for one lens |
lookup_definition |
A governed term's approved meaning verbatim — no SQL, no warehouse |
search_certified |
Similarity-search the certified library |
run_certified |
Deterministic run; a bare question auto-resolves only at ≥ 0.95 similarity, otherwise returns no_exact_match + near misses |
query |
Ask a named lens |
query_metrics |
Ask with a structured intent (metrics/dimensions/filters) instead of prose |
sql |
Run your own read-only SQL inside a named lens's scope — guarded, row-capped, logged |
route_query |
The default door — dst picks the lens or declines |
send_for_review |
Flag an answer into the human review queue |
review_status |
Poll a ticket |
verify_receipt |
Check a receipted number before repeating it — signature + trace cross-check |
plus a getting_started prompt. Every tool returns a uniform envelope — {"ok": true,
…} or {"ok": false, "code": auth | forbidden | not_found | rate_limited |
no_exact_match | upstream | unreachable, "error": …} — so an agent can branch on
code instead of parsing prose. See Agents over MCP.
OAuth¶
A self-contained authorization-server facade for MCP clients (services/api/oauth.py):
PKCE S256 only, dynamic client registration, a server-rendered consent page.
| Route | Description |
|---|---|
GET /.well-known/oauth-protected-resource/mcp |
Protected-resource metadata (RFC 9728) |
GET /.well-known/oauth-authorization-server |
AS metadata |
POST /oauth/register |
Dynamic client registration |
GET /oauth/authorize → POST /oauth/authorize/complete |
Consent flow |
POST /oauth/token |
Code + PKCE → dsto_ access token |
Control plane (/mgmt)¶
Admin auth. One router per concern under services/api/; the surface is large
(~120 endpoints), so this lists the groups and the endpoints worth knowing by name —
the rest is on /docs, and the CLI wraps the ones you'd call by hand.
| Prefix | Concern |
|---|---|
/mgmt/project |
export / plan / apply — the file-first deployment door |
/mgmt/lenses |
drafts, publish, versions, repo + diffs, context, drift, sample queries |
/mgmt/lenses/{lens}/certified · /evals · /distill · /patches |
the certified corpus, eval cases, and drafted fixes per lens |
/mgmt/lenses/{lens}/join-candidates · /profile · /profile-drift |
profiling |
/mgmt/semantic |
shared entities/definitions + GET /mgmt/semantic/introspect |
/mgmt/connections |
warehouse connections, catalog, per-connection profile — plus the audit endpoints (paid-tier engine, in-repo pending extraction) |
/mgmt/reviews |
queue, rulings, patch approve/reject |
/mgmt/callers · /mgmt/users · /mgmt/directory |
identities and keys |
/mgmt/observe |
KPIs, requests, callers, evals |
/mgmt/standards · /mgmt/activation · /mgmt/surface · /mgmt/gap-map |
the rest of the cockpit |
Notable:
POST /mgmt/project/apply— the single deployment door (dst apply): blue/green, one transaction under a per-org advisory lock; a concurrent apply gets a 409 (services/project/apply.py).POST /mgmt/project/plan— the dry run behinddst plan.GET /mgmt/observe/requests/{request_id}— the full trace drill-down for one request: question, SQL, cost, outcome.GET /mgmt/lenses/{name}/repoandGET /mgmt/lenses/{name}/diff?from=&to=— the lens-as-repo file tree and version diffs (services/api/mgmt_lenses.py).
(Routes verified against services/app.py and the routers under services/api/.)