Typed decisions¶
A question either types or it doesn't. With a typed-decision provider configured, dst stops asking a language model to write SQL and instead asks it to decide: which lens, which entity, which governed metric, which dimension, which grain, which filter column, which stored value. Every decision is a choice over a closed set dst already owns — the semantic model and the column profiles — and comes back with a probability per option. The SQL is then compiled from those decisions, deterministically. Nothing in the answer was extracted from prose.
What gets decided¶
| slot | the options | where they come from |
|---|---|---|
| lens | every published lens | routing, when the caller names none |
| shape | a figure, a listing, or neither | — |
| entity | the lens's entities | semantic/entities/ |
| reading | the declared readings of an ambiguous term | definitions with possible_mappings |
| metric, dimension, grain | the entity's declared metrics, dimensions and time grains (an entity that declares no dimensions: groups, when the question asks a breakdown such as "per SDR", "by source" or a ranking, by its string, boolean and date fields and by an integer field whose profile holds a complete value dictionary: the dimensions its metric catalog lists); a metric the question names word for word — by its identifier read as words, its label, or one of its aliases (otd_rate with label: on-time delivery rate) — is read off the text, and a question naming exactly one and nothing else is compiled without any decision; a metric named word for word outranks a voted metric whose words the question says only inside that phrase ("net new members", an alias of the net metric, against new_members); a dimension is named by its name or its aliases ("by region" for store_region with aliases: [region]), in a breakdown, a ranking and a binding key |
the entity file |
| ranking direction | "highest", "top 5" and "fewest" in the question; "best" and "worst" from the metric's declared better: higher \| lower |
the entity file |
| filter column and value | the entity's fields; a value from the column's complete dictionary, or a stored value the question names word for word (declared dimensions keep a whole dictionary for this; a value spelled like a common word, such as a team named Most, only where the question writes it as a name: capitalised mid-sentence, quoted, or possessive), or a word a definition maps to one (value_aliases: {offlaner: offlane} on a definition about: entity.column) |
the column profile (dst introspect --profile); definitions |
| window | none — a stated period is parsed, never decided ("in August 2026", "at the end of August 2026", "as of 2026-08-31", "Q3 2026"), days included ("on 28 August 2026", "August 28, 2026", "between 5 and 12 October", "5–12 Oct", "the week of 5 October", "the first half of August 2026", "the first 7 days of October", "the last 3 days of August", "the start of August", "the end of last month"), ranges included ("January to September", "between March and May", "Jan–Sep 2026", "from 1 January to 25 October", "2021 to 2025"; a range of months without a year takes the year the question states elsewhere, else the most recent one starting on or before the lens clock), periods included ("the end of Q3 2026" and "the end of 2026" are the period's last day, "the first 2 quarters of 2026" and "the last 3 months of 2025" that part of the year, and the number in such a phrase is never a top N); a day written without a year is its most recent occurrence on or before the lens clock, and a day window on a month-form period column asks rather than serving the month. A vague part of a month ("early", "mid-", "late August") asks, offering the days each reading would use, and so does any other day-level qualifier beside a month the parser cannot turn into days ("the first few days of August"): neither is ever served as the whole month. A level metric reads "the end of August" as August's last snapshot, and "the end of Q3 2026" as the quarter's; on a timestamp column its bounds are midnights in the lens's declared timezone (DuckDB, Postgres), whatever the server's zone; a metric declared additivity: level reads the window as its last snapshot |
timewindow; the entity file |
A slot the question does not settle is asked back as a clarification naming
the slot: which metric, which value of status. The asking agent answers by
re-asking with bindings (an open value it supplies) — it never has to know
the SQL. A binding for a column with a complete dictionary is read as the
stored value it matches, whatever its case, and applies whether or not the
decisions picked that column; a value the column does not hold is asked about,
with the values it does hold. Raw-SQL generation survives only as a disclosed escalation: the caller
opts into it with allow_untyped: true, or the lens owner makes it the lens's
default with untyped_fallback: true in lens.yaml, for callers that cannot be
expected to know the flag. A caller's allow_untyped: false still demands
typed-only. Such an answer carries typed: false on its ledger and an
UNTYPED: line, and the audit counts it apart.
When the typed-decision provider itself fails (an HTTP error, a timeout, the
network), the typed attempt ends and the request does not. A question that
accepts an untyped answer is served by raw-SQL generation, and its UNTYPED:
line names the failure: typed decisions unavailable: typesafe HTTP 402. A
typed-only question is refused with the same reason; asked again once the
provider recovers, it types. The server logs the failure once until a typed
decision succeeds again, and /ready reports it under typed_decisions.
Rails that ask instead of acting¶
A few facts are read off the text and the lens, never decided, and each one can only move an answer toward a clarification:
- Candidates are answerable. A field, dimension or filter is never offered
when it is a key (the declared
primary_key, a column named*_id,*_keyorunique_key, a hidden column), nor a date or timestamp when the question is not about time, unless the question names the column or a stored value it holds. A text column that is one part of a composite key (host_namein[day, host_name]) is not a key on its own: it is what a per-host question groups by. A listing with nothing left asks which metric is meant, and a breakdown nothing on offer groups asks with the entity's fields as the options. A column the entity marksinternalis never offered at all, even where the question names it. - Every qualifier maps to the answer. A content word standing in front of a word the answer maps ("deleted" in "how many deleted members") must map too: to the entity's row noun or declared scope (its description, grain, population), the chosen metric (name, label, aliases, description, the columns it reads), a field or dimension, a stored value, a filter or binding value, a definition, or the period. One that maps to nothing asks ("nothing in this lens holds what 'deleted' restricts"), rather than serving every member; one that only another metric carries asks which metric is meant. Words that shape the answer (totals, ranks, shares, changes, series) are grammar, a word after a mapped one is read as a verb, and "new" beside a stated period is the period. Beside a filter whose value a decision chose, the check stays quiet.
- One measure, several settings. When the chosen metric's siblings differ
from it by one parameter token (
rebooked_within_7d,_30d,_90d) and the question states none, the answer asks which one, whatever the probability. The ledger records it as aparameterclarification. - A measure the lens does not declare. A question naming a measure that
the entity holds only as a numeric column or a measure definition ("desk
hours" where every metric counts bookings) asks, rather than answering in
the nearest metric's unit. A bare
COUNT(*)metric answers only a question counting the entity's own rows ("how many records", "number of movements"). - A value no column can take. A code-form value after a preposition ("for APAC") that is no declared name, no period and no stored value, on an entity whose text columns all have complete dictionaries, asks: "this lens has no field for APAC".
- A shape
noneis not always a decline. A question naming a governed metric word for word is a figure; a question matching an entity'scommon_questionsor a "Use when"use_casesline asks for the shape instead of refusing. - The current period is to date. "This month", "this week", "today"
resolve on the lens clock from the period's first day through today, and
the ledger carries the window it used. A
levelmetric over a window asks no further restriction: the window and the snapshot rule settle it. A month named beside the period it restates ("so far this month (October)") is the same window. Alevelmetric asked for "today", "now" or "currently" reads its latest snapshot, and the answer names that snapshot's day; a flow asked for today before today's rows are loaded is answered with where the data ends. - A change is a comparison, with every restriction. "vs August 2026", "vs a year earlier", "compared with the same month last year" and "how did it change" compare two windows and keep every filter, binding and snapshot reading the plain question would carry. A month compared with "last year" or "2025" is compared with the same month that year, never the whole year. A change per unit ("growth per year, 2021 to 2025", "yearly growth", "each year between 2021 and 2025") is one row per unit against the unit before; a breakdown by another unit asks which is meant. "From 2024 to 2025" with no unit compares 2025 with 2024.
- A value on a joined entity filters through the join. A stored value or
a true/false field the question names on an entity the lens's
joins:reach without duplicating the figure's rows ("passengers on in service vessels", the status onvessels, the figure oncrossings) is a filter through that join. A join whoserelationship:is undeclared or fans the rows out asks which entity should answer instead.
What the chosen metric's own name carries belongs to the metric. A number in
it (answered_within_15m) is its setting, never a value a numeric filter takes
from "within 15 minutes". A word the question says only as part of the
metric's name ("branch visits" of branch_visits) is not a breakdown by
branch, so the dimension decision is not asked over it. A grain word such as
"daily" is the time grain unless a column stores it as a value or it is part
of the chosen metric's name, label or the column it reads ("the daily target
loaves" of daily_target_loaves is one figure, not a daily series). A code in
brackets after a noun ("server errors (5xx)") is the stored value of the
column whose dictionary holds it, or, where the chosen metric's name or label
carries it, part of that name; its digits are never a number a filter takes,
and it never asks for a binding.
The policy: act unless none¶
A typed-decision provider acts on its top choice. The one thing that stops an
answer is the provider choosing none — no metric fits, no entity holds
this, the term's readings do not cover the wording. There is no per-slot
confidence threshold to tune. Where a slot's decisions have been measured
against gold, the measured bar is disclosure, not a gate: an answer that
acted below it is served with a decision_confidence check on its
verification, its grade capped at partial, and the slot and probability named
in the answer. Nothing is refused for being unsure; nothing unsure is silent.
The probabilities are recorded as the provider returns them; dst does not
rescale or recalibrate them, and a provider that is overconfident on its top
option moves no threshold, since there is none to move. A provider that
refuses a question instead of choosing (OpenAI's Decisions API can answer
refusal) declines it: the decline names the refusal and the decision is
recorded as refused, never read as a choice or as none.
Every decision — slot, choice, probability, runner-up, verdict — rides the answer's resolution ledger and its receipt, so a wrong answer is a named slot with a number, not a paragraph of SQL to read.
Configuring one¶
providers:
jev:
type: typesafe
api_key_env: DST_API_KEY_JEV
Or, on OpenAI's Decisions API:
providers:
openai:
type: openai-decisions # key from OPENAI_API_KEY; model gpt-6-luna
Add the key to .env (or the deployment's secret store) under the name you
chose. That is all: with DST_TYPED_SERVING=auto (the default) every lens
switches to typed serving the moment a typed-decision provider resolves. With
both declared, the first one in providers that has a key decides. on
runs the same machinery on a voting chat model (five samples per decision — a
coarser probability, and act bars apply); off keeps the one-shot intent
emission.
A typed-decision provider also routes: it decides over every published lens, with no similarity shortlist in front of it. Embeddings remain for one job — shortlisting certified answers a new question might repeat — and as the fallback router when no decider is configured.
Testing what types¶
Typed serving makes the test suite three lanes, each with one owner for its failures (Evaluation):
- Resolution. Every certified question resolved by the provider and
graded per slot against the answer's typed gold. No warehouse, seconds per
corpus;
--repeat Nresolves each question N times and fails any that does not produce the same intent every time. - Compile. The compiled SQL compared to the certified SQL, canonicalised.
Identical is proven without a query. A layer that does not compile is a
dst defect, caught at
dst plan. - Data. The certified SQL executed and compared to its stored value — the
only lane that touches the warehouse, and it runs only for what the first
two could not prove (
--rowsruns it for everything).