Skip to content

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, *_key or unique_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_name in [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 marks internal is 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 a parameter clarification.
  • 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 none is not always a decline. A question naming a governed metric word for word is a figure; a question matching an entity's common_questions or a "Use when" use_cases line 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 level metric 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. A level metric 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 on vessels, the figure on crossings) is a filter through that join. A join whose relationship: 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):

  1. Resolution. Every certified question resolved by the provider and graded per slot against the answer's typed gold. No warehouse, seconds per corpus; --repeat N resolves each question N times and fails any that does not produce the same intent every time.
  2. 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.
  3. 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 (--rows runs it for everything).