Skip to content

Lenses

A lens is a use case — a purpose — that needs a set of data, a set of context, for a set of callers. The purpose comes first: a lens is not a bundle of config that happens to have a name; it is a job someone needs done — commission questions, churn questions, the board's monthly numbers — and the three things it declares are what serving that job requires:

  • A set of data — which entities are in scope. That already settles which warehouse and which tables, because an entity names its own source.
  • A set of context — everything needed to read the data correctly: what the words mean (governed definitions, including the contested ones), what one row is, when to use a table and when not to, and the prose you have attached.
  • A set of callers — who may ask: the deny-by-default access boundary. Keys belong to the people and apps behind the agents, so this is a list of principals, not tools.

Mechanically, a lens is a selection over the semantic layer: which entities, which governed definitions, which certified patterns this use case may draw on — bound to warehouse connection(s), a model config, and the access list (services/contracts/lens_config.py). Callers ask a lens questions; the lens decides what the answer is allowed to touch and what the words in the question mean. Lenses are named for use cases (sales_comp), never for tables.

The purpose is declared, not implied. use_when in queries.yaml is the field that states it, and it is not decorative: those lines become the lens's routing anchors (services/router/profiles.py:41), so when a caller asks a bare question with no lens named, the purpose you wrote is what the question is matched against. A lens with an empty use_when is a lens that has not said what it is for.

One metric, one definition — the word is what varies

A lens is not a private dialect, and you should not give two teams two "revenue" metrics. Metrics are defined once, in the shared semantic layer (semantic/entities/*.yaml, semantic/definitions/*.md), each one clearly itself: bookings is bookings, recognized revenue is recognized revenue. What varies is which of them the word "revenue" points to when a particular person says it — sales says revenue and means bookings; finance says revenue and means recognized. Both are legitimate and neither is a redefinition; the word is just underspecified.

dst's job at that fork is to not guess. Mark the term ambiguous with its possible mappings and the question comes back as a question, naming the options, before anything runs. A lens's scope often settles it on its own — a commission lens has only one revenue-ish metric in play — and where scope does not settle it, the clarification does. Fragmentation is treated as a defect to fix, not a feature: when one word is claimed by several metrics, dst apply warns by name — govern the others, or mark the term ambiguous.

Selection, not copy

lens.yaml names what the lens takes from the shared layer; dst apply compiles the selection into the runtime model (services/project/compile.py:50). A term defined both shared-and-selected and lens-locally is a compile error, never a silent winner (services/project/compile.py:8). Metrics the selection dropped become excluded_metrics, refused deterministically before any model call — see Clarification & refusal. The refusal covers their shape too: an answer that would rebuild a dropped metric from raw columns declines with the same path (select it in lens.yaml, or certify the answer) — one refusal text for both doors. serve_ungoverned_shapes: true in lens.yaml opts back into serving either — the by-name ask and the composed shape — at confidence: unverified (services/contracts/lens_config.py).

The managed file layout (services/project/loader.py:26):

semantic/                     # project scope, shared by every lens
  entities/deals.yaml         # table, grain, use/avoid, fields, metrics, joins
  definitions/commission.md   # governed term: frontmatter + prose
lenses/sales_comp/
  lens.yaml                   # the selection + policy (below)
  queries.yaml                # use_when + sample queries, lens-local
  definitions/*.md            # lens-LOCAL terms
  certified_answers.yaml      # approved question→SQL pairs
  evals/cases.yaml            # behavioral cases (expect: clarify | refuse)
# lenses/sales_comp/lens.yaml (abridged)
name: sales_comp
connections: [bigquery]
select:
  entities:
    - name: reps
    - name: deals
    - name: payouts
  definitions: [commission, earnings]
access:
  allow: []        # deny-by-default; grant callers or groups explicitly

Versioned like code

A lens is stored as a bundle and materialized to a browseable file tree on read (services/lenses/repo.py); every publish snapshots a lens_version (services/lenses/store.py:301), and version-to-version diffs are unified diffs over the rendered tree (services/api/mgmt_lenses.py:534). You review a lens change the way you review a pull request, because structurally it is one.

A lens ranks likely intent — it never silently decides

When a question hinges on a term with more than one governed meaning, the lens does not pick the likelier reading. An ambiguous definition lists its possible mappings, and a question that uses the term without naming a meaning returns a clarification — deterministically, in code, before any generation (services/runtime/pipeline.py:74).

Clarify

"What is the average value per customer?" — value is ambiguous here: lifetime value (customers.customer_lifetime_value) or order amount (orders.amount)? The answer is a question, not a guess. source: services/runtime/pipeline.py:74

Contested vocabulary usually surfaces through the drift audit — the same metric computed N different ways in the warehouse's own query history — and is recorded as an ambiguous definition rather than a silent winner.

One agent, one lens

Because a lens is the whole answering surface — vocabulary, scope, and access in one object — the natural shape for AI agents is one scoped lens per agent, reached over MCP: what list_lenses returns under the agent's key is its entire world (services/mcp/server.py). See Agents over MCP.