DataHub, Polaris and Trino
Many AI agents use an LLM to translate a user’s question directly into SQL. The SQL may run successfully and still return the wrong answer. The LLM can choose the wrong column, create an incorrect join, or use the wrong aggregation without producing an obvious error.
With Semantic Operator, an AI agent selects certified metrics and dimensions instead of generating SQL. Semantic Operator validates the request, applies governance policies, and deterministically generates a single SQL statement.
This walkthrough shows how our Semantic Operator provides a governed semantic layer using DataHub, Polaris, and Trino.
Why direct SQL can return the wrong answer
Section titled “Why direct SQL can return the wrong answer”Consider a simple business question: How much sales revenue is generated per employee in each state?
In the semantic model, this business concept is named store_productivity. It
divides the total sales revenue from stores in a state by the total employee
headcount for those stores.
An LLM can generate SQL that looks correct but counts a store’s employees once for every sale made there. That repeated headcount inflates the denominator and produces a plausible but incorrect result. The database reports no error because the query itself is valid.
Later in this walkthrough, you will query store_productivity by state. The
governed request returns 644,567.24 for Tennessee and 1,826,350.38 for
Texas. You will inspect the generated SQL and see how Semantic Operator avoids
duplicating employee headcount across the sales join.
How the semantic layer fixes it
Section titled “How the semantic layer fixes it”A semantic layer gives business concepts clear, reviewed definitions. It defines which columns represent those concepts, how metrics are calculated, how tables relate to one another, and who is allowed to access the data.
In this example, total_sales uses the column the business recognizes as
revenue. store_productivity calculates sales revenue per employee without
counting the same employees once for every sale.
An AI agent selects these certified metrics and dimensions instead of using an LLM to generate SQL. Semantic Operator validates the request, applies the model’s governance rules, and generates one deterministic SQL statement.
The same model, request, and caller identity produce the same SQL and result. If the caller requests a metric or field they are not allowed to use, the Semantic Server rejects the request before generating any SQL.
Semantic Operator runs this governed semantic layer on Kubernetes.
What you will build
Section titled “What you will build”This walkthrough takes you through six stages on a live Kubernetes cluster
- Load a TPC-DS retail dataset into Apache Iceberg tables stored in Amazon S3 and cataloged by Apache Polaris.
- Install Semantic Operator and connect it to Trino.
- Add business descriptions, glossary terms, and a PII classification in DataHub.
- Derive a semantic model from the physical schema, enrich it with DataHub metadata, and compare it with the certified model.
- Deploy the certified model and watch Semantic Operator validate its tables and columns against Trino.
- Query the model, compare the governed result with the LLM-generated result, and verify column and row-level access policies.
The stack
Section titled “The stack”This walkthrough combines an open table format, an open catalog, a distributed SQL engine, a metadata platform, and Semantic Operator. Each component has a specific role.
| Component | What it does here |
|---|---|
| Amazon S3 | Stores the Iceberg data files |
| Apache Iceberg | Provides the table format for the retail data |
| Apache Polaris | Catalogs the Iceberg tables and tracks their locations |
| Trino | Generates the sample data, reads the tables, and executes SQL |
| DataHub | Stores business descriptions, glossary terms, and data classifications |
| Semantic Operator | Validates semantic models, enforces governance, and generates SQL |
| Apache Ossie | Provides the open standard used to define the semantic model |
| Valkey | Optionally caches query plans and results |
The walkthrough uses the TPC-DS retail dataset generated by Trino, so you do not need to provide any source data.
Request and data flow
Section titled “Request and data flow”User | | "How much sales revenue is generated per employee in each state?" vAI agent | | Selects the certified metric and dimension | metric: store_productivity | dimension: store.s_state vSemantic Server | | Validates the request and checks the caller's permissions | If access is denied, the request stops here | | Generates one governed SQL statement vTrino | | Resolves the Iceberg tables through Apache Polaris | Reads the underlying data from Amazon S3 vQuery resultDataHub is part of the authoring path, not the query path. While the model is
being created, ossiectl reads the physical schema through Trino and enriches
it with descriptions, glossary terms, and classifications from DataHub. A
person then reviews and completes the model before deploying it.
After the model is deployed, Semantic Operator checks its tables and columns against Trino before publishing it. At query time, the AI agent selects certified metrics and dimensions. Semantic Operator applies governance and generates the SQL that Trino executes.
Set up the cluster
Section titled “Set up the cluster”Install kubectl, helm, aws, jq, git, and Go 1.26 on your workstation.
Deploy the semantic-on-eks stack from Data on EKS:
./deploy.shIt deploys EKS, Trino, Polaris, DataHub, Valkey, an S3 bucket, and the required IAM roles. See data-stack.tfvars for the configuration.
You can also use an existing cluster with Trino, Polaris, and DataHub.
Verify the environment
Section titled “Verify the environment”kubectl get nodes | head -3kubectl -n trino get podskubectl get pods -A | grep -E 'polaris|datahub-gms'aws sts get-caller-identitykubectl -n trino exec deploy/trino-coordinator -c trino-coordinator -- \ trino --execute "SHOW CATALOGS" 2>/dev/null | grep polarisThe final command must print polaris. The terminal warning hidden by
2>/dev/null is harmless.
Stage 1. Load the demo data
Section titled “Stage 1. Load the demo data”A semantic model describes tables, so you need tables first. This builds five
retail tables in Polaris using CTAS from Trino’s built in tpcds connector.
Trino writes the Iceberg files and Polaris tracks the metadata. The data is
generated inside the coordinator, so this needs no pre existing warehouse.
bash examples/stacks/eks/datahub-polaris-trino/data-load.shVerify: the script prints its own checks at the end.
Expected load output, first run
[data-load] creating schema polaris.osi_demoCREATE SCHEMA[data-load] loading date_dim (2000-2002)CREATE TABLE: 1096 rows[data-load] loading itemCREATE TABLE: 18000 rows[data-load] loading customerCREATE TABLE: 100000 rows[data-load] loading storeCREATE TABLE: 12 rows[data-load] loading store_sales (this is the big one, a few minutes)CREATE TABLE: 1591154 rows[data-load] spreading stores with sales across two states, so row filters have an effectUPDATE: 12 rows[data-load] TX stores: 2,7,10UPDATE: 3 rows[data-load] verify: row counts in Polaris"customer","100000""date_dim","1096""item","18000""store","12""store_sales","1591154"[data-load] verify: stores by state, with sales, so both states return rows"TN","9","796806""TX","3","794348"[data-load] verify: no orphan foreign keys"0","0"Expected load output, running it again
[data-load] creating schema polaris.osi_demoCREATE SCHEMA[data-load] loading date_dim (2000-2002)CREATE TABLE: 0 rows[data-load] loading itemCREATE TABLE: 0 rows[data-load] loading customerCREATE TABLE: 0 rows[data-load] loading storeCREATE TABLE: 0 rows[data-load] loading store_sales (this is the big one, a few minutes)CREATE TABLE: 0 rows[data-load] spreading stores with sales across two states, so row filters have an effectUPDATE: 12 rows[data-load] TX stores: 2,7,10UPDATE: 3 rows[data-load] verify: row counts in Polaris"customer","100000""date_dim","1096""item","18000""store","12""store_sales","1591154"[data-load] verify: stores by state, with sales, so both states return rows"TN","9","796806""TX","3","794348"[data-load] verify: no orphan foreign keys"0","0"CREATE TABLE: 0 rows is success, not failure. Every create is
IF NOT EXISTS, so a table that already exists is left alone and reports zero
rows written. The counts underneath are read back from Polaris and are the real
numbers, which is why they match the first run.
Verify the loaded data
Section titled “Verify the loaded data”Three things have to hold before the rest of the walkthrough works. Check them yourself rather than trusting the script.
1. Verify the row counts.
kubectl -n trino exec deploy/trino-coordinator -c trino-coordinator -- trino --execute "SELECT 'store_sales' AS table_name, count(*) AS row_count FROM polaris.osi_demo.store_salesUNION ALL SELECT 'customer', count(*) FROM polaris.osi_demo.customerUNION ALL SELECT 'item', count(*) FROM polaris.osi_demo.itemUNION ALL SELECT 'date_dim', count(*) FROM polaris.osi_demo.date_dimUNION ALL SELECT 'store', count(*) FROM polaris.osi_demo.storeORDER BY 1" 2>/dev/null"customer","100000""date_dim","1096""item","18000""store","12""store_sales","1591154"A missing table or a zero count means the load did not finish. Re-run
data-load.sh, which only builds what is absent.
2. Verify the joins and state data.
kubectl -n trino exec deploy/trino-coordinator -c trino-coordinator -- trino --execute "SELECT s.s_state, count(DISTINCT s.s_store_sk) AS stores_with_sales, count(*) AS sales_rows, round(sum(ss.ss_ext_sales_price), 2) AS revenueFROM polaris.osi_demo.store_sales ssJOIN polaris.osi_demo.store s ON s.s_store_sk = ss.ss_store_skJOIN polaris.osi_demo.date_dim d ON d.d_date_sk = ss.ss_sold_date_skJOIN polaris.osi_demo.item i ON i.i_item_sk = ss.ss_item_skJOIN polaris.osi_demo.customer c ON c.c_customer_sk = ss.ss_customer_skGROUP BY s.s_state ORDER BY s.s_state" 2>/dev/null"TN","3","796806","1516666718.80""TX","3","794348","1510391760.72"You need two rows, both with non-zero revenue. One row means the row filter
demo in Stage 6 will return nothing. Re-run data-load.sh, which resets the
store states on every run.
stores_with_sales is 3 for each because an inner join only sees stores that
have rows. Twelve stores exist and six carry sales. That is how tpcds
generates the data.
3. Verify the Iceberg files in S3.
kubectl -n trino exec deploy/trino-coordinator -c trino-coordinator -- trino --execute "SELECT * FROM polaris.osi_demo.\"store_sales\$properties\"" 2>/dev/null"format","iceberg/PARQUET""provider","iceberg""format-version","2""location","s3://<your-polaris-bucket>/demo/osi_demo/store_sales-<uuid>"Take that location and look at it directly. An Iceberg table is a data/
directory of Parquet plus a metadata/ directory holding the table metadata,
the manifests, and the snapshot log.
aws s3 ls s3://<your-polaris-bucket>/demo/osi_demo/store_sales-<uuid>/metadata/00000-....metadata.json the table metadata....-m0.avro a manifest, listing data filessnap-....avro a snapshot, the atomic commitAn empty listing means Trino’s Pod Identity role cannot write to the bucket.
Stage 2. Install Semantic Operator
Section titled “Stage 2. Install Semantic Operator”This deploys the two halves of the system. The manager watches SemanticModel
resources, validates them against the live engine, and publishes compiled
artifacts. The server loads those artifacts and answers queries.
The chart defaults to the published images, so no image flags are needed. If
your cluster has Valkey, turn on result caching while you are here. A
secretKeyRef can only read a Secret in its own namespace, so copy the password
across first.
kubectl create namespace semantic-system --dry-run=client -o yaml | kubectl apply -f -kubectl -n semantic-system create secret generic valkey-auth \ --from-literal=password="$(kubectl -n valkey get secret valkey-auth -o jsonpath='{.data.default}' | base64 -d)"Then install.
helm upgrade --install semantic-operator charts/semantic-operator \ --namespace semantic-system --create-namespace \ --set server.auth.allowInsecureHeaderAuth=true \ --set engine.type=trino \ --set engine.host=trino.trino.svc.cluster.local \ --set valkey.addr=valkey.valkey.svc.cluster.local:6379 \ --set valkey.passwordSecret.name=valkey-auth \ --set valkey.passwordSecret.key=passwordWithout a Valkey, drop the three valkey.* flags. Everything still works, the
server just reports cachedResult: false on every request.
engine.port is not set because the Trino client already defaults to 8080.
Expected install output
Release "semantic-operator" has been upgraded. Happy Helming!NAME: semantic-operatorLAST DEPLOYED: Thu Jul 30 13:06:45 2026NAMESPACE: semantic-systemSTATUS: deployedREVISION: 1TEST SUITE: NoneNOTES:semantic-operator installed in namespace semantic-system.
Endpoints (in-cluster): MCP : http://semantic-operator-server.semantic-system.svc:8090/mcp REST : http://semantic-operator-server.semantic-system.svc:8090/v1/models
Next steps: 1. Apply a model. Pick the one matching the engine you installed: examples/retail/model/semanticmodel.yaml (Glue + StarRocks) examples/stacks/eks/glue-trino/semanticmodel.yaml (Glue + Trino) examples/stacks/eks/datahub-polaris-trino/semanticmodel.yaml (Polaris + Trino)
kubectl apply -f <model>.yaml
2. kubectl get semanticmodels -n semantic-system -w Wait for VALIDATED=True PUBLISHED=True DRIFT=False.
3. Query it. Discovery is governed, so send a role: kubectl port-forward -n semantic-system svc/semantic-operator-server 8090:8090 curl -s localhost:8090/v1/models -H 'X-Semantic-User: demo-user' -H 'X-Semantic-Role: analyst' | jqSTATUS: deployed is the line that matters. On a first install it says
has been installed with REVISION: 1. Re-running says has been upgraded
and the revision climbs, which is normal.
Verify:
kubectl -n semantic-system get podspkill -f 'port-forward.*8090:8090' 2>/dev/nullkubectl -n semantic-system port-forward svc/semantic-operator-server 8090:8090 &curl -s -o /dev/null -w '%{http_code}\n' localhost:8090/readyzExpected verification output
NAME READY STATUS RESTARTS AGEsemantic-operator-manager-78494d67cd-jbtrf 1/1 Running 0 45ssemantic-operator-server-845f47764d-mnrc7 1/1 Running 0 45ssemantic-operator-server-845f47764d-ppq9b 1/1 Running 0 45s
200The 200 from /readyz means the server has synced the model store and
successfully pinged Trino. Check the operator logs if you see anything other
than 200.
If you set the Valkey flags, confirm they landed.
kubectl -n semantic-system get deploy semantic-operator-server -o json \ | jq -r '.spec.template.spec.containers[0].env[] | select(.name|startswith("VALKEY")) | .name'VALKEY_ADDRVALKEY_DBVALKEY_PASSWORDStage 3. Add metadata to DataHub
Section titled “Stage 3. Add metadata to DataHub”Stage 1 gave you tables. Tables tell you a column is called
ss_ext_sales_price and holds a decimal. They do not tell you it is what the
business calls revenue, or that c_email_address is personal data.
That knowledge lives in a metadata catalog, curated by people. This stage puts it into DataHub so Stage 4 can pull descriptions, synonyms, and the PII tag into the model.
First, register the Polaris tables in DataHub. The script creates a DataHub
ingestion job that reads the osi_demo schema through Trino and imports its
datasets, columns, and types.
bash examples/stacks/eks/datahub-polaris-trino/datahub-ingest.shkubectl -n datahub logs job/datahub-ingest-polaris --tail=20Then add the business meaning. In a real organisation a data steward builds this up over weeks. The annotation script creates a small glossary, documents selected datasets and fields, and marks the customer email field as PII, so the demo is reproducible.
GMS=$(kubectl -n datahub get svc -o name | grep -m1 datahub-gms | cut -d/ -f2)pkill -f 'port-forward.*8091:8080' 2>/dev/nullkubectl -n datahub port-forward "svc/$GMS" 8091:8080 &bash examples/stacks/eks/datahub-polaris-trino/datahub-annotate.shVerify the metadata in DataHub
Section titled “Verify the metadata in DataHub”FE=$(kubectl -n datahub get svc -o name | grep -m1 datahub-frontend | cut -d/ -f2)pkill -f 'port-forward.*9002:9002' 2>/dev/nullkubectl -n datahub port-forward "svc/$FE" 9002:9002 &Open http://localhost:9002 and sign in with the credentials your DataHub install was configured with.
Search for osi_demo and open customer on the Trino platform. Look for
three things, because these are exactly what Stage 4 imports into the model.
| In the UI | Becomes in the model |
|---|---|
The dataset’s glossary term, Buyer |
ai_context.synonyms, how an agent grounds a phrase |
| Column descriptions | Field documentation |
The PII tag on c_email_address |
governance.denyFields, a 403 before any SQL runs |
Search results also show datasets on other platforms if something else has
ingested into this DataHub. The ones this walkthrough created are on the
Trino platform, named polaris.osi_demo.*.
Verify without the UI if you prefer, straight from GMS:
SECRET=$(kubectl -n datahub get secret datahub-auth-secrets -o jsonpath='{.data.system_client_secret}' | base64 -d)URN='urn:li:dataset:(urn:li:dataPlatform:trino,polaris.osi_demo.customer,PROD)'curl -s -X POST localhost:8091/api/graphql \ -H 'Content-Type: application/json' -H "Authorization: Basic __datahub_system:${SECRET}" \ -d "{\"query\":\"{dataset(urn:\\\"$URN\\\"){glossaryTerms{terms{term{urn}}} editableSchemaMetadata{editableSchemaFieldInfo{fieldPath globalTags{tags{tag{urn}}}}}}}\"}" | jqThe PII tag sits on the column, not the dataset, so a dataset-level tag count of zero is normal.
Expected metadata
glossary term : urn:li:glossaryTerm:Buyercolumn : c_email_address tag = urn:li:tag:PII desc = Customer contact email.Confirm the descriptions, glossary terms, and PII tag are present before continuing. Stage 4 imports exactly what is there.
Stage 4. Author the model
Section titled “Stage 4. Author the model”This is where the operator’s tooling starts doing the work. A semantic model has two halves. The physical half says which tables and columns exist. The business half says what the numbers mean and who may see them.
ossiectl derive writes the physical half for you by reading the engine. It
cannot write the business half, because no machine knows what your company means
by revenue. Run it twice, once without DataHub and once with, and the difference
shows exactly which parts a catalog can fill in and which parts stay human work.
Output goes to tmp/, which is gitignored.
Pass 1. Derive without DataHub
Section titled “Pass 1. Derive without DataHub”pkill -f 'port-forward.*8081:8080' 2>/dev/nullkubectl -n trino port-forward svc/trino 8081:8080 &export SQL_DIALECT=trino ENGINE_HOST=127.0.0.1 ENGINE_PORT=8081mkdir -p tmpgo run ./cmd/ossiectl derive -source engine -catalog polaris -database osi_demo \ -model tpcds_retail_model -name tpcds-retail -out tmp/scaffold-plain.yamlThe command reads Trino’s information_schema, so it sees exactly what the
engine sees. Every dataset and column is filled in, and candidate joins are
inferred from key naming.
What is missing is meaning. Open the file and look at the governance block:
# denyFields: [] # e.g. ["customer.c_email_address"]That commented-out placeholder is the honest result. The tool knows
c_email_address is a varchar. It has no way to know it holds personal data,
so it refuses to guess and leaves the decision to you. On a real schema with
hundreds of columns, somebody has to remember every sensitive one. That is the
gap the next pass closes.
Pass 2. Derive with DataHub
Section titled “Pass 2. Derive with DataHub”Same command, plus the enrichment flags. This pass needs the DataHub
port-forward on 8091. The export below does not, because it reads the Secret
through the Kubernetes API rather than over the forward, but the derive call
does.
GMS=$(kubectl -n datahub get svc -o name | grep -m1 datahub-gms | cut -d/ -f2)pkill -f 'port-forward.*8091:8080' 2>/dev/nullkubectl -n datahub port-forward "svc/$GMS" 8091:8080 &sleep 3
export DATAHUB_TOKEN="Basic __datahub_system:$(kubectl -n datahub get secret \ datahub-auth-secrets -o jsonpath='{.data.system_client_secret}' | base64 -d)"go run ./cmd/ossiectl derive -source engine -catalog polaris -database osi_demo \ -enrich datahub -datahub-url http://localhost:8091 \ -datahub-platform trino -datahub-dataset-prefix polaris \ -model tpcds_retail_model -name tpcds-retail -out tmp/scaffold-enriched.yamlenriched 5/5 tables from DataHub (1 sensitive columns, 0 deprecated)If DataHub is unreachable the command fails and writes nothing, rather than quietly producing a model that looks enriched and is not.
Compare the derived models
Section titled “Compare the derived models”for f in tmp/scaffold-plain.yaml tmp/scaffold-enriched.yaml; do printf '%-28s fields=%s descriptions=%s synonyms=%s\n' "$(basename $f)" \ "$(grep -c '^ - name:' $f)" "$(grep -c 'description:' $f)" "$(grep -c 'synonyms:' $f)"donediff tmp/scaffold-plain.yaml tmp/scaffold-enriched.yaml | grep '^>' | head -20Expected difference
scaffold-plain.yaml fields=121 descriptions=7 synonyms=6scaffold-enriched.yaml fields=121 descriptions=12 synonyms=9+ synonyms: ["Buyer"]+ description: "Customer contact email."+ description: "Merchandise category."+ synonyms: ["Merchandise"]+ description: "Headcount at the store. Do not sum across a sales join."+ synonyms: ["Storefront"]+ description: "Extended sales price for the line item, net of discounts."+ synonyms: ["Revenue"]+ description: "Net profit for the line item."+ denyFields:+ - "customer.c_email_address"The physical half is identical, 121 fields either way. Both files describe the same tables. What changed is that one of them now carries what people know. Three of those additions matter more than the rest.
synonyms: ["Revenue"] on ss_ext_sales_price. That table has seven
plausible money columns: ss_ext_list_price, ss_ext_sales_price,
ss_list_price, ss_net_paid, ss_net_paid_inc_tax, ss_net_profit,
ss_sales_price. An agent writing SQL picks one and cannot tell you why. The
glossary term records which one the business means.
“Do not sum across a sales join” on s_number_employees. That is the fan-out
trap from the top of this page, written down. A steward wrote that sentence once
and now every consumer of the model sees it.
denyFields is real, not a comment. The steward’s PII tag became an access
rule the compiler enforces. In Stage 6 a request for that column returns 403
before any SQL exists.
Complete the certified model
Section titled “Complete the certified model”Neither scaffold contains a single metric. Apply either one and it will
validate, publish, and report healthy, but it cannot answer a metric request
because total_sales and the other business calculations have not been
defined.
A certified model is the scaffold after a person has finished it. Certified
means somebody with the authority to define these terms has written down what
each metric computes, which joins are real, and who may read what, and has put
their name to it in review. From then on every agent, dashboard, and application
that asks for total_sales gets that one definition.
Semantic Operator does not ask an LLM to turn that name into SQL. Its Go planner reads the metric expression, fields, relationships, and primary keys from the compiled model. It applies governance, builds the required joins and aggregations, and emits SQL for Trino.
This repo already contains the certified version of this model,
semanticmodel.yaml,
so the walkthrough does not stall while you hand write seven metrics.
Here is what a person added on top of your scaffold:
| your scaffold | the certified model | who decided | |
|---|---|---|---|
| datasets | 5 | 5 | machine |
| fields | 121 | 34 | a person, by pruning |
| field descriptions | 5 | 34 | a person, 5 came from DataHub |
| field synonyms | 3 | 20 | a person, 3 came from DataHub |
| primary keys | 0 | 5 | a person |
| relationships | 0 | 4 | a person, from the machine’s candidates |
| metrics | 0 | 7 | a person |
| views | 0 | 5 | a person |
| roles | 1 | 3 | a person |
| denyFields | 1 | 2 | 1 from DataHub, 1 added |
| rowFilters | 0 | 1 | a person |
TODO markers |
23 | 0 |
Two rows are worth pausing on.
Primary keys go from 0 to 5. That is what makes a ratio metric safe across a
join, and it is how the certified store_productivity avoids counting each
store’s employee headcount once per sale.
Fields go down, from 121 to 34. A semantic model is meant to be smaller than the schema. Deciding that 87 columns should not be offered to an agent is authoring work just as much as writing a metric is.
Look at the difference yourself:
diff tmp/scaffold-enriched.yaml examples/stacks/eks/datahub-polaris-trino/semanticmodel.yaml | head -40# the machine left the business half opengrep -c 'TODO' tmp/scaffold-enriched.yaml# none left in the certified modelgrep -c 'TODO' examples/stacks/eks/datahub-polaris-trino/semanticmodel.yaml# what the person wrote: metrics and access policygrep -A12 'governance:' examples/stacks/eks/datahub-polaris-trino/semanticmodel.yaml | head -14Verify: all three validate offline, no cluster needed.
go run ./cmd/ossiectl validate -f tmp/scaffold-plain.yamlgo run ./cmd/ossiectl validate -f tmp/scaffold-enriched.yamlgo run ./cmd/ossiectl validate -f examples/stacks/eks/datahub-polaris-trino/semanticmodel.yamlStage 5. Deploy the model
Section titled “Stage 5. Deploy the model”Applying the model is what makes it live. The operator does not take the file on trust. It introspects every table and column through Trino first and refuses to publish anything that does not match the engine. That check is the difference between a broken model failing here, visibly, and failing later inside somebody’s dashboard.
Apply the certified model from the repo rather than the scaffold you generated, because the scaffold has no metrics and would serve an empty model.
kubectl apply -f examples/stacks/eks/datahub-polaris-trino/semanticmodel.yaml# wait for VALIDATED=True PUBLISHED=True DRIFT=Falsekubectl -n semantic-system get semanticmodels -wPublish governed views
Section titled “Publish governed views”Not every tool speaks REST or MCP. Most BI tools only know how to read a table. So the operator writes ordinary SQL views for them, using the same compiler that answers API calls.
The model asks for them. Near the bottom of semanticmodel.yaml:
views: - name: store_productivity_by_state metrics: [store_productivity] dimensions: [store.s_state] role: adminFive of those are defined, and the top of the file says where they go:
connection: viewDatabase: polaris.semantic_viewsWhen you applied the model, the manager compiled each entry into a
CREATE OR REPLACE VIEW and ran it against Trino. role: admin means the view
is built with that role’s permissions baked in, so a view of restricted data
carries the restriction in its own WHERE clause.
They are replaced, not appended, on every publish. Change a metric and the view follows on the next reconcile. See them:
kubectl -n trino exec deploy/trino-coordinator -c trino-coordinator -- \ trino --execute "SHOW TABLES FROM polaris.semantic_views" 2>/dev/null"clv_by_year""monthly_sales""sales_by_brand_year""sales_by_category_year""store_productivity_by_state"You query one of them in Stage 6.
Verify what the operator did:
kubectl -n semantic-system describe semanticmodel tpcds-retail | grep -A8 Conditionskubectl -n semantic-system get semanticmodel tpcds-retail \ -o jsonpath='{range .status.bindings[*]}{.dataset}{" -> "}{.table}{"\n"}{end}'kubectl -n semantic-system get cm sm-tpcds-retail-compiled -o jsonpath='{.metadata.labels}'Expected output
NAME VERSION VALIDATED PUBLISHED DRIFT AGEtpcds-retail 4ee25537eec3 True True False 30sBindings show the model resolved through Polaris, and each was drift-checked against the live engine:
store_sales -> polaris.osi_demo.store_salesdate_dim -> polaris.osi_demo.date_dimcustomer -> polaris.osi_demo.customeritem -> polaris.osi_demo.itemstore -> polaris.osi_demo.storeAnd the published artifact carries its version:
{"app.kubernetes.io/managed-by":"semantic-operator", "semantic.ossie.io/model":"tpcds_retail_model", "semantic.ossie.io/version":"4ee25537eec3"}DRIFT=False is the interesting one. Had a column been dropped from the table,
the operator would have refused to publish and left the previous version
serving.
Stage 6. Query it
Section titled “Stage 6. Query it”Everything below talks to the semantic server on 8090. Stage 2 started that forward, but start it again if you are in a new terminal.
pkill -f 'port-forward.*8090:8090' 2>/dev/nullkubectl -n semantic-system port-forward svc/semantic-operator-server 8090:8090 &sleep 3Query sales per employee by state
Section titled “Query sales per employee by state”Discovery is authenticated and governed, so pass a role here too.
curl -s localhost:8090/v1/models -H 'X-Semantic-User: demo-user' -H 'X-Semantic-Role: analyst' | jqcurl -s localhost:8090/v1/models/tpcds_retail_model/metrics \ -H 'X-Semantic-User: demo-user' -H 'X-Semantic-Role: analyst' | jq -r '.metrics[].name'
curl -s -X POST localhost:8090/v1/models/tpcds_retail_model/query \ -H 'X-Semantic-User: demo-user' -H 'X-Semantic-Role: analyst' -H 'Content-Type: application/json' \ -d '{"metrics":["store_productivity"],"dimensions":["store.s_state"]}' \ | jq '{rows, requestHash, cachedResult}'# run it twiceExpected output
Seven certified metric names:
total_salestotal_profittotal_quantitytransaction_countcustomer_lifetime_valuestore_productivitysales_by_brandThen the metric, twice:
{"rows":[["TN","644567.241309"],["TX","1826350.375719"]], "requestHash":"66855481b292e9423ed73e2abccb151e","cachedResult":false}
{"rows":[["TN","644567.241309"],["TX","1826350.375719"]], "requestHash":"66855481b292e9423ed73e2abccb151e","cachedResult":true}The numbers depend on the scale factor you loaded, so yours may differ. What must hold is that the hash is identical across both runs.
cachedResult only flips to true if you installed with the Valkey flags in
Stage 2. Without a cache it stays false and the answer is still correct.
This is the fan-out fix from the top of the page, working.
store_productivity is total sales revenue divided by the employee headcount
for stores in each state. Through the certified model it returns 644567.24 for TN and
1826350.38 for TX. The compiler knows that store has a primary key, so it
plans the two sides of the ratio separately and counts each store’s headcount
once rather than once per sale.
The requestHash is the other half. The same request, model version, and identity compile
to the same SQL and the same hash from any client. That is what makes an answer
auditable. You can point at a number in a report and find the exact request that
produced it.
Test the governance policies
Section titled “Test the governance policies”# column policy: analyst may not read PIIcurl -s -X POST localhost:8090/v1/models/tpcds_retail_model/query \ -H 'X-Semantic-User: demo-user' -H 'X-Semantic-Role: analyst' -H 'Content-Type: application/json' \ -d '{"metrics":["total_sales"],"dimensions":["customer.c_email_address"]}' | jq
# row policy: tx_analyst is restricted to Texascurl -s -X POST localhost:8090/v1/models/tpcds_retail_model/query \ -H 'X-Semantic-User: demo-user' -H 'X-Semantic-Role: tx_analyst' -H 'Content-Type: application/json' \ -d '{"metrics":["total_sales"],"dimensions":["store.s_state"]}' | jq '.rows'
# the exact SQL, without executing itcurl -s -X POST localhost:8090/v1/models/tpcds_retail_model/sql \ -H 'X-Semantic-User: demo-user' -H 'X-Semantic-Role: tx_analyst' -H 'Content-Type: application/json' \ -d '{"metrics":["total_sales"],"dimensions":["item.i_category"]}' | jq -r '.plan.sql'Expected output
The PII request is refused:
{"error":"unauthorized: role \"analyst\" may not read field \"customer.c_email_address\""}tx_analyst sees one state, analyst sees both. Same request, same model:
[["TX","1510391760.72"]][["TN","1516666718.80"],["TX","1510391760.72"]]And the compiled SQL carries the row filter:
/* semantic-layer model=tpcds_retail_model version=4ee25537eec3 request=4cfc29f3... */SELECT "item"."i_category" AS "item.i_category", SUM("store_sales"."ss_ext_sales_price") AS "total_sales"FROM "polaris"."osi_demo"."store_sales" AS "store_sales"INNER JOIN "polaris"."osi_demo"."item" AS "item" ON "store_sales"."ss_item_sk" = "item"."i_item_sk"INNER JOIN "polaris"."osi_demo"."store" AS "store" ON "store_sales"."ss_store_sk" = "store"."s_store_sk"WHERE ((("store"."s_state" = 'TX')))GROUP BY 1ORDER BY 1LIMIT 1000The LIMIT is the server’s default row limit, applied to any request that does
not set one of its own.
Two things happened there that are easy to skim past.
The 403 came back before any SQL existed. The compiler refused at planning time, so the email column was never named in a query, never sent to Trino, and never appeared in an engine log. Compare that with filtering results after the fact, where the data has already left the database.
The row filter is inside the WHERE clause. It is not applied to the
response afterwards, and there is no code path where forgetting to apply it is
possible. tx_analyst cannot see Tennessee because the SQL that would return it
is never generated.
The TN total, 1516666718.80, is the same number the hand written SQL returned
in Stage 1. The governed path and a direct query agree.
Query the model from other clients
Section titled “Query the model from other clients”The Trino web UI shows every governed query as the engine saw it, tagged with the model and request identifiers from the SQL comment.
pkill -f 'port-forward.*8081:8080' 2>/dev/nullkubectl -n trino port-forward svc/trino 8081:8080 &Open http://localhost:8081/ui and sign in with your Trino credentials.
Now query one of the views the operator created in Stage 5. It is an ordinary Trino view, so a BI tool reads it knowing nothing about the semantic layer:
POD=$(kubectl -n trino get pods -o name | grep coordinator | head -1 | cut -d/ -f2)kubectl -n trino exec "$POD" -c trino-coordinator -- \ trino --execute "SELECT * FROM polaris.semantic_views.store_productivity_by_state ORDER BY 1""TN","644567.241309""TX","1826350.375719"The same numbers the API returned, from the same compiled definition.
AI agents use the same server over MCP at /mcp (tools: list_models,
list_metrics, list_dimensions, query_metric). The agent selects certified
metrics by name and never writes SQL. The agent example drives exactly this.
Results
Section titled “Results”The question that opened this page now has a precise, governed definition.
store_productivity returns 644567.24 for TN and 1826350.38 for TX, and you
can inspect the SQL that produced both values.
| What you saw | |
|---|---|
| A tool wrote the boring half | derive filled 5 datasets and 121 columns straight from the engine |
| A catalog supplied the meaning | The enriched scaffold gained synonyms, descriptions, and a working denyFields |
| People wrote the rest | 7 metrics, 5 primary keys, and 4 joins no tool could have chosen |
| Kubernetes checked it first | Drift checked against Trino before anything served |
| Same governed input, same answer | Identical requestHash for the same model version, request, and identity |
| Rules that cannot be skipped | 403 before any SQL existed, and a WHERE clause the caller cannot remove |
| One model, every consumer | Same numbers over REST, over MCP, and from a plain SQL view |
Next: run the same model on Glue and Trino or Glue and StarRocks and watch the numbers match, or write your own with the authoring guide.
Reference numbers
Section titled “Reference numbers”At the default sf1 scale factor:
| Check | Value |
|---|---|
store_productivity by state |
TN 644567.241309, TX 1826350.375719 |
total_sales as tx_analyst |
TX 1510391760.72, one row |
total_sales as analyst |
TN 1516666718.80, TX 1510391760.72 |
Clean up
Section titled “Clean up”Remove what this walkthrough created, in reverse order. This leaves your cluster, Trino, Polaris, and DataHub untouched.
# stop every port-forwardpkill -f 'kubectl.*port-forward'
# the model and the operatorkubectl delete -f examples/stacks/eks/datahub-polaris-trino/semanticmodel.yamlhelm uninstall semantic-operator -n semantic-systemkubectl delete namespace semantic-system
# the governed views the operator created in Trinokubectl -n trino exec deploy/trino-coordinator -c trino-coordinator -- \ trino --execute "DROP SCHEMA IF EXISTS polaris.semantic_views CASCADE" 2>/dev/null
# the demo tables and their Iceberg fileskubectl -n trino exec deploy/trino-coordinator -c trino-coordinator -- \ trino --execute "DROP SCHEMA IF EXISTS polaris.osi_demo CASCADE" 2>/dev/null
# local scaffoldsrm -rf tmp/Delete the EKS stack
Section titled “Delete the EKS stack”If you deployed semantic-on-eks for this walkthrough, run its
cleanup.sh
script from the data-stacks/semantic-on-eks directory:
./cleanup.shThis removes the EKS cluster and the AWS resources created by deploy.sh.