# Assembling context for analytics agents from existing data stacks

> Source: <https://blog.getcassis.com/a-blank-beats-a-guess/>
> Published: 2026-09-08 13:04:54+00:00

Cassis maintains context for analytics agents. Before we can maintain it, a customer needs a useful first version. [How to structure that context](/context-engineering-for-analytics-agents/) is a separate question. This article is about where its content comes from.

So far, we have assembled that first version carefully and partly by hand with customers, with Claude helping. What we receive varies: a schema dump, a dbt repository, a glossary, Notion documents, query logs, a semantic layer, Metabase dashboards, or some combination of them. The material rarely arrives in the shape an analytics agent needs, but much of the content is already there.

We want Cassis to automate much more of this onboarding work. We are not there yet. In the meantime, I wanted current and future customers to be able to assemble that first version themselves.

I ended up writing down how the team already does it internally: which source is useful for what, what we can extract with code, where an LLM helps, and where a person still has to answer. Across warehouses of different sizes, we kept returning to the same rule. When the evidence runs out, leave the field blank. An invented definition is much harder to spot than a missing one.

Our first attempt started with an agent reading the prose and reconstructing the useful context. It spent 1.16 million tokens rediscovering information that code now extracts in about two minutes. We removed that stage.

We have made the resulting scripts, prompts, and review checkpoints available as an open-source [context bootstrap kit](https://github.com/GetCassis/ontology-bootstrap) so other teams can use the same method.

The examples below come from the pass we made over GitLab’s public analytics project: 2,921 dbt models resolving to 2,183 tables. We read the public fork [`yogeshkrishnan/analytics`](https://gitlab.com/yogeshkrishnan/analytics/-/tree/a58c84f6bb98f3b7bc0003dc0f53d162036685e8) at commit `a58c84f6`, as the original repository is no longer publicly readable. Its code, decisions, and counts can all be inspected.

## What we’re assuming

We are making four assumptions:

- Most of the context an analytics agent needs already exists in the stack, written for other readers and scattered across several systems. Assembling a first version is mainly recovery and sorting work.
- Missing context is visible. Wrong context often is not. When the evidence does not support a definition, we leave it blank and record the question.
- We are writing for an analytics agent choosing tables and writing SQL. That means organizing the result around business questions and domains instead of copying the dbt project structure.
- Once agents rely on the assembled context, it becomes a source of truth in its own right. It has to retain the evidence behind each claim and change when the underlying sources change.

## What goes in and what comes out

The method starts with the sources that describe the data most directly: the deployed warehouse schema and the transformation code, or a dbt docs export. It can then use query logs, a semantic layer, dashboard definitions, a column glossary, and free-form documentation. It works with whichever of these are available. Each additional source can help choose what to cover, check something another source says, or supply business meaning the code cannot express.

Some decisions remain human. A person decides which tables belong together, whether the proposed domains make sense, which metrics are safe to expose, and which unanswered questions block reliable use.

The output contains YAML for tables, columns, joins, and metrics; Markdown for domains and business rules; provenance for every retained claim; and a list of unresolved questions. Each question records the temporary assumption and what would be wrong if that assumption failed. The result can live in a standalone context repository or be proposed back to dbt. An analytics agent can retrieve the relevant domain, check the table grain, use the approved metrics and joins, and then write SQL.

## Start with the tables people use

Most warehouses are too large to review as one unit. GitLab’s project alone contains 39,271 columns. A script can inventory them quickly. A team cannot check thousands of descriptions, joins, and metrics in one sitting. Starting with one useful area gives people something they can review and use while showing us where the method breaks.

Query history is usually the best place to choose that area because it shows where people already depend on the warehouse:

- Pull about three months of query history from `access_history` on Snowflake or`INFORMATION_SCHEMA.JOBS` on BigQuery.
- Remove queries issued by scheduled jobs, or pipeline activity will dominate the ranking. Keep the BI service account when it proxies human activity, and combine the warehouse logs with the BI tool’s own view counts.
- Rank tables by distinct human readers. Look for tables that people query together and join inside the same queries.
- Start with the leading cluster and the tables joined to it.

Usage is a prioritization signal, not a definition of importance. A table with no reads in three months may be dead, undiscovered, or seasonal. The logs also say nothing about questions people have not asked yet. They simply tell you where incomplete or incorrect context is already most likely to affect someone.

GitLab’s public repository has no query history, so we ranked its consumption models using 13 declared dbt exposures and the number of downstream models that depend on each one. We started from the revenue models that ranked highly on both measures, then added every model one dbt reference away, upstream or downstream.

After review, the revenue scope contained 50 tables and 2,641 columns. The dependency search also pulled in `dim_marketing_contact`. We removed it because a marketing contact is not a revenue entity. That boundary came from review, not the dependency graph.

## Separate what each source proves from what it claims

The inputs are not interchangeable. A schema can establish that a column exists. A dashboard title may tell you what someone believes a chart shows. A query alias can reveal how one author described an expression without establishing that the description is the company’s definition.

In the table below, “proves” has a narrow meaning: what the inspected source declares or records at that version. It does not mean the business logic is correct, every declared constraint is enforced, or the repository matches production.

| Source | What it proves | What it claims | 
|---|---|---|
| Warehouse schema | Tables, columns, types, primary keys, foreign keys, constraints, declared enum values, and configured defaults | Comments, tags, and meaning implied by naming conventions | 
| dbt project | Model SQL, lineage, declared tests, joins present in SQL, and macro computations | Descriptions, doc blocks, tags, model names, and intended grain expressed through tests | 
| Semantic layer | Configured entities, dimensions, metrics, joins, filters, and expressions | Names, descriptions, and intended business meaning | 
| Query logs | That a query ran, plus its observed joins, filters, expressions, CTEs, and tables read | Aliases and selected field names that imply a business rule, such as naming an expression `raw_margin` | 
| Dashboard definitions | Configured SQL, filters, calculated expressions, fields used, and chart configuration | Titles, field descriptions, and calculated-field names | 
| A person-keyed glossary | The binding between an entry and a named column | The definition itself | 
| Free-form docs and wikis | The text that existed at the version inspected | Business meaning, often with no reliable binding to data objects | 

A declared default proves configured insertion behavior. It says nothing about what the resulting value means to the business. A declared constraint tells us what the source said at the inspected version; the business rule may still be wrong, and the warehouse may not enforce it.

A query log proves that a query ran. An alias such as `raw_margin` tells us that the author treated the expression as gross margin in that query, which is useful but weaker than an approved metric definition. A dashboard definition establishes how a chart is configured. Its filters are not necessarily company policy.

We have used the schema, dbt, dashboard, glossary, query-log, and free-form documentation paths on real projects. Dashboard exports from tools such as Looker or Metabase bring configured SQL, filters, formulas, and a popularity signal. A semantic layer brings configured metrics and relationships when one exists. What dbt metadata can establish on its own is [measured separately](/dbt-context-for-analytics-agents/) across 13 public projects and 5,284 models.

## Extract deterministic evidence first

We wrote scripts for the parts that do not require judgment: inventorying tables and columns, expanding dbt doc blocks, parsing SQL when the dbt manifest is unavailable, copying existing descriptions, and recording where each piece of text came from. We did not use an LLM for information the files already state exactly.

On the GitLab scope, the deterministic pass took about two minutes and produced:

- An inventory of 39,271 columns across 2,183 tables. Of those columns, 22,004 were absent from yml files and recoverable only from SQL. A project that parses successfully exposes the same inventory through `manifest.json` ; GitLab’s project requires`dbt deps` and Snowflake environment variables to parse, so we read its SQL files directly.
- 941 candidate descriptions across the selected tables after expanding dbt doc blocks, up from 490 descriptions visible beforehand.
- 876 owner-authored descriptions retained with their source. Another 65 added no usable meaning and were set aside for review rather than treated as documentation.
- A queue of columns, joins, metrics, and contradictions that still required judgment.

One retained description from `mart_arr` looks like this:

```
IS_ARPU:
  description: Flag to identify what counts towards Annual Revenue Per User (ARPU).
    It works by excluding `Storage` product_tier and `EDU` product_rate_plan.
  description_source: repo_text
```

Mentioning the column name was allowed; the description still had to add enough meaning to guide use of the field. `AMENDMENT_NAME: "The name of the amendment in Zuora"`, for example, identifies the source system but says nothing about what the amendment represents. We keep that text available for review without counting it as a useful definition.

On a 97-table production warehouse, a name-keyed glossary supplied by the team contained 1,052 definitions and covered 76% of the documented columns. On another project, 1,491 doc blocks existed but exported as empty descriptions because the `{{ doc() }}` references had never been expanded. Reading the files resolved both problems; no model interpretation was needed.

## Let the LLM verify and draft from evidence

The failure on `is_arpu` is why the order matters. The model expanded it as average revenue per user and attached it to the wrong population. GitLab’s own text says Annual Revenue Per User, excluding the Storage tier and EDU rate plan.

A verification pass caught that contradiction and three more across 38 verdicts. Two of the four came from a rule we had written into the domain grouping ourselves. The check compared each claim with the table SQL and source text, regardless of who had written it.

After the scripts had collected the available evidence, we used an LLM for three tasks:

- Propose domain groupings for a person to review.
- Check prose against SQL and source text.
- Draft a description when every clause can be traced to evidence.

A model already has a fluent expansion for ARR, ARPU, churn, and almost any column name you give it. That prior knowledge is useful for writing, but it is not evidence about this warehouse.

A 17-line macro gave the model enough evidence to describe the Delta ARR movement categories:

```
CASE
  WHEN {{ row_number }} = 1 THEN 'New'
  WHEN {{ arr }} = 0 AND {{ previous_arr }} > 0 THEN 'Churn'
  WHEN {{ arr }} < {{ previous_arr }} AND {{ arr }} > 0 THEN 'Contraction'
  WHEN {{ arr }} > {{ previous_arr }} AND {{ row_number }} > 1 THEN 'Expansion'
  WHEN {{ arr }} = {{ previous_arr }} THEN 'No Impact'
  ELSE NULL
END
```

From that CASE body, the model drafted:

```
Category of this parent-account month's ARR movement, computed by the
type_of_arr_change macro. Exactly one of 'New' (the first period for this
grain, whatever the ARR comparison says), 'Churn' (ARR is 0 and the prior
period's was above 0), [...] 'No Impact' (ARR equal to the prior period's);
NULL when none of those hold.
```

Every clause maps to the macro, including the capitalized literals. That last detail affects the SQL an agent writes: filtering on `'new'` returns no rows.

The GitLab context ended with 2,573 of 2,641 columns covered. GitLab’s own words covered 871, evidence-backed drafts covered 1,702, and 68 remained blank. We had initially retained 876 owner descriptions; one disappeared when we removed a malformed column name, and four contradicted the SQL. We corrected those four and marked them as drafts rather than owner-authored text.

Verification also found defects in documentation that already existed. We found two contradictions in 200 checked claims on one customer project and eight on another. One metric summed a column absent from the deployed warehouse. GitLab’s documentation prevented a different mistake: one opportunity mart describes its `ARR` field as a work-in-progress Salesforce formula that returns 0. Without that warning, an agent could sum the zeros and report them as revenue.

## Bind free-form documentation before using it

Free-form documentation contains business meaning that never made it into code. A paragraph rarely tells you which table or column it governs, so we need a reliable way to attach it.

We only let that prose enter through one of three bindings:

- A person has keyed it to a data object.
- A specific question is asking for it.
- SQL can check the claim it makes.

A binding establishes what the text is about. It does not establish that the text is correct.

GitLab’s handbook showed us the failure mode. We took 4,736 pages and 46.9 million characters, extracted 647 definition-shaped entries, and matched them against the columns in scope. Nine names matched. Eight descriptions were inserted. None described the right field.

The Delta ARR mart’s `DELIVERY` column distinguishes SaaS from self-managed revenue. It received prose from an architecture document about package download modes. The text was valid in its original document and wrong for the column. Worse, we had marked it as owner-authored because the pipeline recorded where the text came from but guessed what it described.

We changed the provenance rule after that result. A guessed binding is labeled as a guess and cannot corroborate another claim. Only a mapping supplied by a person keeps owner status.

The same handbook became useful when we searched it with specific questions. Retrieval reduced 6.8 million tokens to about 5,500 tokens of candidate passages in 2.3 seconds. One question found relevant evidence:

```
What timezone are timestamps stored in?
Answered, from handbook/enterprise-data/platform/_index.md:873:
"All timestamp data in the warehouse should be stored in UTC",
with one named exception, the pacific-time _pt_id date keys.
```

That passage became a cited candidate for a person to confirm, not an automatic edit. The other four questions were left unanswered. One of them found a CRM field with New, Growth, Contraction, and Churn categories, then rejected it because it described an upstream classification rather than the mart’s computation.

Matching prose to a column name had asked the system to guess what the prose described. A specific question gave retrieval a concrete target. We tuned retrieval against these labeled questions; we have not yet tested the same setup on an unrelated corpus.

## Turn unresolved gaps into questions

The GitLab example reached 97.4% column coverage. Every dollar metric still depended on one unanswered currency question, so that coverage was not enough to use them safely.

We recorded the temporary assumption and its consequence:

```
Assumed instead: shipped every dollar metric as a plain SUM, on the working
assumption (unverified) that these marts report in one currency.
If that is wrong: every SUM-based dollar metric in this run is silently wrong
by however much non-reporting-currency revenue exists, with no way to detect
it from the data alone.
```

The process produced 45 questions. Six blockers affected 30 tables, columns, and metrics. Two asked an owner to choose between defensible definitions. The remaining 37 covered defects we found, rules not yet bound to every affected table, standard intake questions, and one provenance issue.

- **Public but unread:** The handbook stated the timezone in one sentence. Question-led retrieval found it.
- **Public but insufficient:** GitLab publishes its reporting currency. That does not establish whether each mart’s rows have already been converted, so the currency question stayed open.
- **Present in the repository:** The Delta ARR macro held the movement categories. Its call appeared 175 times in the authoring inputs, while its body appeared zero times. The categories became verifiable only after we attached the macro body to the tables that call it.
- **Available only inside the company:** One mart’s declared columns exclude the ARR fields its SQL computes. Five reporting tables have no recoverable column list. Another table exists because snapshot and live data disagree, yet has no documentation. Public material cannot tell us how the company wants an analytics agent to handle those cases.

We order the questions by risk. Some need a one-word confirmation. Others deserve a longer investigation. In the meantime, the context carries the assumption openly instead of presenting it as a settled definition.

## The first version is a snapshot

The GitLab work took about two minutes of scripts and under two hours of agent wall clock. The agents reported just under three million tokens, mostly on a mid-tier model. We could not measure analytics-answer accuracy before and after because the public project has no warehouse to query. This example demonstrates the assembled context and its named unknowns. It does not measure downstream answer quality.

Everything above produces a snapshot, and it starts aging as soon as the underlying data stack changes. A new column arrives without a description. A filter moves two models upstream and a mart quietly changes which companies it counts. A definition verified against the SQL in August describes the SQL of August.

This is also why assembling context changes the source-of-truth problem. The analytics agent now reads the assembled context, not the dbt docs, dashboard, and glossary it came from. The evidence needs to travel with it so that a later change can be reviewed against the right source.

We packaged the method in the [context bootstrap kit](https://github.com/GetCassis/ontology-bootstrap) so other teams can produce that first snapshot. Keeping it true while schemas, filters, and business definitions change is a different problem. Nothing in this guide solves it.
