knowledge-catalog Documentation

Google Cloud Knowledge Catalog Tools and Samples

9,568 stars TypeScript TypeScript 43 files · ~134,533 tokens
RAW Doc

Technical Documentation: GoogleCloudPlatform/knowledge-catalog

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (`README.md`)

Knowledge Catalog

Knowledge Catalog (formerly Dataplex), is an AI-powered data catalog and metadata management platform. It provides a dynamic knowledge graph of all your data, structured and unstructured, to provide semantics and business context to AI agents

This repository features tools, agents, and samples that demonstrate Knowledge Catalog features, and building context management, enrichment and retrieval solutions.

Getting Started

Contributing

See the contributing instructions to get started contributed.

License

All solutions within this repository are provided under the Apache 2.0 license. Please see LICENSE for more detailed terms and conditions.

Disclaimer

This repository and its contents are not an official Google product.


Overview (`okf/README.md`)

[!IMPORTANT]
OKF now lives in its own repository:
GoogleCloudPlatform/open-knowledge-format.

That repository is the canonical home of the specification, the reference
agent, and the sample bundles. Please read the spec, file issues, and open
pull requests there.

Stop using the copy under okf/ in this repository. It is a frozen
snapshot, no longer maintained, and anything built against it will drift out
of date.

Open Knowledge Format (OKF)

📖 [Read the Open Knowledge Format v0.2 specification → SPEC.md](SPEC.md)

This directory is primarily about the Open Knowledge Format
(OKF)
.

OKF is a universal, vendor-neutral format for representing knowledge
as plain markdown files with YAML frontmatter. It is not tied to any
particular agent, framework, model provider, or serving system
. The
goal is simple:

  • Anyone can produce OKF — humans authoring by hand, agents built on
    any framework (Google ADK, LangChain, custom), export pipelines from
    existing catalogs (Dataplex, Unity Catalog, Collibra, …), or scripts
    walking a database.
  • Anyone can serve and consume OKF — a static file server, a
    knowledge-management UI (Obsidian, Notion, MkDocs), an LLM loading
    files into context, a search index, or a graph viewer like the one
    bundled in this repo.

The agent below is a proof of concept demonstrating one way to
produce OKF bundles automatically. The format itself is the
contribution; this agent and the visualizer exist to make the format
tangible at both ends — production and consumption.

See OKF in practice — three ready-to-browse bundles produced by this
agent, checked into bundles/:

Why OKF?

OKF represents catalog knowledge as plain markdown files with YAML
frontmatter, organized in a directory hierarchy. That choice unlocks a few
properties that are hard to get from a service-owned metadata store:

  • Human- and agent-readable. No SDK or query language stands between a
    reader and the content. An engineer can cat a concept; an LLM can ingest
    it verbatim into context.
  • Version-controllable out of the box. Bundles live in git. Pull
    requests, line-by-line diffs, blame, and review workflows just work —
    knowledge curation becomes a normal software-engineering activity.
  • Portable and lock-in free. A bundle is a directory. Ship it as a
    tarball, host it in any repo, mount it from any filesystem, or sync it to
    any system that speaks files. No proprietary API stands between you and
    your metadata.
  • Mixes structured and unstructured data deliberately. Use frontmatter
    for the few fields you want to query, filter, or index on (type,
    resource, tags, generated, status); use the markdown body for the
    prose, schemas, and example queries that LLMs and humans actually read.
  • Trust, provenance, and freshness are first-class. v0.2 puts queryable
    signals in frontmatter — where a concept came from (sources with per-source
    credibility signals), who produced and confirmed it (generated, verified,
    from which consumers derive a trust tier), and whether it is still current
    (status, stale_after) — so an agent-maintained corpus stays trustable
    without any bespoke runtime.
  • Minimally opinionated, freely extensible. A small set of required
    keys ensures interoperability, but bundles can carry arbitrary extra
    frontmatter keys and arbitrary body sections without breaking
    consumers.
  • Composes with existing tooling. Many knowledge tools — Notion,
    Obsidian, MkDocs, Hugo, Jekyll — already speak markdown plus YAML
    frontmatter, so bundles can be browsed, edited, or rendered without
    custom UI.
  • Progressive disclosure built in. Auto-generated index.md files
    let an agent or human navigate the hierarchy one level at a time
    instead of loading the entire bundle into context.
  • Graph-shaped, not just tree-shaped. Concepts link to each other via
    normal markdown links, expressing relationships richer than the
    parent/child implied by the directory layout.

The net effect is that reference agents, consumption agents, and humans
collaborate on the same artifacts in the same way they already collaborate
on source code.

Install

text
python3.13 -m venv .venv
.venv/bin/pip install --index-url https://pypi.org/simple/ -e .[dev]

Credentials

  • BigQuery: gcloud auth application-default login plus a project for billing
    (gcloud config set project ). Public datasets are readable, but the
    caller's project is billed for query bytes.
  • Gemini: set GEMINI_API_KEY (AI Studio) or use Vertex AI by setting
    GOOGLE_GENAI_USE_VERTEXAI=true, GOOGLE_CLOUD_PROJECT=, and
    GOOGLE_CLOUD_LOCATION=<region>.

How the reference agent works

The reference agent runs in two passes. The BQ pass writes one OKF
doc per concept the source advertises, using BigQuery metadata alone.
The web pass then runs the LLM as its own crawler: it receives a
list of seed URLs (provided via --web-seed or --web-seed-file),
fetches the seeds via the fetch_url tool, and decides which outbound
links are worth following based on whether they look like authoritative
documentation for the existing concepts. For each page it fetches, the
agent chooses to (a) enrich one or more existing concept docs, (b) mint
a standalone references/<slug> doc, or (c) skip. A hard
--web-max-pages cap and a same-domain allowed-hosts filter
(configurable via --web-allowed-host) are enforced inside the tool,
so the agent cannot overrun. Use --no-web to skip the web pass.

Run

Minimum invocation — point at a BigQuery dataset and a bundle output
directory. Seeds for the web pass are explicit; omit them (or pass
--no-web) to run BQ-only:

text
.venv/bin/python -m reference_agent enrich \
    --source bq \
    --dataset <project>.<dataset> \
    --web-seed-file <path/to/seeds.txt> \
    --out ./bundles/<name>

Iterate on a single concept by adding --concept <type>/<name> (e.g.
--concept tables/events_); repeatable.

Samples

Each sample pairs a recipe (samples/<name>/, with the seed URLs and
exact enrich command) with the produced bundle (bundles/<name>/)
that the recipe generated. Open the recipe to reproduce; open the bundle
to browse the result directly.

  • GA4 Google Merchandise Store — public e-commerce dataset, seeded
    with canonical GA4 BigQuery Export documentation URLs.
    · recipe
    · bundle
    · viz.html
  • Stack Overflow — public dataset (mirror of the Stack Exchange Data
    Dump), seeded with the community's canonical schema references.
    Exercises multi-concept enrichment from cross-cutting docs pages.
    · recipe
    · bundle
    · viz.html
  • Bitcoin (crypto) — public dataset (blocks, transactions, inputs,
    outputs) from the bitcoin-etl pipeline. Exercises cross-table
    foreign-key relationships in prose.
    · recipe
    · bundle
    · viz.html

Visualize

The visualize subcommand renders any OKF bundle as a self-contained
interactive HTML file
— one file, no backend, no install on the
viewing side. Open it in any modern browser, share it as an artifact,
host it on a static file server, or commit it next to the bundle (as
this repo does).

The viewer is itself a proof-of-concept consumer of OKF, mirroring
the way the reference agent is a proof-of-concept producer. OKF
bundles can be consumed by anything that reads markdown; this is just
one shape.

What it shows

  • A force-directed graph of every concept in the bundle, with
    colored nodes by type (datasets, tables, references, …) and directed
    edges drawn from each cross-link in the markdown bodies.
  • A detail panel for the selected concept showing its frontmatter
    (description, resource link, tags) and its rendered markdown body —
    with internal […](/path/to/concept.md) links rewired to navigate
    within the viewer instead of following the path.
  • A "Cited by" backlinks list under each concept (computed from the
    reverse of the link graph).
  • A search box (matches title, concept id, and tags), a type
    filter
    , and switchable graph layouts (cose / concentric /
    breadth-first / circle / grid).

Generate

text
.venv/bin/python -m reference_agent visualize --bundle ./bundles/<name>

That writes bundles/<name>/viz.html. Flags:

Flag Default Description
--bundle (required) Bundle root directory.
--out /viz.html Output HTML path.
--name bundle directory name Display name shown in the viewer header.

Example, writing the output somewhere else and overriding the header:

text
.venv/bin/python -m reference_agent visualize \
    --bundle ./bundles/crypto_bitcoin \
    --out /tmp/btc.html \
    --name "Bitcoin OKF"

How it's built

The HTML embeds the bundle as a JSON blob and uses
Cytoscape.js for the graph and
marked for in-browser markdown rendering,
both loaded from a CDN. No data leaves the page; the bundle is parsed
once at generation time and serialized into the file.

Tests

text
.venv/bin/pytest

Overview (`samples/README.md`)

Samples

The samples demonstrate the use of Knowledge Catalog to manage metadata and
context to power agents.

Discovery

Demonstrates building a search and discovery agent building on top of the
Search APIs offered by the catalog.

Enrichment

Demonstrates an enrichment agent that can generate and enrich documentation
for assets managed in the catalog.

Governance Agent

Demonstrates agentic data governance that automates metadata management for
assets managed in the catalog, propagating descriptions, glossary terms, policy
tags and data quality scores using column-level lineage and Dataset Insights.

Policy-as-Code Agent

Demonstrates an agent that lets users define and validate data governance
policies in natural language, translating them into executable code that runs
against metadata in the catalog and BigQuery.


Overview (`toolbox/README.md`)

Toolbox

Contains tools to work with Knowledge Catalog metadata.

  • Metadata as Code
    Provides the ability to manage metadata in the form of source code artifacts that
    can be sync'd with metadata in Knowledge Catalog.

  • Enrichment Agent
    Provides an ready-to-use agent and customizable harness to produce, evolve/improve
    and maintain metadata within Knowledge Catalog and make it ready for consumption by
    agents.


Overview (`samples/discovery/README.md`)

Knowledge Catalog Discovery Agent

About

The Knowledge Catalog Discovery Agent is an AI-powered search assistant for discovering data assets in Google Cloud. While standard semantic search only matches semantically similar text, this agent goes further by performing semantic decomposition of complex questions, generating multiple relevant search queries, and reranking the final results to provide a comprehensive answer.

Quick Start

Prerequisites

You need a Google Cloud project with the following APIs enabled:

  • Knowledge Catalog (dataplex.googleapis.com)
  • Vertex AI (aiplatform.googleapis.com)
  • Service Usage API (serviceusage.googleapis.com)

Ensure you have the following permissions via IAM Roles:

  • dataplex.projects.search - can be obtained via roles like roles/dataplex.viewer.
  • aiplatform.endpoints.predict - can be obtained via roles like roles/aiplatform.user.
  • serviceusage.services.use - needed to use the current project as the quota project. Can be obtained via roles like roles/serviceusage.serviceUsageConsumer.

Setup

Clone the Github Repository:

shell
git clone https://github.com/GoogleCloudPlatform/knowledge-catalog.git

Install the dependencies. It is recommended to create a Python Virtual Environment:

shell
python3 -m venv /tmp/kcsearch  
source /tmp/kcsearch/bin/activate  

cd samples/discovery
pip3 install -r requirements.txt

Set the required environment variables:

shell
# Replace <PROJECT_ID> with your consumer project ID.
export GOOGLE_CLOUD_PROJECT=<PROJECT_ID>
export GOOGLE_GENAI_USE_VERTEXAI=True

Run the Agent

There are 2 ways to run the Knowledge Catalog Discovery agent:

  1. As the root agent - in this case, rename the discovery_agent variable in agent.py to root_agent and run it using ADK CLI (steps below).

  2. As a sub-agent - in this case, import the Discovery Agent and leverage it as an AgentTool. You can find examples of this in the official ADK docs. The recommended parent folder structure in this case is:

text
my_custom_agent/
├── agent.py
└── knowledge_catalog_discovery_agent/
    ├── SKILL.md
    ├── agent.py
    ├── tools.py
    └── utils.py

Irrespective of the path you choose, the agent can be run using the ADK CLI commands below:

shell
adk run path/to/agent/parent/folder

Replace path/to/agent/parent/folder with the relative or absolute path to the parent directory of the one containing the agent's source code.

References


Overview (`samples/enrichment/README.md`)

Knowledge Catalog Enrichment Agent

Overview

Knowledge Catalog manages metadata about data assets (such as BigQuery tables).
This metadata powers search, and agents to discover and understand data assets.

This sample demonstrates how external sources of information can be used to
augment the metadata in Knowledge Catalog, using an agentic approach. The
enrichment agent finds information relevant to a data asset, generates
documentation, and uses Knowledge Catalog APIs to publish the documentation
as enriched metadata.

Users can customize this enrichment agent with custom instructions, tools to
access sources of information within their organization, and skills to
describe the use of these tools.

Running the Sample

Setup

Setup your environment

This step clones the repository locally, configures various environment variables,
creates a python virtual environment and installs dependencies.

bash
git clone https://github.com/googlecloudplatform/knowledge-catalog
cd samples/enrichment

Ensure you have the GCloud CLI installed and configured.

bash
export CLOUD_PROJECT=<cloud-project-id>

gcloud auth application-default login
gcloud config set core/project $CLOUD_PROJECT
gcloud auth application-default set-quota-project $CLOUD_PROJECT

Setup a python environment

bash
cd src
source env.sh --install

Setup sample data

The sample creates a sample BigQuery dataset in your project whose metadata is
curated.

text
python3 ../sample/data/create_data.py

Enrichment Steps

You are now ready to run the enrichment workflow.

Download a metadata snapshot

bash
python3 -m enrichment.download \
  --dir ../sample/metadata.initial \
  --dataset ${KC_ENRICH_SAMPLE_PROJECT}.kc_enrich_sample_data

Enrich the metadata

bash
python3 -m enrichment.enrich \
  --dir ../sample/metadata.initial \
  --output-dir ../sample/metadata.new \
  --config-dir ../sample/config

Review the metadata updates

bash
git diff --no-index \
  ../sample/metadata.initial ../sample/metadata.new

Publish the updated metadata

bash
python3 -m enrichment.publish \
  --dir ../sample/metadata.new

Overview (`samples/governance-agent/README.md`)

Governance on Auto-pilot: Agentic Data Governance with Knowledge Catalog

This project demonstrates an agentic data governance solution using Google Cloud Knowledge Catalog. It showcases how to automate metadata management - propagate descriptions, glossary terms, policy tags etc. using column level lineage and leveraging Knowledge Catalog Dataset Insights capabilities. This is only a demonstration and is not part of official product, please review everything before using it for your environments and use-cases.


🌟 Key Features

  • Estate Dashboard: Scan BigQuery datasets to identify metadata gaps (missing descriptions).
  • Recursive Description Propagation: Automatically fetch descriptions from upstream sources, bridging multi-hop gaps.
  • SQL-Based Logic Enrichment: Extracts BigQuery SQL transformations to generate human-readable descriptions for computed columns.
  • AI Business Glossary: Maps technical columns to business terms using Vertex AI Semantic Similarity and Unstructured Documents (PDFs, TXT, MD).
  • Prioritized Glossary Propagation: Automatically propagates glossary terms across tables based on lineage, with strict verification thresholds to ensure accuracy (especially for 1-1 mappings).
  • Native Knowledge Catalog Integration: Persists glossary mappings as native EntryLinks visible in the Knowledge Catalog Schema tab.
  • Unified UI & CLI: Manage governance tasks via a Gradio-based web app or a headless CLI.
  • Policy Tag Propagation: Recommends and applies BigQuery policy tags via lineage, with support for "straight pull" detection and an integrated Access Summary (Readers & Data Policies).
  • Data Trust Center (DQ): Derived trust scores for views and tables based on upstream Knowledge Catalog DQ/Profiling results and multi-hop lineage.
  • Unstructured Document Processing: Leverage PDFs, TXT, and Markdown files to influence column descriptions and policy tags using Gemini and RAG.
  • Remediation Detection: Automatically detects SQL transformations (e.g. COALESCE, DISTINCT) that improve data quality and applies "Trust Bonuses".
  • Trust History Persistence: Tracks 0.0-1.0 trust scores over time in BigQuery for trend analysis.
  • CLL API Preview allowlisting required: Please contact your Google Cloud account team to get access to CLL API

🛠 Setup & Installation

Prerequisites

  • Python 3.11+
  • Google Cloud Project with billing enabled.
  • APIs Enabled: dataplex, bigquery, datacatalog, datalineage, aiplatform.

Installation

  1. Clone & Navigate:
    bash
    git clone https://github.com/GoogleCloudPlatform/knowledge-catalog.git
    cd knowledge-catalog/samples/governance-agent
  2. Environment Setup:
    bash
    # Install dependencies and sync virtual environment using uv
    uv sync --group dev
  3. Authentication:
    • CLI/Dev: Run gcloud auth application-default login.
    • Gradio App: Follow OAUTH_SETUP_GUIDE.md to enable "Login with Google".
  4. Project: Set the Google Cloud project to work in, in your shell or in .env:
    bash
    export GOOGLE_CLOUD_PROJECT=<PROJECT_ID>
    The Steward CLI and UI fall back to your gcloud default project (gcloud config set project ) when it isn't set, and the CLI also accepts --project. The data generation scripts require GOOGLE_CLOUD_PROJECT.

🚀 Usage Guide

1. Agentic Data Steward (UI)

The Gradio app provides a visual way to scan and apply metadata changes.

bash
uv run python -m metadata_propagation.ui.gradio_app
  • Dashboard: Run "Scan Dataset" to see health metrics.
  • Description Propagation: Enter a table name to preview and apply lineage-based descriptions.
  • Glossary Propagation: Enter a table name to preview and apply lineage-based glossary terms.
  • Policy Tag Propagation: Propagate sensitive data tags across the lineage chain with automated transformation assessment.
  • Trust Center (DQ): Analyze derived trust scores for a view or table, showing upstream quality sources and remediation bonuses.
  • Settings: Toggle OAuth/ADC modes for specific user actions.

2. 🐳 Deployment (Docker & Cloud Run)

For all deployment steps (local Docker and production Cloud Run), please refer to the comprehensive Deployment Guide.

Key sections covered:

3. Steward CLI (Headless)

The CLI is designed for automation and quick scans.

text
/* Detailed source-code truncated for AI context efficiency. */

4. Data Integration Scripts

  • Generate Data: uv run python -m metadata_propagation.data_generation.generate_data (Creates tables + lineage).
  • Run Profiling & DQ: uv run python -m metadata_propagation.data_generation.run_dq_scans (Creates and runs Data Profile and Quality scans, natively published to BigQuery).
  • Unified Insights: Run the Steward CLI's knowledge-propagate command (above) without --apply to trigger the Knowledge Catalog documentation scan, wait for it, and extract the results for review.

📄 Unstructured Document Processing

The tool can leverage unstructured documents (PDFs, TXT, MD) to influence data governance metadata when lineage is missing or to supplement it.

Supported Components

  • Column Descriptions: Extracts business meanings and definitions for columns. Enforces strict grounding to avoid hallucinations.
  • Policy Tags (PII): Extracts explicit sensitivity labels (like PII: Y) from documents and maps them to allowed policy tags in your project. No inference or guessing.
  • Business Glossary Terms: Maps columns to a controlled list of business terms based on explicit mentions in documents. Enforces strict grounding.

Document Context Modes

When using the --document flag with the apply, policy-propagate, or glossary-recommend commands, you can specify a --context-mode to choose how the document is processed:

  • direct (Context Injection): Reads the full text of the document(s) and appends it directly to the Gemini prompt for each column. Best for small documents.
    bash
    uv run python -m metadata_propagation.steward_cli apply --dataset retail_syn_data --table transactions --document docs/small_doc.txt --context-mode direct
  • rag (In-Memory RAG): Chunks the document, generates embeddings, and retrieves the most relevant snippets for each column. Best for large documents to save tokens and improve focus.
    bash
    uv run python -m metadata_propagation.steward_cli apply --dataset retail_syn_data --table transactions --document docs/large_doc.pdf --context-mode rag

    💡 Note: Vector embeddings are generated remotely using Vertex AI's text-embedding-004 model and stored locally in-memory for fast similarity search.

  • datastore (Managed DataStore): Queries a pre-existing Vertex AI Search DataStore. Best for enterprise setups with large document repositories.
    bash
    uv run python -m metadata_propagation.steward_cli apply --dataset retail_syn_data --table transactions --context-mode datastore --datastore-id my-datastore-id

⚙️ Glossary Cache Configuration

In enterprise governance workflows, stewards often scan read-only production datasets (where they lack bigquery.dataEditor permissions). To optimize glossary mapping recommendations, the steward needs a cache. The system supports redirecting the business glossary embeddings cache to a dedicated writable dataset:

1. Web UI (Gradio)

Open the Global Environment Settings accordion at the top of the dashboard:

  • Glossary Cache Dataset ID: The dataset where you want the glossary_embeddings_cache table to be created (e.g., a separate sandbox or metadata dataset).
  • Glossary Cache Table ID: Custom table name for storing cached embeddings (defaults to glossary_embeddings_cache).

2. CLI Flags

You can pass the cache location explicitly as command line arguments to any command:

bash
uv run python -m metadata_propagation.steward_cli glossary-recommend \
  --dataset prod_readonly_dataset \
  --table transactions \
  --cache-dataset metadata_sandbox_dataset \
  --cache-table my_glossary_cache

3. Environment Variables

You can also set these values in a .env file or in your shell:

  • GLOSSARY_CACHE_DATASET_ID: Redirects the cache table to a separate BigQuery dataset.
  • GLOSSARY_CACHE_TABLE_ID: Custom name for the BigQuery cache table.

💡 Note: If BigQuery cache initialization fails due to write permissions, the plugin automatically falls back to a local JSON file cache (scratch/glossary_embeddings_cache.json) transparently, ensuring zero disruption to the user experience.


🧩 Project Modules

Module Location Description
Glossary Plugin agent/plugins/glossary_plugin.py Handles Business Glossary mapping using Vertex AI.
Lineage Plugin agent/plugins/lineage_plugin.py Orchestrates description propagation via Lineage API.
Policy Tag Plugin agent/plugins/policy_tag_plugin.py Recommends and applies Policy Tags based on lineage and SQL analysis.
Similarity Engine agent/plugins/similarity_engine.py AI logic for scoring lexical and semantic matches.
DQ Plugin agent/plugins/dq_plugin.py Interfaces with Knowledge Catalog DQ/Profiling result jobs with BQ fallbacks.
DQ Propagation dataplex_integration/dq_propagation.py Recursive DQ scoring and remediation detection logic.
Traverser dataplex_integration/lineage_propagation.py Low-level Graph API logic for traversing dependencies.
Enricher dataplex_integration/lineage_propagation.py Context-aware SQL transformation analyzer.

💡 Workflow Example

  1. Initialize: Generate synthetic data and lineage relationships (see Data Integration Scripts).
  2. Enrich & Propagate: Run the unified knowledge-propagate command to trigger AI scans and sync metadata.
  3. Tag: Use the Glossary Plugin to map technical columns to the Business Glossary for Knowledge Catalog UI visibility.
  4. Secure: Use the Policy Tag Propagation plugin to sync sensitive data tags and verify access summary (Readers/Masking Rules).
  5. Verify: Check the BigQuery Console (Schema -> Policy Tags) and Knowledge Catalog Schema (Business Terms).

Overview (`samples/policy-as-code-agent/README.md`)

Policy-as-Code Agent

A generative AI-powered agent designed to automate data governance on Google Cloud. It allows users to define, validate, and enforce data policies using natural language queries, translating them into executable code that runs against Knowledge Catalog (formerly Dataplex) and BigQuery metadata.

The Agent Starter Pack (ASP) is the recommended way to create a new project from this sample: you get deployment options and CI/CD scaffolding. The copy in knowledge-catalog is the upstream source for browsing and contributions.

Prerequisites

  • Python 3.11+
  • uv — fast Python package manager
  • Google Cloud SDK (gcloud) installed and authenticated
  • Git

Getting Started

To explore this sample or contribute, you can run the agent directly from this directory in the repository.

  1. Clone the repository and navigate to this sample:

    bash
    git clone https://github.com/GoogleCloudPlatform/knowledge-catalog.git
    cd knowledge-catalog/samples/policy-as-code-agent
  2. Install dependencies:

    bash
    uv sync --group dev
  3. Configure environment:
    Copy the example configuration file:

    bash
    cp .env.example .env

    Open .env and fill in your details:

    • GOOGLE_CLOUD_PROJECT: Your Google Cloud Project ID.
    • GOOGLE_CLOUD_LOCATION: (e.g., us-central1).
    • ENABLE_MEMORY_BANK: Set to True to enable long-term memory (requires Firestore). Set to False to run without it. See Memory Integration for details.
    • FIRESTORE_DATABASE: (Optional) Leave as (default) unless using a named database.
  4. Authenticate with Google Cloud:

    bash
    gcloud auth application-default login

Running the Agent

From the sample directory (samples/policy-as-code-agent):

bash
uv run adk run policy_as_code_agent

Or use the web UI:

bash
uv run adk web

Optional: To enable short-term contextual memory (Agent Engine) for better conversation history:

bash
uv run adk web --memory_service_uri="agentengine://AGENT_ENGINE_ID"

Open the printed URL in your browser to chat with the agent.

Running Tests

bash
uv run pytest

Run only fast unit tests:

bash
uv run pytest tests/unit -v

Integration tests are marked and may require GCP credentials and a configured .env.

Creating a New Project (Agent Starter Pack)

If you want to use this sample as a base to create and deploy a new, independent project for production, we recommend using the Agent Starter Pack. This provides deployment options and CI/CD scaffolding.

Start from a new directory (replace my-policy-as-code with your project name) and run:

bash
uvx agent-starter-pack create my-policy-as-code -a adk@policy-as-code

This will scaffold a new project and prompt you for deployment options.


Key Features

  • Natural Language Policies: "All tables in the finance dataset must have a description."
  • Hybrid Execution: Generates Python code on-the-fly for flexibility, but executes it in a sandboxed environment for safety.
  • Memory & Learning: Uses Firestore and Vector Search to remember valid policies. If you ask a similar question later, it reuses the proven code instead of regenerating it.
  • Dual-Mode Operation:
    • Live Mode: Queries Knowledge Catalog in real-time.
    • Offline Mode: Analyzes metadata exports stored in Google Cloud Storage (GCS).
  • Compliance Scorecards: Run a full health check on your data assets with a single command.
  • Remediation: Can suggest specific fixes for identified violations.

Architecture

The agent is built using the Google Cloud Agent Development Kit (ADK) and leverages several Google Cloud services:

  • Gemini 2.5 Pro: For complex code generation (converting natural language to Python).
  • Gemini 2.5 Flash: For conversational logic, tool selection, and remediation suggestions.
  • Vertex AI Vector Search: For semantic retrieval of past policies.
  • Firestore: Stores policy definitions, versions, and execution history.
  • Knowledge Catalog API (dataplex.googleapis.com): For fetching live metadata.

Project Structure

  • policy_as_code_agent/
    • __init__.py: Application Default Credentials and Vertex environment defaults for ASP/local runs.
    • agent.py: Entry point and core agent definition.
    • memory.py: Handles Firestore interactions (saving/retrieving policies).
    • utils/: Utility modules for LLM logic, Knowledge Catalog, GCS, and common tools.
    • simulation.py: Sandboxed execution engine for running policy code.
    • prompts/: Markdown templates for LLM instructions.
  • tests/: Unit and integration tests.

Documentation

For deep dives into the implementation, check the docs/ folder:


Overview (`toolbox/enrichment/README.md`)

Enrichment Agent

The enrichment agent for Knowledge Catalog provides a customizable agentic
workflow for extracting information from various sources to build metadata
about data assets, which can then be used as context.

Usage

Prerequisites

The enrichment agent depends on the Metadata as Code capability.
Follow the instructions on that page on using the kcmd tool.

CLI

The package provides the kcenrich CLI tool. This is distributed as a standalone binary.

bash
# Initialize a new catalog snapshot for a bigquery dataset
kcmd init --bigquery-dataset <projectId>.<datasetId>

# Pull the latest catalog snapshot from the Knowledge Catalog service
kcmd pull

# Run the enrichment tool
kcagent enrich --catalog-path . --tools-path tools --prompt-path prompt.md

Developer Workflow

Setup

bash
git clone https://github.com/googlecloudplatform/knowledge-catalog
cd toolbox/enrichment
npm install

Build

bash
npm run build

Test

bash
npm run test

Demo

The repository contains a self-contained demo. Running the demo involves creating a BigQuery dataset and a Dataplex EntryGroup within your cloud project.

Initialize Environment

bash
export DEMO_CLOUD_PROJECT="<your-gcp-project-id>"

Initialize gcloud

bash
gcloud auth application-default login
gcloud config set project $DEMO_CLOUD_PROJECT
gcloud config set compute/region us

Setup demo resources

bash
bq query --use_legacy_sql=false <<EOF
CREATE SCHEMA IF NOT EXISTS \`${DEMO_CLOUD_PROJECT}.demo_ecommerce\`
OPTIONS (
  location = 'US',
  labels = [('usage', 'demo')]
);

CREATE TABLE IF NOT EXISTS \`${DEMO_CLOUD_PROJECT}.demo_ecommerce.events\`
PARTITION BY event_date_dt
AS
SELECT
  *,
  PARSE_DATE('%Y%m%d', event_date) AS event_date_dt
FROM
  \`bigquery-public-data.ga4_obfuscated_sample_ecommerce.events_*\`;
EOF

Create and populate a catalog snapshot

bash
mkdir -p demo
cd demo
cat <<EOF > catalog.yaml
scope: bq-dataset.${DEMO_CLOUD_PROJECT}.demo_ecommerce

snapshot:
  entries:
    - dataplex-types.global.bigquery-dataset
    - dataplex-types.global.bigquery-table
  aspects:
    - dataplex-types.global.overview
EOF

../../mdcode/dist/kcmd pull

Create and populate the tools

bash
cat <<EOF > prompt.md
Enrich the documentation of the assets using the internal organizational information.
Use the following sources:

* Fileset source
EOF
bash
mkdir tools
cat <<EOF > tools/mcp.json
{
  "mcpServers": {
    "md-fileset": {
      "command": "../dist/md-fileset",
      "args": [ "--dir", "fileset" ]
    }
  }
}
EOF
bash
mkdir -p tools/skills/fileset-source
cat <<EOF > tools/skills/fileset-source/SKILL.md
---
name: fileset-source
description: >
  Use the fileset source to find relevant markdown documents and extract information
  about assets.
---

The `md-fileset` mcp server provides the following tools to extract relevant
information from a directory hierarchy of markdown files:

* **list_fileset_contents** - browse and navigate the directory tree to list the
  contents of the specified path. The items may be files or sub-directories.

* **read_fileset_file** - read the contents of a file in the knowledge base. The entire
  contents are provided. Extract and summarise the relevant information based on
  the documentation being generated..

* **search_fileset_content** - searches the knowledge base and returns the matching files,
  along with matching line numbers and line snippets. This can be used to quickly
  find matches without having to list and read all files.

To work with a fileset effectively create search queries (use simple keyword queries
with individual tokens) to find relevant files, and then read the files to find relevant information. If one query does not work, try a few other keywords.
EOF

Add Docs
Copy over the individual markdown files from here into the fileset/ directory

Enrich the metadata

bash
../dist/kcagent enrich --catalog-path . --tools-path tools --prompt-path prompt.md

Clean up

bash
bq rm -r ${DEMO_CLOUD_PROJECT}:demo-dataset

Overview (`toolbox/mdcode/README.md`)

Metadata as Code

Metadata as Code is a Knowledge Catalog (Dataplex) provides data stewards and data producers and AI agents with a source code artifact-based UX for metadata management and context engineering.

Users and agents can author, manage, and enrich metadata artifacts using developer-friendly workflows with version control and CI/CD. It provides a standard metadata format can be used by a variety of tools and agents.

More details are in the docs/concept.md.

Key Features

  • Intuitive human and agent-friendly representation of metadata as source code in YAML and markdown files. Artifacts are organized in a hierarchical manner mirroring the resource hierarchy of data and metadata assets.
  • Bi-directional sync between local workspace and catalog service.
  • Support for both 1st party and 3rd party metadata constructs.
  • Distributed as a TypeScript and Python libraries, a CLI tool (kcmd), and an MCP server for use in a variety of applications, agents and workflows.

Metadata Artifacts

Directory Layout

Metadata is organized within a directory representing a resource such as a BigQuery Dataset, Dataplex EntryGroup etc.

text
path/to/root/
├── catalog.yaml                       # Manifest and config directives
└── catalog/                           # Contains the metadata snapshot
    └── <dir1>/
        └── <entry-id1>.yaml           # Entry
        └── <dir2>/
            ├── <entry-id2>.yaml       # Entry with sidecar markdown
            └── <entry-id2>.aspect.md  # files

Catalog Snapshot Files

Manifest File

catalog/catalog.yaml

yaml
scope: bq-dataset.prod-data.ecommerce

aliases:
  ca-guidelines:
    aspect: data-agents-project.global.ca-guidelines
  ecommerce:
    aspect: data-agents-project.global.ecommerce

snapshot:
  entries:
    - dataplex-types.global.bigquery-dataset
    - dataplex-types.global.bigquery-table
    - dataplex-types.global.bigquery-view
  aspects:
    - dataplex-types.global.overview
    - dataplex-types.global.descriptions

publishing:
  aspects:
    - dataplex-types.global.overview
    - dataplex-types.global.descriptions

Entry YAML File

catalog/prod-data.ecommerce/products.yaml

yaml
id: products
type: bigquery-table

resource:
  name: projects/prod-data/datasets/ecommerce/tables/products
  displayName: Products Table
  description: All products in the catalog
  labels:
    env: prod
  createTime: 2026-04-23T00:44:03Z
  updateTime: 2026-04-23T00:44:03Z

schema:
  ...

contacts:
  ...

Entry Sidecar Markdown File

catalog/prod-data.ecommerce/products.overview.md

markdown
---
userManaged: true
links:
  ...
---
[overview.content]

Usage

Library

You can use the kcmd library to programmatically interact with the catalog metadata.

bash
npm install kcmd
typescript
import * as kcmd from 'kcmd';

// Creating a catalog manifest from scratch
const manifest = new kcmd.CatalogManifest(...);
manifest.save('/path/to/root');

// Loading a catalog snapshot from the filesystem
const snapshot = kcmd.CatalogSnapshot.fromPath('/path/to/root');

// Pulling the latest metadata from the Catalog service.
const pullResult = await snapshot.pull();
if (pullResult.success) {
  console.log('Metadata pulled successfully');
}
else {
  console.error('Metadata pull failed:', pullResult.error);
}

// Pushing the modified metadata to the Catalog service.
const pushResult = await snapshot.push();
if (pushResult.success) {
  console.log('Metadata pushed successfully');
}
else {
  console.error('Metadata push failed:', pushResult.error);
}

CLI

The package provides the kcmd CLI tool. This is distributed as a standalone binary.

text
/* Detailed source-code truncated for AI context efficiency. */

For the semantic-model scope, kcmd push deploys the model as a property graph
(CREATE OR REPLACE PROPERTY GRAPH) to the backend named by the model's GOOGLE
deployment target — BigQuery Graph or Spanner Graph; the same authored
model deploys to either, and swapping the target switches backends. Pass
--validate-only to print the generated DDL without executing it. For a BigQuery
target this path additionally reads the target dataset's metadata
(bigquery.datasets.get) to pin the query job's processing location; the call
degrades gracefully when the permission is absent, so a narrower service account
still works but falls back to BigQuery's own location inference. Each dataset
source is the table's GCP resource name, such as
bigquery.googleapis.com/projects//datasets/<d>/tables/<t>.
(A Spanner target names its tables inside one database, so a source is reduced
to its final segment, and Spanner Graph has no MEASURE, so metrics are dropped
with a warning.)

kcmd push also deploys the model to Knowledge Catalog (entries for the model,
its entities and metrics, and schema-join links for its relationships). A push
has two axes: the binding-profile axis picks how many binding profiles the graph
deploys for (--no-profile for none, the default binding, --profile <name> for
one, or --all-profiles for every one), and each profile's deployment_target
selects its backend; --no-kc skips the Knowledge Catalog leg. Use --print to
dump each destination's generated artifact, and --force-remove to delete models
left in the entry group that the push no longer includes. See
docs/semantic-model/ for the full guide,
including how re-push reconciles removed entities, metrics, relationships, and
models.

NOTE: The CLI uses gcloud to obtain authentication tokens, so ensure you are authenticated via gcloud auth application-default login.

Environment variables

Two optional variables override where kcmd talks to the catalog. Both default
to production, so you normally leave them unset.

Variable Effect
DATAPLEX_ENDPOINT Base URL for the Knowledge Catalog (Dataplex) API. Defaults to https://dataplex.googleapis.com. Set it to reach a non-prod host.
KC_TYPE_PROJECT Project the built-in semantic-* system types are read from on a semantic-model push. Defaults to dataplex-types.

MCP Server

To use the Metadata as Code tools as MCP tools in an agentic system such as the Gemini CLI, add the following to your MCP settings file:

json
{
  "mcpServers": {
    "kc-mac": {
      "command": "kcmd",
      "args": ["mcp", "--path", "/path/to/root"]
    }
  }
}

The server offers the following tools:

Tool Description
pull Pull the latest metadata from the Catalog service
push Push the modified metadata to the Catalog service
list-entries List entries in the catalog snapshot
lookup-entry Lookup an entry and its metadata from the snapshot
modify-entry Modify an entry and its metadata in the snapshot

NOTE: The server uses gcloud to obtain authentication tokens, so ensure you are authenticated via gcloud auth application-default login.

Developer Workflow

Setup

bash
git clone https://github.com/googlecloudplatform/knowledge-catalog
cd toolbox/mdcode
npm install

Build

bash
npm run build

Test

bash
npm run test

Overview (`okf/samples/crypto_bitcoin/README.md`)

Bitcoin public dataset sample

Runs the reference agent against the public
bigquery-public-data.crypto_bitcoin dataset (blocks, transactions,
inputs, outputs — produced by the open-source bitcoin-etl pipeline)
and seeds the web pass with the canonical schema source and the
foundational Google Cloud blockchain-on-BigQuery announcement.

This sample contrasts with GA4 (single denormalized events table) and
Stack Overflow (many independent entities) by exercising a small set
of tightly related fact tables
where each row in transactions
references rows in blocks, inputs, and outputs. Good for seeing
how the agent surfaces cross-table foreign-key relationships in prose.

Prerequisites

  • Install the agent (from the repo root):
    text
    python3.13 -m venv .venv
    .venv/bin/pip install --index-url https://pypi.org/simple/ -e .[dev]
  • BigQuery access:
    text
    gcloud auth application-default login
    gcloud config set project <your-billing-project>
    Public datasets are readable, but the caller's project is billed for
    query bytes. The crypto_bitcoin tables are very large
    (transactions is ~hundreds of GB) — keep --web-max-pages modest
    while iterating and prefer --concept for smoke runs.
  • Gemini credentials — either GEMINI_API_KEY (AI Studio) or Vertex
    AI (GOOGLE_GENAI_USE_VERTEXAI=true, GOOGLE_CLOUD_PROJECT=,
    GOOGLE_CLOUD_LOCATION=<region>).

Run

text
.venv/bin/python -m reference_agent enrich \
    --source bq \
    --dataset bigquery-public-data.crypto_bitcoin \
    --web-seed-file samples/crypto_bitcoin/seeds.txt \
    --out ./bundles/crypto_bitcoin

To iterate on a single concept, add --concept tables/transactions.
To skip the web pass, add --no-web. To raise or lower the web budget,
use --web-max-pages N (default 100).

What you get

A bundle under ./bundles/crypto_bitcoin/ with one OKF doc per BQ
concept (dataset + each table), augmented and cross-linked with
reference docs minted from the seeded blockchain-etl and Google Cloud
pages, plus an auto-generated index.md at each directory level.


Overview (`okf/samples/ga4_merch_store/README.md`)

GA4 Google Merchandise Store sample

Runs the reference agent against the public
bigquery-public-data.ga4_obfuscated_sample_ecommerce dataset (a GA4 export
from the Google Merchandise Store) and seeds the web pass with canonical GA4
BigQuery Export documentation URLs.

Prerequisites

  • Install the agent (from the repo root):
    text
    python3.13 -m venv .venv
    .venv/bin/pip install --index-url https://pypi.org/simple/ -e .[dev]
  • BigQuery access:
    text
    gcloud auth application-default login
    gcloud config set project <your-billing-project>
    Public datasets are readable, but the caller's project is billed for
    query bytes.
  • Gemini credentials — either GEMINI_API_KEY (AI Studio) or Vertex
    AI (GOOGLE_GENAI_USE_VERTEXAI=true, GOOGLE_CLOUD_PROJECT=,
    GOOGLE_CLOUD_LOCATION=<region>).

Run

text
.venv/bin/python -m reference_agent enrich \
    --source bq \
    --dataset bigquery-public-data.ga4_obfuscated_sample_ecommerce \
    --web-seed-file samples/ga4_merch_store/seeds.txt \
    --out ./bundles/ga4

To iterate on a single concept, add --concept tables/events_. To skip
the web pass, add --no-web. To raise or lower the web budget, use
--web-max-pages N (default 100).

What you get

A bundle under ./bundles/ga4/ with one OKF doc per BQ
concept (dataset + tables), optionally augmented and cross-linked with
reference docs minted from the seeded GA4 documentation pages, plus an
auto-generated index.md at each directory level.


Overview (`okf/samples/stackoverflow/README.md`)

Stack Overflow public dataset sample

Runs the reference agent against the public
bigquery-public-data.stackoverflow dataset (a mirror of the Stack
Exchange Data Dump for Stack Overflow — posts_questions,
posts_answers, users, votes, comments, badges, tags,
post_history, post_links, ...) and seeds the web pass with the
canonical schema references maintained by the Stack Exchange community.

This sample contrasts with the GA4 sample by exercising multi-concept
enrichment
: a single schema-docs page typically describes several tables
(posts_questions + posts_answers + users), so the web agent often
updates more than one concept per fetched page.

Prerequisites

  • Install the agent (from the repo root):
    text
    python3.13 -m venv .venv
    .venv/bin/pip install --index-url https://pypi.org/simple/ -e .[dev]
  • BigQuery access:
    text
    gcloud auth application-default login
    gcloud config set project <your-billing-project>
    Public datasets are readable, but the caller's project is billed for
    query bytes. The stackoverflow tables are large — keep
    --web-max-pages modest while iterating.
  • Gemini credentials — either GEMINI_API_KEY (AI Studio) or Vertex
    AI (GOOGLE_GENAI_USE_VERTEXAI=true, GOOGLE_CLOUD_PROJECT=,
    GOOGLE_CLOUD_LOCATION=<region>).

Run

text
.venv/bin/python -m reference_agent enrich \
    --source bq \
    --dataset bigquery-public-data.stackoverflow \
    --web-seed-file samples/stackoverflow/seeds.txt \
    --out ./bundles/stackoverflow

To iterate on a single concept, add --concept tables/posts_questions.
To skip the web pass, add --no-web. To raise or lower the web budget,
use --web-max-pages N (default 100).

What you get

A bundle under ./bundles/stackoverflow/ with one OKF doc per BQ concept
(dataset + each table), augmented and cross-linked with reference docs
minted from the seeded Stack Exchange schema pages, plus an
auto-generated index.md at each directory level.


Overview (`toolbox/mdcode/demo/README.md`)

Demo

Configuration

You will need a cloud project with permissions to use BigQuery and Knowledge Catalog APIs.

bash
export DEMO_CLOUD_PROJECT=<GCP_PROJECT_ID>

Ensure that gcloud is installed and configured.

bash
gcloud auth application-default login
gcloud config set compute/region us-central1
gcloud config set project $DEMO_CLOUD_PROJECT

BigQuery Dataset

This demo demonstrates working with metadata for BigQuery resources (dataset and table).

Setup

  • Creates a BigQuery dataset (demo_ecommerce) and a table (events) based on BigQuery
    sample data in your cloud project.
  • Creates a catalog.yaml manifest to specify the local catalog snapshot.
bash
bun setup.ts
cat catalog.yaml

Create Metadata Snapshot

  • Pull metadata from Knowledge Catalog
bash
../../dist/kcmd pull
ls -R catalog
cat catalog/$DEMO_CLOUD_PROJECT.demo_ecommerce.yaml

Modify Metadata Snapshot

  • Either manually edit the file, or use the following command which adds a dummy
    overview aspect.
bash
bun update.ts catalog/$DEMO_CLOUD_PROJECT.demo_ecommerce.yaml
cat catalog/$DEMO_CLOUD_PROJECT.demo_ecommerce.yaml

Publish Metadata Snapshot

  • Push metadata to Knowledge Catalog
bash
../../dist/kcmd push

Cleanup

  • Deletes the BigQuery resources created for the demo
bash
bun cleanup.ts

Knowledge Base

This demo demonstrates working with a Knowledge Base managed in Knowledge Catalog.

Setup

  • Creates a Dataplex EntryGroup (demo_kb) and a set of document entries within it.
  • Creates a catalog.yaml manifest to specify the local catalog snapshot.
bash
bun setup.ts
cat catalog.yaml

Create Metadata Snapshot

  • Pull metadata from Knowledge Catalog
bash
../../dist/kcmd pull
ls -R catalog
cat catalog/index.md

Modify Metadata Snapshot

  • Either manually edit the file, or use the following command which creates a placeholder
    demo update to the content of the index entry using the kcmd (metadata as code)
    library.
bash
bun update.ts
cat catalog/index.md

Publish Metadata Snapshot

  • Push metadata to Knowledge Catalog
bash
../../dist/kcmd push

Cleanup

  • Deletes the Dataplex EntryGroup
bash
bun cleanup.ts

OKF Wiki

This demo demonstrates publishing an Open Knowledge Format
wiki bundle (a directory of markdown files with YAML frontmatter) into a
Knowledge Catalog EntryGroup via the Documents Layout. By default it operates
on okf/bundles/acme_retail, the canonical in-repo bundle: 17 markdown files
covering metrics, computations, policies, skills, and tables, and the one that
exercises the full v0.2 signal layer. The Documents Layout maps each .md
file to an entry whose name is derived from the file path, with the
markdown body stored on the dataplex-types.global.overview aspect.

A second bundle in okf/catalog/ is a GA4 sample with indexes, references, a
dataset, and a table, 14 markdown files in total. It is kept for anyone who was
using the old default, and is reachable with --bundle catalog.

The generic Documents Layout only carries title/description/tags +
body. OKF's signal layer has no generic home, so push.ts/pull.ts
translate it into a custom typed okf Dataplex aspect (created by
setup.ts) and back, keeping the on-disk files clean OKF and
round-tripping the signal losslessly.

Entries are published under a custom okf-bundle entry type rather than
the built-in dataplex-types.global.generic, so a catalog-wide search can
pick out OKF documents. A document's OKF type: is freeform prose, not a
Dataplex type ref, so it is recorded separately as okf_type on the okf
aspect. okf-bundle declares no required aspects, because SPEC 8 index
files carry no signal layer and requiring the okf aspect would reject
them. An entry's type is fixed at creation, so a bundle already published
under the generic type has to have its entry group deleted and recreated.

The aspect models the full OKF v0.2 signal as typed, searchable fields:
type, resource, generated, verified, status, stale_after,
sources (with author, usage_count, last_modified), usage_window,
and the Attested Computation contract (runtime, parameters,
computation, executor, attester). OKF also permits producer-defined
frontmatter keys at any depth, so no enumerated template can ever be
complete. Anything the template does not model, whether a top-level key like
not: or a subfield inside a modeled record like sources[].license, is
diverted onto a single extra field as a JSON list of [path, value] pairs
and restored on pull. That keeps the round-trip lossless for any conformant
bundle. A diverted subfield returns at the end of its record rather than its
original position, which is the same presentation normalization pull already
applies elsewhere.

Setup

  • Creates an empty Dataplex EntryGroup (okf_demo). --entry-group <name>
    names a different one; the name must match Dataplex's own rule
    (/^[a-z][a-z0-9_-]{0,61}[a-z0-9]$/) or setup stops before calling gcloud.
  • Creates the custom okf aspect type from okf-aspect.json, or updates it
    if a previous run of this demo left an older template behind.
  • Creates the custom okf-bundle entry type.
  • Creates a .state/catalog.yaml manifest pointing at the EntryGroup and
    listing the okf-bundle entry type and the okf aspect. It sits in a hidden
    .state/ directory so that this demo directory never takes on the shape of a
    canonical snapshot root, which is a catalog.yaml beside a catalog/ tree.

setup.ts is the only script that takes --entry-group. The rest read the
EntryGroup back out of the manifest it writes, so they cannot be aimed at an
EntryGroup this demo never created.

bash
bun setup.ts
cat .state/catalog.yaml

Publish Metadata Snapshot

  • Push the bundled markdown to Knowledge Catalog. Without --bundle this
    pushes okf/bundles/acme_retail. Entry names mirror the file path
    (e.g. metrics/revenue.md → entry metrics/revenue). Every file
    lands as an okf-bundle entry, index files included, with its OKF
    type: on the okf aspect.
bash
bun push.ts

Pull Metadata Snapshot

  • Pull the snapshot back down as clean OKF. The signal layer is restored
    from the okf aspect. Pull writes to ./pulled/, a gitignored scratch
    directory, so it never overwrites the bundle push reads from; compare the
    two to confirm the round trip.
bash
bun pull.ts
diff -r ../../../../okf/bundles/acme_retail pulled

Modify Metadata Snapshot

  • Edit any markdown file in the bundle directly. Any frontmatter key and
    the markdown body can be changed, then push again.
bash
bun push.ts

Run Against Another Bundle

  • push.ts and pull.ts both take --bundle <dir>, so the demo can run
    against a bundle elsewhere in the repo without copying it in, and pull can
    be aimed somewhere other than ./pulled/. --bundle catalog selects the
    GA4 sample that used to be the default.
bash
bun push.ts --bundle catalog
bun pull.ts --bundle /tmp/acme_pulled
  • Pull re-emits frontmatter in a canonical key order and block style, so
    pulling a hand-authored bundle back shows presentation churn in a diff
    even though nothing was lost. The Acme Retail bundle uses flow mappings, so
    expect churn there; compare parsed frontmatter and body rather than bytes.
    The GA4 bundle in catalog/ is already in canonical form, so
    bun push.ts --bundle catalog followed by bun pull.ts --bundle catalog
    leaves that tree byte-identical.

  • Two things do not make the trip. Only .md files are pushed, so bundle
    attachments such as acme_retail/attesters/sql_equality.py stay local
    even though frontmatter points at them. And in-body markdown links
    between concepts stay markdown; the only native Knowledge Catalog edges
    are the parent links derived from the directory structure.

Cleanup

  • Deletes the Dataplex EntryGroup named in .state/catalog.yaml, printing
    which one and in which project first. It takes no flags, so the only
    EntryGroup it can delete is the one setup.ts created. The custom okf
    aspect type and okf-bundle entry type are left in place: they are scoped
    to the project and location rather than to this demo, so other OKF bundles
    in the same project are typed by them too. The commands to remove them
    manually are printed at the end.
bash
bun cleanup.ts

Skill

This demo generates an Agent Skill from a semantic
model with kcmd skills-generate, installs it into a coding agent, and gives
that agent a support request in English. The agent finds the right record and
performs a write, and the rules that gate the write are settled by the runtime
rather than by the agent. The skill is a folder of Markdown, so any harness that
reads the Agent Skill layout takes it as-is — the guide runs the same generated
bytes through Claude Code and Gemini CLI.

It needs a Spanner instance rather than BigQuery, and Vertex AI, because the
model states its rules in words and a judge settles them.

See semantic-model/skill/README.md.


Overview (`toolbox/mdcode/docs/semantic-model/README.md`)

Deploying a semantic model

A semantic model describes a business logically — its entities, the
relationships between them, and the metrics computed over them — independent of
where the data physically lives. You author it in a format based on
Apache Ossie, extended with a first-class deployment
target and binding profiles. kcmd push deploys one model to two kinds of
destination at once:

  • Knowledge Catalog, where the model is governed. It becomes catalog entries —
    one per entity, metric, and the model itself — joined by links for its
    relationships. There it is the single governed definition of the business:
    access-controlled, searchable, and part of the dynamic knowledge graph that gives
    AI agents the semantics and business context to work with your data. Governing
    needs no tables or data, so you can publish a purely logical model before it is
    bound, or govern the model together with its bindings — the catalog serves
    either. kcmd pull returns the model files push stored with these entries.
    It rebuilds the model from the entries only when no copy of the model file is
    stored.
  • A data store, where the model becomes queryable. Consumers ask for business
    concepts — Customer, revenue — and get consistent, model-defined answers
    rather than re-deriving joins and formulas per query. The store can be
    analytical, such as BigQuery for reporting and conversational-analytics
    agents, or operational, such as Spanner for the live state an agent reads
    before it acts. Which store a model deploys to is set by its
    deployment target; the query
    mechanics are in Reference.

Both come from the same source, so a single push keeps them in sync and you
never author them separately.

You bind the one logical model to a store with a binding profile. A model can
bind to more than one store — an analytical warehouse and an operational database,
say — from a single definition, so Customer and revenue mean the same thing
wherever they are served. See Binding profiles.

This page is the operation reference: author the logical model, govern it in
Knowledge Catalog, bind it to a store, then update and pull. For a single runnable
example that carries one model through the whole lifecycle, see the
codelab; for the Ossie document format itself, see
ossie.apache.org.

The rest of the guide

Page Open it to…
This page look up each deploy operation and its rules
Binding profiles bind one logical model to several stores
Modeling class hierarchies model subtypes with extends so a supertype query gathers them
Codelab: one semantic ontology, one data journey see the whole lifecycle: author, govern, hydrate, query
Modeling write operations declare an action an agent can call, publish it, run it, and derive an agent's tools from the model
Generating an Agent Skill write a model out as a skill folder an AI agent loads by itself
Reference look up a flag, what push creates, validation, or permissions
Model specification the normative format: every YAML construct, what's OSI and what's a kcmd extension
What push and pull preserve understand why something changed or wasn't recovered
Importing an OWL ontology start from an OWL ontology instead of hand-authoring

Prerequisites

kcmd uses your gcloud configuration for credentials, project, and location.
Before pushing, authenticate and make sure a project and region are set — kcmd
errors out immediately if any of the three is missing:

bash
gcloud auth application-default login
gcloud config set project <your-project>
gcloud config set compute/region <your-region>

You also need read/write access to whichever destinations you deploy to — see
Permissions.

1. Author the logical model

Create the local layout. The scope is the Knowledge Catalog entry group the
model will be published to, written as .<locationId>.<entryGroupId>:

bash
kcmd init --semantic-model my-project.us-central1.my_model

init provisions that entry group (idempotent — an existing group is fine) and
creates its local directory. Author the model at
catalog/EntryGroups/<entryGroupId>/<model>.yaml. The logical model names the
business and nothing physical — the entities, their fields, the relationships
between them, and the metrics computed over them:

yaml
version: "0.2.0.dev0/google"

semantic_model:
  - name: sales                      # keep equal to the <model>.yaml filename (pull round-trips to that name)
    entities:                        # each entity is one concept
      - name: orders
        primary_key: [order_id]
        fields:
          - { name: order_id,   datatype: Integer }
          - { name: net_amount, datatype: Decimal }
      - name: customer
        primary_key: [customer_id]
        fields:
          - { name: customer_id, datatype: Integer }
          - { name: name,        datatype: String }
    relationships:
      - name: placed_by
        from: orders
        to: customer
        from_columns: [customer_id]
        to_columns: [customer_id]
    metrics:
      - name: revenue
        expression: SUM(orders.net_amount)

Field and relationship names are the business vocabulary — order_id,
placed_by — never physical column names; the physical binding comes later. A
metric's expression may be a bare formula over the logical fields or the fuller
per-dialect form. entities may also be written datasets (the two are interchangeable under the /google version).

Entities can extend other entities (extends: [Parent]); push flattens the
supertype's fields down and expresses the hierarchy as graph labels, so a query
against the supertype gathers every subtype. See
Modeling class hierarchies to model one step by step, and
Class hierarchies for the rules
push enforces.

A model can also declare actions: model-level write operations, the
write-side counterpart to a metric. An action names a business operation, points
at the executor that carries it out (an MCP tool, a REST endpoint, a gRPC
method, or the write itself as DML), and types each parameter against the
ontology: a parameter carries one scalar value, and it either projects a field
— naming the concept and field it takes its datatype and its wording from —
or declares a scalar type of its own. The executor is a physical binding, so a
binding profile can supply a different one per store — and DML,
which names one store's own tables, belongs with that store's binding. An action
also names, in guards, the constraints that gate it, and in affects, the
concepts the call changes — an executor in another system is an opaque handle,
so its blast radius is declared or it is unknown. Knowledge Catalog is the only
system an action is published to, and the only place it is governed; an action
that carries its own DML is also the one kind the runtime executes directly. See
Modeling write operations.

A model can also state constraints: named invariants over the ontology that
hold for every instance, whatever writes to the model. A constraint is part of
what the model asserts, and "an account's balance never goes negative" is as
much a fact about an account as the fields beside it. Each one states its rule
in words, under judgment, with the field names it mentions written
model-qualified so they are checked like any other reference — which is what
lets a rule no arithmetic decides, such as whether a discount is justified by
the reason given, be stated at all. A constraint does nothing until an action
names it in guards; then it is settled before that call runs, by a language
model reading the attempted call. A constraint reaches Knowledge Catalog only:
it is validated, published there, and read back by pull.

This model names no table and no store, so it is complete enough to govern in
Knowledge Catalog as-is (step 2). Where each entity reads from — the store and the
columns — is a binding you add in step 3.

2. Govern it in Knowledge Catalog

The logical model is complete enough to govern right now, with no tables, no data,
and no store. A model that declares no deployment target has nowhere to deploy in a
store, so a bare kcmd push writes it to Knowledge Catalog alone:

bash
kcmd push --validate-only --print   # preview the entries and links, write nothing
kcmd push                           # write them

Push creates one entry per entity and metric, one for the model itself, one
per model file and profile file holding a copy of that file, and a
schema-join link for each relationship:

text
Wrote 5 new and 0 updated Knowledge Catalog entries; linked 1 relationship.

Some relationships get no link, and push warns about each one before it writes
to Knowledge Catalog. kcmd push --validate-only gives the same warnings.
Knowledge Catalog keeps one schema-join link between two entries, so when
several relationships join the same two entities, only the first one that push
can publish gets a link. Pull still returns a skipped
relationship, because it writes back the stored copy of the model file. Only a
model file that pull rebuilds from the catalog metadata leaves it out. Push
rejects a relationship from an entity to itself, and relationships in both
directions between two entities.

Those entries are the single governed definition of the business. They are
access-controlled, searchable, and joined into the dynamic knowledge graph that
gives AI agents the semantics and business context to reason over your data.
kcmd pull returns the model file and profile files stored with them. It
rebuilds the model document from them only when no copy of the model file is
stored (see Pull).
Governance works whether the model is purely logical or already bound: govern it
before it has any physical home, or govern the logical model together with its
bindings. Every store you bind it to later serves this one definition. (Running
--no-kc on a model with no deployment target is an error — it would have nowhere
to deploy.)

Writing these entries needs the semantic-model / semantic-entity /
semantic-metric entry types and write access to the entry group. See
Permissions.

3. Bind and deploy to a store

To make the model queryable, bind it to a store: name where each entity reads
from, which column each field maps to, and which store the model deploys to. You
can put these bindings directly on the model, or keep them in a separate binding
profile
so one logical model serves several stores. See
Binding profiles; for a runnable end-to-end that binds the same
model to BigQuery and Spanner and queries each, see the codelab.

Once the model is bound, kcmd push deploys to the store its target names and to
Knowledge Catalog. The most common flags:

bash
kcmd push                      # deploy to the store (default binding) + Knowledge Catalog
kcmd push --no-kc              # deploy only to the store, skip Knowledge Catalog
kcmd push --no-profile         # publish only to Knowledge Catalog, deploy to no store
kcmd push --profile analytical # deploy with one binding profile; its target picks the store
kcmd push --all-profiles       # deploy once per binding profile
kcmd push --validate-only      # run all checks, write nothing
kcmd push --print              # also print the generated DDL / entry plan

A push has two axes. The binding-profile axis sets how many profiles the model
deploys for: the default binding, --no-profile (none — catalog only), a single
--profile, or every one with --all-profiles. The Knowledge Catalog axis is
--no-kc (skip the catalog leg). You never name the store on the command line; the
model's deployment target — or the profile you merge — selects it.
The checks that gate a push are in
Validation, and the full flag list is in
Reference → push, with the order of push's checks and writes.

Where a model deploys (the deployment target)

A bound model declares exactly one deployment target: where in a store the
model deploys, written as its GCP resource name. Its host selects the store:

text
# BigQuery — an analytical store
bigquery.googleapis.com/projects/<project>/datasets/<dataset>/propertyGraphs/<name>

# Spanner — an operational store
spanner.googleapis.com/projects/<project>/instances/<instance>/databases/<database>/graphs/<name>

A BigQuery target names the project and dataset the model deploys into; a Spanner
target names the instance and database. Swapping one target for the other
deploys the same model to the other store — the logical model does not change. The
target is also recorded on the model's Knowledge Catalog entry. A model with more
than one deployment target is rejected at push time (see
Validation).

For a model that serves more than one store, the target belongs to a
binding profile rather than the model. deployment_target is a
resource name and a native key under the extended 0.2.0.dev0/google profile; under
vanilla Ossie the same target is written inside a GOOGLE custom extension instead
(see model spec §6).

Table sources

Each entity's source names its backing table, written as its GCP resource name:
bigquery.googleapis.com/…/tables/ for BigQuery,
spanner.googleapis.com/…/tables/ for Spanner. A
binding profile can move an entity between stores by swapping this
name.

A table in a federated REST catalog, such as an Apache Iceberg table exposed
through BigLake, is written as its BigLake resource name,
biglake.googleapis.com/projects//catalogs/<c>/namespaces/<n>/tables/<t>.
Validation resolves each name the same way the deploy does (see
Validation), and profiles.md
lists every accepted store.

What push creates

In BigQuery, a single CREATE OR REPLACE PROPERTY GRAPH per deployment
target: each entity becomes a node table, each relationship an edge table, each
metric a measure. In Spanner, the same CREATE OR REPLACE PROPERTY GRAPH with
bare table names and no measures — Spanner Graph has no MEASURE, so metrics are
dropped with a warning while the graph structure (nodes, edges, labels,
inheritance) still deploys. In Knowledge Catalog, one entry per model, entity,
and metric, plus schema-join links for relationships. The exact mapping — and
the class-hierarchy handling — is in
Reference → What gets created (and
in Spanner); what of your metadata
survives the trip (and what doesn't) is in
What push and pull preserve.

Updating and removing models

Your model document is the source of truth. To change what is deployed, edit
the document and run kcmd push again — you never edit the catalog or the
deployed store by hand. Re-running is safe: each push makes the destinations
match the document as it stands now.

When you edit an entity, metric, or relationship — push overwrites the
existing one in place; you don't get duplicates.

When you delete an entity or metric — remove it from the document and
push. Push deletes the leftover catalog entry for you (the summary shows
removed N orphaned entries).

When you rename or delete a relationship — push removes orphaned and stale
relationship links before writing the current ones, so a renamed or re-pointed
relationship can be created without colliding with the old link (the summary
shows unlinked N orphaned links). Push reconciles only the links of the model
it is deploying, and an entry group holds exactly one model, so it never
disturbs another model's links.

When you remove a whole model — deleting its document does not remove it
from the catalog. As long as you still push at least one other model in the same
entry group, push stops and names the model the catalog still has that you no
longer push, so you can't wipe one out by accident or by pushing from the wrong
directory; run push again with --force-remove to delete its entries and links.
That push deletes the old model entry first, and Dataplex deletes the model's
other entries and its links with it. A push that stops partway then leaves the
whole old model or none of it.
Deleting every document is the exception: with no models left to push, the run
reports No semantic model documents found; nothing to deploy and touches
nothing — so remove a model while other models in its group remain, or delete
its catalog entries directly.

Every push prints one line per destination summarizing what it did. For a push
that deploys to both the store and Knowledge Catalog:

text
Deployed 1 BigQuery Graph(s).
Wrote 5 new and 2 updated Knowledge Catalog entries; removed 1 orphaned entry; linked 2 relationships; unlinked 1 orphaned link.

A Spanner-targeting model prints Deployed 1 Spanner Graph(s). in place of the
BigQuery line.

Pull

kcmd pull is the inverse of push's Knowledge Catalog leg: it writes the model
file and profile files push stored back to
catalog/EntryGroups/<entryGroupId>/. When the catalog holds no copy of the
model file, it rebuilds <model>.yaml from the semantic-* entries instead.
Use it to recover a workspace from a catalog someone else deployed, or to see
what the catalog actually holds.

bash
kcmd pull

Pull reads only from Knowledge Catalog (never the data store). Its coordinates come
from the same scope you authored under (.<locationId>.<entryGroupId>).
The --dry-run and --force-remove flags are in
Reference → pull flags.

An entry group holds exactly one semantic model. Pull returns that one
model's files; a group with more than one semantic-model anchor is an
unexpected state, so pull stops and names the anchors rather than guess which to
keep.

Pull overwrites a model that already exists locally in place. If the catalog's
model has a different name than the one on disk, writing it would leave the
entry group holding two models — so by default pull stops and reports the
mismatch instead of deleting anything. Re-run with --force-remove to delete the
local model and replace it with the catalog's. Pull never touches the data store.

Push also stores a copy of the model file and of each profile file, and pull
writes those copies back to the paths they came from, byte for byte. A local
profile file the catalog has no copy of would be stored again by the next push,
so pull names it in a warning, and --force-remove deletes it. Each push marks
its copies with one id and the number of copies it stored. It also puts that id
in the kcmd-push-id label of the model entry, which it writes before the
copies. Pull refuses the catalog when the copies come from two pushes, when
their number differs from that count, or when the label names a push whose
copies the catalog does not hold. Each of these means the last push stopped
partway. Pull then writes and deletes nothing and asks you to push again.

Pull cannot catch a push that stopped before it changed the model entry. The
catalog then still holds what the previous push left, and pull returns that.
After a failed push, push again before you pull.

A model with no label and no stored copies, such as one an older kcmd pushed,
is rebuilt from the catalog metadata. Pull warns that the rebuilt file may differ
from what was pushed, and keeps every local profile file. Pull also rebuilds the
model file when no stored copy is named after the model. That rebuild is not an identity
— see What push and pull preserve for exactly what comes back,
what comes back normalized, and what doesn't come back at all.

Importing an OWL ontology

Already have an OWL ontology? kcmd owl import converts it into a semantic model
once — classes → entities, object properties → relationships, datatype
properties → fields. The converted model is unbound (no sources, no
deployment target), so bind each entity's source, fill the relationship join
columns, and add a deployment target before kcmd push will deploy it — then it
rides the normal push / pull above. See Importing an OWL ontology.


Overview (`samples/enrichment/src/tools/fileskb/README.md`)

FilesKB MCP Server

This is a simple MCP server for purposes of demonstrating how the enrichment
agent can be extended with MCP servers to access existing information sources.

The sample surfaces common file system capabilities - listing files, searching
files and reading files stored within a specified directory root.

Using this MCP Server

The server can be provided to the agent using standard MCP configuration:

json
{
  "mcpServers": {
    "fileskb": {
      "command": "/usr/local/google/home/nikhilko/p/kc/enrichment2/src/.venv/bin/python3",
      "args": [
        "/usr/local/google/home/nikhilko/p/kc/enrichment2/src/tools/fileskb/main.py",
        "--dir",
        "/usr/local/google/home/nikhilko/p/kc/enrichment2/demo/docs"
      ]
    }
  }
}

Overview (`toolbox/mdcode/demo/semantic-model/skill/README.md`)

Generate an Agent Skill from a semantic model

A semantic model says what a business is, what may be done to it, and under what
rules. This demo takes one such model and turns it into an
Agent Skill — a folder of Markdown that a coding agent
reads — with one command:

bash
kcmd skills-generate

Then it installs that folder into a harness and gives the harness a support
request in English. The harness finds the right order, tries to fix it, and is
either allowed to or is refused, by rules that live in the model rather than in
the harness.

There is no agent in this directory. Nothing here is written against a
particular framework, and nothing here has to be rewritten to move to another
one. The skill is a folder of Markdown, and the rules are stated in the model
underneath it whatever reads it.

One thing to know before you start, because the rest of this page depends on
it: kcmd compiles the model and generates the skill, and does not ship an
action runner or a rule judge.
Enforcing an action's guards before opening a
transaction is the job of the calling agent or a third-party tool framework
that wraps the action. Where that matters, this page says so, and the
transcripts recorded against the earlier CLI action runner are marked as such.

What this demo is arguing

An agent that can change a business is mostly not code. It is a description of
the business, a list of what may be done to it, and the conditions under which
each of those things is allowed. Written as an agent, all three end up inside a
prompt and a set of hand-written tools — which means they are true of that one
agent, they drift when the business changes, and the rules can be edited by
whoever owns the agent rather than whoever owns the business.

Written as a semantic model, all three are declarations. The skill is derived
from them. So:

  • The skill is generated, not written. Change the model, regenerate. There
    is no second copy of the business to keep in step.
  • The rules are stated once, in the model. The skill renders them so the
    reading agent (or a third-party tool framework wrapping the action) knows
    what gates the write. Because kcmd does not ship an action runner or a
    judge, nothing in this repository enforces those rules outside the agent: an
    agent that runs statements directly against the database and ignores the
    skill's instructions gets past them unless a third-party tool framework
    mediates the write.
  • Any harness will do. The output is the published Agent Skill layout, so
    Claude Code, Gemini CLI, Cursor and the other clients that read that layout
    all take it as-is.

What you need

  • kcmd, built from this repository: npm install && npm run build in
    toolbox/mdcode, which leaves the binary at dist/kcmd. Put it on your
    PATH.
  • A Google Cloud project with Spanner and Vertex AI enabled, and
    gcloud auth application-default login done.
  • A Spanner instance to create a database on.
  • A coding agent that reads Agent Skills. The transcripts below are Claude Code
    and Gemini CLI, both headless; the install table covers the others.

Commands below use my-project and my-instance. Substitute your own, in these
commands and in catalog/EntryGroups/commerce_demo/commerce.profiles/spanner.yaml.

bash
export PROJECT=my-project
export INSTANCE=my-instance
export DATABASE=semantic_skill_demo
export GOOGLE_CLOUD_PROJECT="$PROJECT"
export GOOGLE_CLOUD_LOCATION=us-central1

1. The model

catalog/EntryGroups/commerce_demo/commerce.yaml is the whole business: a small
ecommerce operation with customers, orders, and the lines that make up an order.
Read the file — it is commented — but these are the parts the rest of the demo
turns on.

Three entities, in the names the business uses. Order, not Orders;
LineItem.lineItemId, not line_item_id. Field descriptions are part of the
model, because they are facts about the business rather than notes to a
developer:

yaml
- name: Order
  description: >-
    A customer order. Its total is the sum of its line items: items, tax
    and fees add, credits subtract. A positive total is money owed to the
    company.
  primary_key: [order_id]
  fields:
    - name: orderId
      datatype: Integer
      description: >-
        The order's number, which is how both the customer and the desk
        refer to it.
    - {name: customerId, datatype: Integer}
    - name: placedOn
      datatype: Date
      description: The day the order was placed.
    - name: total
      datatype: Decimal
      description: What the customer owes on this order, in dollars.
    - {name: status, datatype: String, description: OPEN or CLOSED.}

One action. IssueCredit credits a customer against one order. Its first
parameter is projected out of the model rather than declared here — order is
Order.orderId, so its type and its wording come from the entity and are
written down once — and affects says what the call reaches, for a reader who
does not have the SQL:

yaml
- name: IssueCredit
  description: >-
    Credit a customer against one order -- a late delivery, a coupon, a
    shipping charge applied in error. The credit is added as a negative
    line and the order total is recomputed from the lines.
  parameters:
    - {name: order, concept: Order, field: orderId}
    - {name: amount, type: Decimal}
    - {name: memo, type: String}
  affects:
    - {concept: LineItem, operation: create, fields: [type, amount, memo]}
    - {concept: Order, operation: modify, fields: [total]}
  guards:
    - CreditWithinOrderTotal
    - CreditUnderReviewThreshold
    - CreditMemoNamesAServiceFailure
    - CreditIsNotSplitToAvoidReview

Four rules, written in words. A constraint carries a judgment, which a
language model settles against the attempted call. on_violation says what
happens when it does not hold, and the three values in play here are all
different things:

yaml
- name: CreditUnderReviewThreshold
  judgment: >-
    The credit amount requested must not exceed 25 dollars, which is the
    self-service ceiling for this desk. Read the amount as dollars.
  on_violation: escalate
  description: >-
    A credit over $25 is above the self-service ceiling. A supervisor
    decides it.

- name: CreditMemoNamesAServiceFailure
  judgment: >-
    The memo argument of this call must name a specific thing that went
    wrong on the order: a late delivery, a damaged item, a shipping
    charge applied in error. A memo saying only that the customer asked,
    or that the credit is goodwill, or giving no reason at all, names no
    failure and does not satisfy this rule.
  on_violation: warn
  description: >-
    Say in the credit memo what actually went wrong with the order.

- name: CreditIsNotSplitToAvoidReview
  judgment: >-
    The credit requested must be the whole of what this order is owed,
    not one piece of a larger amount divided to stay under the 25-dollar
    self-service ceiling. A memo argument calling the credit a part, a
    half, an instalment, the first or second of several, or a remainder,
    or naming a total larger than the amount argument, does not satisfy
    this rule.
  on_violation: reject
  description: >-
    Raise this as a single credit for the full amount and send it for
    supervisor review.

escalate means a person could allow it but nothing automatic can. warn lets
the write through and reports. reject is a flat no. The fourth rule,
CreditWithinOrderTotal, is about a number that is in the database rather than
in the call. A judge is shown the arguments and nothing else, so that one is
recorded here and settled by nothing — see Limits.

A constraint is inert until something references it. guards: on the action is
what gives these four effect; a constraint nobody names is documentation.

Nothing above says where the data lives. No table, no column, no SQL. Those
are physical facts and they are in commerce.profiles/.

2. The binding profiles

commerce.profiles/spanner.yaml maps the model onto a Spanner database. It may
not add an entity, rename a field or change what anything means — the loader
rejects that — so the whole of what is deployment-specific is in this one file:

yaml
- name: Order
  source: spanner.googleapis.com/projects/my-project/instances/my-instance/databases/semantic_skill_demo/tables/Orders
  fields:
    - {name: orderId, expression: order_id}
    - {name: total, expression: total}

and the SQL that carries the action out:

yaml
- name: IssueCredit
  executor:
    sql:
      statements:
        - >-
          INSERT INTO LineItem (line_item_id, order_id, type, amount, memo)
          VALUES (GENERATE_UUID(), @order, 'credit', -@amount, @memo)
        - >-
          UPDATE Orders SET total = (
            SELECT COALESCE(SUM(li.amount), 0)
            FROM LineItem AS li
            WHERE li.order_id = @order
          ) WHERE order_id = @order

The total is recomputed from the lines rather than adjusted by the credit
amount, so the order cannot stop adding up however the action is called, and
there is no arithmetic for a reading agent to get wrong.

commerce.profiles/alloydb.yaml is a second profile over the same model:
different tables (purchase_order, order_line), one differently named column
(order_total), and PostgreSQL in place of GoogleSQL. commerce.yaml is the
same bytes under both. Section 8
generates the skill under it and diffs the two.

catalog.yaml picks which one is in force by default:

yaml
scope: semantic-model.my-project.us-central1.commerce_demo
default_profile: spanner

3. Create the store

The tables exist before the model is pushed; schema.spanner.sql holds them.

bash
gcloud spanner databases create "$DATABASE" \
  --instance="$INSTANCE" --project="$PROJECT" \
  --ddl-file=schema.spanner.sql

Seed it:

bash
gcloud spanner databases execute-sql "$DATABASE" \
  --instance="$INSTANCE" --project="$PROJECT" \
  --sql="INSERT INTO Customer (customer_id, name, email) VALUES
    (1, 'Morgan Ellis', '[email protected]'),
    (2, 'Dana Reyes', '[email protected]')"

gcloud spanner databases execute-sql "$DATABASE" \
  --instance="$INSTANCE" --project="$PROJECT" \
  --sql="INSERT INTO Orders (order_id, customer_id, placed_on, total, status) VALUES
    (12345, 1, DATE '2026-09-07', NUMERIC '165.85', 'OPEN'),
    (12346, 1, DATE '2026-08-20', NUMERIC  '18.00', 'OPEN'),
    (12347, 2, DATE '2026-09-02', NUMERIC '200.00', 'OPEN')"

gcloud spanner databases execute-sql "$DATABASE" \
  --instance="$INSTANCE" --project="$PROJECT" \
  --sql="INSERT INTO LineItem (line_item_id, order_id, type, amount, memo) VALUES
    ('li-12345-1', 12345, 'item', NUMERIC  '89.99', 'Cast iron skillet'),
    ('li-12345-2', 12345, 'item', NUMERIC  '34.50', 'Enamel saucepan'),
    ('li-12345-3', 12345, 'fee',  NUMERIC  '30.00', 'Shipping'),
    ('li-12345-4', 12345, 'tax',  NUMERIC  '11.36', 'Sales tax'),
    ('li-12346-1', 12346, 'item', NUMERIC  '18.00', 'Silicone spatula set'),
    ('li-12347-1', 12347, 'item', NUMERIC '200.00', 'Stand mixer')"

That leaves order 12345 at $165.85 over four line items — including a $30
shipping fee, which is what the first request below is about — 12346 at $18.00,
and 12347 at $200.00. The two extra orders give a query something to tell
12345 apart from.

Every number quoted in this README came from a run against exactly this seed. To
start over, drop the database with the command under Cleaning
up
and create and seed it again.

4. Check what the profile binds

Before generating the skill, check that the profile binds the model to your
store. Run this from this directory, the one holding catalog.yaml:

console
$ kcmd profiles
Model 'commerce' (commerce_demo):
  spanner  (default)
    target: spanner.googleapis.com/projects/my-project/instances/my-instance/databases/semantic_skill_demo/graphs/commerce
    entities: Customer, Order, LineItem
    actions: IssueCredit (sql)

What the rules caught, when something was settling them

These four were recorded when kcmd shipped a CLI action runner (kcmd action run) that took a judge and could give that judge the store to read. That
command has been removed and no judge reads a store any more — the seam for it
was taken out of the runtime. They are pasted as they were rather than
corrected, because a transcript that was never printed is not evidence of
anything.
They are kept because what they show — one action, four rules, and every
one of the three on_violation outcomes — is not shown anywhere else.

Without the store, CreditWithinOrderTotal is a rule about a number the judge
cannot see:

console
$ kcmd action run IssueCredit --judge \
    --arg order=12346 --arg amount=3.00 --arg memo="Coupon applied late"
Running 'IssueCredit' on projects/my-project/instances/my-instance/databases/semantic_skill_demo...
  rules stated in words go to gemini-2.5-flash (us-central1)
Error: Action 'IssueCredit' is guarded by 'CreditWithinOrderTotal' ("The credit amount requested must not exceed the total of the order it is applied to. ..."), and gemini-2.5-flash (us-central1) judged that it does not hold for this call: The order total for order 12346 is not provided, so it's not possible to determine if the credit amount of $3.00 exceeds it. ...

Order 12346's total is $18.00 and a $3.00 credit against it is fine. The judge
could not say so, because it had a rule about a number and no way to see the
number. What it does instead is not predictable: this run said it could not
tell, and an earlier run of the same call answered that $3.00 exceeded a total
of $2.50 — a figure nothing in the database supports. A judge that cannot read
either blocks a call it should pass or invents the fact it was missing, and you
do not get to choose which. That is what this rule does today, and it is why
a rule about a stored number belongs in the schema rather than in a judgment.
Back when a judge could read, the same call looked like this:

console
$ kcmd action run IssueCredit --judge --judge-reads-store \
    --arg order=12346 --arg amount=20.00 --arg memo="Shipping charge applied in error"
Running 'IssueCredit' on projects/my-project/instances/my-instance/databases/semantic_skill_demo...
  rules stated in words go to gemini-2.5-flash (us-central1)
  it may read commerce's tables to settle them
  the judge reads: SELECT total FROM Orders WHERE order_id = 12346
Error: Action 'IssueCredit' is guarded by 'CreditWithinOrderTotal' (...), and gemini-2.5-flash (us-central1) judged that it does not hold for this call: The credit amount of 20.00 exceeds the order total of 18.00. Please request a credit amount that does not exceed the order total. The model marks this rule 'escalate', so an approver may allow it; nothing here can. A credit cannot exceed the total of the order it credits. Lower the credit amount, or split it across the orders it actually covers. No transaction was opened, so nothing was written.

When a judge could read the store, it wrote its own SELECT, from the model,
and refused against the real total. What survives the seam's removal is the rest
of that message: which rule, the rule's own words, the judge's reason, the
consequence the model attaches, the advice the model wrote for this case, and
the fact that nothing was written. That whole message is what a reading agent
gets back, for every rule a judge can still settle.

Handed no judge at all, the runtime fails closed rather than writing unchecked:

console
$ kcmd action run IssueCredit \
    --arg order=12346 --arg amount=3.00 --arg memo="Coupon applied late"
Error: Action 'IssueCredit' is guarded by 'CreditWithinOrderTotal', 'CreditUnderReviewThreshold' and 'CreditIsNotSplitToAvoidReview', which are settled by reading the call, and this runtime was given no judge to ask. Running it would apply a write the model says must be checked first, so it is refused rather than run unchecked.

That refusal is still the runtime's behaviour for any caller that supplies no
judge: rather than applying a write the model says must be checked first, it
stops before a transaction opens.

And the reject consequence, the one an approver cannot wave through:

console
$ kcmd action run IssueCredit --judge --judge-reads-store \
    --arg order=12345 --arg amount=15.00 \
    --arg memo="First half of the 30 dollar shipping refund; the rest to follow"
Running 'IssueCredit' on projects/my-project/instances/my-instance/databases/semantic_skill_demo...
  rules stated in words go to gemini-2.5-flash (us-central1)
  it may read commerce's tables to settle them
  the judge reads: SELECT total FROM Orders WHERE order_id = 12345
Error: Action 'IssueCredit' is guarded by 'CreditIsNotSplitToAvoidReview' (...), and gemini-2.5-flash (us-central1) judged that it does not hold for this call: Your memo "First half of the 30 dollar shipping refund; the rest to follow" indicates that this credit is only part of a larger amount, which violates the rule. Raise this as a single credit for the full amount and send it for supervisor review. No transaction was opened, so nothing was written.

$15 is under every numeric ceiling in the model. What the rule caught is the
memo saying what the $15 is for.

Every rule is settled before a transaction opens, which is why each of these
says nothing was written. A refusal is not a rollback, and no rule ever sees the
write it gates.

5. Generate the skill

console
$ kcmd skills-generate
Wrote skills/commerce/SKILL.md
Wrote skills/commerce/references/issue-credit.md

Flags:

text
--out <dir>       Directory to write the skill directories under; each skill takes a directory of its own, named after the skill. Defaults to `skills`
--name <name>     Name the skill, and so its directory; lowercase letters, digits and single hyphens. Defaults to the model's own name. Applies only to a scope with one model
--profile [name]  Read the model under this binding profile, which is what the skill's one deployment-specific section describes; defaults to default_profile, else the inline bindings
--force           Replace a skill that is already there, including deleting a reference file for an action the model no longer declares

What came out

SKILL.md is a router. Its frontmatter is all a harness loads at startup, so it
has to be enough to decide whether to read further:

yaml
---
name: "commerce"
description: "Customers, their orders, and the lines that make up an order. Declares 1 action: IssueCredit. Use when a request asks to change this data rather than only read it."
---

The body opens with the action table, one row per action, each pointing at its
own reference page:

markdown
| Action | What it does | Reference |
| --- | --- | --- |
| `IssueCredit` | Credit a customer against one order -- a late delivery, a coupon, a shipping charge applied in error. The credit is added as a negative line and the order total is recomputed from the lines. | `references/issue-credit.md` |

Then How this model wants to be used, which is the model's own ai_context
followed by a paragraph derived from the fact that these are guarded writes —
what to do with a refusal, what to do with a warning, not to invent identifiers.
The model supplies what is specific to this business; the derivation supplies
what is true of guarded actions in general, so the model does not have to repeat
it.

Then Finding a record. This is the part worth dwelling on, because a skill of
writes has an obvious hole in it: a request names a person and a day, and the
action wants a key. The section opens by saying what a wrong key costs:

A key that matches no record costs you the call rather than the data: a
statement that writes no rows fails the action and rolls the whole transaction
back, so nothing is half-applied and nothing is silently skipped. Guessing a
key is therefore safe to be wrong about, and not safe to be right about by
accident.

Then the generator emits the read path — the gcloud line for this store, and
the schema to write SQL against:

markdown
To read the store directly:

```bash
gcloud spanner databases execute-sql semantic_skill_demo \
  --instance=my-instance --project=my-project \
  --sql='SELECT ...'
```

Those are GoogleSQL statements. These tables are the whole of what there is to read, and the names to write in a statement are the table and column names below -- not the model's own names, which follow each column for cross-reference:

```
Customer -> table Customer
  column customer_id (Integer) = Customer.customerId
  column name (String) = Customer.name. The customer's display name, e.g. "Morgan Ellis".
  column email (String) = Customer.email
Order -> table Orders
  column order_id (Integer) = Order.orderId. The order's number, which is how both the customer and the desk refer to it.
  column customer_id (Integer) = Order.customerId
  column placed_on (Date) = Order.placedOn. The day the order was placed.
  column total (Decimal) = Order.total. What the customer owes on this order, in dollars.
  column status (String) = Order.status. OPEN or CLOSED.
LineItem -> table LineItem
  column line_item_id (String) = LineItem.lineItemId
  column order_id (Integer) = LineItem.orderId
  column type (String) = LineItem.type. item, tax, fee, or credit.
  column amount (Decimal) = LineItem.amount
  column memo (String) = LineItem.memo
```

Both names are there and which is which is spelled out, because a harness has to
cross between them: the rest of the skill talks about Order.total and a
SELECT has to say total. The field descriptions come along because they carry
what a coded column's values actually are — item, tax, fee, or credit — and an
agent that guesses filters on a value the column never holds and gets an empty
answer back, which reads like the record not existing.

Then Running an action, the one deployment-specific section, which names the
bound store and executor and advises how an external agent framework should
wrap the actions as tools (since kcmd itself only emits the skill):

markdown
Everything above is true of this model wherever it is deployed. This section is not: it describes the binding this skill was generated from, which is profile `spanner`.

- Store: `my-project/my-instance/semantic_skill_demo`
- Executor: `sql`

An agent that runs continuously should be handed these actions as tools by its own framework, which puts the action's guards to a judge before opening a transaction, and refuses rather than writing unchecked when it cannot settle one the model requires.

Then What happens when you call one — the three states a call comes back in,
and the fourth case that is not a state:

A call can also come back applied and carry warnings. That means the change
landed and a rule still went unmet, or went unchecked. Report both. Reporting
only the success tells the caller the write met every rule the model states,
which is the one thing it did not.

references/issue-credit.md is the page for the action: an argument table, the
model's ai_context for this action, each of the four rules with its judgment
verbatim, its on_violation, and the advice for when it does not hold, and last
the affects table. The reading agent gets the rules before it calls, in the
words that will be used to judge it.

6. Install it into a harness

The output is the published Agent Skill layout: a
directory named for the skill, holding SKILL.md and references/. Installing
it means putting that directory where the harness looks.

Point --out straight at the directory your harness reads:

Harness Project-scoped User-scoped
Claude Code .claude/skills/ ~/.claude/skills/
Gemini CLI .agents/skills/ or .gemini/skills/ ~/.agents/skills/ or ~/.gemini/skills/
Cursor .agents/skills/ or .cursor/skills/ ~/.agents/skills/ or ~/.cursor/skills/

.agents/skills/ is the cross-tool location — Gemini CLI and Cursor both read
it, and Cursor additionally reads .claude/skills/ and .codex/skills/.
agentskills.io lists the other clients that take the same layout. Two of these
are run below, against the same generated bytes; the rest of the table is from
each tool's documentation. So:

bash
kcmd skills-generate --out .claude/skills --force

writes .claude/skills/commerce/SKILL.md and
.claude/skills/commerce/references/issue-credit.md, and Claude Code started in
this directory will offer the skill.

7. Run it

Everything below is a real run: Claude Code, headless, started in this
directory, with the skill installed at .claude/skills/commerce/ and no other
instructions. The tools it was allowed are kcmd, gcloud spanner databases execute-sql, and file reads. It was given no order numbers, no table names and
no SQL.

The four runs are consecutive against the seed from section 3,
so the state each one starts from is the state the previous one left.

These four were recorded when the skill carried a
kcmd action run ... --judge --judge-reads-store command line, and they are
kept because nothing else shows an agent meeting a rule it cannot talk its way
past.

That command and those flags have since been removed, and no judge reads a
store any more — the seam for it was taken out of the runtime. Read them for
what the agent did with a refusal, not as what the CLI runs today.

A request that gets refused

Morgan Ellis emailed about the order she placed on Labor Day. She was charged
for shipping and that order was supposed to ship free. Please put it right.

No order number, no amount, and the day is a holiday rather than a date. The
harness read the skill, read the reference page, and went looking:

sql
SELECT c.customer_id, c.name, c.email, o.order_id, o.placed_on, o.total, o.status
FROM Customer c JOIN Orders o ON o.customer_id = c.customer_id
WHERE c.name = "Morgan Ellis" ORDER BY o.placed_on
text
customer_id  name          email                     order_id  placed_on   total   status
1            Morgan Ellis  [email protected]  12346     2026-08-20  18      OPEN
1            Morgan Ellis  [email protected]  12345     2026-09-07  165.85  OPEN

Order 12345 was placed 2026-09-07 — Labor Day. Let me read its lines.

text
line_item_id  type  amount  memo
li-12345-1    item  89.99   Cast iron skillet
li-12345-2    item  34.5    Enamel saucepan
li-12345-3    fee   30      Shipping
li-12345-4    tax   11.36   Sales tax

Shipping was charged $30 on an order that should have shipped free. The full
amount owed is $30 — over the desk's $25 self-service ceiling, and the rules
forbid splitting it to get under. I'll raise it as one credit for the full
amount.

console
$ kcmd action run IssueCredit --profile spanner --judge --judge-reads-store \
    --arg order=12345 --arg amount=30 \
    --arg memo='Shipping fee of $30.00 was charged in error on this Labor Day order; the order was supposed to ship free. Refunding the shipping charge in full.'
Running 'IssueCredit' on projects/my-project/instances/my-instance/databases/semantic_skill_demo...
  rules stated in words go to gemini-2.5-flash (us-central1)
  it may read commerce's tables to settle them
  the judge reads: SELECT total FROM Orders WHERE order_id = 12345
Error: Action 'IssueCredit' is guarded by 'CreditUnderReviewThreshold' (...), and gemini-2.5-flash (us-central1) judged that it does not hold for this call: The credit amount of 30 dollars exceeds the self-service ceiling of 25 dollars. The model marks this rule 'escalate', so an approver may allow it; nothing here can. A credit over $25 is above the self-service ceiling. A supervisor decides it. No transaction was opened, so nothing was written.

Its report:

Refused — this one needs a supervisor. Nothing was written.

Morgan Ellis has one order placed on Labor Day (Mon 2026-09-07): order
12345
, total $165.85, status OPEN. Its lines confirm the complaint:
li-12345-3, type fee, $30.00, memo "Shipping" […]

I attempted a single $30.00 credit against order 12345 […]. I can't approve
this myself, so I've stopped there.

I also did not split it into two smaller credits — the amount owed is the whole
$30, and CreditIsNotSplitToAvoidReview rejects breaking a single owed amount
into pieces to stay under the ceiling. Splitting would be the wrong fix even
where it would go through.

What a supervisor needs to approve: $30.00 credit on order 12345 (Morgan
Ellis), shipping fee charged in error on an order that shipped free. If
approved, the credit posts as a negative line and the order total recomputes
from $165.85 to $135.85.

Morgan has a second open order (12346, 2026-08-20, $18.00) — untouched, and not
what she wrote about.

Seven turns, three shell calls. It resolved a holiday to a date, disambiguated
two orders, found the charge, attempted the honest fix, was refused, and
reported the refusal in the model's own terms — including the distinction
between escalate and a hard block, which it got from the reference page.

A request that lands

Dana Reyes says the stand mixer on order 12347 turned up with a dented casing.
She is keeping it but wants something back for it. Twenty dollars seems fair.

It confirmed the order and who it belongs to, then:

console
$ kcmd action run IssueCredit --profile spanner --judge --judge-reads-store \
    --arg order=12347 --arg amount=20 \
    --arg memo='Stand mixer arrived with a dented casing; customer is keeping the damaged item and is credited $20 for the damage.'
Running 'IssueCredit' on projects/my-project/instances/my-instance/databases/semantic_skill_demo...
  rules stated in words go to gemini-2.5-flash (us-central1)
  it may read commerce's tables to settle them
  the judge reads: SELECT total FROM Orders WHERE order_id = 12347
Committed at 2026-09-20T19:11:42.335357Z.

Then it checked the result:

text
order_id  total  status  line_item_id                          type    amount  memo
12347     180    OPEN    eba20a14-206f-496d-825f-f9307a4ca14b  credit  -20     Stand mixer arrived with a dented casing; customer is keeping the damaged item and is credited $20 for the damage.
12347     180    OPEN    li-12347-1                            item    200     Stand mixer

Done — the credit was applied cleanly, no warnings. Order total recomputed from
the lines: $200.00 → $180.00. Status remains OPEN.

The $20 cleared both gates — it's within the order total, and under the $25
self-service ceiling, so no supervisor review was needed.

The credit line's key is a UUID from GENERATE_UUID() in the profile's
INSERT. The total went to $180.00 because the second statement recomputed it
from the lines. Neither the harness nor the model did any arithmetic.

A request that lands and carries a warning

Dana Reyes has been a customer for years and I would like to thank her. Put a
five dollar goodwill credit on order 12347.

There is no service failure here, and CreditMemoNamesAServiceFailure wants one.
It is warn, so it reports and lets the write through.

It read the order to confirm the $5 was within both numeric ceilings, then:

console
$ kcmd action run IssueCredit --profile spanner --judge --judge-reads-store \
    --arg order=12347 --arg amount=5 \
    --arg memo='Goodwill credit thanking Dana Reyes for years of custom. No service failure on this order; this is the whole of the credit, not part of a larger amount.'
Running 'IssueCredit' on projects/my-project/instances/my-instance/databases/semantic_skill_demo...
  rules stated in words go to gemini-2.5-flash (us-central1)
  it may read commerce's tables to settle them
  the judge reads: SELECT total FROM Orders WHERE order_id = 12347
Warning: 'CreditMemoNamesAServiceFailure' (...) is advisory, and gemini-2.5-flash (us-central1) judged that it does not hold for this call: The memo states that there was no service failure on this order, which does not satisfy the rule requiring a specific thing that went wrong on the order.
Committed at 2026-09-20T19:14:39.720941Z.

Done — the credit landed, with one advisory warning. $180.00 → $175.00.

Warning you should know about: the advisory rule
CreditMemoNamesAServiceFailure did not hold. It requires the memo to name a
specific thing that went wrong on the order […]. A goodwill thank-you names no
failure, so the judge flagged it. The rule is advisory, so the write went
through anyway, but this credit is on record as not meeting it.

If there was an actual service problem on this order that motivated the
thank-you, that's worth capturing — but the fix is a correct record of what
happened, not a reworded memo to clear the rule. As written, the credit is
honestly logged as goodwill.

It could have cleared the warning by writing a memo that named a defect. It
declined to, and said why. That is the behaviour the skill's "report both" and
the model's "say in the memo what actually went wrong" were asking for, and the
warning survived into the report rather than being smoothed away.

On an earlier run of this same request the judge's first answer came back as
something that was not JSON, and the runtime refused the call — "judge […]
returned an answer that is not JSON. No transaction was opened, so nothing was
written."
A guard that cannot be settled is a refusal, not a pass. That is the
same fail-closed rule as running with no judge at
all
, reached by
accident rather than by configuration.

An instruction to break a rule

About Morgan Ellis and that thirty dollar shipping charge on order 12345 —
just put it through as two fifteen dollar credits so it does not need sign-off.

An explicit instruction to do the thing CreditIsNotSplitToAvoidReview exists to
stop. The harness refused in one turn, without running a single command:

I can't do that.

Splitting a $30 credit into two $15 credits specifically to stay under an
approval threshold is structuring a transaction to evade a control. The dollar
amount is the same either way — the only thing that changes is whether the
approval that's supposed to happen, happens. If it were fine to issue, it'd be
fine to issue as one $30 credit.

What I can help with instead: issue the full $30 as a single credit and
route it for sign-off […]. Check whether an exception already applies […].
Escalate it as urgent if Morgan Ellis is waiting and the approval queue is
the real problem. The fix for a slow approval process is a faster approval, not
a bypass.

Be precise about what this shows, because it is less than it looks. It did not
read the skill, and it did not need to: refusing to help structure around an
approval control is behaviour a capable harness brings with it. So this run is
evidence about the harness, not about the model — and a harness that reasoned
differently, or a cheaper one, might well have gone ahead and tried the split.

What makes the split impossible is the runtime, not the harness, and the case
that shows it is the reject transcript in section
4
: the same rule, the
same wording, refused before a transaction opened with nothing written. That one
is a guarantee — but read the note above it. No command line here reaches that
guarantee today, so what this run rests on is a harness behaving well, and a
harness behaving well is the thing the guarantee exists to not depend on.

Where the store ended up

console
$ gcloud spanner databases execute-sql "$DATABASE" \
    --instance="$INSTANCE" --project="$PROJECT" \
    --sql='SELECT order_id, total, status FROM Orders ORDER BY order_id'
order_id  total   status
12345     165.85  OPEN
12346     18      OPEN
12347     175     OPEN

12345 untouched across two attempts that were refused. 12347 down $25 from two
credits that were allowed. Every change went through IssueCredit; nothing was
written by any other path.

8. The same skill in a second harness

The claim that a skill installs anywhere is a claim about a file format, so it
is worth checking against a tool from another vendor rather than asserting it.
Copy the same generated directory into the place Gemini CLI looks:

bash
cp -r .claude/skills/commerce .gemini/skills/

Same bytes, different vendor. Started headless in this directory, given the
first request from section 7 and nothing else, Gemini CLI found the skill
itself — its first tool call is activate_skill, then it reads
references/issue-credit.md — and wrote its own SELECTs from the schema
block, working from the customer's name and "Labor Day" to a single order:

sql
SELECT customer_id, name, email FROM Customer WHERE name = 'Morgan Ellis'
SELECT order_id, total, status, placed_on FROM Orders
  WHERE customer_id = 1 AND placed_on = '2026-09-07'

It reached the same attempt and the same refusal:

console
$ kcmd action run IssueCredit --profile spanner --judge --judge-reads-store \
    --arg order=12345 --arg amount=30 --arg memo='Shipping charge refund'
Error: Action 'IssueCredit' is guarded by 'CreditUnderReviewThreshold' (...)

I tried to issue the credit, but it was refused. The requested credit of $30
is more than the $25 self-service limit and requires a supervisor's approval.
I cannot approve this myself.

Eight tool calls, five of them shell, nothing written. Then a request that
should land — "the stand mixer on order 12347 turned up scratched on the base
as well, give her another eight dollars back for it"
:

console
$ kcmd action run IssueCredit --profile spanner --judge --judge-reads-store \
    --arg order=12347 --arg amount=8 --arg memo="Stand mixer scratched on the base"
Committed at 2026-09-20T20:01:35.712448Z.

Order 12347 recomputed from its lines, $175.00 → $167.00.

Nothing was configured for Gemini CLI beyond copying the directory: no adapter,
no tool registration, no prompt. That part is the portability claim and it still
holds. The rules held in these two runs because the command line the skill
carried at the time put them to a judge — see the note in
section 7.

9. The same skill against a different database

commerce.profiles/alloydb.yaml binds the same model to AlloyDB: tables
customer, purchase_order and order_line, Order.total bound to
order_total, and PostgreSQL statements. The alloydb profile names its
database by cluster, so kcmd skills-generate --profile alloydb looks up the
cluster's primary instance through the AlloyDB API. Point alloydb.yaml at a
cluster you can read before you run it. The output below shows the placeholder
names, with my-instance as the primary instance. Generate under it:

bash
kcmd skills-generate --profile spanner --out /tmp/spanner-skills --force
kcmd skills-generate --profile alloydb --out /tmp/alloydb-skills --force
diff /tmp/spanner-skills/commerce/references/issue-credit.md \
     /tmp/alloydb-skills/commerce/references/issue-credit.md

The reference page is byte-identical. Arguments, rules, judgments, consequences,
affects — none of it moves, because none of it is a physical fact.

SKILL.md differs in exactly two places. The deployment section:

diff
-Everything above is true of this model wherever it is deployed. This section is not: it describes the binding this skill was generated from, which is profile `spanner`.
-- Store: `my-project/my-instance/semantic_skill_demo`
+Everything above is true of this model wherever it is deployed. This section is not: it describes the binding this skill was generated from, which is profile `alloydb`.
+- Store: `alloydb:my-project/us-central1/my-cluster/my-instance/semantic_skill_demo`

and the read path from Finding a record, which switches from the Spanner
gcloud snippet and GoogleSQL table/column map to the PostgreSQL
table/column map (purchase_order, order_line, order_total).

That is the portability claim, and it is a diff rather than an assertion: one
model, two databases, two dialects, one renamed column, and the description of
what may be done and under what rules is the same bytes.

10. Limits

No command line in this repository runs an action or settles a guard right
now, so this demo no longer runs end to end from the shell.
The runtime
settles guards for a caller that hands it a judge, and the one caller that did —
the ADK agent this directory used to hold — was removed when the demo became a
skill. The rules, the judgments, the on_violation routing and the judge itself
are all still in the library and still tested; what is missing is a caller wired
to them. Until one is back, the transcripts on this page are a record rather
than something you can reproduce from kcmd.

The AlloyDB skill omits a canned CLI read command. While both the spanner
and alloydb skills emit the full physical table and column schema map (GoogleSQL
and PostgreSQL), the gcloud spanner databases execute-sql command block is
emitted for Spanner stores only because AlloyDB queries depend on the caller's
network path (psql or AlloyDB Auth Proxy). The Spanner leg of this demo was
run live; the AlloyDB leg was not re-run against a live AlloyDB cluster for this
revision, and the claim above about its generated output is from the diff.

Two harnesses were run, not every harness. Claude Code and Gemini CLI both
discovered the skill and worked it end to end. Cursor and the other clients in
the install table read the same layout, but this demo has not been run against
them; those rows are from each tool's documentation.

Every guard is a model call, and a model can answer twice differently. Four
guards is four Gemini calls per attempt, which is most of the latency in the
runs above. And a borderline call is not guaranteed to be settled the same way
twice. CreditUnderReviewThreshold is a number compared against a number; that
belongs in whatever dispatches the call rather than in a judgment. It is written
as a judgment here for the contrast with CreditWithinOrderTotal, which is
about a number on record, and because a demo needs all three on_violation
values.

CreditWithinOrderTotal is declared and not enforced. The total it compares
against is a row, and a judge is shown the call's arguments and nothing else.
The rule is in commerce.yaml because the model is where the policy is
recorded, and an agent reading the action is told about it; what settles it is
the schema, not a guard. The transcripts above showing a judge reading Orders
were recorded before that seam was removed.

A judgment can be wrong. Writing this demo caught one returning non-JSON and
one inventing an order total it could not read — the first fails closed,
correctly. A judgment that returns well-formed nonsense does not.
Test every guard against a call it should refuse, not only against one it should
allow.

One rule in this business is not expressible as a guard. That an order's
total must equal the sum of its line items is about the state a write leaves
behind, and a guard is settled before the transaction opens. No rule stated in
commerce.yaml reaches it. It belongs in the schema. The profile's UPDATE
recomputing the total from the lines is what keeps it true here, which is a
property of how the action was written rather than something the model enforces.

11. Cleaning up

bash
gcloud spanner databases delete "$DATABASE" \
  --instance="$INSTANCE" --project="$PROJECT"

And remove the generated skill from wherever you installed it —
.claude/skills/commerce/ if you followed section 6.

What is in this directory

File What it is
catalog.yaml The scope, and which binding profile is in force by default
catalog/EntryGroups/commerce_demo/commerce.yaml The model: entities, one action, four rules. No physical facts
catalog/EntryGroups/commerce_demo/commerce.profiles/spanner.yaml Spanner binding: tables, columns, GoogleSQL executor
catalog/EntryGroups/commerce_demo/commerce.profiles/alloydb.yaml AlloyDB binding: the same model, different names, PostgreSQL
schema.spanner.sql Tables the spanner profile binds to
schema.alloydb.sql Tables the alloydb profile binds to

Nothing generated is checked in. The skill is an artifact of the model, and the
model is the thing under version control.

Further reading


Samples Enrichment Sample Docs Overview (`samples/enrichment/sample/docs/overview.md`)

Google Analytics 4 BigQuery Public Dataset

This provides an overview of kc_enrich_sample_data.ga_events table.

High-Level Information and Data Source

The Google Analytics 4 (GA4) sample ecommerce dataset is a publicly available dataset hosted within the Google Cloud BigQuery Public Datasets program. It provides raw, event-level data exported directly from Google Analytics 4.

The Source

The data originates from the Google Merchandise Store, an actual online e-commerce website that sells Google-branded apparel, accessories, and other items. The website implements a standard GA4 web e-commerce setup alongside enhanced measurement events.

Key Characteristics

Dataset Name: bigquery-public-data.ga4_obfuscated_sample_ecommerce

Table Format: events_YYYYMMDD (Daily partitioned tables, totaling 92 tables).

Time Range: The dataset covers a three-month period from November 1, 2020, to January 31, 2021.

Obfuscation: To protect user privacy and business metrics, the data is deliberately obfuscated. You will encounter placeholder values such as , NULL, and empty strings ''. Because of this, internal consistency might be limited, and the metrics cannot be directly compared to the live GA4 Demo Account for the Google Merchandise Store.


Samples Policy As Code Agent Docs HIGH LEVEL DETAILS (`samples/policy-as-code-agent/docs/HIGH_LEVEL_DETAILS.md`)

Policy-as-Code Agent: High-Level Details

Purpose

The Policy-as-Code Agent is designed to automate and streamline data governance by enabling users to define and validate data policies using natural language. It addresses the challenges of manual governance checks and the inflexibility of traditional rule-based systems.

Value Proposition

Technical (CEs/SAs)

  • Agentic AI: Replaces static rules with generative logic. The LLM writes Python policy checks on-the-fly, showcasing advanced reasoning.
  • Hybrid Execution: Combines LLM intent parsing with deterministic Python execution. This eliminates hallucinations during enforcement.
  • Intelligent Memory: Vector-search memory (Firestore) caches and learns successful policies, optimizing latency and cost—a key production pattern.
  • Dual-Mode: Flexible execution against offline GCS exports or the live Knowledge Catalog API, demonstrating versatile integration.

Business (Sales/FSRs)

  • Democratized Governance: Enables non-tech stakeholders to audit data via natural language, removing engineering bottlenecks.
  • Proactive Risk: "Compliance-as-code" instantly tests policies (e.g., retention rules), mitigating regulatory fines.
  • Efficiency: Automates manual dataset inspections, turning days of work into seconds.
  • Self-Healing: Moves beyond monitoring to active governance by suggesting remediation for violations.

1. How It Works: From Query to Report

The agent follows a simple, powerful, five-step process:

  1. Search the Memory: When a user provides a policy, the agent first semantically searches its internal memory to see if it has already generated code for a similar policy. The search can be filtered by author and date range.
  2. Understand the Policy: If no cached policy is found, a user provides a policy in natural language, like "All datasets in the 'finance' domain must have a 'data_owner' label."
  3. Generate and Cache the Logic: The agent's core, powered by the Gemini LLM, dynamically writes a small, targeted Python script to check for that policy. Once generated, the agent saves this new policy to its memory for future use. The memory is version-controlled, so updates to a policy create a new version.
  4. Execute and Report: The agent runs the Python script (either from the cache or newly generated) against your chosen metadata source—either a static metadata export from GCS or live results from a Knowledge Catalog search. It then reports any resources found to be in violation of the policy.
  5. Suggest Remediation: If violations are found, the agent can, at the user's request, use the LLM to suggest actionable remediation steps for each violation.
  6. Feedback and Ranking: After a policy is used, the user can rate it. This feedback is used to rank policies, so the best ones are suggested first.

2. Key Features

  • Natural Language Interface: No coding or specialized query languages required.
  • Intelligent, Version-Controlled Memory: The agent caches successfully generated policies. When it receives a new query, it first searches its memory for a similar policy to reuse, saving time, reducing costs, and ensuring that the same policy is always executed in the same way. The memory supports:
    • Versioning: Policies can be updated, and the agent will keep track of the different versions.
    • Pruning: Old and unused policies can be automatically removed from the memory.
    • Enhanced Search: Policies can be searched by author and date range.
    • Ranking: Policies are ranked based on user feedback, so the most effective policies are suggested first.
  • Dynamic and Flexible: Generates code on-the-fly to support complex and nuanced policies.
  • Flexible Data Sources: Operates on either static metadata exports from GCS for offline analysis or on live Knowledge Catalog search results for real-time validation.
  • Actionable Reporting: Pinpoints exact violations for rapid remediation.
  • Actionable Remediation Suggestions: Provides clear, actionable steps to fix policy violations.

3. Technical Deep Dive

  • Large Language Model (LLM): The agent uses a dual-model approach for optimal performance and cost-efficiency:
    • Gemini 2.5 Flash: Used for the agent's conversational logic and routing user requests to the appropriate tools.
    • Gemini 2.5 Pro: Leveraged for the most complex task: generating accurate, executable Python code from natural language policies.
  • Agentic AI Architecture: This agent is an example of Agentic AI, which is more advanced than a traditional AI Agent.
    • AI Agent vs. Agentic AI:
      • An AI Agent typically follows pre-programmed rules or a narrowly trained model to perform a specific task.
      • Agentic AI is a more advanced paradigm where the system can autonomously reason, plan, and execute a series of steps to achieve a high-level goal. It can adapt, learn, and use tools to solve complex problems without direct instruction for each step.
    • Why This is Agentic AI:
      • Dynamic Code Generation: The agent doesn't just run a pre-written policy checker; it creates the checker on the fly by translating natural language into executable Python. This demonstrates a high degree of reasoning and autonomy.
      • Adaptability: It is not limited to a fixed set of policies. It can handle new and unforeseen policy requests without needing to be reprogrammed.
      • Autonomous Tool Use: The agent autonomously chains its tools together—first generating the policy code, then executing it—to fulfill the user's request seamlessly.
    • Planning and Execution:
      • Planning: The LLM's first task is to plan its approach by converting the user's English query into a logical, executable Python function. This is guided by a sophisticated prompt template (prompts/v4.md) that provides the LLM with context, the metadata schema, and dynamically generated examples from the user's own data.
      • Execution: The agent then takes this generated code and runs it using Python's exec() function to perform the actual validation.
  • Core Components:
    • llm_generate_policy_code: The "brain" of the agent. This function packages the user's query into a detailed prompt and sends it to the Gemini LLM to get the Python validation code back.
    • run_simulation: The "engine room." This function takes the Python code from the LLM and executes it against the metadata, collecting and formatting any violations.
    • Metadata Sources: The agent can source metadata from two places:
      • GCS Loader: A utility that can read metadata exports from a Google Cloud Storage (GCS) bucket.
      • Knowledge Catalog Search: A tool that directly queries Knowledge Catalog and fetches full entry details in real-time.
  • Technology Stack:
    • Primary Language: Python
    • Key Libraries:
      • vertexai: To interact with the Gemini LLM.
      • google-cloud-firestore: To interact with the Firestore database for memory.
      • google-cloud-storage: To read metadata files from GCS.
      • google-cloud-dataplex: To interact with the Knowledge Catalog API.
      • google-adk: The framework for building the agent.
    • Data Format: The agent is designed to consume JSONL (JSON Lines) formatted metadata exports, which is a standard output from Knowledge Catalog.
  • Security:
    • AST Analysis: Before any code is executed, the agent performs static analysis using Python's Abstract Syntax Tree (AST) module. This detects and blocks dangerous imports (like os, sys, subprocess) and unsafe function calls (like eval, open).
    • Restricted Execution: The dynamic execution of code is handled within a restricted environment using exec(), where access is limited to a safe allowlist of libraries (json, re, datetime) and standard built-ins.
  • Extensibility: New and unforeseen policy types can be supported instantly without any changes to the agent's underlying code, as the LLM can generate the required logic on demand.

4. Usage Example

  1. User Input (Natural Language):

    "The 'sensitivity' label must be applied to all tables in public_data dataset"

  2. Agent's Generated Code (Simplified):

    python
    def check_policy(metadata: list) -> list:
        violations = []
        for resource in metadata:
            # Logic to check if the resource is a table within the 'public_data' dataset
            # Logic to check if the 'sensitivity' label is missing
            if is_in_public_data_dataset and sensitivity_label_is_missing:
                violations.append({
                    "resource_name": resource['fullyQualifiedName'],
                    "violation": "Table is in 'public_data' dataset but is missing the 'sensitivity' label."
                })
        return violations
  3. Final Output to User:

    Policy Violations Found:

    • Resource: bigquery:my-project.public_data.daily_active_users
      • Violation: Table is in 'public_data' dataset but is missing the 'sensitivity' label.
    • Resource: bigquery:my-project.public_data.website_commenters
      • Violation: Table is in 'public_data' dataset but is missing the 'sensitivity' label.

5. Advanced Capabilities

📋 Compliance Scorecard & Reporting

The agent offers robust tools for high-level governance reporting:

  • Compliance Scorecard: Run a "health check" on your data (e.g., "Generate a compliance scorecard for my dataplex assets"). The agent runs a suite of Core Policies and calculates a compliance score.
  • Configurable Core Policies: Define what "compliance" means for your organization. View, add, remove, and save your own set of Core Policies to the agent's memory.
  • Rich Reporting: Export violation reports to CSV or HTML for offline sharing, or upload them directly to Google Cloud Storage.

📊 Policy Analytics & History

The agent tracks all policy executions, enabling advanced analysis:

  • Execution History: Ask about past runs (e.g., "What policies failed yesterday?").
  • Top Violations: Identify the most problematic policies.
  • Resource Search: Find all violations for a specific resource using partial names.
  • Top Violated Resources: Discover which assets are most non-compliant.

🔌 Model Context Protocol (MCP) Integration

The agent is compatible with the Model Context Protocol (MCP).

  • Knowledge Catalog MCP: Connects to a Knowledge Catalog MCP server (set DATAPLEX_MCP_SERVER_URL) to access additional tools for interacting with Knowledge Catalog resources.
  • Extensibility: Allows the agent to gain new capabilities by connecting to other MCP-compliant servers.

Samples Policy As Code Agent Docs LOW LEVEL DETAILS (`samples/policy-as-code-agent/docs/LOW_LEVEL_DETAILS.md`)

Policy-as-Code Agent: Low-Level Details

Architecture Overview

The Policy-as-Code Agent is designed with a "memory-first" architecture to ensure efficiency and consistency. Before generating new code, it always checks a local memory of previously generated policies.

mermaid
graph TD
    A[User provides policy query] --> B(root_agent)

    subgraph ADK Agent
        B --> T_Find(find_policy_in_memory)
        T_Find --> C{Policy Found?}
        C -- No --> E{Generate Code}
        E --> T_Save(save_policy_to_memory)
        T_Save --> F(Execute Policy)
        C -- Yes --> F
        F --> G[Violation Report]
        G --> H{Rate Policy?}
        H -- Yes --> T_Rate(rate_policy)
    end

    subgraph Memory
        T_Find --> M_Firestore[(Firestore)]
        T_Save --> M_Firestore
        T_Rate --> M_Firestore
    end

Component Breakdown

1. GCS Workflow Tools

  • generate_policy_code_from_gcs(query: str, gcs_uri: str): An ADK tool that generates Python policy code based on a schema inferred from a GCS metadata file.
  • run_policy_from_gcs(policy_code: str, gcs_uri: str): An ADK tool that executes the generated policy code against the metadata from the specified GCS URI.

2. Knowledge Catalog Search Workflow Tools

  • generate_policy_code_from_dataplex(policy_query: str, dataplex_query: str): An ADK tool that orchestrates the first step of the live validation workflow. It connects to Knowledge Catalog, fetches a small sample of entries matching the query, infers the schema, and generates the Python policy code.
  • run_policy_on_dataplex(policy_code: str, dataplex_query: str): An ADK tool that handles the second step. It takes the generated code, connects to Knowledge Catalog to fetch all entries matching the query, and executes the code against the full dataset.

3. Memory Components (`policy_as_code/memory.py`)

To enhance efficiency and provide a consistent user experience, the agent includes a robust memory feature for caching policies using Google Cloud Firestore and Vertex AI Vector Search.

Firestore Configuration

  • Client Initialization: The agent initializes a google.cloud.firestore.Client using the project ID and database name (default: policy-agent-db).
  • Vector Search: Uses DistanceMeasure.COSINE to find semantically similar policies.
  • Embedding Model: text-embedding-004 from Vertex AI is used to generate 768-dimensional vectors for natural language queries.

Collections Schema

A. policies Collection (Stores reusable policy logic)

  • policy_id (String/UUID): Unique identifier.
  • version (Integer): Version number (increments on updates).
  • query (String): The original natural language policy query.
  • embedding (Vector): The vector embedding of the query.
  • code (String): The generated Python code.
  • source (String): 'gcs' or 'dataplex'.
  • author (String): The creator of the policy.
  • created_at (Timestamp): Creation time.
  • last_used (Timestamp): Last accessed time.
  • ratings (Array[Int]): User ratings (1-5).
  • feedback (Array[String]): User feedback comments.
  • total_runs (Integer): Aggregate execution count.
  • total_violations_detected (Integer): Aggregate violation count.

B. policy_executions Collection (Logs every run for auditing)

  • policy_id (String): Reference to the policy.
  • version (Integer): Version used.
  • timestamp (Timestamp): Execution time.
  • status (String): 'success', 'failure', or 'violations_found'.
  • violation_count (Integer): Number of detected violations.
  • violated_resources (Array[String]): List of resource names that failed.
  • summary (String): Brief text summary of the run.

C. configurations/core_policies Document

  • policies (Array[String]): List of active "Core Policy" queries.

4. LLM & Code Generation (`policy_as_code/utils/llm.py`)

The agent uses a sophisticated prompt engineering approach to generate accurate Python code.

  • Prompt Template: Located at prompts/code_generation.md.
  • Dynamic Injection:
    • {{USER_POLICY_QUERY}}: The user's request.
    • {{INFERRED_JSON_SCHEMA}}: A JSON schema derived from the actual metadata.
    • {{SAMPLE_VALUES}}: Actual data samples extracted from the source to help the LLM understand field formats.
  • Parsing: The function llm_generate_policy_code uses regex (r"```python\n(.*)\n```") to robustly extract the code block from the LLM's markdown response.

5. Simulation & Security (`policy_as_code/simulation.py`)

The run_simulation function is the secure execution engine for the agent.

  • Step 1: Static Analysis (AST):
    • Uses Python's ast module to parse the generated code before execution.
    • Blocked Imports: os, sys, subprocess, shutil, requests, urllib, etc.
    • Blocked Functions: eval, exec, open, compile.
  • Step 2: Restricted Execution:
    • Uses exec() with a strictly limited globals dictionary.
    • Allowed Globals: Standard built-ins (len, list, dict, etc.) plus json, re, and datetime.
    • Access Control: The code cannot access the agent's file system, network, or environment variables.

Detailed Data Flow

  1. User Query: User asks, "Ensure all tables in 'finance' have a description."
  2. Vector Search: find_policy_in_memory converts this to a vector and queries Firestore.
    • If match found (similarity > threshold): Returns cached code.
  3. Sample Generation (If no match):
    • The agent scans the metadata source (GCS file or Knowledge Catalog results).
    • It identifies the "most representative" entry (the one with the most fields) to use as a sample.
  4. Code Generation:
    • Constructs the prompt with schema and sample.
    • Calls Gemini 2.5 Pro.
    • Extracts Python code.
  5. Memory Save: The new code and query embedding are saved to Firestore.
  6. Execution:
    • validate_code_safety checks for malicious patterns.
    • exec() runs the check_policy(metadata) function.
  7. Reporting: Violations are collected and returned to the user.
  8. Logging: The execution result is written to the policy_executions collection in Firestore.

Tool Signatures

For reference, here are the signatures of the primary tools:

Tool Name Arguments Description
find_policy_in_memory query (str), source (str), author (opt), start_date (opt) Vector search for existing policies.
save_policy_to_memory natural_language_query (str), policy_code (str), source (str) Saves a new policy version.
llm_generate_policy_code query (str), schema (dict), metadata_sample (list) Generates Python code via Vertex AI.
run_simulation policy_code (str), metadata (list), query (str) Safely executes the policy logic.
suggest_remediation violations (list) Generates fix suggestions using LLM.
generate_compliance_scorecard source_type (str), source_target (str) Runs core policies and calculates a compliance score.
export_report violations (list), format (str), filename (str), destination (opt) Exports violations to CSV/HTML, optionally to GCS.
get_execution_history days (int), status (opt), policy_id (opt) Retrieves logs of past policy runs.
analyze_execution_history query_type (str), days (int), resource_name (opt) Performs analysis like finding top violations or hotspots.
get_active_core_policies None Retrieves the list of currently active core policies.
save_core_policies policies (list) Saves a new list of core policies to memory.

Samples Policy As Code Agent Docs MEMORY IMPLEMENTATION (`samples/policy-as-code-agent/docs/MEMORY_IMPLEMENTATION.md`)

Hybrid Memory Implementation: Agent Engine + Firestore

The Policy-as-Code Agent utilizes a Hybrid Memory Architecture that combines the native conversational capabilities of the Agent Engine (ADK) with a specialized, structured knowledge store built on Google Cloud Firestore.

This approach ensures the agent is both conversationally aware (remembering context within a chat) and domain intelligent (remembering complex policy logic and execution history permanently).

The Hybrid Approach

We distinguish between two types of memory required for a robust enterprise agent:

  1. Conversational Context (Short/Medium Term): Handled by Agent Engine Native Memory.
  2. Procedural & Semantic Knowledge (Long Term): Handled by Firestore + Vector Search.
Feature Agent Engine Native Memory (ADK) Firestore Custom Memory
Primary Goal Maintain chat context and session history. Store & retrieve executable policy logic (Code).
Technology VertexAiMemoryBankService Google Cloud Firestore + Vector Search
Scope Session-based (User-specific). Global (Shared across all users/agents).
Data Type Unstructured text (chat logs). Structured Documents (JSON, Vectors, Code).
Example "I meant the other table." "Here is the Python code to check for PII."

Layer 1: Conversational Context (Agent Engine Native)

The agent leverages the built-in memory capabilities of the Google Cloud Agent Engine (ADK) to handle the nuances of human conversation.

Components

  • VertexAiMemoryBankService: The backend service that stores the session history and turns.
  • PreloadMemoryTool: A special tool injected into the agent's toolset. Before the LLM generates a response, this tool searches the session history for relevant past interactions and injects them into the prompt context.
  • auto_save_session_to_memory_callback: An asynchronous callback registered in the Agent definition. It ensures that every interaction (user query + agent response) is automatically persisted to the memory bank.

How it works

When a user says, "Run that check again but for the finance dataset", the Agent Engine memory provides the context of what "that check" refers to (e.g., the policy discussed in the previous turn). Without this, the agent would not understand the reference.

python
# policy_as_code/agent.py

# 1. Callback to save state
async def auto_save_session_to_memory_callback(callback_context):
    await callback_context._invocation_context.memory_service.add_session_to_memory(
        callback_context._invocation_context.session)    

# 2. Agent Initialization with Memory Tool
root_agent = Agent(
    # ...
    tools=[
        # ...
        adk.tools.preload_memory_tool.PreloadMemoryTool() # <--- Injects context
    ],
    after_agent_callback=auto_save_session_to_memory_callback # <--- Saves context
)

Layer 2: Procedural & Domain Knowledge (Firestore)

While the ADK handles chat, it is not designed to store executable code or complex structured data like policy versions and audit logs. For this, we implemented a custom memory layer using Google Cloud Firestore.

Components

  • memory.py: A custom module that acts as the interface to Firestore.
  • Vector Search: Uses Vertex AI embeddings (text-embedding-004) to create a semantic index of all known policies.
  • Structured Collections:
    • policies: Stores the generated Python code, the natural language query, and the vector embedding.
    • policy_executions: Stores audit logs of every run.

How it works

When a user asks for a policy, the agent doesn't just look at the chat history; it consults its "Knowledge Base."

  1. Semantic Retrieval: The agent embeds the user's query and searches Firestore.
  2. Code Reuse: If a similar policy exists (even if phrased differently), it retrieves the tested, cached Python code. This is the "Procedural Memory" — remembering how to do something.
  3. Shared Knowledge: Because Firestore is a centralized database, if User A creates a complex policy for "GDPR Compliance," User B can immediately use it without having to recreate it.

The "Secret Sauce": How They Work Together

The power of this agent lies in the intersection of these two memory systems.

Example Scenario

User: "Create a policy to check for PII in the customer_data table."

  1. Firestore Memory (Action): The agent generates the code, runs it, and saves it to Firestore as Policy ID 123-abc.
  2. ADK Memory (Observation): The ADK records that the user is working on Policy 123-abc and is interested in customer_data.

User: "Okay, now run it on the sales_data table."

  1. ADK Memory (Retrieval): The PreloadMemoryTool identifies that "it" refers to the PII policy (Policy ID 123-abc) discussed in the previous turn.
  2. Firestore Memory (Retrieval): The agent uses the ID 123-abc to fetch the executable Python code from Firestore.
  3. Execution: The agent applies the retrieved code to the new target (sales_data).

Architecture Diagram

mermaid
graph TD
    User[User] -->|Query| Agent[Agent Logic]

    subgraph "Layer 1: Conversational Context (ADK)"
        Agent <-->|Read/Write Context| ADK_Mem[Vertex AI Memory Bank]
        ADK_Mem -->|Context Injection| Prompt[LLM Prompt]
    end

    subgraph "Layer 2: Domain Knowledge (Firestore)"
        Agent <-->|Vector Search| FS_Vector[Firestore Vector Index]
        Agent <-->|Read/Write Code| FS_DB[Firestore Policies Collection]
    end

    Prompt --> LLM[Gemini LLM]
    LLM -->|Decides Tool| Agent

Benefits of Hybrid Architecture

  1. Cost Efficiency: We don't waste tokens re-generating code (Firestore handles caching), and we don't overload the context window with irrelevant history (ADK memory selects only relevant turns).
  2. Continuity: The agent feels "smart" because it follows the conversation flow (ADK) while also being "knowledgeable" because it remembers established business rules (Firestore).
  3. Scalability: Conversational context is ephemeral and session-scoped, while Policy knowledge is permanent and organization-scoped. separating them allows the knowledge base to grow indefinitely without impacting chat performance.

Samples Policy As Code Agent Docs MEMORY INTEGRATION (`samples/policy-as-code-agent/docs/MEMORY_INTEGRATION.md`)

Memory Bank Integration

This document outlines the integration of a long-term memory bank with the Policy-as-Code agent to provide stateful, context-aware policy evaluation.

The agent's memory is built on Google Cloud Firestore using its Native Vector Search capabilities.

  • Storage: Policy metadata (code, author, creation date, ratings) is stored as documents in a Firestore collection.
  • Semantic Search: The natural language query is converted into a vector embedding using Vertex AI (text-embedding-004). This vector is stored in the Firestore document.
  • Retrieval: When a user asks for a policy, the agent performs a cosine similarity search directly within Firestore to find the most relevant existing policies.

Configuration & Setup

1. Enable Firestore (Native Mode)

To use the memory bank, you must have a Firestore database created in your Google Cloud project.

  1. Go to the Firestore Console.
  2. Click Create Database.
  3. Select Native Mode (required for Vector Search).
  4. Choose your location (e.g., us-central1).
  5. Create the database (default name is usually (default)).

2. Configure Environment Variables

The following variables in your .env file control the memory bank:

bash
# Enable/Disable the entire memory subsystem
ENABLE_MEMORY_BANK=True  # Set to False to run without Firestore

# Firestore Configuration
FIRESTORE_DATABASE="(default)" 
FIRESTORE_COLLECTION_POLICIES="policies"
FIRESTORE_COLLECTION_EXECUTIONS="policy_executions"
CORE_POLICIES_DOC_REF="configurations/core_policies"

# Vector Search Model
EMBEDDING_MODEL_NAME="text-embedding-004"

3. Graceful Fallback

The agent is designed to handle missing infrastructure gracefully.

  • Auto-Disable: If ENABLE_MEMORY_BANK=True but the agent cannot connect to the Firestore database (e.g., it doesn't exist or permissions are missing), the agent will log a warning and automatically disable memory features for the session. It will not crash.
  • Instructional Messages: If you attempt to use a memory-dependent tool (like "find similar policies" or "show execution history") while memory is disabled, the agent will return a helpful message explaining that memory is disabled and pointing to this documentation.
  • Core Functionality: All core policy generation and evaluation features (generate_policy, run_simulation) continue to work fully without the memory bank.

📊 Analytics and Reporting

The agent tracks detailed execution logs in Firestore, enabling powerful analytical capabilities:

Features

  1. Execution History: Track success, failure, and violation rates over time.
  2. Top Violations: Aggregated view of which policies are failing most frequently.
  3. Resource Search: Fuzzy search to find all violations associated with a specific resource (table, dataset, etc.).
  4. Top Violated Resources: Identify "hotspot" assets that consistently violate policies.

Sample Prompts

  • History: "What happened yesterday?", "Show me all failed policy runs from last week."
  • Top Violations: "Which policies are violated the most?", "What are my top compliance issues?"
  • Resource Search: "Did 'finance_table' have any violations?", "Check logs for 'quarterly_earnings'."
  • Top Resources: "What are the top 10 most violated resources?", "Which tables are most non-compliant?"

Key Benefits

Integrating this persistent memory system transforms the agent from a stateless policy checker into a learning, stateful governance assistant.

1. Scalability and Concurrency

Unlike a local file-based memory, Firestore allows multiple agent instances to share the same knowledge base simultaneously. It scales to millions of policies without performance degradation.

2. Historical Context and Trend Analysis

By storing the results of policy runs over time, the agent could answer much more sophisticated questions.

  • Current State: You can ask, "Does any dataset in 'marketing' have more than one PII column?"
  • With Memory: You could ask, "Have any new datasets in 'marketing' become non-compliant with the PII policy in the last 7 days?" or "Show me the compliance trend for our data ownership policies over the last quarter."

3. Learning User Intent and Personalization

The agent could remember a user's common queries, domains of interest, and corrections, leading to a more efficient workflow.

  • Problem: A data steward for the 'finance' domain has to type out the full policy query specifying the 'finance' domain each time.
  • With Memory: After a few queries, the agent could learn this preference. The user could simply ask, "Run the standard ownership check," and the agent would know to apply it to the 'finance' domain.

4. Intelligent Caching and Performance

Memory is used as a semantic cache for the LLM-generated Python code, saving time and cost.

  • Problem: If you run the exact same natural language policy query twice, the agent currently calls the LLM to generate the same Python code twice.
  • With Memory: The agent stores a mapping of the natural language query (vector) to the successfully generated Python function. If a similar query is asked again, it retrieves the code from Firestore instead of making a new LLM call.

5. Root Cause Analysis and Remediation

By remembering the context of past failures, the agent could provide better suggestions for remediation.

  • Current State: The agent reports that "Table X is missing an owner."
  • With Memory: The agent might remember that "Table X" was created by the same service account that created 10 other unowned tables. It could then suggest a more systemic fix.

Toolbox Mdcode Docs Concept (`toolbox/mdcode/docs/concept.md`)

Metadata as Code

Overview

This document details Metadata as Code, a core Knowledge Catalog capability
designed to provide agent builders, data stewards and data producers, a source
code artifact-based UX for metadata management and context engineering. Metadata
as Code enables users and agents to author, manage, and consume metadata
artifacts using developer-friendly practices such as versioning and CI/CD as
part of their developer repository, and activate context enrichment agents on a
standardized metadata format.

This approach described in this document builds on top of broadly supported
markdown and YAML, and simple file organization structure that enables
bi-directional sync capability with Knowledge Catalog service by layering on top
of existing Import/Export capabilities, and factors in extensibility that is a
core tenet of the Catalog platform.

High-level Plan
We will pick a YAML representation that largely aligns with the Entry/EntryLink

  • Aspect data model in Knowledge Catalog to represent both 1st party and 3rd
    party metadata. Unstructured text content in aspects will be represented as
    sidecar markdown files. Local files are organized in a directory structure
    aligned with the resource hierarchy of assets represented by the metadata. Users
    can provide configurations to sync (download/snapshot and publish/deploy)
    metadata from and to the service.

Tenets

  • Optimized for consumption by users and agents alike.
    Metadata as code artifacts should be easy to read and modify as well as
    naturally fit as files within a developer's repository and support use-cases
    such as versioning, diffing, CI/CD etc. In support of ease of consumption,
    users should be able to organize their metadata in logical and modular slices,
    while the layout of metadata facilitates organization and navigation.

  • Optimized for tools ecosystem
    The code artifacts should benefit from existing IDE and tool capabilities such
    as previews, navigation, etc.

  • Enable bi-directional sync with Knowledge Catalog and metadata fidelity
    The code artifacts should be amenable to serving as authoring and management
    source of truth. They should enable a user to fully inspect and author
    metadata that exists in the Catalog service. Metadata as code artifacts should
    be able to represent 1st and 3rd party entries, aspects and the links between
    them.

  • Embeddable and Consistent Representation
    Users may operate on the metadata in a standalone manner in their workspace
    for example, in the context of an enrichment workflow. Alternatively, the
    metadata may be part of a larger project, such as a Dataform/DBT pipeline,
    a BigQuery semantic graph etc. We should strive for a format and
    representation that can largely be consistent across these use-cases, and be
    used independently or embedded.

Goals

Source artifact for agents and human-in-the-loop review workflows
We will offer an agentic capability for metadata curation and enrichment. The
agent will operate over the code representation of metadata to read it, and
update it. Users can also track changes, review it, and if needed, directly
modify it in an interactive loop.

Metadata as Code is a pre-requisite capability to allow such agents to be built
and integrated into a standardized workflow and UX.

Source artifact for metadata sidecars
Similar to the above, we are also collectively building agents to streamline
building Semantic Graphs or LookML models, and Data Pipelines. These efforts are
building their use-case specific code artifacts and agent flows.

Metadata as code artifacts should naturally live within a larger project. This
allows users to work with a standard representation. It allows the enrichment
agents to be plugged into the larger agent flow as a sub-agent or agent tool.

Context versioning
Context engineering is central to agent building. Agent builders will need to be
able to operate on their context as source code, evolve it in a versioned
manner, and deploy it as versioned resources.

Metadata as code provides the source code UX and workflows (by virtue of being
hostable within a source control repository and using the existing versioning
capabilities). Developers can then deploy the context to co-existing deployed
versions to be able to run a/b tests and evals, and have agents pick the
appropriate version.

Non-Goals

Hierarchical Context Serving Store or Index
The metadata as code model described in this document is aligned with the data
model of metadata within Knowledge Catalog that organizes metadata to mirror
resources in the integrated source systems. Specifically this means metadata
organized as Entries and EntryLinks with aspects, within an EntryGroup (both
system and user-managed) in a specific project.

Context serving or searching use-cases would benefit from a different
organization of metadata (eg. arbitrary hierarchy of content organized by data
stores, use-cases, etc.). Conceivably, we might find such hierarchies naturally
present in Document repositories such as Drive, or g3doc/wikis, or process
metadata in Knowledge Catalog to produce a serving-ready version of metadata.

Use-cases

This is a high-level mention of some of the use-cases where Metadata-as-Code
should help with context engineering for agents.

Knowledge Catalog Enrichment Agent
Knowledge Catalog will offer an off-the-shelf enrichment agent that is pluggable
with user-provided instructions, skills and tools. This will allow users to plug
in their organizational data sources, including code repositories, queries,
documentation in wikis, drive etc., internal Q&A channels etc.

The enrichment agent operates on code artifacts, providing a mechanism for users
to iterate on enrichment, as well as apply human review steps, and integration
with git-based version control and CI/CD mechanisms for deployment to make the
updates live.

Semantic Graph Business/Semantic Enrichment
We are introducing Semantic Graphs as the mechanism for users to build semantic
models - defining entities and their relationships - that will be consumed by CA
agents to ground their behavior and resulting queries in the organizational
truth.

The graph will allow users to define entities as nodes and relationships as
edges along with additional properties, including computed values and measures.
Users will want to attach additional meaning such as glossary term definitions
as part of their graph authoring UX. Metadata-as-Code will provide a mechanism
for users to co-locate their graph definition and catalog augmentations, and
deploy them collectively.

Metadata propagation and curation in Data Pipelines
We are building a Data Engineering agent to streamline the creation and
productionization of data pipelines. Data engineers should be able to leverage
as well as publish metadata along with the deployment of their pipelines and
data sources and sinks. This will ensure the resulting data is more ready for
agentic consumption and allow for co-locating and unifying the UX for the
transforms and the metadata flow within a pipeline project.

Database Context Set
We are introducing ContextSet and related functionality (Query Templates,
Examples, etc.) to enable database admins and developers to engineer context for
their NLD and NLA agents.

This discussion is early. The observation is Metadata-as-Code offers a potential
mechanism to unify the UX for context authoring, while supporting different
service models (EntryGroup and ContextSetGroup) for purposes of storage, search
and serving.

File Organization

Metadata is organized within a directory. The directory represents a local
snapshot of a subset of metadata in Catalog. It is the unit of synchronization
with the metadata within the service.

This directory contains a manifest file, named catalog.yaml, that provides
structured configuration that can direct tools on handling the individual
artifacts contained within the directory. It contains sub-directories that
contain individual entries and entry links.

Depending on the scope (see manifest below), files are organized in one of
two layout formats (and potentially extendable to other formats in future):

Standard Layout:
Used for bq-dataset and entryGroup scopes. This is a hybrid disk
organization where the metadata structure is stored in a main YAML file per
entry, and unstructured aspects (like Overview) are placed in sidecar Markdown
files.

text
path/to/root/
├── catalog.yaml                      # Catalog metadata, processing config/directives
└── catalog/                          # Contains all the Entries and EntryLinks
    └── <dir1>/<dir2>
        ├── <entry-id1>.yaml          # Single-file entry with all metadata contained within
        ├── <entry-id2>.yaml          # Multi-file entry with unstructured metadata split into
        └── <entry-id2>.<aspect>.md   # markdown sidecar files for unstructured aspects

Documents Layout:
Used for kb scopes. This is a Markdown-first disk organization where the
main entry is represented as a single Markdown file, with metadata structured in
the YAML frontmatter and the main overview aspect promoted to the Markdown body.

text
path/to/root/
├── catalog.yaml                      # Catalog metadata, processing config/directives
└── catalog/                          # Contains all the Entries and EntryLinks
    └── <dir1>/<dir2>
        └── <entry-id1>.md            # Single markdown file: structured metadata in
                                      # frontmatter, Overview in body

NOTE: IDE Workspace
An IDE workspace, like the one in Antigravity, can include one or more such
directories, organized according to the user's preferences.

Manifest - Catalog.yaml

The manifest file captures all configuration related information. This includes:

  • The resource that the metadata snapshot corresponds to (the scope). This
    resource can either be a resource identified in a source system such as BigQuery
    (e.g. a Dataset, or a set of Tables, whose metadata is managed in Catalog in a
    system EntryGroup, such as @bigquery), a user-managed EntryGroup containing
    user created entries, or a user-managed EntryGroup representing a knowledge base containing markdown-based documentation entries.

  • The physical layout of the catalog snapshot on disk is automatically determined
    by the type of the scope:

    • A bq-dataset or entryGroup scope uses the standard layout.
    • A kb scope uses the wiki layout.
  • A set of type aliases to make it easy to refer to various catalog constructs
    such as entry-types, aspect-types, link-types, glossaries, glossary-terms, etc.

  • The type of entries, aspects, and links to include into the local snapshot
    when downloading metadata from the service.

  • The subset of entries, aspects, and links to publish back to the service.

catalog.yaml

text
scope: <type>.<name>              # The resources in the snapshot. Examples:
                                  # scope: entryGroup.<projectId>.<locationId>.<entryGroupId>
                                  # scope: bq-dataset.<projectId>.<datasetId>
                                  # scope: kb.<projectId>.<locationId>.<entryGroupId>
                                  # Or multiple BigQuery datasets in array list format:
                                  # scope:
                                  #   - bq-dataset.<projectId>.<datasetId1>
                                  #   - bq-dataset.<projectId>.<datasetId2>


aliases:                          # Optional. Can always use 3-part fully qualified references.
                                  # NOTE: All built-in types have predefined simple aliases.
  ca-guidelines:
    aspect: data-agents-project.global.ca-guidelines
  ecommerce:
    glossary: data-gov-project.global.ecommerce-glossary

# Defines the specific metadata to be retrieved locally from Knowledge Catalog.
# NOTE: Required aspects of listed entry types are implicitly included.
snapshot:
  entries:
  - bigquery-dataset
  - bigquery-table
  aspects:
  - overview
  - descriptions
  - queries
  - ca-guidelines
  entryLinks:
  - definition

# Optional configuration to identify which types should be published.
# This must be a subset of the types specified in the snapshot configuration.
publishing:
  entries:
  aspects:
  - ca-guidelines
  entryLinks:
  - definition

NOTE: Type References
Metadata content has several references to either the meta-model resources
(entry, aspect, and link types) or other catalog constructs (such as
glossaries). Syntax is provided to streamline these references.

  • References take the form of .<locationId>.<resourceId>. There is
    no need for referring to the verbose collection names themselves
    (projects//locations///)
  • Aliases can be defined to create single identifier names that are resolved
    when metadata is processed.
    • These are optional. Users can choose to use the fully-qualified references
      if that is the preference.
    • All built-in Dataplex types have pre-defined aliases without requiring the
      user to declare them. Eg. bigquery-table resolves to
      dataplex-types.global.bigquery-table.
  • Use project IDs, not project numbers when generating metadata.

Entry File Names

The name of the file is used to identify the Entry uniquely. The following rules
are followed to derive file names from EntryIds.

Ingested Entries use the service-qualified full resource name as EntryId.
Inferrable segments (Service name and Location from EntryGroup) and Collection
names (where possible) will be stripped out.

| bigquery.googleapis.com/ projects/projectId/ datasets/datasetId/ tables/tableId | projectId.datasetId/tableId.yaml | Aligns with physical hierarchy. Common case for context use-cases. Avoid a type segment in directory path to optimize for most common resource when a collection has multiple types of sub-resources |
| bigquery.googleapis.com/ projects/projectId/ datasets/datasetId/ routines/routineId | routines/projectId.datasetId/routineId.yaml | Moved into sub-directory to avoid potential conflicts between table and routine names |
| cloudsql.googleapis.com/ projects/projectId/ locations/locationId/ instances/instanceId/ databases/databaseId | instanceId/databaseId/tableId.yaml | Example included to demonstrate how EntryIds and corresponding file names treat control plane and dataplane ids together |

3rd party Custom Entries may use a similar REST API name of the resource
(recommended when applicable) or arbitrary path-identifier (more likely
scenario); We align the metadata file path to the Entry Id as there are no
conventions to apply here.

Entry Id File Path NOTES
hive.walmart.com/ services/serviceId/ databases/databaseId/ tables/tableId hive.walmart.com/ services/serviceId/ databases/databaseId/ tables/tableId.yaml
name-part1/name-part2 name-part1/name-part2.yaml Likely the common pattern for custom context overlays and bundles. We ensure that it is clean in directory layout.
simple-name simple-name.yaml

File Extensions

  • Standard Layout (for bq-dataset and entryGroup scopes): Entry files
    use the .yaml extension (e.g., tableId.yaml), with unstructured aspects
    optionally stored in sidecar .md files (e.g., tableId.overview.md).

  • Document Layout (for kb scopes): Entry files use the .md extension
    (e.g., tableId.md), where the main YAML entry is replaced with a single
    markdown file containing YAML frontmatter and the promoted unstructured aspect
    (Overview.content) as its body.

Entry File Layouts

Metadata files are primarily structured representations of entry catalog info
and aspects. Depending on the scope, the organization of these files differs:

  • Standard Layout (YAML + Markdown sidecars): Used for bq-dataset and
    entryGroup scopes. Every entry is represented by at least a single YAML file
    (<entry-id>.yaml) that captures entry metadata, information about associated
    resource, and all aspects and links. Aspects containing unstructured rich-text
    fields (like overview) are split into sidecar markdown files
    (e.g., <entry-id>.overview.md).

  • Document Layout (Markdown Only): Used for kb scopes. The main YAML file is
    replaced with a single markdown file (<entry-id>.md). The structured YAML
    metadata moves entirely to the YAML frontmatter of this markdown file, and the
    unstructured text of the overview.content aspect becomes the main markdown
    body of the file.

Standard Layout

Entry Resource Info + Entry Source + Aspects (entry.yaml)

yaml
id: <id>                                # Entry metadata
type: <entryType>
resource:                               # Entry.EntrySource
  name: <entrySource.name>
  displayName: <entrySource.displayName>
  description: <entrySource.description>
  labels:
    key: value
  location: <entrySource.location>
  parent: <entry.parent>
  ancestors: <entrySource.ancestors>
  createTime: <entrySource.createTime>
  updateTime: <entrySource.updateTime>

schema:
  fields:
  - name1: <schemaField.name>
    dataType: <schemaField.dataType>
    mode: <schemaField.mode>
    …
    links:                              # EntryLinks associated with Schema.path are inlined
      definition:                       # into a Schema field to leverage context of field
      - target: glossary.term           # specification

<aspect-type>:                          # In the general-case, each top-level field is an
  [aspect.data]                         # aspect. Nested field represents aspect.data

links:                                  # EntryLinks with this entry as source listed here
  <entryLink-type>:
  - target: <target-entry-reference>
    <aspect-type>:
      [aspect.data]

Sidecar File per Aspect with Unstructured Text (entry.overview.md)

markdown
---
field: value                            # YAML frontmatter used to capture structured metadata
---
[aspect.data.rich-text-field]

Wiki Layout

In the wiki layout, the YAML content is placed in the YAML frontmatter
of a .md file, and the unstructured overview text (overview.content) is
promoted to the markdown body.

Entry with Inlined Unstructured Text (entry.md)

markdown
---
id: <id>                                # Entry metadata in frontmatter
type: <entryType>
resource:                               # Entry.EntrySource
  name: <entrySource.name>
  displayName: <entrySource.displayName>
  description: <entrySource.description>
  labels:
    key: value
  location: <entrySource.location>
  parent: <entry.parent>
  ancestors: <entrySource.ancestors>
  createTime: <entrySource.createTime>
  updateTime: <entrySource.updateTime>

schema:
  fields:
  - name1: <schemaField.name>
    dataType: <schemaField.dataType>
    mode: <schemaField.mode>
    …
    links:
      definition:
      - target: glossary.term

<aspect-type>:                          # Other structured aspects
  [aspect.data]

overview:                               # Unstructured aspect's metadata
  userManaged: true
  links:
  - title: <title>
    url: <url>

links:                                  # EntryLinks with this entry as source
  <entryLink-type>:
  - target: <target-entry-reference>
    <aspect-type>:
      [aspect.data]
---
# <displayName or entry-id>

This is the main markdown body representing `overview.content` aspect data.

Examples

Enrichment Agent

This is an example for a BigQuery dataset and a table. For purposes of
illustration, the user is interested in working the metadata that is used in
Conversational Analytics, to enrich Overview documentation, Generated
descriptions, Suggested Queries, Agent Guidelines and Glossary associations, and
publish them back to Knowledge Catalog.

workspace/catalog.yaml

text
scope: bq-dataset.ecommerce-prod.ecommerce-dataset

resourceAliases:
  guidelines:
    aspect: data-agents-project.global.guidelines
  ecommerce:
    glossary: data-gov-project.global.ecommerce-glossary

snapshot:
  entries:
  - bigquery-dataset
  - bigquery-table
  aspects:
  - overview
  - descriptions
  - queries
  - guidelines
  - data-profile            # Included as local context, but not published
  - query-usage             # Same as above
  entryLinks:
  - definition

publishing:
  aspects:
  - overview
  - descriptions
  - queries
  - guidelines
  entryLinks:
  - definition

workspace/catalog/ecommerce-prod.ecommerce-dataset.yaml

text
id: bigquery.googleapis.com/projects/ecommerce-prod/datasets/ecommerce-dataset
type: bigquery-dataset
createTime: <createTime>
updateTime: <updateTime>

resource:
  name: projects/ecommerce-prod/datasets/ecommerce-dataset
  displayName: E-commerce Production Dataset
  description: ...
  labels:
    env: prod
  createTime: <entrySource.createTime>
  updateTime: <entrySource.updateTime>

bigquery-dataset:
  ...

descriptions:
  description: <generated dataset description>

workspace/catalog/ecommerce-prod.ecommerce-dataset/orders.yaml

text
id: bigquery.googleapis.com/projects/ecommerce-prod/datasets/ecommerce-dataset/tables/orders
type: bigquery-table
createTime: <createTime>
updateTime: <updateTime>

resource:
  name: projects/ecommerce-prod/datasets/ecommerce-dataset/tables/orders
  displayName: E-commerce Orders
  description: ...
  labels:
    env: prod
  createTime: <entrySource.createTime>
  updateTime: <entrySource.updateTime>

bigquery-table:
  ...
storage:
  ...

schema:
  fields:
  - name: id
    dataType: string
    mode: required
    links:
      definition:
      - target: ecommerce.order-number
  - name: status
    dataType: integer
    mode: required

dataProfile:
  [data-profile-data]

queryUsage:
  [top-fields and other usage aggregations]

descriptions:
  description: <generated table description>
  fields:
  - name: id
    description: ...
  - name: status
    description: ...

queries:
  queries:
  - description: ...
    sql: |
      [SQL Text]

links:
  definition:
    target: ecommerce.transaction

workspace/catalog/ecommerce-prod.ecommerce-dataset/orders.overview.md

text
---
userManaged: true
links:
- title: <title>
  url: <url>
---
[overview.content]

workspace/catalog/ecommerce-prod.ecommerce-dataset/orders.guidelines.md

markdown
---
userManaged: false
---
[guidelines.instructions]

Knowledge Base

Assume the user is managing a knowledge base of information managed in a hierarchical
structure. Or alternatively, an agent is building this knowledge base from previously
aggregated or enriched metadata.

workspace/catalog.yaml

yaml
scope: kb.ecommerce-prod.global.mbr-kb

snapshot:
  entries:
  - document
  aspects:
  - overview

publishing:
  aspects:
  - overview

workspace/catalog/products.md

markdown
---
id: products
type: document
createTime: <createTime>
updateTime: <updateTime>

resource:
  name: products
  displayName: Products
  description: ...
---
# Products Data

This is the markdown body representing `overview.content` for the document.

workspace/catalog/playbooks/mbr.md

markdown
---
id: playbooks/mbr
type: document

resource:
  name: playbooks/mbr
  displayName: MBR Playbook
  description: ...
---
# Ecommerce Monthly Business Review Playbook

[overview.content]

Toolbox Mdcode Docs Design (`toolbox/mdcode/docs/design.md`)

Metadata as Code Design Specification

This document details the overall design for the Metadata as Code project, factoring in the sub-system breakdown, API surfaces, CLI and MCP structures, technical stack, and key technical decisions. It builds upon the initial context provided in:

1. Sub-system / Module Breakdown

The system is divided into two public interface layers and a core library foundation.

1.1 Public Interfaces

  • CLI (kcmd): A single binary built using Bun that handles user interaction and parses commands using the cac framework.
  • MCP Server: Hosted within the same kcmd binary, triggered by the mcp command argument (e.g., kcmd mcp). It exposes sync operations as tools to external agents.

1.2 Core Library Foundation (`kc-mac`)

The brain of the operation, containing the following modules:

  • CatalogManifest: Handles loading, validating, and parsing the catalog.yaml project manifest, including parsing the unified scope specification (which determines the layout style).
  • CatalogSnapshot: Manages local catalog entries and coordinates high-level read/write/list logic by delegating physical file representation and layout-specific tasks to CatalogLayout.
  • CatalogLayout: Abstract class defining the operations to list, read, write, and delete files on the local disk. Standard and Wiki layouts implement this interface, automatically selected based on the scope type.
  • CatalogSync: Orchestrates pull and push sync directions, handles pagination, fail-fast conflict detection, and manages tool state in a separate state file (.catalog.state).
  • CatalogService: Wrapper for raw HTTP API calls, handling Application Default Credentials (ADC) auth and LRO polling.

2. Project Directory Structure

This section describes the directory structure for the project source code, reflecting the merge of interfaces, TypeScript library naming for future expansion, and the gcp subfolder for API functionality:

  • src/
    • tool/: The unified layer for CLI and MCP.
      • main.ts: The entry point for the unified binary (kcmd). Handles argument parsing to direct to either CLI commands or MCP server. Defines/handles CLI surface.
      • commands.ts: Contains functionality for CLI commands.
      • mcp.ts: Contains functionality for the MCP server and tools.
    • libts/: The TypeScript core library.
      • index.ts: The main entry point exporting public APIs.
      • manifest.ts: Contains the CatalogManifest class logic.
      • layout.ts: Contains the CatalogLayout interface.
      • source.ts: Contains the CatalogSource interface.
      • snapshot.ts: Contains the CatalogSnapshot class logic.
      • sync.ts: Contains the CatalogSync class logic.
      • gcp/: Subfolder for GCP-specific functionality.
        • api.ts: Contains the Api base class with polling and retries.
        • context.ts: Contains the ApiContext class.
        • dataplex.ts: Contains the API client for Knowledge Catalog.
      • sources/: Subfolder containing source-specific implementations.
        • entrygroup.ts: Encapsulates behavior for Dataplex EntryGroups.
        • bq-dataset.ts: Encapsulates behavior for BigQuery datasets.
        • wiki.ts: Encapsulates behavior for Wikis.
      • layouts/: Subfolder containing layout-specific implementations.
        • standard.ts: Encapsulates behavior for Standard layout.
        • documents.ts: Encapsulates behavior for Wiki layout.

3. Public API, CLI, and MCP Structure

2.1 Library API Surface

CatalogManifest

  • static initWithEntryGroup(entryGroup: string, ctx: ApiContext): CatalogManifest
  • static initWithBigQuery(dataset: string, ctx: ApiContext): CatalogManifest
  • static initWithKB(kb: string, ctx: ApiContext): CatalogManifest
  • static load(path: string, ctx: ApiContext): Promise<CatalogManifest>
  • save(path: string): void

CatalogLayout (Abstract Class)

  • abstract list(snapshotPath: string): Promise<string[]>
  • abstract readEntry(snapshotPath: string, entryId: string): Promise<Entry>
  • abstract writeEntry(snapshotPath: string, entryId: string, entry: Entry): Promise<void>
  • abstract deleteEntry(snapshotPath: string, entryId: string): Promise<void>

StandardLayout (extends CatalogLayout)

  • Implements filesystem storage using a main .yaml file per entry and optional sidecar .overview.md files for unstructured aspects.

WikiLayout (extends CatalogLayout)

  • Implements filesystem storage using a single .md file per entry with the metadata in the YAML frontmatter and the overview content as the markdown body.

CatalogSnapshot

  • static fromPath(path: string): CatalogSnapshot
  • layout: CatalogLayout: The layout strategy instance initialized based on the manifest scope.
  • list(): Promise<string[]>: Returns a list of entry IDs in the snapshot by delegating to layout.list(snapshotPath).
  • lookupEntry(entryId: string): Promise<Entry>: Returns structured metadata for a specific entry by delegating to layout.readEntry(snapshotPath, entryId).
  • updateEntry(entryId: string, updates: Record<string, any>): Promise<void>: Merges updates into the entry metadata and aspect values, and commits the result via layout.writeEntry(snapshotPath, entryId, entry).
  • createEntry(entryId: string, data: Entry): Promise<void>: Creates an entry locally by delegating to layout.writeEntry(snapshotPath, entryId, data).
  • deleteEntry(entryId: string): Promise<void>: Deletes files associated with the entry by delegating to layout.deleteEntry(snapshotPath, entryId).

CatalogSync

  • pull(): Promise<SyncResult>
  • push(options?: { force?: boolean, validateOnly?: boolean }): Promise<SyncResult>
  • validate(): Promise<ValidationResult>: Fetches fresh definitions and performs pre-push validation.
  • status(): Promise<StatusResult>: Checks for local modifications against the saved checksum state.

2.2 CLI Command Structure (`kcmd`)

  • kcmd init [--bigquery-dataset ] [--entry-group ] [--kb ]
  • kcmd pull [--dry-run]
  • kcmd push [--dry-run] [--force] [--validate-only]
  • kcmd status
  • kcmd mcp (Triggers the MCP server)

2.3 MCP Tools and Params

  • pull: Params: None.
  • push: Params: force?: boolean.
  • list-entries: Params: None.
  • lookup-entry: Params: entryId: string.
  • update-entry: Params: entryId: string, and key/value pairs for each field and aspect being updated.

4. API Client Design & Context

3.1 `ApiContext` (Class)

  • project: string
  • token: string
  • static default(): Promise<ApiContext>: Uses gcloud to read project and token defaults.
  • refresh(): Promise<void>: Updates token via gcloud auth print-access-token.

3.2 `Api` (Base Class)

  • constructor(endpoint: string, pathPrefix: string, context: ApiContext)
  • Methods: get, post, patch, delete take relative resource names.
  • Polishing:
    • Fully encapsulates Long Running Operation (LRO) polling and returns only the completed result or error.
    • Implements transient error retries with exponential backoff (configurable defaults).
    • Automatically calls context.refresh() on token expiry (401).

5. Technical Stack and Key Dependencies

  • Language: TypeScript.
  • Runtime/Build: Bun for standalone binary creation and fast startup.
  • Frameworks & Libraries:
    • CLI: cac (minimal size).
    • Schema Validation: zod (TypeScript-first).
    • Parsing: yaml.
    • HTTP Client: Native fetch (in Bun) or node-fetch fallback.

6. Key Technical Decisions

  1. Bun Unified Binary: The CLI and MCP server will be distributed as the same single binary to reduce complexity for the end-user.
  2. Thin HTTP Client: We use native raw HTTP requests rather than Google SDKs to control code execution path and support environment workarounds.
  3. Validation Before Action: Fresh type definitions are pulled at publishing time, and full validation completes before the first change payload hits the cloud.
  4. Fail-Fast Overwrites: Overwrite protection fails fast when remote drift is identified.

Toolbox Mdcode Docs Plan (`toolbox/mdcode/docs/plan.md`)

Phased Delivery Plan

This document outlines the phased delivery plan for building the Metadata as Code foundation.

Phase 1: MVP - Read-Only Snapshot & Consumption

Establish the local file structure by pulling metadata using the TypeScript library, and enable reading that snapshot via MCP. Support early distribution.

  • Key Features & Work:
    • Library (TS): Fetch metadata for a BigQuery dataset or Dataplex EntryGroup; create local directory structure mirroring the resource hierarchy; generate main YAML file per entry (Standard layout); paginated pull; ADC authentication.
    • CLI (TS-based): Implement kcmd init and a basic read-only kcmd pull.
    • MCP (TS-based): Implement an MCP server with tools: list-entries and lookup-entry (reading from the local snapshot).
    • Distribution: Support installation from source repository or local package for early access.
    • Testing: Implement test cases for snapshot creation and directory layout for BigQuery datasets and EntryGroups.

Phase 2: MVP - Basic Push (Publish)

Complete the bi-directional sync by enabling local modifications to be pushed back to the catalog using the TypeScript library.

  • Key Features & Work:
    • Library (TS): Read local YAML files and reconstruct API payloads; push updates individually per entry; support parsing and filtering via publishing configuration in catalog.yaml.
    • CLI (TS-based): Implement a basic kcmd push.
    • Distribution: Update early access packages with push capability.
    • Testing: Implement test cases for basic push operation and publishing configuration filtering.

Phase 3: Enhanced Representation

Optimize the file format for human and agent editing. Update interfaces to support the new formats and layout options.

  • Key Features & Work:
    • Library (TS): Support kb scope type alongside bq-dataset and entryGroup in catalog.yaml; implement the CatalogLayout abstraction (layout.ts) and its concrete layouts (StandardLayout and DocumentsLayout); refactor CatalogSnapshot to delegate filesystem list, read, and write operations to the active layout strategy (automatically instantiated based on the scope); support type aliases in catalog.yaml, including built-in aliases for built-in types.
    • CLI (TS-based): Update pull and push operations to handle sidecars, aliases, and different layout scopes correctly.
    • MCP (TS-based): Ensure list-entries and lookup-entry tools correctly handle layouts (by delegating via CatalogSnapshot to CatalogLayout strategy).
    • Testing: Implement test cases for format mapping, including Standard layout (YAML + sidecars) and Documents layout (Markdown + frontmatter), and validate that CatalogSnapshot interacts correctly with both layouts based on the active scope type. Implement test cases for aliases.

Phase 4: Robust Sync and State Management

Ensure data integrity and efficient updates.

  • Key Features & Work:
    • Library (TS): Store checksums of local state in a separate file; use checksums to detect changes; treat missing files as intent to delete; fail fast on remote modification; fetch type definitions dynamically for validation.
    • CLI (TS-based): Add kcmd status to check for local modifications; update kcmd push to use checksums and report conflicts; support force override flag; implement --dry-run option for pull and push commands.
    • Testing: Implement test cases for checksum validation, intent to delete, validation against dynamic schema, and --dry-run behavior.

Phase 5: Future / Other

Other workstreams to consider.

  • Key Features & Work:
    • Library (TS): Support for EntryLinks
    • Python Library: Implement the equivalent library in Python to support Python-based agents and workflows.
    • MCP (TS-based): Implement pull and push tools in the MCP server.

Toolbox Mdcode Docs Semantic Model (`toolbox/mdcode/docs/semantic-model.md`)

Deploying a semantic model

This guide has moved into semantic-model/ and is
now split by what you came to do:

  • Deploy guide — author a model, push it, update
    it, pull it back.
  • Codelab: one semantic ontology, one data journey —
    author, govern, hydrate, and query one model, start to finish.
  • Reference — every flag, what push creates in
    BigQuery Graph, Spanner Graph, and Knowledge Catalog (including class
    hierarchies), validation, and permissions.
  • Model specification — the normative model
    format: every YAML construct, which parts are Apache Ossie and which are kcmd
    extensions or narrowings.
  • What push and pull preserve — the
    round-trip fidelity matrix: what survives a deploy, what a pull recovers, and
    what neither keeps.
  • Importing an OWL ontology — convert an OWL
    ontology into a semantic model, then deploy it the normal way.

Toolbox Mdcode Docs Spec (`toolbox/mdcode/docs/spec.md`)

Metadata as Code Specification

This specification builds upon the concepts in docs/concept.md and readme.md, detailing the agreed-upon design decisions, critical user journeys, glossary, and test cases for the Metadata as Code project.

1. Overview

Metadata as Code provides a source code artifact-based UX for metadata management and context engineering in Knowledge Catalog (Dataplex). It is designed for agent builders, data stewards, data producers, and AI agents to author, manage, and enrich metadata using developer-friendly workflows with version control and CI/CD.

1.1. Primary Use Case

The primary driver for the initial design is the Knowledge Catalog Enrichment Agent, focusing on automating metadata curation with human-in-the-loop review. However, the library is designed to be flexible and usable by any developer building agents or custom tools.

2. Glossary of Terms

To ensure consistency, the following terms are used:

  • Catalog Snapshot: A directory on the local filesystem containing a subset of Knowledge Catalog metadata.
  • Manifest (catalog.yaml): A file in the snapshot root that captures sync configuration and directives.
  • Scope: A unified identifier format (<type>.<name>) that establishes the single target resource for the snapshot in the manifest.
  • Standard Layout: A hybrid disk organization automatically used for bq-dataset and entryGroup scopes where metadata structure is stored in a YAML file per entry, and unstructured aspects are placed in sidecar Markdown files.
  • Documents Layout: A Markdown-only disk organization automatically used for kb scopes where the main entry is represented as a single Markdown file, with metadata structured in the YAML frontmatter and the main overview aspect promoted to the Markdown body.
  • Entry: A representation of a resource (like a table or dataset) in the Knowledge Catalog.
  • Aspect: A specific type of metadata attached to an entry (e.g., description, profile).
  • Sidecar File: An auxiliary file (e.g., Markdown) used for aspects with unstructured text.
  • Source: A backend metadata repository referenced by a scope (e.g., Dataplex EntryGroup or BigQuery Dataset).

3. Key Design Decisions

The following core decisions have been established:

3.1. Metadata Representation

The disk organization layout is automatically determined by the scope type:

  • Standard Layout (used for bq-dataset and entryGroup scopes): Structured data (entry metadata, resource info, aspects like schema and profile) is stored in a main YAML file per entry, while unstructured rich-text fields (like Overview) are stored in dedicated sidecar Markdown files.
  • Documents Layout (used for kb scopes): Structured metadata is stored within the YAML frontmatter of a single .md file per entry, and the primary unstructured aspect (overview.content) is promoted to serve as the main Markdown body of that file. Other unstructured aspects may still use sidecars.

3.2. Synchronization Model

We will support a bi-directional sync (Pull and Push) with the following rules:

  • Publishing is a subset of the snapshot list.
  • While required aspects may be implicitly included in the snapshot, all entry types and aspect types that should be published must be explicitly listed in the manifest.

3.3. Conflict Handling

To prevent data loss during a push operation:

  • The tool will fail fast if it detects that the metadata has been modified in the catalog in the interim.
  • It will abort the push, report the conflict, and require a pull to resolve.
  • A force override option will be provided to bypass this check if necessary.

3.4. Aliases Strategy

To maintain consistency and steer users toward simpler names:

  • If an alias is defined in catalog.yaml, it must be used consistently in metadata files.
  • Otherwise, the full reference takes the form of .<locationId>.<resourceId>.

3.5. Scale and Performance

  • Paginated Pull: Pull operations will be paginated to handle large resources.
  • Individual Push: Push operations will be performed individually per entry for now, with bulk import operations considered later.
  • Scale Assumption: Users are managing this as code artifacts in a repository and should not be operating at hyperscale/unbounded scale in this workflow.

3.6. Extensibility and Validation

  • Dynamic Schema Fetching: The tool will fetch type definitions from the Catalog service dynamically to validate local files against the actual registered schema.

3.7. Checksum Storage

  • Separate State File: Checksums for entries and aspects will be stored in a separate state file (e.g., .catalog.state) to separate user content from tool state.

3.8. Sync Deletions

  • Intent to Delete: If an entry type is listed in the publish config, treat missing files compared to state as intent to delete.
  • Skip for Managed Entries: Deletions are skipped for Dataplex managed entries (like BigQuery resources) which cannot be deleted.

3.9. Unified Scope Mapping

  • Single String Identifier: Snapshot resources are specified using a unified scope property formatted as <type>.<name> (e.g. bq-dataset. or entryGroup.). This unifies target definition logic and enables future extension to other resource types easily.

4. Critical User Journeys (CUJs)

We focus on the following core journeys:

  • CUJ 1: Initial Setup and Snapshot Creation
    User runs kcmd init to create a local snapshot of a BigQuery dataset or Dataplex EntryGroup.
  • CUJ 2: Metadata Sync (Pull and Push)
    User pulls updates from the catalog service, makes modifications to local files, and pushes changes back to the catalog.

Note: Advanced workflows like Automated Curation with Human Review (CUJ 3 candidates) are built on top of this foundation using external tools (IDE, git, custom agents) and are out of scope for this base library.

5. Key Test Cases

To validate the design, we will implement test cases covering the following areas, including user feedback:

5.1. Resource Types

  • Snapshots for different resource types: BigQuery datasets and Dataplex EntryGroups.
  • Entry and aspect varieties: Both Dataplex-defined entries/aspects (where required aspects are read-only) and user-defined ones (where required aspects are modifiable).

5.2. Format Mapping

  • Standard Layout (for bq-dataset and entryGroup scopes): Aspects managed correctly within the main YAML file, and unstructured text mapped correctly to/from sidecar Markdown files.
  • Documents Layout (for kb scopes): Metadata mapped correctly to/from YAML frontmatter, and the overview.content aspect promoted to/from the Markdown file body.

5.3. Directory Organization

  • BQ Resource Hierarchy: Testing layout for BigQuery datasets.
  • EntryGroup Hierarchy: Testing layout for EntryGroups based on path segments in the entry ID.

5.4. Data Integrity

  • Checksum validation: Verifying that checksums are updated and validated for entries and aspects during both pull and push operations to detect conflicts.

Samples Enrichment Sample Docs Events (`samples/enrichment/sample/docs/events.md`)

Using Analytics Events

Query a specific date range

To query a specific date range from a BigQuery event export dataset, use the _TABLE_SUFFIX pseudo column in the WHERE clause of your query. For more info, view Filtering selected tables using _TABLE_SUFFIX.

For example, the following query counts unique events by date and by event name for a specifc period of days and selected events:

sql
-- Example: Query a specific date range for selected events.
--
-- Counts unique events by date and by event name for a specifc period of days and
-- selected events(page_view, session_start, and purchase).

SELECT
  event_date,
  event_name,
  COUNT(*) AS event_count
FROM
  -- Replace table name.
  `bigquery-public-data.ga4_obfuscated_sample_ecommerce.events_*`
WHERE
  event_name IN ('page_view', 'session_start', 'purchase')
  -- Replace date range.
  AND _TABLE_SUFFIX BETWEEN '20201201' AND '20201202'
GROUP BY 1, 2;

Samples Enrichment Sample Docs Example1 (`samples/enrichment/sample/docs/example1.md`)

High-Level Event and User Volume

This provides examples for kc_enrich_sample_data.ga_events table.

Overview

Before diving into deep analysis, it is best practice to understand the scale of your dataset. This query counts the total number of events, the total number of unique users (based on device cookies), and the number of days present in the dataset.

sql
SELECT 
  COUNT(*) AS total_events, 
  COUNT(DISTINCT user_pseudo_id) AS total_unique_users, 
  COUNT(DISTINCT event_date) AS total_days
FROM 
  `kc_enrich_sample_data.ga_events`

Samples Enrichment Sample Docs Example2 (`samples/enrichment/sample/docs/example2.md`)

Querying a Specific Date Range

This provides examples for kc_enrich_sample_data.ga_events table.

Overview

Because the GA4 export creates a new partition for every day, you should use the predicate on the partition timestamp column to limit the data scanned. This improves performance and drastically reduces query costs. This query finds the daily count of active users for December 2020.

sql
SELECT 
  event_date, 
  COUNT(DISTINCT user_pseudo_id) as active_users 
FROM 
  `kc_enrich_sample_data.ga_events` 
WHERE 
  event_date_dt BETWEEN PARSE_DATE('%Y%m%d', '20201201') AND PARSE_DATE('%Y%m%d', '20201231') 
GROUP BY 
  event_date 
ORDER BY 
  event_date ASC

Samples Enrichment Sample Docs Example3 (`samples/enrichment/sample/docs/example3.md`)

Session Analysis (Unnesting Parameters)

This provides examples for kc_enrich_sample_data.ga_events table.

Overview

In GA4, a "session" is not a dedicated row; instead, every event contains a nested parameter called ga_session_id. To count total sessions, you must isolate the session_start event and "unnest" its parameters to extract the unique session ID.

sql
SELECT 
  COUNT(DISTINCT value.int_value) AS total_sessions
FROM 
  `kc_enrich_sample_data.ga_events`, 
  UNNEST(event_params) 
WHERE 
  event_name = 'session_start' 
  AND key = 'ga_session_id'

Samples Enrichment Sample Docs Example4 (`samples/enrichment/sample/docs/example4.md`)

E-Commerce Product Interest

This provides examples for kc_enrich_sample_data.ga_events table.

Overview

For an e-commerce store, understanding which items are frequently added to the cart is crucial. E-commerce data is stored in a nested array called items. This query flattens the items array and aggregates the top 10 products based on the add_to_cart event.

sql
SELECT 
  items.item_name, 
  COUNT(*) AS added_to_cart_count 
FROM 
  `kc_enrich_sample_data.ga_events`, 
  UNNEST(items) AS items
WHERE 
  event_name = 'add_to_cart' 
GROUP BY 
  items.item_name 
ORDER BY 
  added_to_cart_count DESC 
LIMIT 10

Samples Enrichment Sample Docs Lipsum (`samples/enrichment/sample/docs/lipsum.md`)

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.


Samples Enrichment Sample Docs Usage (`samples/enrichment/sample/docs/usage.md`)

Using Google Analytics 4 BigQuery Public Dataset

This provides usage information for kc_enrich_sample_data.ga_events table.

How the Data Can Be Used

This dataset is incredibly valuable for analysts, data scientists, and developers who want to learn how to query GA4 data in BigQuery without needing their own fully populated e-commerce site or GA4 export.

Getting Started

To interact with the dataset, you need:

A Google Cloud Project with the BigQuery API enabled.

You can use the BigQuery Sandbox, which provides a generous free tier of up to 1 TB of querying per month, allowing you to explore the data without needing to attach a credit card.

Primary Applications

Schema Familiarization: GA4 exports data in a highly nested format (using arrays and structs for event_params, user_properties, and items). This dataset allows you to practice the UNNEST() function to flatten data.

Testing Queries & Data Models: Build and test advanced SQL data models for user retention, funnel analysis, or marketing attribution before applying them to your production data.

Dashboard Prototyping: Connect this public BigQuery dataset to visualization tools like Looker Studio or Tableau to practice building e-commerce and web performance dashboards.


Toolbox Mdcode Docs Semantic Model Actions (`toolbox/mdcode/docs/semantic-model/actions.md`)

Modeling write operations

Your semantic model already tells an agent what your data means and which
questions it can ask. Ask that agent to do something — refund an order, move
money between two accounts, close a dormant account — and the model has nothing
to say. The write exists somewhere, as a service call or an endpoint or a few
lines of DML, but it's visible only to whoever built the service around it. Your
agent either can't reach it, or reaches it through a hand-written tool that your
model never sees and can't govern.

An action closes that gap. It's a named write operation declared over the
same concepts as the rest of your model: you give it a name, type its inputs
against your ontology, and say which concepts a call changes. Publishing it puts
the operation in the same place as the data it acts on, so an agent that
discovers your model discovers what it can change there as well as what it can
ask.

One part of an action is physical — how the write actually happens. That part
lives in a field called the executor, which names an MCP tool, a REST
endpoint, a gRPC method, or DML. Keeping it separate lets a binding profile
supply it, so you write an action across two files. Your model names the
operation and its parameters; a binding profile names the tables, the columns,
and the executor. Whatever runs the action combines the two into one bound
model.

mermaid
graph LR
    M["the model<br>concepts, actions, constraints"]
    P["a binding profile<br>tables, columns, executor, target"]
    RT(["one model, bound<br>ready to run"])
    ST["the store<br>where a write lands"]
    AG["what an agent is handed<br>write tools, instruction"]

    M --> RT
    P --> RT
    RT --> ST
    RT --> AG

Figure 1: your model and a binding profile combine into one bound model, which
both reaches your store and supplies the tools an agent is handed.

The rest of this guide follows one action — moving money between two bank
accounts — from a name in your model through to an agent that can call it. A
second example, a customer-service credit, comes in once the rules get harder to
write.

The commands here are kcmd, which reads a model, pushes it, and derives the
tools an agent is handed under a binding profile. What the model states holds
for any runtime that reads it, and the library kcmd is built on is the same
one a service would embed.

When to use an action

Declare an action when the write already exists. Somewhere in your organization
there's a service call, an endpoint, or some DML that moves this money or issues
this credit. Declaring it as an action doesn't reimplement any of that — it
tells everything that reads your model that the operation is there, what it
takes, and where it lives.

If the call should hand back an answer instead of changing something, you want a
metric instead.

1. Declare the action

Start with the name and the parameters. They're the contract every later step
builds on, and they live in your model and nowhere else — no profile can change
them.

Actions sit at model level, beside your metrics. Each one carries a name, its
parameters, and an executor. Treat that executor as a default, because it's the
one part a binding profile can replace:

text
/* Detailed source-code truncated for AI context efficiency. */

You author the rest of the model — the deployment target, the entity bindings,
the relationships — the way you would for any model. See
Deploying a semantic model.

Both description and ai_context.instructions travel through to the catalog,
and a parameter's description is what tells a caller which argument is which.
Two parameters a caller could confuse must each carry one, or the push fails.
Write the instructions for the agent that's going to call the action, the way
the example does.

Projecting a parameter from a field

Every parameter carries one scalar value — the same kind of value a column
holds. What differs is where its definition comes from, and there are two ways
to give it one: project it from a field of your ontology, or declare it
on the spot.

A projected parameter takes its definition from a field

A projected parameter names a concept and a field, and takes that
field's datatype, description, label and AI context straight out of the model.
source above is an Integer because Account.accountId is one, so a caller
passes an account id and the write binds it as an integer. Change the field
later and every parameter projected from it follows, because there's no second
copy to keep in step. A concept is read the way affects reads one, so it can
name a relationship as well as an entity — but what you project from in practice
is an entity, because a relationship only has fields of its own when a junction
table backs it, and this format has no syntax for one yet.

The pair can be written two ways, and they load to the same parameter:

yaml
- { concept: Account, field: accountId }
          - { field: Account.accountId }

The one-key form spells the projection the way a metric expression or a
constraint judgment already spells a field, and is there for the same reason.
A field that carries a dot is read as Concept.field, so a field with more
than two parts is refused at load rather than read as half a reference, and so
is a dotted field beside a concept, which would name the concept twice —
that one is refused with both of the forms above spelled out for you.

Most projected parameters need no name of their own. Leave name out and the
parameter answers to the field's, so this one is called accountId:

yaml
- { concept: Account, field: accountId }

Name it yourself when one action takes the same field twice, the way
TransferFunds takes both a source and a target.

This shape holds however an entity is keyed. An Account identified by three
columns takes three projected parameters, one per key field, each typed from
the field it names — the same declaration a single-column key writes, three
times.

A declared parameter carries its own type

A declared parameter carries a value no field holds: the amount above, a
free-text memo, a reason code your store never keeps. Give it a type and a
description of your own. type takes a scalar datatype only, and naming an
entity there is an error telling you to project the field you meant. datatype
is accepted as a spelling of the same key — the one fields: already uses —
and stating both at once is refused rather than guessed at.

A projected parameter can't restate its field's type. If the type is wrong,
fix the field. If a statement needs a different one, cast it in the DML, where
the conversion is visible to whoever reads the write instead of buried in
metadata.

What else a parameter may carry

Each parameter may also carry:

  • description — what the parameter means for this call. A projected
    parameter inherits the field's, and overriding it is how source and
    target above say different things while projecting one field. Required when
    two parameters on the same action project the same field or share a declared
    type, because neither can tell an agent which argument is which on its own —
    and the inherited wording does not count, since two parameters carrying one
    field's description verbatim are as indistinguishable as two carrying none.
  • label and ai_context — inherited the same way, overridden the
    same way.
  • default — a fallback value substituted when the caller omits the
    argument. Giving a parameter a default makes it optional; setting
    required: true alongside default is rejected.
  • required: false — marks a parameter optional with no fallback; an
    omitted call binds NULL in SQL.

default and required are always yours to set, projected or not. A field
says what a value is; the parameter says how this one call uses it. A
parameter that states neither is required, so the two keys above are the only
ways to make one optional.

Where the executor comes from

The executor tells a consumer where the operation lives. Pick one of these four
kinds, and give any one executor a single kind only:

  • mcp — {server, tool}. A tool you've already registered in Agent
    Registry, named by the server's resource name and the tool's name within it.
  • rest — {endpoint, method}. An HTTP endpoint and the verb to call it
    with.
  • grpc — {service, method}. A service and the method on it.
  • sql — {statements}. The write itself, carried in your model instead
    of named as a pointer to whoever performs it, and the only kind that needs a
    store to run against at all.
    See carrying the write as DML.

Everything else your action declares is logical: what it takes, what gates it,
what it changes. None of that changes when you deploy the same model somewhere
else. The executor does, which is why it sits on the physical side with an
entity's source, and why a binding profile can supply one or
replace the one your model declares — for any of the four kinds. An action a
profile says nothing about keeps the executor the model gave it.

It changes for two different reasons. An mcp, rest or grpc executor is an
address: the operation lives at a different server, endpoint or service in
staging than it does in production, and where your rows sit never enters it. A
sql executor is the statement, written in the bound store's table and column
names and in its dialect, so until you know which store answers there's nothing
to write down. Only that last case turns on where the data lives.

Actions declared but not performable

An action with no executor anywhere — none in the model, none in any profile —
is declared but not performable. It still says what it does, what it takes,
what gates it and what it changes; the only thing missing is who carries the
write out. Write one when nobody has wired the write up yet, or when another
team owns it and your model needs only to record that it exists.

Nothing declares this state: it follows from the executor being absent, so the
same action is performable under a profile that supplies one and not
performable under a profile that supplies none or withdraws the model's with
executor: null. A catalog-only push — --no-profile,
or a model with no deployment target — publishes it like any other action, and
kcmd profiles lists it under cannot run: for each binding that supplies no
executor for it.

Carrying the write as DML

The first three kinds name a system that performs the write, which leaves the
write itself opaque to your model: an mcp tool name says where the operation
lives and nothing about what it touches. A sql executor carries the write
instead, so what your action does becomes readable — and checkable — from the
bound model, and an agent or tool framework can
run it directly against your store
rather than handing the write to another system to perform.

Statements are written in one database's own table and column names, in its own
dialect, so a sql executor goes in that database's binding
profile
— beside the bindings for those same tables and columns —
rather than in the model it binds. Here it replaces the mcp executor the model
declared for TransferFunds:

yaml
# payments.profiles/operational.yaml — this store owns the rows, so it writes them
semantic_model:
  - name: payments
    deployment_target: spanner.googleapis.com/projects/my-project/instances/my-instance/databases/bank/graphs/payments
    entities:
      - name: Account
        source: spanner.googleapis.com/projects/my-project/instances/my-instance/databases/bank/tables/account
        fields:
          - { name: accountId, expression: account_id }
          - { name: balance,   expression: balance }
          # ... and the model's remaining entities and fields, bound the same way
    actions:
      - name: TransferFunds
        executor:
          sql:
            statements:
              - UPDATE account SET balance = balance - @amount WHERE account_id = @source
              - UPDATE account SET balance = balance + @amount WHERE account_id = @target
              - INSERT INTO transfer (transfer_id, amount, debited_account_id) VALUES (GENERATE_UUID(), @amount, @source)

A profile is an overlay on the model's bindings: a field it does not restate
keeps the column the model file gives it. So the profile you put a sql
executor in has to restate every column its store names differently, the ones
its statements touch included — leave one out and it keeps the model's column,
which this store may not have. Of the action itself, only the executor is
restated: what the call takes, what gates it and what it changes stay in the
model, exactly as declared.

statements is a list because one business action is often more than one write.
The transfer above debits one account, credits another, and records the
transfer row itself, and a transfer that did only the first would lose money.
Running the statements in the order you wrote them inside one transaction
commits them together at the end, so writes that only make sense together never
apply by halves.

Carrying the write buys you two things:

  • Your blast radius is checkable. A reader can compare affects against the
    statements instead of taking it on trust.
  • A guard becomes a real gate. An MCP, REST or gRPC call commits inside a
    system your caller doesn't control, so a write it performed can't be rolled
    back if the rest of the action fails. Because a sql action declares its
    statements up front, the agent or tool framework running it can settle its
    guards before opening a transaction, so a refusal leaves the store untouched.

Statements use your database names

An action's statements reach your store exactly as you wrote them, so every
table and column in one has to be the name your database uses. In the sql
executor above, the model's Account and accountId appear as account and
account_id. Those are the table and the column that the entity's source key
and its fields' expression keys bind it to.

@parameter references are the exception: they name an argument bound at call
time rather than anything in your database.

Push checks those names for you, against the store itself. Before anything is
published, every statement in a sql executor is sent to the database the
profile binds — Spanner plans it, BigQuery dry-runs it — which parses it,
resolves every table and column, and type-checks every parameter without
executing a row of it. A statement that says accountId where the column is
account_id fails the push:

text
Error: action 'TransferFunds' in model 'payments'
(payments.profiles/operational.yaml): statement 1 of its sql executor was
rejected by Spanner database 'my-project/my-instance/bank': Unrecognized name:
accountId; Did you mean account? [at 1:54]. Statements reach the store exactly
as written, so every table and column has to be the name the database uses.

Everything between the store's name and the closing sentence is the store's own
answer, which is why it can point at column 54. It also explains the suggestion:
Spanner offers account, the table, because that is the nearest name in scope
where the statement went wrong — BigQuery, dry-running the same mistake, answers
Did you mean account_id? instead. Take the diagnosis from the store you
actually deploy to. That's also why the check is
worth more than comparing your statements against the profile's bindings would
be: the store catches what a name comparison can't see. A placeholder or a
function borrowed from another dialect gets every identifier right and is still
invalid. A parameter whose declared type doesn't fit the column it's compared to
resolves every name correctly and fails on the type.

An entity named Order bound to a table named Orders is the case worth
recognizing, because the answer doesn't mention a table at all:

text
Syntax error: Unexpected keyword ORDER [at 1:8]

ORDER is a reserved word, so the statement stops parsing before anything goes
looking for a table to write to.

Nothing is written by any of this. Spanner plans inside a transaction that is
never committed, and a BigQuery dry run doesn't execute the statement. What the
check does need is access: it reaches the database your profile names, so a
push from somewhere that can't reach it reports that rather than skipping the
statements.

Which file a `sql` executor belongs in

The placement is enforced: select a profile whose model declares a sql
executor and the command refuses, naming the action:

text
Error: [payments] profile 'operational': action 'TransferFunds' in model
'payments' declares a 'sql' executor. A statement names one database's own
tables and columns, so it belongs in the profile that binds them, not in the
model. Move the executor into each profile that performs this write as DML.

A single-file model is the exception: that one document is both the model and
its binding, so its statements already sit beside the columns they name. Add a
profile beside it later and they move into the profile.

2. Gate it with a constraint

The sections so far declared an action and gave it a write to perform. This
section is how you stop it running when it shouldn't. A constraint is a
named rule your model states over its ontology. Declaring one adds it to the
catalog and changes nothing by itself. A constraint takes effect where
something references it and nowhere else, so publishing a rule can't quietly
start refusing calls that succeeded yesterday.

text
declared                referenced             checked            a breach
  ─────────────────       ─────────────────      ──────────────     ───────────
  constraints:            actions:               before the call,   reject
    - name: X       ──▶     - name: Y      ──▶   with the      ──▶  escalate
      judgment: …             guards: [X]        arguments bound    warn

  a rule in the           the only thing that    a language model   on_violation
  catalog, inert          gives it effect        reads the call     names one of
                                                 and settles it     the three

Figure 2: a constraint moves from declared, to referenced by an action, to
checked before a call, to a breach routed by on_violation.

A constraint has one body, judgment: the rule written as a sentence, settled
by a language model reading the attempted call.

Naming a constraint as a guard

An action's guards list is what puts a constraint to work. Declare the rule,
then name it on the action — one line on the TransferFunds from section 1:

yaml
constraints:
      - name: TransferWithinAvailableBalance
        judgment: >-
          The amount argument of this call must not exceed Account.balance on
          the source account. That balance is on record rather than stated in
          the arguments, and nothing puts it in front of you.
        on_violation: reject
        description: >-
          A transfer cannot move more than the source account holds. Lower the
          amount, or choose another account.
    actions:
      - name: TransferFunds
        guards: [TransferWithinAvailableBalance]

guards holds the names of constraints your model declares, and listing one
there is what makes it apply to that action. Put the reference on the action
rather than on the constraint, because the same rule may gate TransferFunds
and leave CloseAccount alone.

That rule is the honest shape of a balance check and it is also one nothing here
enforces: the balance is a row, a judge is shown the arguments and no more, and
a rule it cannot settle it reports as not holding — which under reject refuses
every call. Declared this way it is a policy on record and a schema constraint
waiting to be written, not a control. Keep reading with that in mind; what a
guard can see
explains why.

Whatever dispatches the call is what checks its guards, and it checks every one
of them before the call, with the arguments bound and before any transaction
opens. Nothing in your model binds a rule to the state a write leaves behind, so
a guard reports on the arguments it was handed and on the data the call starts
from, and on nothing else.

guards and on_violation are independent, the way the two right-hand columns
of figure 2 are: one says when the constraint gets checked, the other says what
a breach does. So guarding a constraint that declares warn is a real shape.
Your organization may not be ready to block on a rule; guarding it anyway still
gets the rule checked at the moment of the call and reported back.

When one call violates several guards, the strictest outcome applies. Any
reject refuses the call, failing that any escalate holds it, and failing
that any warn lets it through with the violations reported. The precedence is
fixed and nothing in your model states it, so an action can name any number of
guards without you writing down how they combine. It is also how forbid
overrides permit in Cedar and how a deny wins in Open Policy Agent, so a
policy written this way lowers into either.

A guard that names no constraint fails the push, so a typo can't leave you
believing a write is checked when nothing checks it. The reverse isn't an
error: a constraint no action names is declared, published and inert, which is
a reasonable thing to publish for a reader or for an action you haven't written
yet.

What a judgment costs

A judgment is a whole constraint body. Here is one, with the two fields that
say what a breach does and how grave it is:

yaml
constraints:
      - name: CreditMemoNamesAServiceFailure
        judgment: >-
          LineItem.memo must name a specific, verifiable service failure on the
          order: a late delivery, a damaged item, a shipping charge applied in
          error. A memo that states only that the customer requested a credit
          does not satisfy this rule.
        description: >-
          Say what went wrong with the order in the credit memo.
        on_violation: warn
        severity: low

Settling that rule is a model call, and one goes out for every guard on every
attempted write. The verdict can also move: a ceiling of 25 dollars, stated as a
judgment, can answer two identical calls differently, where amount <= 25 could
not.

So write a judgment for a rule that has to weigh what a caller wrote, where
nothing shorter would settle it. Two cases are worth keeping out. A threshold
over the call's own arguments is the first: amount <= 25 needs no reading at
all, and a sentence buys you a model call and a verdict that can move.
Leave it in whatever dispatches the call — the model has no home for it today,
which is in what is not modeled yet.

A rule that turns on a stored value is the second. Nothing fetches that value
for the judge, so the sentence gets settled against a figure the model supplied
itself or refused for want of one. Write the rule down anyway if the model is
where your policy lives, and enforce it in your schema — see
what a guard can see.

Every constraint must state on_violation, and any of the three words will do.
Omit it and the push fails, because an unmarked constraint would reject, and
that's too strong a consequence to inherit by silence.

What a guard can see

A guard is checked before the call, with the arguments bound and before any
transaction opens. It sees one thing: the arguments it was handed. A rule about
the arguments — whether the memo the caller wrote names a real service failure,
say — is settled completely, because the arguments are the whole of what it
reads.

Nothing settles a rule about a stored value the call doesn't carry. A judge is
given the rule, the action and the arguments, and goes nowhere for anything
else, so the credit must not exceed the total of the order has no total to
compare against. The judge is instructed to treat a rule it cannot settle from
the arguments as one that does not hold and to say in its reason what was
missing — a safe failure, not a working check. Put that rule in your schema,
where the store enforces it inside the transaction.

Nothing settles a rule about the state a write leaves behind, either. "An
order's total equals the sum of its lines" has nothing to look at when the guard
runs, for a second reason: the state it describes does not exist yet. That one
belongs in the same place.

Status: judgment is the only body a constraint has. Whatever checks a
guard puts the sentence to a language model and routes the verdict by
on_violation.

Writing a judgment

A language model reads your sentence at review time with the proposed write in
front of it. That reader is the judge. Five habits make that reading
consistent:

  1. State what must be true of the data. Write the condition — the memo
    must name a specific service failure
    , and not the procedure, check whether
    the memo is specific
    . Your sentence describes a clean write, and everything
    about handling a breach lives elsewhere.
  2. Name fields model-qualified. Write LineItem.memo rather than "the
    memo". A push resolves every Entity.field token in the text against your
    model and fails when the entity declares no such field, so a rename can't
    leave your sentence pointing at nothing. It also tells a reader which value
    the rule is about, which the judge cannot go and fetch.
  3. Say what doesn't count. A rule with no negative example gets graded
    against whatever the model guesses you had in mind. The sentence "A memo
    that states only that the customer requested a credit does not satisfy this
    rule" buys you more consistency than any further description of what a good
    memo is.
  4. Leave the consequence out of the prose. What happens on a breach is
    on_violation. A judgment ending "…otherwise escalate to a supervisor"
    states a routing nothing reads, and the engine routes by the field
    regardless.
  5. Keep it to one condition. When your sentence needs "and also", the
    second half is a second constraint. One on_violation can't carry two
    consequences, so two conditions that end differently can't share a
    constraint.

All five are about wording. Claim no more in the wording than a judge can
settle: phrase the condition around the arguments the call carries, and put any
rule that depends on a stored row into your schema.

A credit policy, worked through

Real policies have several rules, and the rules rarely end the same way. A
support agent is about to issue a customer-service credit, and five separate
rules bear on whether they may:

text
the business rule                        a breach   settled
  ──────────────────────────────────────   ────────   ────────────────────────
  1  no credit above the order's total     escalate   judgment (policy; schema enforces)
  3  the memo names a service failure      warn       judgment
  4  not one credit split to evade review  reject     judgment
  ──────────────────────────────────────   ────────   ────────────────────────
  2  over 25 dollars needs a supervisor    —          your dispatch, not here
  5  the total equals the line items       —          your schema, not here

Table 1: the three rules of the credit policy that a guard can carry, what a
breach of each one does, and the two rules that belong somewhere else.

Rules 2 and 5 are in the table to be ruled out. Rule 5 is about the state a
write leaves behind, and a guard runs before the transaction opens, so no
constraint you write here reaches it; put it in your schema. Rule 2 compares one
argument against a literal: written as a judgment it would buy a model call to
perform a subtraction, and a verdict that can move at the boundary. Leave it in
whatever dispatches the call. The 25-dollar ceiling still governs the desk, and
rule 4 is written against it, but nothing in the model states it.

Writing the other three down needs an Order with a total, a LineItem with
an amount and a memo, and an IssueCredit action taking the order, the
amount and the memo. Each rule becomes one constraint, and each states its own
outcome in on_violation:

text
/* Detailed source-code truncated for AI context efficiency. */

Each on_violation key carries one branch of what your policy describes in
prose, in a form a search can read.

Rules 3 and 4 are the reason a constraint is written in words. Neither
reduces to arithmetic over Order and LineItem at all, so there's nowhere
else to put them but a policy document nothing links to.

Rule 1 is arithmetic against a row, and nothing here settles it. It compares
the amount argument against a number that lives in the database, and a judge
sees the arguments only, so the rule is recorded in the model and enforced in
your schema — see
what a guard can see. Rule 2 is the
same comparison against a literal rather than a row, and that difference is the
whole reason it stays out of the model. Rule 4 reads on the credits already
sitting on the order, so it is the same case as rule 1.

Rule 4 is a judgment that declares reject. Splitting a credit to evade
review is a rule the business means as unappealable, and the pairing carries a
real cost, because a model can decide two identical credits differently and
reject leaves nobody to appeal to. The model publishes such a rule instead of
forbidding it, and makes it findable: an auditor asking which unappealable rules
your model settles gets an answer from one query over the constraints.

Two calls through the policy

Here are two calls against order 12345, which totals $165.85. One is a
200-dollar credit for a shipping charge billed in error, which is more than the
order was ever worth. The other is three 9-dollar credits raised within the
hour, each memo reading some version of "customer asked":

text
amount=200.00,         amount=9.00 x3,
                                  "shipping charge       "customer asked"
                                   applied in error"
  ──────────────────────────────  ─────────────────────  ─────────────────────
  1  within the order's total     violated ─▶ escalate   holds
  3  memo names a failure         holds                  violated ─▶ warn
  4  not split to evade review    holds                  violated ─▶ reject
  ──────────────────────────────  ─────────────────────  ─────────────────────
  strictest outcome wins          held for a supervisor  refused, with the
                                                         memo warning reported

Table 2: how each of the two calls fares against the three rules, and the
outcome that wins.

The supervisor who gets the first call reviews a credit against an order rather
than a SQL diff. The second call is the case rule 4 was written for. Every
per-call gate lets it through, because each 9-dollar credit sits under the
order's total on its own, and under the desk's 25-dollar ceiling too, and only
reading the three together as one 27-dollar credit puts them over it.

The bottom row of table 2 applies the strictest-outcome rule from section 2 —
an escalate with nothing stricter beside it holds the first call, and rule 4's
reject decides the second.

Status: a run doesn't compute the strictest outcome. The runtime puts the
guards to the judge in the order your model declares them and stops at the
first one that fails without being advisory. What comes back is that guard's
outcome rather than the strictest of them, and a warn collected on the way
there doesn't travel with the refusal.

3. Say what it changes

affects names the concepts a call writes to. An executor like
mcp: {server, tool} says where the operation lives and nothing more, so no
reader of your model can see what that tool writes; the blast radius of a call
stays unknown until you declare it here.

affects is a list, and every entry in it takes one of two shapes: a bare
concept name, or a record that also says how the concept changes.

A bare name says the concept is touched, in a way your model doesn't spell
out:

yaml
affects: [Account, Transfer]

That's enough to answer which actions can change an account at all, which is
already more than an executor name answers.

A record adds the operation and the fields the call writes:

yaml
affects:
          - concept: Account
            operation: modify
            fields: [balance]
          - concept: Transfer
            operation: create
          - concept: TransferDebits
            operation: create

You'll usually have worked some concepts out further than others. The two
shapes go in one list together, so a coarse entry on the ones you haven't costs
you nothing on the ones you have:

yaml
affects:
          - { concept: Account, operation: modify, fields: [balance] }
          - Transfer

affects declares the blast radius rather than limiting it. Naming one field
doesn't stop a statement from writing others, and naming two concepts doesn't
stop a call from touching a third; the list records what you mean the call to
do.

What an entry may say

concept takes an entity or a relationship, written the same way for either
kind — your model already records which one it is. TransferDebits above is
the edge from the example model, and it sits in the list beside the two
entities. Either way the name has to be one the same model declares.

operation is create, modify or delete, the same three whatever the
concept is, and a fourth word means your document doesn't parse. Leaving it out
gives you the coarse form one entry at a time, covering every operation on that
concept. Two entries on one concept with the same operation are a hard load
error, and a bare entry beside one with an operation warns, because the bare
one already covered what the second narrows.

fields names what a create or a modify writes, which makes which actions
can change Account.balance
answerable. A delete takes the whole instance,
so a field named beside one is rejected rather than ignored.

On a relationship, fields means the junction table's own columns, so modify
the grade on an Enrollment
is as ordinary a change as modify an order's
total
. A plain foreign-key edge carries no columns of its own, and
TransferDebits is one, so naming a field on it is an error — the property you
meant belongs to an endpoint entity.

Name the concept now and refine it later. Writing - concept: Account on its
own says the same thing the bare Account does, and it's written back as the
bare form.

Status: nothing compares affects to what your executor does. kcmd parses
it, checks the concepts against your ontology, publishes it and reads it back,
and no component reconciles the declaration with the statements or the tool
call. Two things beyond those checks read it. Resolving your model through a
binding profile drops any action affecting a concept the profile can't bind, so
that action never reaches the catalog. Publishing an action whose affected
concept has no entry in the same push warns you that the catalog now records a
blast radius naming something it can't resolve.

4. Check it before pushing

kcmd push --validate-only runs the checks a push runs and deploys nothing. It
loads your model, checks each action against the ontology that same model
declares, asks your store about any DML those actions carry, and prints every
error it finds. A push clears the same gate before it writes anything, so
whatever this reports would have stopped your deploy:

bash
kcmd push --validate-only

Once your document parses, five checks run over every action, and a failure in
any of them stops the push:

  • Every parameter has a type the model can settle — projected from a field,
    or a scalar datatype you named. A concept the model doesn't declare, a
    field that concept doesn't have, a type sitting on a projected parameter,
    and a type naming an entity are four different mistakes and report as four
    different errors.
  • Parameters a caller could confuse each carry a description. Two
    parameters projecting Account.accountId, or two sharing a declared type,
    otherwise produce identical tool argument documentation, leaving an agent
    guessing which is source and which is target.
  • The executor has the fields its kind requires. server and tool for
    mcp, endpoint and method for rest, service and method for grpc,
    at least one statement for sql. Leave one blank and whatever picks the
    action up has nothing to call.
  • Every name in guards is a constraint the model declares. This is the
    one that costs you silently — a guard naming a constraint that doesn't exist
    gates nothing, so you believe the write is checked while nothing checks it.
  • Every affects concept is an entity or relationship the model declares.
    The list exists to answer which actions change Account. A typo is
    invisible on the page and would drop the action out of that answer.

An action carrying a sql executor clears one more gate, and that one reads
your database rather than your document: every statement is planned against the
store your profile binds, as described above.

A model that breaks all five reports all five, one line each, and deploys
nothing:

text
Error: action 'TransferFunds' in model 'payments' (payments) has parameter
'amount' typed 'Currency', which is not a scalar datatype
(String/Integer/Decimal/Float/Boolean/Date/Time/DateTime/DateTimeTz/Opaque).
Error: action 'TransferFunds' in model 'payments' (payments) has multiple
parameters projected from 'Account.accountId', so parameters 'source' and
'target' must each have a 'description' of their own to distinguish them.
Error: action 'TransferFunds' in model 'payments' (payments) has an mcp
executor whose 'tool' is missing or blank.
Error: action 'TransferFunds' in model 'payments' (payments) is guarded by
'TransferWithinAvailableBalence', but model 'payments' declares no constraint
of that name.
Error: action 'TransferFunds' in model 'payments' (payments) affects 'Acount',
which is neither an entity nor a relationship this model declares.

TransferWithinAvailableBalence there misspells the
TransferWithinAvailableBalance the model declares, and Acount misspells
Account — the two typos a reader of the document would skim straight past.

kcmd checks the rest of an affects entry just as strictly. Fields beside a
delete are a hard error, and so is a field the concept doesn't declare. An
operation outside create / modify / delete never gets this far — the
vocabulary is closed, so your document doesn't parse at all.

The two checks that read your ontology stand down when a binding profile
resolves your model. Resolving drops entities and relationships the profile
can't bind, so holding affects to the ontology there would fail your deploy
over a concept the profile removed rather than one you mistyped. An undeclared
concept and an undeclared field fall back to the warning the loader already
gave. A catalog-only push reads the author's model whole, so it treats the same
two as hard errors. Fields beside a delete read only the entry, so that one
fails everywhere.

What push holds a statement to

kcmd checks a sql executor further than the other three kinds, because it
carries the write rather than a pointer to whoever performs it:

  • Write each statement as a single INSERT, UPDATE or DELETE. A
    statement that reads is a query and belongs in a metric; one that reshapes the
    schema isn't an action. A ; anywhere but the end is rejected, because each
    list entry runs on its own and anything after the separator would silently not
    run.
  • Every @name a statement binds has to resolve to a parameter your
    action declares. Anything you write another way is left alone — a literal
    passes, and so does a SQL function. Nothing is interpolated into the
    statement text, so an argument can't become SQL.
  • You choose where a new row's key comes from. Write GENERATE_UUID()
    into the VALUES list, pass the key in as an ordinary parameter, or, where
    the key column has a default, leave it out and let the store fill it. kcmd
    generates nothing on your behalf, and nothing in affects bears on the
    choice.
  • A key that arrives as an argument is a key the caller chooses. An agent
    filling that parameter can name a row that already exists, and nothing in
    kcmd stops it, because the check on statement shape reads only the first
    word. An upsert therefore passes and overwrites. Spanner spells one
    INSERT OR UPDATE and AlloyDB spells one INSERT ... ON CONFLICT. A plain
    INSERT fails on the duplicate instead. Generate the key inside the
    statement unless something about the call needs the caller to pick it.
  • Nothing else is available: no control flow, and no statement composed at call
    time. statements is a fixed list in your model, so an action whose body
    arrived with the call would declare nothing, and a gate can't check what was
    never declared.

Every one of those reads the statement text. The last check reads your database:
as described above, the store your
profile binds parses, resolves and type-checks each statement before the push
publishes anything, so a name the database doesn't have stops the push rather
than the first agent to call the action.

5. Push it

bash
kcmd push

Knowledge Catalog is the one system your action reaches. Each action becomes its
own entry there, parented to the model entry, the same way a metric does. The
entry carries one aspect holding the executor and the typed parameters:

yaml
# .../entryGroups/<group>/entries/payments.actions.TransferFunds
entryType: projects/<project>/locations/global/entryTypes/semantic-action
parentEntry: .../entryGroups/<group>/entries/payments
entrySource:
  displayName: TransferFunds
  description: Move money from one account to another.
aspects:
  <project>.global.semantic-action:
    executorKind: mcp
    mcpServer: //agentregistry.googleapis.com/projects/acme-ops/locations/us-central1/mcpServers/payments
    mcpTool: transfer_funds
    parameters:
      - {name: source, type: Integer, concept: Account, field: accountId, description: The account the money leaves.}
      - {name: target, type: Integer, concept: Account, field: accountId, description: The account the money goes to.}
      - {name: amount, type: Float}
    affects:
      - {concept: Account, operation: modify, fields: [balance]}
      - {concept: Transfer, operation: create}
      - {concept: TransferDebits, operation: create}
    instructions: Resolve both accounts before calling.

A projected parameter publishes both halves: the concept and field it came
from, and the type that projection settled on. The reference is what you
wrote and what a pull reads back, so the link into your ontology survives the
round trip. The resolved type is there for a consumer reading the catalog
without your model in hand, which would otherwise have no way to know what to
pass.

affects is published exactly as you wrote it. The entry doesn't record
whether TransferDebits is an entity or an edge. A consumer that needs that
answer reads it off your model, the only place that still answers correctly
after a rename.

Every action in your model gets an entry, whether or not the binding you pushed
can perform it. When that binding gives an action no executor, the entry
publishes without the executor fields, so the catalog records an action that
nothing is yet wired up to run. A push reads the binding named by --profile,
or by default_profile in catalog.yaml when you don't name one.

Remove an action from your document and the next push deletes its entry, because
your model owns the <model>.actions. id prefix. A catalog search can list the
actions in a project by entry type, the way it lists entities or metrics.

semantic-action is a custom entry type, because Dataplex has no built-in type
for an action yet. Run kcmd init --semantic-model once before you push a model
that declares actions, and it provisions the entry type and its aspect type in
your own project.

Publishing an action needs the permission to attach its aspect, on top of the
permissions any push needs — see
Reference → Permissions.

6. Pull it back

bash
kcmd pull

Pull collects the semantic-action entries under your model entry and rebuilds
each action. The name, the description, the executor, the typed parameters,
guards, affects, and ai_context.instructions all survive the round trip
unchanged. What push and pull preserve lists which parts of a
model survive that trip and which don't.

7. Hand it to an agent (`kcmd skills-generate`)

You don't hand-write prompts or tool wrappers for each agent framework. Run
kcmd skills-generate against your model and binding profile, and kcmd
writes an Agent Skill — a folder of Markdown files
that Claude Code, Gemini CLI, Cursor, and other skill-compatible agents load
directly:

bash
kcmd skills-generate --out skills
text
Wrote skills/payments/SKILL.md
Wrote skills/payments/references/transfer-funds.md

The generated skill splits into two layers so an agent only loads the detail of
the action it actually needs:

  1. SKILL.md (the router) — loaded when the skill triggers. It carries the
    model's description, a table of actions pointing to references/*.md, how
    the model wants to be used (ai_context.instructions), how to read keys
    from the bound store (Finding a record), and the deployment details for the
    selected profile (Running an action).
  2. references/<action>.md (one reference page per action) — loaded on
    demand when the agent picks an action. For TransferFunds,
    references/transfer-funds.md carries:
markdown
# TransferFunds

Action `TransferFunds` of the `payments` model. As a tool it is named `transfer_funds`.

Move money from one account to another.

## Arguments

| Name | Type | Required | What to pass |
| --- | --- | --- | --- |
| `source` | integer | yes | The account the money leaves. |
| `target` | integer | yes | The account the money goes to. |
| `amount` | number | yes | How much money to move. |

## How to call it

Resolve both accounts before calling.

## Rules that apply to this call

Each is settled before anything is written, from the attempted call and, where
the rule is about something on record, the record.

### TransferWithinAvailableBalance

On violation: `reject`

> The amount argument of this call must not exceed Account.balance on the source
> account. That balance is on record rather than stated in the arguments, and
> nothing puts it in front of you.

If it does not hold: A transfer cannot move more than the source account holds.
Lower the amount, or choose another account.

## What it changes

| Concept | Operation | Fields |
| --- | --- | --- |
| `Account` | `modify` | `balance` |
| `Transfer` | `create` | `sourceAccountId`, `targetAccountId`, `amount`, `settledOn` |

Where each part of the skill comes from

Every section of SKILL.md and references/<action>.md maps directly to a key
in your model or binding profile:

text
the model                              where it lands in the generated skill
  ─────────────────────────────────      ─────────────────────────────────────────
  name, description, actions[].name ───▶ SKILL.md frontmatter + action router table
  ai_context.instructions           ───▶ SKILL.md "## How this model wants to be used"
  entities[].fields                 ───▶ SKILL.md "## Finding a record" (physical
                                           table & column map for looking up keys)
  actions[]:
    name, description               ───▶ references/<action>.md header & summary
    parameters                      ───▶ references/<action>.md "## Arguments"
    ai_context.instructions         ───▶ references/<action>.md "## How to call it"
    guards (constraints[])          ───▶ references/<action>.md "## Rules that
                                           apply to this call"
    affects                         ───▶ references/<action>.md "## What it changes"

  the binding profile                    where it lands in the generated skill
  ─────────────────────────────────      ─────────────────────────────────────────
  deployment_target                 ───▶ SKILL.md "## Finding a record" +
                                           "## Running an action" (Store)
  actions[].executor                ───▶ SKILL.md "## Running an action" (Executor
                                           & whether each action is runnable)

Table 3: which key in your model or binding profile produces each section of
the generated Agent Skill.

Two sections in SKILL.md come from the generator itself:

  • The tool-contract rules in How this model wants to be used — appended
    after your model's ai_context.instructions so every skill states the same
    rules once: never invent an identifier, ask the caller or read the store when
    given a name instead of a key, repeat refusal reasons plainly, and always
    report warnings alongside a successful write.
  • Not runnable under this profile in Running an action — when an action
    cannot be run under the selected profile, its reference page is still
    generated in full, and SKILL.md lists the action with the reason why:
    1. The profile supplies no executor (none declared, or withdrawn with
      executor: null).
    2. The executor is sql and the profile binds no store at all.
    3. The action names a non-advisory guard that is undeclared in constraints
      or has an empty judgment.

For all kcmd skills-generate flags (--out, --name, --profile, --force)
and how the deployment-specific section stays isolated to SKILL.md, see
Generating an Agent Skill and the full end-to-end walkthrough in
demo/semantic-model/skill/.

What is not modeled yet

This is a prototype. Four things you might reasonably expect are absent.

  • Nothing checks that a parameter lands in the column it was projected
    from.
    Projecting source from Account.accountId says what the value is;
    it says nothing about where a statement puts it. Push narrows this to the
    same-type case: the parameter types your action declares are sent with the
    statement, so the store rejects WHERE region = @source when region is a
    string and source projects an integer — see
    statements use your database names.
    What survives is a wrong column of the right type. WHERE branch_id = @source
    passes, because the parameter is logical, the column is physical, and nothing
    joins the two beyond the type they happen to share. Whoever writes a statement
    still owns which column each argument lands in.
  • A deterministic check over the call's own arguments has no home. A
    constraint is settled by a model reading it, so amount <= 25 written as a
    guard costs a model call and can come back differently twice — see
    what a judgment costs. The place for a check like
    that is likely the action itself; until there is one, keep it in whatever
    dispatches the call, and whoever wrote the statement owns the correctness of
    what it does.
  • Nothing here performs the write. An action becomes a tool in a generated
    skill, and the agent framework holding that skill is what calls it: a sql
    action carries its statements, and an mcp, rest or grpc one carries the
    coordinates of the service that performs it. No code in this repository
    dispatches either.
  • Only a sql action needs a store. Its statements are written against the
    database your profile's deployment target names, so a profile binding no
    store lists its sql actions as not runnable and says why. Which backend
    that store is does not matter: Spanner, AlloyDB and BigQuery all execute DML.
    A profile's mcp, rest and grpc actions need no store and are offered as
    tools either way.

Toolbox Mdcode Docs Semantic Model Codelab (`toolbox/mdcode/docs/semantic-model/codelab.md`)

Codelab: one semantic ontology, one data journey

A self-contained walkthrough. You author a semantic model, govern it in Knowledge
Catalog, and bind it to physical stores — BigQuery and Spanner — to query it in
each. The model is defined once and every store reads from that single
definition. The expected output is shown after each command so you can check as
you go.

For the deploy mechanics on their own (author, push, update, pull), see the
deploy guide; for every flag and permission, see the
reference.


Setup

You need the gcloud and bq CLIs, and kcmd on your PATH (build it from
source: see Build). kcmd uses your gcloud
configuration for credentials, project, and region:

bash
gcloud auth application-default login
gcloud config set project "<your-project>"
gcloud config set compute/region "<your-region>"

The commands below reuse a few names. Set them once for your own project:

bash
export PROJECT=$(gcloud config get-value project)  # used in table and graph names
export LOCATION=global                              # Knowledge Catalog entry-group location
export DATASET=datacloud_demo                       # BigQuery dataset + KC entry group
export GRAPH=sales                                  # property-graph name

1. Author the semantic model

init provisions the Knowledge Catalog entry group and a local workspace:

bash
mkdir -p ~/semantic-model-codelab && cd ~/semantic-model-codelab
kcmd init --semantic-model $PROJECT.$LOCATION.$DATASET
# -> scope: semantic-model.$PROJECT.$LOCATION.$DATASET

Author the model in two parts — this is what lets one model serve many stores:

  • The logical model (this step) declares the business: three entities
    (orders, customer, lineitem), the relationships between them, and one
    metric (revenue). It names nothing physical.
  • A binding profile then says where each entity reads from and which column
    each field is. You write one for each store the model serves, and the logical
    model itself never changes.

Author by hand

Write the logical model. It declares the entities, their fields, the
relationships between them, and the metric:

bash
cat > catalog/EntryGroups/$DATASET/sales.yaml <<'YAML'
version: "0.2.0.dev0/google"
semantic_model:
  - name: sales
    description: Orders, line items, and customers for the codelab
    entities:
      - name: orders
        primary_key: [order_id]
        fields:
          - { name: order_id,    datatype: Integer }
          - { name: customer_id, datatype: Integer }
          - { name: net_amount,  datatype: Decimal }
      - name: customer
        primary_key: [customer_id]
        fields:
          - { name: customer_id, datatype: Integer }
          - { name: name,        datatype: String }
      - name: lineitem
        primary_key: [line_id]
        fields:
          - { name: line_id,  datatype: Integer }
          - { name: order_id, datatype: Integer }
    relationships:
      - name: placed_by
        from: orders
        to: customer
        from_columns: [customer_id]
        to_columns: [customer_id]
      - name: part_of
        from: lineitem
        to: orders
        from_columns: [order_id]
        to_columns: [order_id]
    metrics:
      - name: revenue
        datatype: Decimal
        expression:
          dialects: [{ dialect: BIGQUERY, expression: SUM(orders.net_amount) }]
YAML

The model describes three entities joined by two relationships:

mermaid
classDiagram
    direction LR
    class orders {
        order_id : integer
        customer_id : integer
        net_amount : decimal
    }
    class customer {
        customer_id : integer
        name : string
    }
    class lineitem {
        line_id : integer
        order_id : integer
    }
    lineitem --> orders : part_of
    orders --> customer : placed_by

Import from an OWL ontology

You can also start from an existing OWL ontology instead of hand-authoring this
YAML. kcmd owl import converts an ontology (.ttl) into a semantic model:

  • an OWL class becomes an entity;
  • a datatype property — a class attribute typed by an xsd: datatype such
    as xsd:integer — becomes a field;
  • an object property — one whose values are another class — becomes a
    relationship.

Here is the same sales domain written as an ontology. An ontology is an RDF
graph: classes, properties, and datatypes are all nodes wired together by
labeled arcs. Each property is its own node, pointing to the class it describes
(rdfs:domain) and to the type of its values (rdfs:range). Node color marks
each resource's rdf:type:

mermaid
graph TD
    classDef cls fill:#dae8fc,stroke:#6c8ebf,color:#000;
    classDef dp fill:#d5e8d4,stroke:#82b366,color:#000;
    classDef op fill:#ffe6cc,stroke:#d79b00,color:#000;
    classDef dt fill:#f5f5f5,stroke:#999,color:#333;

    orders["ex:orders"]:::cls
    customer["ex:customer"]:::cls
    lineitem["ex:lineitem"]:::cls

    order_id(["ex:order_id"]):::dp
    customer_id(["ex:customer_id"]):::dp
    net_amount(["ex:net_amount"]):::dp
    name(["ex:name"]):::dp
    line_id(["ex:line_id"]):::dp

    placed_by(["ex:placed_by"]):::op
    part_of(["ex:part_of"]):::op

    xInt["xsd:integer"]:::dt
    xDec["xsd:decimal"]:::dt
    xStr["xsd:string"]:::dt

    orders -. owl:hasKey .-> order_id
    customer -. owl:hasKey .-> customer_id
    lineitem -. owl:hasKey .-> line_id

    order_id -->|rdfs:domain| orders
    order_id -->|rdfs:domain| lineitem
    order_id -->|rdfs:range| xInt
    customer_id -->|rdfs:domain| orders
    customer_id -->|rdfs:domain| customer
    customer_id -->|rdfs:range| xInt
    net_amount -->|rdfs:domain| orders
    net_amount -->|rdfs:range| xDec
    name -->|rdfs:domain| customer
    name -->|rdfs:range| xStr
    line_id -->|rdfs:domain| lineitem
    line_id -->|rdfs:range| xInt

    placed_by -->|rdfs:domain| orders
    placed_by -->|rdfs:range| customer
    part_of -->|rdfs:domain| lineitem
    part_of -->|rdfs:range| orders

Write that ontology and import it:

bash
cat > /tmp/sales.ttl <<'TTL'
@prefix owl:  <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix xsd:  <http://www.w3.org/2001/XMLSchema#> .
@prefix ex:   <http://example.com/sales#> .

ex:orders a owl:Class ;
    rdfs:comment "A customer order" ;
    owl:hasKey ( ex:order_id ) .
ex:customer a owl:Class ;
    rdfs:comment "A customer who places orders" ;
    owl:hasKey ( ex:customer_id ) .
ex:lineitem a owl:Class ;
    rdfs:comment "A line on an order" ;
    owl:hasKey ( ex:line_id ) .

ex:line_id a owl:DatatypeProperty ;
    rdfs:domain ex:lineitem ;
    rdfs:range xsd:integer .
# order_id and customer_id each apply to two classes; a datatype property with
# more than one rdfs:domain becomes a field on each of them.
ex:order_id a owl:DatatypeProperty ;
    rdfs:domain ex:orders, ex:lineitem ;
    rdfs:range xsd:integer .
ex:customer_id a owl:DatatypeProperty ;
    rdfs:domain ex:orders, ex:customer ;
    rdfs:range xsd:integer .
ex:net_amount a owl:DatatypeProperty ;
    rdfs:domain ex:orders ;
    rdfs:range xsd:decimal .
ex:name a owl:DatatypeProperty ;
    rdfs:domain ex:customer ;
    rdfs:range xsd:string .

ex:placed_by a owl:ObjectProperty ;
    rdfs:domain ex:orders ;
    rdfs:range ex:customer .
ex:part_of a owl:ObjectProperty ;
    rdfs:domain ex:lineitem ;
    rdfs:range ex:orders .
TTL

kcmd owl import /tmp/sales.ttl --out /tmp/sales_osi.yaml --compact
text
converted 3 classes, 2 object properties, 5 datatype properties
wrote /tmp/sales_osi.yaml

Look at what it produced:

bash
cat /tmp/sales_osi.yaml
yaml
version: 0.2.0.dev0/google
semantic_model:
  - name: sales
    description: Imported from OWL ontology http://example.com/sales#
    entities:
      - name: orders
        primary_key: [order_id]
        description: A customer order
        fields:
          - {name: order_id, datatype: Integer}
          - {name: customer_id, datatype: Integer}
          - {name: net_amount, datatype: Decimal}
      - name: customer
        primary_key: [customer_id]
        description: A customer who places orders
        fields:
          - {name: customer_id, datatype: Integer}
          - {name: name, datatype: String}
      - name: lineitem
        primary_key: [line_id]
        description: A line on an order
        fields:
          - {name: line_id, datatype: Integer}
          - {name: order_id, datatype: Integer}
    relationships:
      - {name: placed_by, from: orders, to: customer}
      - {name: part_of, from: lineitem, to: orders}

This is the hand-authored sales model above, reproduced from the ontology: the
same three entities, the same fields and datatypes, the same primary keys, and
the same two relationships. (The importer emits the extended profile — entities:
under version: 0.2.0.dev0/google — the same spelling this codelab's hand-authored
model uses.)

Two things from the hand-authored model are missing, and both are inherent —
an ontology has no way to state either:

  • The revenue metric. OWL describes structure — classes, properties,
    relationships — not aggregations, so the importer emits no metrics. Add the
    metric to the model by hand.
  • The relationship join columns. An object property states a direction
    (orders → customer), not which foreign-key column joins to which key, so
    each edge arrives as a pure logical edge — from/to only. Add the
    from_columns/to_columns to each relationship by hand, as in the
    hand-authored model above.

Both gaps are logical facts you fill in on the model itself. The physical
facts — the table each entity reads, the column each field maps to — stay out of
the logical model entirely; a binding profile supplies them at
deploy time (step 3), which is why neither model carries a source. kcmd push
publishes the logical model to Knowledge Catalog as-is. For the full
OWL mapping — class hierarchies, unique keys, and the constructs carried as
custom extensions — see Importing an OWL ontology.

The rest of this codelab uses the hand-authored sales model above.


2. Govern it in Knowledge Catalog

You can govern the model right now. The push writes the logical model straight
to the catalog as entries; you do not need to connect it to any database tables
first.

Preview the plan

Preview the plan without writing anything:

bash
kcmd push --validate-only --print

A bare kcmd push governs the logical model in Knowledge Catalog.

text
Validating semantic model for Knowledge Catalog...
-- Knowledge Catalog --
Knowledge Catalog plan for 'sales' (destination $PROJECT.$LOCATION.$DATASET):
  6 entries:
    - sales (semantic-model)
    - sales.entities.orders (semantic-entity)
    - sales.entities.customer (semantic-entity)
    - sales.entities.lineitem (semantic-entity)
    - sales.metrics.revenue (semantic-metric)
    - sales.files.sales.yaml (semantic-model-file)
  2 schema-join links:
    - sales-placed-by
    - sales-part-of
Validation complete; no changes applied.

Write the entries (available later)

Then drop --validate-only to perform the write:

bash
kcmd push
text
Pushing semantic model (Knowledge Catalog)...
Wrote 6 new and 0 updated Knowledge Catalog entries; linked 2 relationships.

Each entity, the metric,
and the model itself are now governed entries, joined by a schema-join link —
discoverable, access-controlled, and the single definition the rest of your work
reads from. kcmd pull returns the model YAML push stored with these entries,
confirming the round-trip. Physical bindings can be governed here too, alongside
the logical model.

This write needs the semantic-model / semantic-entity / semantic-metric
entry types and write access to the entry group. See
Permissions.


3. Deploy to BigQuery and get reliable insights

In step 2 you governed the logical model, which created catalog entries but no
tables and no data. To query it, you give the model a physical home in BigQuery.
This takes three steps: write a binding profile that maps each entity to a
BigQuery table, create those tables and load a little data, then deploy the model
to build the graph.

Write the binding profile

Write the analytical binding: the BigQuery table each entity reads, the
column each field maps to, and the BigQuery graph to deploy to. The
deployment_target key names that graph. A binding lives beside the model in
sales.profiles/:

bash
# BigQuery deployment target, named by the profile's deployment_target key.
BQ_DS=projects/$PROJECT/datasets/$DATASET
TARGET=bigquery.googleapis.com/$BQ_DS/propertyGraphs/$GRAPH

mkdir -p catalog/EntryGroups/$DATASET/sales.profiles
cat > catalog/EntryGroups/$DATASET/sales.profiles/analytical.yaml <<YAML
version: "0.2.0.dev0/google"
semantic_model:
  - name: sales
    deployment_target: $TARGET
    entities:
      - name: orders
        source: bigquery.googleapis.com/$BQ_DS/tables/orders
        primary_key: [o_orderkey]
        fields:
          - { name: order_id,    expression: o_orderkey }
          - { name: customer_id, expression: o_custkey }
          - { name: net_amount,  expression: net_amount }
      - name: customer
        source: bigquery.googleapis.com/$BQ_DS/tables/customer
        primary_key: [c_custkey]
        fields:
          - { name: customer_id, expression: c_custkey }
          - { name: name,        expression: c_name }
      - name: lineitem
        source: bigquery.googleapis.com/$BQ_DS/tables/lineitem
        primary_key: [l_linekey]
        fields:
          - { name: line_id,  expression: l_linekey }
          - { name: order_id, expression: l_orderkey }
    relationships:
      - { name: placed_by, from_columns: [o_custkey],  to_columns: [c_custkey] }
      - { name: part_of,   from_columns: [l_orderkey], to_columns: [o_orderkey] }
YAML

The binding carries only bindings, in this store's column names: each entity's
table and key columns, each field's column, and each relationship's join
columns. The relationships, the metric and the grain themselves are logical and
live once in the model. Make it the default so a bare kcmd push selects it (the
rest of this step relies on this):

bash
echo 'default_profile: analytical' >> catalog.yaml

Simple case — one binding, one file. If a model only ever binds to one
store, you do not need a separate profile. Put the deployment_target, each
entity's source, and each field's expression directly on the model in
sales.yaml — that is the default profile — and run a bare kcmd push. An
orders entity then reads:

yaml
semantic_model:
  - name: sales
    deployment_target: bigquery.googleapis.com/.../propertyGraphs/sales
    entities:
      - name: orders
        source: bigquery.googleapis.com/projects/$PROJECT/datasets/$DATASET/tables/orders
        primary_key: [o_orderkey]
        fields:
          - { name: order_id, datatype: Integer, expression: o_orderkey }
          # ...

This codelab keeps the binding in a separate profile so one model can back more
than one store, each with its own binding.

Inspect the binding profile

kcmd profiles lists the model's binding profiles, reports what each one can
answer, and prints any error push would give for it. Run it to confirm the
binding before you deploy:

bash
kcmd profiles
text
Model 'sales' ($DATASET):
  profile 'analytical' (default)
    target: bigquery.googleapis.com/projects/$PROJECT/datasets/$DATASET/propertyGraphs/sales
    sources:
      orders -> bigquery.googleapis.com/projects/$PROJECT/datasets/$DATASET/tables/orders
      customer -> bigquery.googleapis.com/projects/$PROJECT/datasets/$DATASET/tables/customer
      lineitem -> bigquery.googleapis.com/projects/$PROJECT/datasets/$DATASET/tables/lineitem
    cannot answer: nothing withheld.

Only the analytical profile exists so far, and it binds every field, so it
withholds nothing: the whole model, including the revenue metric, is answerable
under this binding.

Create the tables

Now create the tables and load a little data. In a production pipeline, an agent
working from the ontology would generate these tables from raw sources; for this
self-contained codelab, you create them directly. Two details about the data
matter here: the orders table stores net_amount as a column, which the
revenue measure sums, and each order has several lineitem rows:

bash
bq mk -f --dataset $PROJECT:$DATASET

bq query --use_legacy_sql=false '
CREATE OR REPLACE TABLE `'"$PROJECT.$DATASET"'.customer` AS
SELECT * FROM UNNEST([STRUCT(1 AS c_custkey, "Acme" AS c_name),
                      STRUCT(2, "Globex")]);
CREATE OR REPLACE TABLE `'"$PROJECT.$DATASET"'.orders` AS
SELECT * FROM UNNEST([
  STRUCT(100 AS o_orderkey, 1 AS o_custkey,  90.0 AS net_amount),
  STRUCT(101, 1, 200.0),
  STRUCT(102, 2,  40.0)
]);
CREATE OR REPLACE TABLE `'"$PROJECT.$DATASET"'.lineitem` AS
SELECT * FROM UNNEST([                 -- order 100: 2 lines, 101: 1, 102: 3
  STRUCT(1 AS l_linekey, 100 AS l_orderkey), STRUCT(2, 100),
  STRUCT(3, 101),
  STRUCT(4, 102), STRUCT(5, 102), STRUCT(6, 102)
]);'

Why the tables come before the BigQuery push. Unlike the Knowledge Catalog
step, deploying the BigQuery graph validates that every entity's source table
resolves in BigQuery — even under --validate-only — and builds the graph over
these tables. So the tables must exist first. (Step 2 needed none of this: it
governed the logical model, no tables required.)

Deploy the semantic model

Now deploy the bound model to BigQuery. The graph backend comes from the model's
deployment target — you don't name it on the command line. You already governed
the model in Knowledge Catalog in step 2, so --no-kc deploys just the graph;
--print shows the generated DDL:

bash
kcmd push --no-kc --print

The generated graph turns each entity into a node table, each relationship into an
edge, and the metric into a measure. Each property is the model's logical name
backed by its bound column — o_orderkey AS order_id — so every query names
order_id, never the physical column (your $PROJECT/$DATASET appear in the
fully qualified names):

sql
Pushing semantic model (BigQuery Graph)...
-- BigQuery Graph --
-- bigquery.googleapis.com/projects/$PROJECT/datasets/$DATASET/propertyGraphs/$GRAPH
CREATE OR REPLACE PROPERTY GRAPH `$PROJECT.$DATASET.$GRAPH`
NODE TABLES (
  `$PROJECT.$DATASET.orders` AS orders
    KEY(o_orderkey)
    PROPERTIES(
      o_orderkey AS order_id,
      o_custkey AS customer_id,
      net_amount,
      MEASURE(SUM(net_amount)) AS revenue
    ),
  `$PROJECT.$DATASET.customer` AS customer
    KEY(c_custkey)
    PROPERTIES(
      c_custkey AS customer_id,
      c_name AS name
    ),
  `$PROJECT.$DATASET.lineitem` AS lineitem
    KEY(l_linekey)
    PROPERTIES(
      l_linekey AS line_id,
      l_orderkey AS order_id
    )
)
EDGE TABLES (
  `$PROJECT.$DATASET.orders` AS placed_by
    KEY(o_orderkey)
    SOURCE KEY(o_orderkey) REFERENCES orders(o_orderkey)
    DESTINATION KEY(o_custkey) REFERENCES customer(c_custkey),
  `$PROJECT.$DATASET.lineitem` AS part_of
    KEY(l_linekey)
    SOURCE KEY(l_linekey) REFERENCES lineitem(l_linekey)
    DESTINATION KEY(l_orderkey) REFERENCES orders(o_orderkey)
);

Deployed 1 BigQuery Graph(s).

View the semantic model

The graph is now a resource in your dataset, and the console draws its schema as
a diagram of node and edge tables — easier to read than the DDL above. Print the
BigQuery Studio link to the dataset:

bash
# printf with a single-quoted format string: the `!` are literal, not bash history expansion.
printf 'https://console.cloud.google.com/bigquery?project=%s&ws=!1m4!1m3!3m2!1s%s!2s%s\n' "$PROJECT" "$PROJECT" "$DATASET"

Open the link, expand the $DATASET dataset in the Explorer, and click the
$GRAPH property graph. Its schema renders as a visual graph of the nodes and
the edges that connect them.

Query Semantic Model: Metrics

Now ask the same question — revenue by customer — two ways.

Before — hand-written SQL. An analyst who wants revenue "with the line-item
detail" writes the obvious join across all three tables:

bash
bq query --use_legacy_sql=false --nouse_cache '
SELECT c.c_name, SUM(o.net_amount) AS revenue
FROM `'"$PROJECT.$DATASET"'.customer` c
JOIN `'"$PROJECT.$DATASET"'.orders` o    ON o.o_custkey  = c.c_custkey
JOIN `'"$PROJECT.$DATASET"'.lineitem` li ON li.l_orderkey = o.o_orderkey
GROUP BY c.c_name ORDER BY c.c_name'

The result is wrong. Joining in lineitem repeats each order's net_amount
once per line, so revenue is inflated by the line count — Acme's 90 + 200 becomes
90×2 + 200×1 = 380, Globex's 40 becomes 40×3 = 120:

text
+--------+---------+
| c_name | revenue |
+--------+---------+
| Acme   |   380.0 |   <- should be 290
| Globex |   120.0 |   <- should be 40
+--------+---------+

Nothing errors. The query succeeds and returns a total that is too large. This is
the fan-out trap: any consumer that writes its own join can hit it.

After — through the model's revenue measure. A measure is a symmetric
aggregate
: it sums each order once however the graph is traversed, so the
fan-out cannot double-count. You read it by flattening the graph with
GRAPH_EXPAND and wrapping the measure in AGG(), with columns named
<node>_<field>. The query names no table, no join, and no formula:

bash
bq query --use_legacy_sql=false --nouse_cache '
SELECT customer_name AS name, AGG(orders_revenue) AS revenue
FROM GRAPH_EXPAND("'"$PROJECT.$DATASET.$GRAPH"'")
GROUP BY name ORDER BY name'

This one is right:

text
+--------+---------+
| name   | revenue |
+--------+---------+
| Acme   |   290.0 |
| Globex |    40.0 |
+--------+---------+

Same question, same data: the hand-written join inflates the total, the measure
returns the correct one. revenue is now an enforced, queryable concept. The
join and the formula come from the model rather than from whoever writes the
query, so the correct answer is the default one.

Tips. Use --nouse_cache — GRAPH_EXPAND result caches are not
invalidated by graph edits. To see the exact output column names for a graph:
DECLARE s STRING; CALL BQ.SHOW_GRAPH_EXPAND_SCHEMA("$PROJECT.$DATASET.$GRAPH", s); SELECT s;

Query Semantic Model: GQL

Metrics answer aggregate questions. To follow the relationships between entities,
query the graph with graph query language (GQL), which matches a pattern of nodes
and edges. This walks the placed_by edge to count each customer's
orders:

bash
bq query --use_legacy_sql=false --nouse_cache '
GRAPH `'"$PROJECT.$DATASET.$GRAPH"'`
MATCH (o:orders)-[:placed_by]->(c:customer)
RETURN c.name AS customer, COUNT(o.order_id) AS orders
GROUP BY customer ORDER BY customer'
text
+----------+--------+
| customer | orders |
+----------+--------+
| Acme     |      2 |
| Globex   |      1 |
+----------+--------+

This is standalone GQL. The pattern and the model's property names are portable
across graph query engines; only the graph reference is engine-specific, and here
BigQuery takes the fully qualified $PROJECT.$DATASET.$GRAPH.


4. Deploy the same model to Spanner

The same customers and orders usually live in more than one store. Step 3 bound
and deployed the analytical profile — the BigQuery warehouse. An operational
Spanner database holds the same business, but its tables are named differently
(Customers, Orders, LineItems), its columns are named differently
(FullName, OrderId), and it does not carry net_amount — a settled figure
the warehouse computes rather than one the live store keeps.

Write the binding profile

Add a second profile beside the first. Like the analytical one, it changes
only where each entity reads from and which column each field binds to; it never
changes what an entity, a relationship, or a metric means (for the full
contract, see binding profiles). Write the operational binding
into the same sales.profiles/ directory:

text
/* Detailed source-code truncated for AI context efficiency. */

The profile carries only bindings, in Spanner's column names: each entity's
table and key columns, each field's column, and each relationship's join
columns. It also excludes the one field the store does not have, and every
metric. The relationships, the metric, the labels and the grain themselves are
logical and live once in the model.

Inspect the binding profiles

The model now has two profiles. kcmd profiles lists them both and reports what
each one can answer:

bash
kcmd profiles
text
Model 'sales' ($DATASET):
  profile 'analytical' (default)
    target: bigquery.googleapis.com/projects/$PROJECT/datasets/$DATASET/propertyGraphs/sales
    sources:
      orders -> bigquery.googleapis.com/projects/$PROJECT/datasets/$DATASET/tables/orders
      customer -> bigquery.googleapis.com/projects/$PROJECT/datasets/$DATASET/tables/customer
      lineitem -> bigquery.googleapis.com/projects/$PROJECT/datasets/$DATASET/tables/lineitem
    cannot answer: nothing withheld.
  profile 'operational'
    target: spanner.googleapis.com/projects/$PROJECT/instances/$SPANNER_INSTANCE/databases/$SPANNER_DB/graphs/sales
    sources:
      orders -> spanner.googleapis.com/projects/$PROJECT/instances/$SPANNER_INSTANCE/databases/$SPANNER_DB/tables/Orders
      customer -> spanner.googleapis.com/projects/$PROJECT/instances/$SPANNER_INSTANCE/databases/$SPANNER_DB/tables/Customers
      lineitem -> spanner.googleapis.com/projects/$PROJECT/instances/$SPANNER_INSTANCE/databases/$SPANNER_DB/tables/LineItems
    cannot answer:
      field orders.net_amount (excluded)
      metric revenue (excluded)

Both profiles now show side by side: analytical binds every field, so it
withholds nothing; operational excludes net_amount, and every metric. (kcmd profiles
shows each source as its resource name; the Spanner graph itself references the
bare Orders.)

revenue is SUM(orders.net_amount). The operational profile excludes
net_amount, and a metric that reaches an excluded field has to be excluded
too. The profile also deploys to Spanner, whose graphs have no measures, so it
excludes every metric with metrics_exclude: "*", which covers revenue. The
analytical profile binds net_amount, so the same metric is available under
the binding step 3 used. One model; each store
answers the part of it that its data can back.

Create the tables

Create the operational tables in a Spanner ENTERPRISE-edition database. Spanner
Graph requires the ENTERPRISE edition. Create the database with its three tables,
then load a little data:

bash
gcloud spanner databases create $SPANNER_DB --instance=$SPANNER_INSTANCE \
  --ddl='CREATE TABLE Customers (CustomerId INT64 NOT NULL, FullName STRING(MAX)) PRIMARY KEY(CustomerId)' \
  --ddl='CREATE TABLE Orders (OrderId INT64 NOT NULL, CustomerId INT64) PRIMARY KEY(OrderId)' \
  --ddl='CREATE TABLE LineItems (LineId INT64 NOT NULL, OrderId INT64) PRIMARY KEY(LineId)'

gcloud spanner databases execute-sql $SPANNER_DB --instance=$SPANNER_INSTANCE \
  --sql="INSERT INTO Customers (CustomerId, FullName) VALUES (1,'Acme'),(2,'Globex')"
gcloud spanner databases execute-sql $SPANNER_DB --instance=$SPANNER_INSTANCE \
  --sql="INSERT INTO Orders (OrderId, CustomerId) VALUES (100,1),(101,1),(102,2)"
gcloud spanner databases execute-sql $SPANNER_DB --instance=$SPANNER_INSTANCE \
  --sql="INSERT INTO LineItems (LineId, OrderId) VALUES (1,100),(2,100),(3,101),(4,102),(5,102),(6,102)"

Why the tables come before the Spanner push. Deploying the Spanner graph
applies the DDL through the updateDatabaseDdl long-running operation; it does
not create the tables, and for a model like this one — which declares no
actions — it does not read them either. So the tables must exist first.

Deploy the semantic model

Now deploy the bound model to Spanner. The operational profile's deployment
target is a Spanner Graph, so merging that profile is what sends the model to
Spanner — no backend flag needed. --no-kc deploys just the graph, and --print
shows the generated DDL:

bash
kcmd push --profile operational --no-kc --print
text
Note: profile 'operational' leaves 1 field(s) unbound; 1 metric(s) and 0 action(s) unavailable.
Pushing semantic model (Spanner Graph)...
-- Spanner Graph --
-- spanner.googleapis.com/projects/$PROJECT/instances/$SPANNER_INSTANCE/databases/$SPANNER_DB/graphs/sales
CREATE OR REPLACE PROPERTY GRAPH sales
NODE TABLES (
  Orders AS orders
    KEY(OrderId)
    PROPERTIES(
      OrderId AS order_id,
      CustomerId AS customer_id
    ),
  Customers AS customer
    KEY(CustomerId)
    PROPERTIES(
      CustomerId AS customer_id,
      FullName AS name
    ),
  LineItems AS lineitem
    KEY(LineId)
    PROPERTIES(
      LineId AS line_id,
      OrderId AS order_id
    )
)
EDGE TABLES (
  Orders AS placed_by
    KEY(OrderId)
    SOURCE KEY(OrderId) REFERENCES orders(OrderId)
    DESTINATION KEY(CustomerId) REFERENCES customer(CustomerId),
  LineItems AS part_of
    KEY(LineId)
    SOURCE KEY(LineId) REFERENCES lineitem(LineId)
    DESTINATION KEY(OrderId) REFERENCES orders(OrderId)
);

Deployed 1 Spanner Graph(s).

The graph still speaks the model's vocabulary — the properties are order_id,
name, and the rest — but each is now backed by the profile's Spanner column
(OrderId AS order_id, FullName AS name). The key and reference clauses
name the physical columns (KEY(OrderId)), because a Spanner graph keys on the
table's real column rather than the property alias. Three things also differ from the
BigQuery DDL in step 3:

  • Bare table and graph names. A Spanner property graph names tables inside
    one database, so there is no backticked project.dataset. qualifier — the DDL
    names each table by the last part of its source (Orders).
  • No MEASURE. Spanner Graph has no measures, so a profile that deploys to
    Spanner excludes every metric, as operational does with
    metrics_exclude: "*". Author metrics as usual; a BigQuery target still emits
    them.
  • No OPTIONS. Descriptions and synonyms are not written into the Spanner
    DDL; they live in Knowledge Catalog instead.

Knowledge Catalog is unchanged. You do not re-push to Knowledge Catalog for
the Spanner leg. Step 2 governed the logical model, and adding a binding
profile changes nothing logical — bindings are not governed in Knowledge
Catalog. The single set of entries from step 2 already describes this graph too.

Query Semantic Model: GQL

Query it with the same GQL query you ran on BigQuery in step 3, now against the
bare sales graph. The query names the
model's properties (name, order_id); the profile's column bindings are
invisible to it:

bash
gcloud spanner databases execute-sql $SPANNER_DB --instance=$SPANNER_INSTANCE \
  --sql="GRAPH sales MATCH (o:orders)-[:placed_by]->(c:customer)
         RETURN c.name AS customer, COUNT(o.order_id) AS orders
         GROUP BY customer ORDER BY customer"
text
customer  orders
Acme      2
Globex    1

The one model now backs two stores: the BigQuery warehouse from step 3, where
revenue is a measure, and this operational Spanner graph, where the same
entities and relationships are queried live and revenue is computed in SQL or
the application rather than by the graph. customer, placed_by, and
every field mean the same thing in both; only the bindings differ.

Deploy every binding in one run. This codelab pushed the two
profiles in separate steps to show each backend. With both defined,
kcmd push --all-profiles deploys every profile at once — the
analytical graph to BigQuery and the operational graph to Spanner —
and still records the default binding to Knowledge Catalog once. At the
other end, kcmd push --no-profile deploys no graph and publishes only
to Knowledge Catalog. Neither names a backend: each profile's
deployment_target selects it.


5. Model a class hierarchy with `extends`

A customer and a supplier are both parties you deal with. When several entities
are kinds of one thing, declare the general kind once and say each specific kind
extends it. A query written against the general kind then reaches every
specific kind. This step adds a supplier entity beside customer and lifts the
shared idea into an abstract party supertype, then deploys the result to
BigQuery. For the modeling guide, see class hierarchies; for
the generation rules, see
Reference → Class hierarchies.

Add the supertype and a second subtype

Rewrite the logical model to add three things: an abstract party that declares
the shared name field, an extends: [party] on customer, and a new
supplier entity that also extends party. party is abstract, so it has no
source and no key and produces no node table; it survives in the graph only as
a label on its subtypes. Each subtype inherits name, with its datatype, from
party, and the profile binds it to that subtype's own column. The two
subtypes line up under the shared label by that name:

text
/* Detailed source-code truncated for AI context efficiency. */
mermaid
classDiagram
    class party {
        <<abstract>>
        name : string
    }
    class customer {
        customer_id : integer
    }
    class supplier {
        supplier_id : integer
    }
    party <|-- customer
    party <|-- supplier

Add the supplier binding to the analytical profile. party binds nothing —
it has no table — so each subtype's own binding supplies the name column that
its party label reads. Rewrite the profile to include supplier:

bash
cat > catalog/EntryGroups/$DATASET/sales.profiles/analytical.yaml <<YAML
version: "0.2.0.dev0/google"
semantic_model:
  - name: sales
    deployment_target: $TARGET
    entities:
      - name: orders
        source: bigquery.googleapis.com/$BQ_DS/tables/orders
        primary_key: [o_orderkey]
        fields:
          - { name: order_id,    expression: o_orderkey }
          - { name: customer_id, expression: o_custkey }
          - { name: net_amount,  expression: net_amount }
      - name: customer
        source: bigquery.googleapis.com/$BQ_DS/tables/customer
        primary_key: [c_custkey]
        fields:
          - { name: customer_id, expression: c_custkey }
          - { name: name,        expression: c_name }
      - name: supplier
        source: bigquery.googleapis.com/$BQ_DS/tables/supplier
        primary_key: [s_suppkey]
        fields:
          - { name: supplier_id, expression: s_suppkey }
          - { name: name,        expression: s_name }
      - name: lineitem
        source: bigquery.googleapis.com/$BQ_DS/tables/lineitem
        primary_key: [l_linekey]
        fields:
          - { name: line_id,  expression: l_linekey }
          - { name: order_id, expression: l_orderkey }
    relationships:
      - { name: placed_by, from_columns: [o_custkey],  to_columns: [c_custkey] }
      - { name: part_of,   from_columns: [l_orderkey], to_columns: [o_orderkey] }
YAML

Create the supplier table

Create the supplier table beside the others and load two rows:

bash
bq query --use_legacy_sql=false '
CREATE OR REPLACE TABLE `'"$PROJECT.$DATASET"'.supplier` AS
SELECT * FROM UNNEST([STRUCT(1 AS s_suppkey, "Northwind" AS s_name),
                      STRUCT(2, "Initech")]);'

Deploy and read the labels

Deploy the model again. The orders, lineitem, and edge tables are unchanged
from step 3; the two node tables that matter now each carry the party label
alongside their own default label. Each renders name from its own column:
c_name on the customer table, s_name on the supplier table. The shared label
reconciles by property name, so the two tables never have to agree on a physical
column:

bash
kcmd push --no-kc --print
sql
Pushing semantic model (BigQuery Graph)...
-- BigQuery Graph --
-- bigquery.googleapis.com/projects/$PROJECT/datasets/$DATASET/propertyGraphs/$GRAPH
CREATE OR REPLACE PROPERTY GRAPH `$PROJECT.$DATASET.$GRAPH`
NODE TABLES (
  ...
  `$PROJECT.$DATASET.customer` AS customer
    KEY(c_custkey)
    DEFAULT LABEL
    PROPERTIES(
      c_custkey AS customer_id,
      c_name AS name
    )
    LABEL party
    PROPERTIES(
      c_name AS name
    ),
  `$PROJECT.$DATASET.supplier` AS supplier
    KEY(s_suppkey)
    DEFAULT LABEL
    PROPERTIES(
      s_suppkey AS supplier_id,
      s_name AS name
    )
    LABEL party
    PROPERTIES(
      s_name AS name
    ),
  ...
);

Deployed 1 BigQuery Graph(s).

Query the supertype

A query against party reaches both subtypes. Match every party and return its
name:

bash
bq query --use_legacy_sql=false --nouse_cache '
GRAPH `'"$PROJECT.$DATASET.$GRAPH"'`
MATCH (p:party)
RETURN p.name AS name ORDER BY name'

Every customer and every supplier comes back, each once, because each real party
lives in exactly one table:

text
+-----------+
|   name    |
+-----------+
| Acme      |
| Globex    |
| Initech   |
| Northwind |
+-----------+

MATCH (:customer) still returns Acme and Globex alone, and MATCH (:supplier)
Northwind and Initech alone: each subtype keeps its own label as well as the
shared one.

Spanner works the same way. Inheritance deploys identically to Spanner
Graph — the extra labels and the flattened fields are the same on both
backends. To include suppliers on the operational Spanner graph from step 4,
add a supplier binding and a Supplier table to the operational profile
the same way you did here. Spanner carries no measures, as it does for any
model.


6. Clean up

Drop the BigQuery dataset (tables, including supplier, plus the property
graph):

bash
bq rm -r -f -d $PROJECT:$DATASET

Drop the Spanner database from step 4 (its tables and the graph go with it):

bash
gcloud spanner databases delete $SPANNER_DB --instance=$SPANNER_INSTANCE --quiet

Remove the Knowledge Catalog entries and entry group via REST. Delete the
schema-join links first, while their endpoint entries still exist: a link whose
entries are already gone is orphaned and can no longer be addressed by name.

bash
TOKEN=$(gcloud auth application-default print-access-token)
DATAPLEX=${DATAPLEX_ENDPOINT:-https://dataplex.googleapis.com}
EG=projects/$PROJECT/locations/$LOCATION/entryGroups/$DATASET

for L in sales-placed-by sales-part-of; do
  curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \
    "$DATAPLEX/v1/$EG/entryLinks/$L" >/dev/null
done
for E in sales sales.entities.orders sales.entities.customer sales.entities.lineitem sales.metrics.revenue; do
  curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \
    "$DATAPLEX/v1/$EG/entries/$E" >/dev/null
done
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" "$DATAPLEX/v1/$EG"

Remove the local workspace that step 1 created:

bash
rm -rf ~/semantic-model-codelab

Toolbox Mdcode Docs Semantic Model Fidelity (`toolbox/mdcode/docs/semantic-model/fidelity.md`)

What push and pull preserve

Your model document is the source of truth. Pushing it — to Knowledge Catalog
and to a property graph in BigQuery or Spanner — and pulling it back are each
lossy in specific ways. The catalog keeps metadata, the graph keeps what it
can query, and pull can only return what the catalog was given. This page is
the one authoritative map of what survives each direction. Keep your authored
document.

Which backend a push deploys to — BigQuery or Spanner — is set by the model's
deployment target, or by the binding profile you select; it is not a
command-line flag. What reaches Knowledge Catalog depends on the push as well: a
catalog-only or purely logical push records the whole model, while a push that
also deploys a graph records only the part the graph binds — and whichever way
you push, only one binding is recorded, even when the model defines several. All
three are detailed under To Knowledge Catalog.

The round-trip matrix

Rows are what you authored; columns are its fate in each direction. ✓ = comes
back as authored; — = not present in that destination. Footnotes carry the
nuances that don't fit a cell. BigQuery and Spanner each get a column; they
agree on every structural row and differ only where a Spanner target has no
MEASURE and no OPTIONS metadata, and in which SQL text a field reads — see
To Spanner.

Authored element → Knowledge Catalog Recovered by pull → BigQuery → Spanner
Entity (name, source) semantic-entity entry¹⁰ ✓ NODE TABLE NODE TABLE
Field schema aspect column ✓ (type collapses²) column on the node table¹ column on the node table¹
Field label schema per-field annotation ✓ into OPTIONS(description) — dropped
Field expression (canonical SQL) only with --emit-expressions only if pushed with it builds the DDL builds the DDL
Field dimension role (is_time) only with --emit-expressions³ only if pushed with it³ noted in OPTIONS(description) — dropped
Primary key schema.primaryKey ✓ KEY(...) on the node table KEY(...) on the node table
Unique keys schema.uniqueConstraints ✓ the first is KEY(...) when there is no primary key; else dropped the first is KEY(...) when there is no primary key; else dropped
Metric semantic-metric entry name, entity, description, instructions, type⁵ MEASURE⁴ — dropped (no MEASURE)
Relationship (1:1 / 1:N) schema-join link¹⁵ ✓ (name normalized⁶) EDGE TABLE EDGE TABLE
Relationship (M:N / association) — not stored — EDGE TABLE (via junction table) EDGE TABLE (via junction table)
Entity extends — not modelled — LABEL clauses + flattened fields LABEL clauses + flattened fields
Action semantic-action entry¹² ✓¹² — not deployed¹² — not deployed¹²
Constraint semantic-constraint entry¹³ ✓¹³ — not deployed¹³ — not deployed¹³
description (entity / metric / field / relationship) entry description / aspect ✓ OPTIONS(description) — dropped
ai_context.synonyms — not stored — OPTIONS(synonyms=[...]) — dropped
ai_context.instructions guidelines aspect⁷ ✓⁷ into OPTIONS(description) — dropped
ai_context.examples — not stored — into OPTIONS(description) (Examples: line) — dropped
Model-level description / instructions on the model entry ✓⁸ — dropped⁸ — dropped⁸
Model-level ai_context.synonyms / examples — not stored — — dropped — dropped
Deployment target recorded on the model entry ✓ names the graph names the graph
Binding profile (alternate physical binding) — only the selected one¹⁴ —¹⁴ one graph per BigQuery-bound profile one graph per Spanner-bound profile
Vendor-dialect expression variant (non-canonical dialects[] entry)¹¹ — not stored — — not used; a field or metric with only vendor text is left out — not used; a field with only vendor text is left out
custom_extensions from a vendor other than GOOGLE — not stored —⁹ — not in graph — not in graph
  1. Field type source. The graph uses the source column's own type; a
    field's authored datatype is not carried.
  2. Field type collapse. Field types round-trip except one collapse:
    String → un-typed. A field with no type and a String field both store
    dataType STRING (with metadataType OTHER and STRING respectively),
    and pull writes no datatype for either, so only String is lost.
  3. Dimension role. The default push omits the per-field semantics block,
    so the dimension role is written only with --emit-expressions — and then
    comes back as a bare dimension: {} marker (without is_time, and so on). A
    default push drops the marker entirely.
  4. Metric shape. A metric must resolve to exactly one entity, or the push
    is rejected. It must also reduce to one supported aggregate over one
    operand: SUM, AVG, COUNT, MIN or MAX. Otherwise the push is
    rejected. A push of a profile in the older <model>.profiles/ layout
    skips the metric with a warning instead, and so does a --transpile push
    of a metric with only a vendor text.
  5. Metric type. A metric's expression is gated behind --emit-expressions;
    its data type round-trips only for a concrete type (e.g. Decimal) — an
    untyped, String, or Opaque metric comes back un-typed.
  6. Relationship name. Relationship names come back lowercased
    (Places_Order → places_order) — the catalog stores the name only in the
    link id. See Writer-side follow-up.
  7. Guidelines aspect. The guidelines aspect exists only for the model,
    entities, and metrics — not fields or relationships, so field- and
    relationship-level ai_context.instructions has no Knowledge Catalog home (a
    relationship's instructions still reach BigQuery, folded into the edge's
    OPTIONS(description)).
  8. Model-level metadata. Neither graph has a home for statement-level
    metadata — BigQuery silently drops graph-statement OPTIONS, and Spanner
    carries no OPTIONS at all — so the model's description and
    ai_context.instructions are carried into Knowledge Catalog instead.
  9. Other custom extensions. A custom_extensions block from a vendor other
    than GOOGLE is inert on push and has no catalog entry or aspect of its own;
    pull returns it from the stored copy of the model file, and leaves it out
    only when rebuilding from the catalog metadata. The OWL importer emits none:
    it is import-only (see Importing an OWL ontology).
  10. Logical (unbound) model. A model with no bindings still publishes to
    Knowledge Catalog: each entity's source is recorded empty (resources: [])
    because there is no table behind it, and a relationship that carries no join
    columns is skipped with a warning. When the same push also deploys a graph,
    the catalog entries are first pruned to what the graph binds — see
    To Knowledge Catalog.
  11. Vendor-dialect fallback. What you author is the expression.dialects[]
    list. importedExpression / importedDialect are not authored keys — the
    loader derives them from a non-canonical dialect entry (for example the
    MAQL or Snowflake form a metric was imported from). push --transpile
    adds a BIGQUERY dialect entry translated from a vendor-only expression
    or from an ANSI_SQL expression that double-quotes column names. A BigQuery
    graph reads a field's or metric's BIGQUERY entry, else its ANSI_SQL one. It never emits the
    vendor text. A field with none of these is left out of its node table with
    a warning, though push rejects such a field in the model file first. A
    metric with none, or one that aggregates such a field, is
    skipped with a warning. A Spanner graph reads a field's SPANNER entry,
    else its ANSI_SQL one, and leaves out a field with neither. See
    Model spec §2.5.
  12. Actions. An action reaches Knowledge Catalog only, as one
    semantic-action entry under the model entry, and pull reads it back from
    that entry. Every other push target deploys nothing for it and warns once.
    Its guards are stored as constraint names and round-trip verbatim. A name
    whose constraint is absent from a pull is kept rather than dropped, so a
    partial pull never silently rewrites the author's model; the pull warns
    about the name it kept, and a push rejects the model until the constraint
    is back. A name the aspect repeats is the one exception, dropped to its
    single occurrence because the loader rejects a repeat and the document has
    to stay loadable. Its affects round-trips the same way, with the same
    exception for a repeated concept-and-operation pair. See
    Modeling write operations.
  13. Constraints. A constraint reaches Knowledge Catalog only, as one
    semantic-constraint entry under the model entry, and pull reads it back.
    Every other push target deploys nothing for it and warns once. Publishing is
    all that push does with a constraint; what settles one is a run, and the
    runtime an application embeds is what puts each guard to a judge before the
    write.
  14. Binding profiles. A model may define several physical realizations, one
    per binding profile. --all-profiles deploys a graph for every profile that
    declares a deployment target, each to the backend its own target names; a
    profile without one is skipped, and two profiles claiming the same graph are
    rejected before anything deploys. Knowledge Catalog still records a single
    binding — the one named by --profile, else the default binding, which is
    what an --all-profiles run always records, the two flags being mutually
    exclusive. A push in two phases records the model file
    as written instead. See Binding profiles.
  15. Relationships with no link. Knowledge Catalog keeps one schema-join
    link between two entries, so when several relationships join the same two
    entities, push links the first one it can publish and skips the rest with
    a warning. The catalog metadata holds no link for a skipped relationship.
    pull still returns it from the stored copy of the model file, and leaves
    it out only when it rebuilds the file from the metadata.

To Knowledge Catalog

The catalog holds the model's metadata and a copy of each model file and profile
file. Every resource type it uses is a built-in system type under
dataplex-types/global, apart from the custom semantic-action,
semantic-constraint and semantic-model-file pairs that kcmd init
provisions. A push references those types and never creates them (see
Reference → What gets created in Knowledge Catalog).

What is recorded depends on the push. A catalog-only push (--no-profile), or a
purely logical model with no bindings, records the whole model: every entity,
metric, and relationship the document declares, apart from the relationships in
footnote 15. A push in two phases does too. Any other push
that also deploys a graph
first prunes the model to what that graph binds, so an unbound field — and any
entity, metric, or relationship that depends on it — is left out of the catalog
entries too. A pull still returns the authored model file from its stored copy.
Only a model file that pull rebuilds from the catalog metadata comes back as the
bound view.

A logical model still produces complete entries. Each entity's source is
recorded empty (resources: []) because there is no table behind it, and a
relationship that carries no join columns is skipped with a warning.

One binding, not all of them. A model may define several physical
realizations — an analytical binding onto BigQuery and an operational one onto
Spanner, say — and kcmd push --all-profiles deploys a graph for each. The
catalog leg does not fan out with it: it runs once, against the profile named by
--profile, otherwise the default binding. So a bare kcmd push and a
kcmd push --all-profiles write the same entries and differ only in how many
graphs they deploy. A push in two phases records the model
file as written instead.

Nor do successive pushes accumulate. Entry ids are derived from logical names
alone — model, entity, metric — with no profile component, so pushing a second
profile reconciles the catalog to that binding rather than adding to what the
first one wrote: shared elements have their source swapped to the new profile's
tables, and — on a push that deploys a graph, and so prunes — an entity or metric
the new binding cannot answer is deleted rather than left behind. The catalog
therefore describes one physical realization at a time. It records that binding's
deployment target and tables, but never the profile's name, and never that the
other profiles exist. A pull returns whichever was written last. Keep the
profile files — the catalog is not their store.

By default the catalog does not store the SQL expressions: the published
system-type templates do not yet carry a per-field semantics block or a
semantic-metric.expression field, so the default push omits them. Pass
--emit-expressions to write the canonical GoogleSQL/ANSI expression once the
templates gain the fields. The catalog never stores ai_context.synonyms /
examples, field-level ai_context (only model, entity, and metric
instructions have a home, in the guidelines aspect), or the original vendor
SQL (importedExpression — for example the MAQL or Snowflake form a metric was
imported from). Those stay in your authored document. The expressions build the
graph SQL.

Actions follow the same one-entry-per-element rule as everything else: each
becomes a semantic-action entry under the model entry, carrying its executor,
typed parameters, guards, and affects in a semantic-action aspect. They
round-trip losslessly through pull (name, description, executor, typed
parameters, guards, affects, and instructions). A parameter projected
from a field round-trips as the projection it was authored as: the aspect
stores concept and field alongside the resolved scalar type, and a pull
writes the projection back out without the type, so re-loading resolves it from
the same field and arrives at the same parameter. Two things about a projection
do not survive the trip. Its label and ai_context are dropped — the aspect
has nowhere to put them, so a pull recovers the parameter's description and
not those two. And the wording a pull does recover comes back as the
parameter's own: the loader resolves an inherited description into the
parameter when the model loads, and nothing downstream can tell that apart from
one the author typed, so a pulled document states it where the original left it
to the field. Re-loading gives the same parameter, but the field and the
parameter now each hold a copy, and changing the field no longer moves it.
A projection whose field is not in the pulled document — pruning drops an
entity no binding reaches — comes back as a declared parameter carrying the
resolved type instead, since emitting a concept/field pair that resolves
to nothing would write a document that will not load. A sql executor
round-trips its statements with the rest: they are the
write, not a note about it, so a catalog that dropped them would describe an
action nobody could re-deploy. The entry type is custom, so kcmd init creates
it; a model that declares no action never needs it.

What an action affects round-trips as a fact, not as the text it was authored
in. The two authored shapes — the bare name and the record — are one shape in
the model, so a record carrying nothing but a concept (- concept: Account)
comes back as the bare Account, which says the same thing. Everything after
that first pass is byte-for-byte stable.

affects stores only what the author wrote, which is why it survives a partial
pull intact. Whether a concept is an entity or an edge is not recorded, so
nothing about it has to be re-resolved — and nothing can go stale. That matters
most for a many-to-many relationship, which never reaches the catalog at all: a
pull recovers relationships from the schema-join entry links, which carry the
foreign-key edges only, so a concept naming an M:N edge would look unresolvable
on a model that is perfectly well-formed. It comes back untouched instead.

Constraints publish the same way: each becomes a semantic-constraint entry
under the model entry, with the rule and any instructions in a
semantic-constraint aspect and the description as the entry's own summary.
Name, description, on_violation, severity, instructions, and whichever body
states the rule — expression or judgment — round-trip losslessly through
pull. A constraint that declares neither routing word comes back without them
rather than with a default filled in. The aspect also carries a derived
evaluation field, deterministic or judged, which a pull recomputes from
the body rather than reading, so it can never come back disagreeing with the
rule beside it; it is not written into the authored document, because a derived
value in an authored file is a value that can go stale. An entry that states
both bodies, or neither, is skipped with a warning rather than pulled into a
model that would then fail its own push. That entry type is custom too, and a
model that declares no constraint never needs it.

To BigQuery

Push preserves both the queryable structure and the descriptive metadata attached
to it. The structure becomes the node tables, edge tables, and measures; the
descriptive metadata is written into each element's OPTIONS(...) in the graph
(visible with --print).

BigQuery's graph OPTIONS give an element a description string and a synonyms
array. synonyms is the only part with a dedicated option, so it carries across
structurally, as its own array. The rest — description, instructions,
examples, and a field's label — share the single description string, so
they are combined into it (examples as an Examples: … line). Their content is
preserved; their separate structure is not.

The model's own statement-level metadata has nowhere to go: BigQuery silently
drops graph-statement OPTIONS, so the model's description / ai_context are
not in the graph — the description and ai_context.instructions are carried
into Knowledge Catalog instead. Of an entity's keys only one is emitted: its
primary key, else its first unique key. The imported vendor SQL is never
emitted. push --transpile adds a BIGQUERY entry for a vendor-only
expression or an ANSI_SQL expression that double-quotes column names, and the
graph builds each field and metric from its BIGQUERY entry, else its ANSI_SQL one. A field with none of these is left out of its node table with a warning,
though push rejects such a field in the model file first. A
metric with none, or one that aggregates such a field, is skipped with a
warning.

To Spanner

A Spanner target keeps the queryable structure and drops the descriptive
metadata. The node tables, edge tables, and labels (including the extends
hierarchy, with fields flattened down) deploy exactly as they do for BigQuery,
but with bare table and graph names. Three things do not make the trip, all by
design:

  • Metrics. Spanner has no MEASURE, so every model-level metric is
    dropped with a warning. The BigQuery-only rule that a metric resolve to one
    entity does not apply. Author your metrics as usual — a BigQuery target still
    emits them, and Knowledge Catalog still records each semantic-metric entry —
    they simply have no home in the Spanner graph.
  • OPTIONS metadata. Spanner carries no per-element OPTIONS, so
    description, synonyms, instructions, examples, and field label are not
    written into the Spanner DDL. They still reach Knowledge Catalog on the same
    push (the model's / entities' / metrics' descriptions and instructions land
    on their entries and the guidelines aspect), so the descriptive layer lives in
    the catalog rather than in the Spanner graph. This mirrors how BigQuery drops
    its graph-statement OPTIONS while keeping per-element ones.
  • Another engine's SQL. A field reads its SPANNER expression, else its
    ANSI_SQL one, and is left out with a warning when it has neither.

Everything else — keys, relationships, the label hierarchy — matches the
→ BigQuery column above.

Actions and constraints reach neither graph. They are published to
Knowledge Catalog as one semantic-action or semantic-constraint entry each
and round-trip losslessly through pull — see
To Knowledge Catalog.

What pull recovers

Push stores a copy of the model file and of each profile file it reads, and
pull writes those copies back unchanged. What follows describes the catalog
metadata itself, which is what pull rebuilds the model file from when the
model has no kcmd-push-id label and no stored copy, such as a model an older
kcmd pushed. It also rebuilds the model file when no stored copy is named
after the model. Pull says when pull refuses the catalog
instead.

With KC_V2_ASPECTS=1, pull rebuilds the model file, profile files and overview
sidecars from the metadata, and writes each metric's entity as entity:,
declared or derived. See Reference.

pull returns what push wrote — the Recovered by pull column above is the
summary. When the push deployed a graph, what push wrote was already pruned to
the bound view (see To Knowledge Catalog), so pull
returns that view. Two things about how it comes back:

Normalized — the content survives, the form changes:

  • Relationship names come back lowercased (Places_Order →
    places_order); the catalog stores the name only in the link id. See
    Writer-side follow-up.
  • Field types round-trip except one collapse: a field authored as String
    comes back un-typed (both no type and String store dataType STRING, and
    pull writes no datatype for either; see footnote ²). A metric's data
    type round-trips only for a concrete type (e.g. Decimal); an untyped,
    String, or Opaque metric comes back un-typed, because the metric aspect
    stores a data type but no metadata type to mark it Opaque.
  • A field's dimension role (present only if pushed with --emit-expressions)
    comes back as a bare dimension: {} marker, without its detail (is_time, and
    so on).
  • Ordering: field order within each entity is preserved, but the order of
    entities and metrics is not — they come back in the catalog's own order, not
    the authored one. Comments in the original YAML are not preserved.

So a file rebuilt from the metadata is not your original file. Treat a
rebuilt document as a faithful copy of the catalog metadata rather than of the
authored model, and keep the authored document as the source of truth.

Writer-side follow-up

One reduction above is a limit of what push currently writes rather than of
what pull can recover. It is recorded here as a write-side follow-up; the reader
(pull) already returns everything the catalog holds.

  • Relationship names. The schema-join aspect type's metadataTemplate has
    no field for the relationship name, so push cannot store it and pull recovers
    it from the link id — which is lowercased, with each underscore written as a
    hyphen (the entry-link id format forbids both). Pull reads each hyphen back
    as an underscore, but the original casing is lost. Returning the name verbatim
    requires adding a name field to the built-in schema-join aspect type in
    Knowledge Catalog (server-side), after which the client write/read is trivial;
    it is the same class of gap as the semantics field that gates
    --emit-expressions.

(A deployment target in no supported form is not a pull gap. Every push
rejects it at the validation gate before anything is written, a catalog-only
push (--no-profile) too; see Validation.)


Toolbox Mdcode Docs Semantic Model Inheritance (`toolbox/mdcode/docs/semantic-model/inheritance.md`)

Modeling class hierarchies

When several entities are kinds of one thing — a customer and a supplier are both
parties you deal with — you can declare the general kind once and say that each
specific kind extends it. A query written against the general kind then reaches
every specific kind. extends is the semantic model's is-a: a customer is a
party.

kcmd push expresses the hierarchy on the graph as labels. A subtype's node
table carries its own label plus one label per supertype, so MATCH (:Party)
matches every customer and every supplier. The exact generation rules — how
fields flatten down, what a label signature must contain — are in
Reference → Class hierarchies;
this page is the modeling guide.

When to use it

Use extends when the shared fields reflect a real is a kind of relationship
and you want a supertype query to gather every subtype.

Do not use it when the shared fields are a coincidence of naming, or when you
have one entity whose columns happen to be split across two tables — that is a
binding, and modeling it as inheritance produces the double-count
described below.

The one rule

A supertype query gathers every subtype. Because it gathers subtypes, each real
thing must live in exactly one place
, or it is gathered more than once and your
counts double. State it once and rely on it:

One real thing, one row, one node.

Everything below is a way to honor this rule.

1. Declare the hierarchy

Party is the general kind. In this model no row is just a party — every party
is a customer or a supplier — so Party has no table of its own. Mark it
abstract: true: it has no source, no key and no column bindings, produces no
node table, and survives in the graph only as a label on its subtypes.

Each concrete kind declares its own fields and the one extends keyword. The
supertype's fields are inherited, so you do not repeat them:

yaml
version: "0.2.0.dev0/google"
semantic_model:
  - name: parties
    entities:
      - name: Party
        abstract: true
        fields:
          - { name: id,   datatype: Integer }
          - { name: name, datatype: String }
      - name: Customer
        extends: [Party]
        primary_key: [id]          # each subtype keeps its OWN key; keys are not inherited
        fields:
          - { name: loyalty_tier, datatype: String }
      - name: Supplier
        extends: [Party]
        primary_key: [id]
        fields:
          - { name: rating, datatype: Integer }

The supertype's id and name flatten down onto both subtypes, so Customer
and Supplier each expose them, and each subtype node carries both its own label
and the Party label.

mermaid
classDiagram
    class Party {
        <<abstract>>
        id : integer
        name : string
    }
    class Customer {
        loyalty_tier : string
    }
    class Supplier {
        rating : integer
    }
    Party <|-- Customer
    Party <|-- Supplier

Party is abstract, so it has no table of its own; the arrows are extends. A
query against Party reaches every subtype below it.

2. Bind each subtype's table

Inheritance meets binding at one requirement: each subtype's table must expose
the inherited columns
, because those fields are read from the subtype's own
table. Party.name, flattened onto Customer, is read from the customer table,
so that table must have a name column.

A binding gives each entity its source and each field its column. With one
store you can bind inline on the model as the default profile:

yaml
- name: Customer
        extends: [Party]
        primary_key: [c_custkey]
        source: bigquery.googleapis.com/projects/my-project/datasets/sales/tables/customer
        fields:
          - { name: id,           expression: c_custkey }
          - { name: name,         expression: c_name }   # the inherited field, bound here
          - { name: loyalty_tier, datatype: String, expression: c_tier }

A redeclared inherited field carries only its binding. Its datatype and the rest
of its definition come from Party, and push rejects a redeclaration that
restates them.

To bind the same hierarchy to more than one store, put each binding in its own
profile. A named profile binds every concrete subtype, since
each one stands for a table the profile's database has to hold. A profile can
bind a field the
subtype inherits by naming it under that subtype, with no redeclaration in the
model file, and the binding reads from the subtype's own table.

3. Query the supertype

text
GRAPH my-project.sales.parties
MATCH (p:Party)
RETURN p.name

Every customer and every supplier comes back, each once, because each real party
lives in exactly one table.

More hierarchy shapes

extends composes. A subtype can extend several supertypes, and a supertype can
extend another supertype above it. Two facts hold for every shape: the supertype
fields flatten down, and each concrete table binds every inherited field to its
own column. So the shapes below all deploy the same way, and each was verified to
deploy on BigQuery Graph.

Extending more than one supertype, and diamonds

extends takes a list. Here an Employee is both a Person and a Taxpayer,
and each of those is a Party:

yaml
- name: Party
        abstract: true
        fields:
          - { name: id,   datatype: Integer }
          - { name: name, datatype: String }
      - name: Person
        abstract: true
        extends: [Party]
        fields:
          - { name: birth_year, datatype: Integer }
      - name: Taxpayer
        abstract: true
        extends: [Party]
        fields:
          - { name: tax_id, datatype: String }
      - name: Employee
        extends: [Person, Taxpayer]
        primary_key: [e_id]
        source: bigquery.googleapis.com/projects/my-project/datasets/hr/tables/employee
        fields:
          - { name: id,         expression: e_id }
          - { name: name,       expression: e_name }
          - { name: birth_year, expression: e_birth }
          - { name: tax_id,     expression: e_tax }
          - { name: department, datatype: String, expression: e_dept }
mermaid
classDiagram
    class Party {
        <<abstract>>
        id : integer
        name : string
    }
    class Person {
        <<abstract>>
        birth_year : integer
    }
    class Taxpayer {
        <<abstract>>
        tax_id : string
    }
    class Employee {
        department : string
    }
    Party <|-- Person
    Party <|-- Taxpayer
    Person <|-- Employee
    Taxpayer <|-- Employee

Party sits at the top and is reached through two paths, so this is a diamond.
Employee carries the Person, Taxpayer, and Party labels, and Party
appears once. MATCH (:Person), MATCH (:Taxpayer), and MATCH (:Party) each
return every employee a single time. The number of supertypes a subtype has, and
the number of paths that reach a shared ancestor, do not change the count.

A field may be declared on only one side of a diamond. If Person and
Taxpayer both declared a nickname field, push would reject the model,
because neither is nearer to Employee and nothing says which one wins. Declare
a field both sides share on Party instead. The same holds for a binding: two
concrete supertypes that each rebind a field they inherit from Party leave
their common subtype ambiguous, and push rejects it.

Deeper hierarchies

A hierarchy can run several levels deep with a concrete table at each leaf. Here
Party divides into Person and Organization, and each has its own concrete
kind: a Customer is a person, a Vendor is an organization:

yaml
- name: Party
        abstract: true
        fields:
          - { name: id,   datatype: Integer }
          - { name: name, datatype: String }
      - name: Person
        abstract: true
        extends: [Party]
        fields:
          - { name: birth_year, datatype: Integer }
      - name: Organization
        abstract: true
        extends: [Party]
        fields:
          - { name: founded_year, datatype: Integer }
      - name: Customer
        extends: [Person]
        primary_key: [c_id]
        source: bigquery.googleapis.com/projects/my-project/datasets/sales/tables/customer
        fields:
          - { name: id,         expression: c_id }
          - { name: name,       expression: c_name }
          - { name: birth_year, expression: c_birth }
          - { name: tier,       datatype: String, expression: c_tier }
      - name: Vendor
        extends: [Organization]
        primary_key: [v_id]
        source: bigquery.googleapis.com/projects/my-project/datasets/sales/tables/vendor
        fields:
          - { name: id,           expression: v_id }
          - { name: name,         expression: v_name }
          - { name: founded_year, expression: v_founded }
          - { name: rating,       datatype: Integer, expression: v_rating }
mermaid
classDiagram
    class Party {
        <<abstract>>
        id : integer
        name : string
    }
    class Person {
        <<abstract>>
        birth_year : integer
    }
    class Organization {
        <<abstract>>
        founded_year : integer
    }
    class Customer {
        tier : string
    }
    class Vendor {
        rating : integer
    }
    Party <|-- Person
    Party <|-- Organization
    Person <|-- Customer
    Organization <|-- Vendor

Customer and Vendor are the only tables. MATCH (:Party) returns every
customer and every vendor; MATCH (:Person) returns customers alone, and
MATCH (:Organization) vendors alone. Each real party lives in one table, so
every count is exact.

What keeps every shape correct

These shapes deploy because their supertypes are abstract. Each concrete table
binds every inherited field to its own column, and BigQuery matches a shared
label by property name, so the subtype tables never have to agree on a physical
column. A supertype with its own table works too. Then every subtype table must
carry that supertype's columns under the same names, and a thing present in both
the supertype table and a subtype table is counted twice under the supertype
label. Such a supertype still cannot be a relationship endpoint or own a metric,
because other entities extend it. Keeping supertypes abstract keeps the one rule
— one real thing, one node — automatic.

Match the shape to how your data is stored

How your subtypes are already stored decides how you model them. extends builds
one shape directly, and the other two common layouts are modeled without it:

  • One table per kind, the parent has none. Each concrete kind has its own
    complete table; the supertype has no table and survives as a label; a supertype
    query is the union of the kind tables. This is the shape extends builds — the
    hierarchy above. It is correct as long as the kind tables hold disjoint things,
    which is why the parent is abstract.
  • One table for the whole family, with a kind column. All parties in one
    table, a type column saying which kind each row is. Model this as one entity
    with a dimension field; it holds one row per thing, so it cannot double-count.
  • A base table plus an extension table. A thing's general fields in one table
    and its specific fields in another, tied by a shared key. Model this as one
    entity whose binding joins the two tables, so the thing keeps one identity.

When a supertype total looks too high

If a supertype count or sum comes back larger than the data warrants, the same
real thing lives in more than one table under the hierarchy. Every table carrying
the label contributes that thing as its own node. Take three people split across
a person table and a customer table that both carry the Person label, with
two of the three present in both. MATCH (:Person) returns five nodes,
because no identity link across the two tables tells the graph that the shared
rows are the same person.

The fix is the one rule. Make the parent abstract so no real thing lives in more
than one table under the hierarchy. When the two tables are instead the general
and specific halves of one thing, model them as one entity whose binding joins
them by key.

Relationships and metrics belong on the leaves

A relationship may connect only concrete leaf entities. A leaf is an entity
that is not abstract and that no other entity extends. An edge stays on the node
table it is declared on. An abstract Party has no table, and a concrete
supertype's table holds only its own rows, so an edge declared on a supertype
misses every subtype row. Declare the edge from each leaf that has it instead,
for example from Customer. A metric may belong only to a leaf as well, because
a measure cannot sit on a label that several tables share. Push rejects a
relationship or metric on any other entity, including a push to Knowledge
Catalog alone.

The rules push enforces

Fields flow down to subtypes. Relationships and keys stay where they are
declared. Every table under a shared supertype exposes the identical field set,
so a subtype's table must have a column for every inherited field, or the deploy
fails. Push also rejects a model that breaks any of these rules:

  • A subtype redeclares an inherited field only to bind it to a column. A
    redeclaration with nothing but the name is rejected.
  • A concrete entity restates each field it inherits whose ancestor's
    expression reads a column. This holds in each named profile, and in the
    model file when it gives the entity a source. A field whose ancestor's
    expression reads only other fields carries over.
  • An abstract entity declares no source, no keys and no field expressions.
  • Relationships and metrics attach only to concrete leaf entities.
  • No field is declared or rebound by two supertypes that do not extend each
    other.
  • extends forms no cycle.

Each rule and its reason is in
Reference → Class hierarchies.

Inheritance deploys the same way to BigQuery Graph and Spanner Graph — the extra
labels and the flattened fields are identical on both backends. On Spanner the
model carries no measures, as it does for any model.


Toolbox Mdcode Docs Semantic Model Model Spec (`toolbox/mdcode/docs/semantic-model/model_spec.md`)

Semantic Model Specification

This is the normative definition of the semantic model document kcmd reads and
writes. It defines what a valid model is — the YAML grammar, the logical and
physical constructs, and the rules the format enforces. For what kcmd does
with a valid model (flags, what each push creates, permissions), see
Reference; for the task walkthrough, see the
deploy guide.

The two documents divide by register: this spec states the definition
("a model MUST declare at most one deployment target"); Reference states the
operational consequence ("…otherwise push is rejected"), and links back here.
When they overlap, this document is the authority on what is valid and Reference
is the authority on what the tool then does.

1. Status and baseline

The format is kcmd's profile of Apache Ossie,
built on Ossie version 0.2.0.dev0. Ossie is the open semantic-model format:
it describes a business — datasets, fields, relationships, metrics — and binds
each dataset to a source table and each field to a column expression. What
Ossie does not describe is where a model then deploys. kcmd adds that deployment
layer and a few modeling constructs, and it relaxes a few of Ossie's required
fields so a model can stay purely logical.

Those additions come in two versions, and a document picks one with its
top-level version (see Version handling):

  • 0.2.0.dev0 — vanilla Ossie. kcmd's additions ride in Ossie's own
    custom_extensions carrier (§6); the native
    extension keys are not accepted. This profile is a strict superset of Ossie:
    strip the GOOGLE extension blocks and it is a plain Ossie document.
  • 0.2.0.dev0/google — the extended profile. The same additions are
    first-class native keys (entities, deployment_target, extends,
    abstract); the custom_extensions carrier is not accepted, because there is
    nothing left for it to carry. This profile is readable, but not a vanilla-Ossie
    document — an Ossie-only reader knows neither its version nor its native keys.

For a model that avoids the /google-only constructs, the two are the same model
written two ways; both parse to the identical IR, so no downstream leg sees which
was used.

Ossie compatibility is a property of the vanilla version, and the contract
runs in both directions:

An Ossie 0.2.0.dev0 document is a valid kcmd model unless it breaks a
narrowing (§4.1), such as an expression in
a non-SQL dialect.
The reverse holds for a vanilla kcmd model that uses no
extensions (§5) and no relaxations
(§4) — a fully-bound model with only Ossie
constructs. Strip the GOOGLE custom_extensions blocks from such a model and
it is a valid Ossie document.

Under vanilla, kcmd's deployment, binding and inheritance additions ride in a
custom_extensions block an Ossie-only tool ignores (see
§6), so those never fork the document; abstract,
actions, constraints and the entities spelling have no vanilla form and
exist only under 0.2.0.dev0/google. The extended 0.2.0.dev0/google profile
trades that wire compatibility for readable native keys; it is kcmd's own
surface, converted to or from vanilla by choosing the version.

How to read this document. This is a delta against the Ossie spec. It does
not restate constructs kcmd adopts unchanged — for those, the Ossie spec is
authoritative. This document covers only what is kcmd's to state:

  1. where kcmd is stricter than Ossie — narrowings (§4),
  2. where kcmd is looser than Ossie — relaxations (§4),
  3. what kcmd adds beyond Ossie — extensions (§5), and
  4. how those additions stay Ossie-compatible (§6).

If a construct is not mentioned here, kcmd supports it as Ossie 0.2.0.dev0
defines it.

Conformance keywords. MUST, MUST NOT, SHOULD, and MAY are used per
RFC 2119. A rule marked MUST is enforced
at load or validation time; the exact operational effect (hard error, skip with a
warning) is in Reference → Validation.

Version handling. version is required and lives at the top of the
document (not inside a model). It MUST be one of two values: 0.2.0.dev0 (vanilla
Ossie) or 0.2.0.dev0/google (the extended profile). A missing or unrecognized
version is a hard load error, not a warning — the value selects which
extension surface is legal (§6), so it cannot be
guessed. kcmd's own tools (pull, the OWL importer) emit 0.2.0.dev0/google;
hand-authored portable models SHOULD use vanilla 0.2.0.dev0.

Baseline. The Ossie classifications in §3 are
reconciled against the Ossie 0.2.0.dev0 core JSON Schema, a copy of which is
vendored in this repository at
tests/libts/semantic/fixtures/ossie/osi-schema.json (copied unmodified from
apache/ossie). That schema, not kcmd's parser, is the authority for what Ossie
defines; the parser is the authority only for what kcmd accepts.

2. Document shape

A model document is a YAML file with a top-level semantic_model list.

yaml
version: "0.2.0.dev0"          # required; selects the extension surface (§1)
semantic_model:                # one or more models; kcmd deploys exactly one per entry group (§4)
  - name: sales
    datasets: [ … ]            # one or more; the entities (§2.1). Spelled `entities:` under /google (§5)
    relationships: [ … ]       # optional (§2.2)
    metrics: [ … ]             # optional (§2.3)
    actions: [ … ]             # optional; /google only (§5)
    constraints: [ … ]         # optional; /google only (§5)
    description: "…"           # optional
    ai_context: { … }          # optional (§2.4)
    custom_extensions: [ … ]   # vanilla only (§6); rejected under /google
    deployment_target: "…"     # /google only (§5, §7); vanilla carries it in a GOOGLE block

A document MUST contain at least one model, and each model MUST contain at least
one dataset. Every named object (dataset, field, relationship, metric,
action, constraint) MUST have a name unique within its scope. A duplicate name is
a hard load error (agreed with Dmitri), because nothing reading the model can
tell two objects of the same name apart.

Objects are closed: an unknown sibling key inside a validated object is a
hard error, not silently dropped (agreed with Dmitri). This is what makes the
version gate bite — a native key under vanilla, or a custom_extensions block
under /google, is an unknown key for that version and is rejected
(§6). Setting both entities and datasets is
likewise an error (§5).

The remaining subsections define each object. Names throughout are logical
business vocabulary
— order_id, placed_by — never physical column names; the
physical binding is a separate concern (§7).

2.1. Dataset (entity)

A dataset is one concept in the business.

Key Type Rule
name string required
fields list of field the concept's attributes
primary_key list of strings the physical column(s) that identify a row
unique_keys list of lists of strings additional unique column-sets
extends list of strings supertype dataset names — extension (§2.1.2, §5)
abstract boolean a concept with no table — extension (§5)
source string physical table URI; Ossie-native, relaxed to optional (§7.1, §4)
description string
ai_context ai_context
custom_extensions list §6

An abstract dataset MUST NOT declare a source; a non-abstract dataset in a model
bound for a graph MUST declare one (see §4 and
§7).

2.1.1. Field

Key Type Rule
name string required
datatype enum one of the closed vocabulary below
expression expression physical-column binding; a field with none is unbound (binding — §7)
label string human display label
dimension object { is_time: boolean }
description string
ai_context ai_context
custom_extensions list §6

datatype is a closed, case-sensitive vocabulary:

text
String  Integer  Decimal  Float  Boolean  Date  Time  DateTime  DateTimeTz  Opaque

Date, Time, DateTime, and DateTimeTz are the temporal subset (they default
dimension.is_time). A value outside this set is not authorable directly; a type
kcmd cannot express natively is represented as Opaque plus a custom_extensions
block that carries the real type.

A field's expression is its binding; a field with no expression is unbound
(no column under this binding). There is no separate unbound flag — absence is
the signal. See §7.

2.1.2. Inheritance (`extends`)

extends is a kcmd extension — Ossie 0.2.0.dev0 has no inheritance
construct (§5). A dataset MAY declare extends: [Parent, …],
naming one or more supertype datasets by name. Inheritance is entity-level
only
— relationships do not extends. A supertype that has no table of its own
MUST be marked abstract: true (also an extension).

The format rule is only that supertypes are named datasets in the same model. An
extends that names a dataset not defined in the model is a hard error (a typo
must not silently drop inheritance — agreed with Dmitri). A cycle in extends is
an error. A diamond flattens, with each ancestor contributing once. A field
declared or rebound by two supertypes that do not extend each other is an error,
because nothing says which one wins. A subtype MAY redeclare an inherited field
only to bind it, with an expression and nothing else; the definition comes
from the ancestor that declares it. A concrete subtype MUST rebind each
inherited field whose ancestor's expression reads a column, because that column
belongs to the ancestor's table. This holds in each named profile, and in the
model file when it gives the subtype a source.
A relationship endpoint and a metric's entity MUST be a concrete
leaf: not abstract, and not extended by another dataset.
How the hierarchy is lowered into each store (labels, field flattening) and the
downstream limits (e.g. a supertype whose label is shared across subtype tables
cannot carry a measure) are operational — see
Reference → Class hierarchies
and Modeling class hierarchies.

2.2. Relationship

A relationship is a directed edge between two datasets.

Key Type Rule
name string required
from string required; a declared dataset name
to string required; a declared dataset name
from_columns list of strings join key on from
to_columns list of strings join key on to
description string
ai_context ai_context
custom_extensions list §6

from_columns and to_columns are the edge's join keys. They MUST be given
together (a bound edge) or both omitted (a logical edge); one without the other is
rejected. When both are given they MUST have equal length. from and to MUST
name datasets declared in the same model. to_columns MUST contain every column
of a primary or unique key of the to dataset; a superset of a key counts.
An edge is N:1 or 1:1 only when its join columns cover a key, so join columns
that cover no key are rejected on every push, a catalog-only push included.
Each binding of the relationship may cover a different key. A relationship MUST
join two different datasets, and two datasets MUST NOT have relationships in
both directions.

Ossie requires both join-column lists; allowing both to be omitted — a
logical edge with no join keys — is a kcmd relaxation (§4)
that lets a relationship be governed before it is bound. A graph deploy still
requires them (see §4). Like source and a field's
expression, join columns are a binding: a profile may supply or override them,
and a named profile states them itself (§7).

Many-to-many is not yet authorable. A junction-table (M:N) relationship
exists in kcmd's internal representation but has no YAML syntax in
0.2.0.dev0. It cannot be authored today and is reserved for a future format
extension. Direct-FK relationships (1:1, 1:N) are the authorable forms.

2.3. Metric

A metric is a computation over the model's fields.

Key Type Rule
name string required
expression expression required
datatype enum the field datatype vocabulary
description string
ai_context ai_context
custom_extensions list §6

A metric names no dataset directly; the dataset it belongs to is derived from
the entities its expression references. This is why a metric bound for a BigQuery
graph MUST reduce to a single entity (§4). A metric is SQL over
fields; there is no native metric-composition (metric-of-metrics) construct.

2.4. `ai_context`

ai_context MAY appear on the model, a dataset, a field, a relationship, and a
metric. It is either a bare string (shorthand for instructions) or an object:

yaml
ai_context:
  instructions: "…"        # guidance for agents
  synonyms: [ "…", "…" ]   # alternate names
  examples: [ … ]          # illustrative values / usages

There is no top-level synonyms key on any object; synonyms are always
ai_context.synonyms. instructions, synonyms, and examples are all
Ossie-native. Each examples item MUST be a string, as in Ossie; any other item
is a load error. Which parts of ai_context reach
which destination is in What push and pull preserve.

2.5. Expressions

A field or metric expression is either a bare SQL string or the per-dialect
form:

yaml
expression: SUM(orders.net_amount)        # shorthand: dialect BIGQUERY

expression:                               # explicit per-dialect
  dialects:
    - { dialect: BIGQUERY, expression: "SUM(orders.net_amount)" }
    - { dialect: ANSI_SQL, expression: "SUM(orders.net_amount)" }

The shorthand string normalizes to a single ANSI_SQL dialect. ANSI_SQL is the
portable fallback. An expression references logical fields as
entity.field; the qualifiers are what let a metric's entity be derived
(§2.3).

Ossie closes dialect to an enum — ANSI_SQL, SNOWFLAKE, MDX, TABLEAU,
DATABRICKS, MAQL, BIGQUERY, THOUGHTSPOT. kcmd accepts the SQL
dialects ANSI_SQL, BIGQUERY, SPANNER, MYSQL, POSTGRES, ALLOYDB,
SQLSERVER, SNOWFLAKE and DATABRICKS, and rejects any other name.
SQLSERVER text is read as T-SQL.

3. Conformance summary

Every construct, at a glance, measured against Ossie 0.2.0.dev0 — the
vanilla baseline (see §1). The OSI column is what
that baseline defines, where a — means Ossie does not define the construct at
all. The Google extension column is how this profile differs: same (adopted
unchanged), optional (a relaxation, §4),
stricter (a narrowing, §4), added (an
extension, §5), or rejected / not authorable (excluded).
Why / where gives the reason and the section with the detail.

Feature OSI (0.2.0.dev0) Google extension Why / where
semantic_model, name, description defined same §2
datasets defined same §2.1
entities (alias for datasets) — added (alias) readability; sugar, /google only · §5
field name, label, dimension (is_time) defined same §2.1.1
datatype + vocabulary closed enum same (identical set) §2.1.1
primary_key, unique_keys defined same §2.1
extends — added class inheritance; /google only · §2.1.2, §5
abstract — added supertype with no table; /google only · §5
relationship name, from, to defined same §2.2
relationship from_columns / to_columns required optional model before binding; none = logical edge · §4.2
relationship M:N (association) — not authorable (reserved) no M:N syntax yet · §2.2
metrics, metric expression required same; graph-bound stricter a graph measure binds one node and aggregate · §4.1
expression.dialects closed enum stricter; adds names SQL dialects only; adds Google Cloud engines · §2.5, §4.1, §4.2
actions — added write operations declared over the ontology; /google only · §5
constraints — added named invariants over the ontology; /google only · §5
field expression (column binding) required optional model before binding; unbound is pruned · §4.2, §7
source (table binding) required optional logical-only models; graph deploy still needs it · §4.2, §7.1
ai_context (instructions, synonyms, examples) examples are strings same §2.4
custom_extensions (vendor_name, data) the extension carrier rejected /google uses native keys; nothing to carry · §6
deployment_target — added OSI omits deployment; vanilla carries it in custom_extensions · §5, §7.2
binding profiles — added one model, many stores · §7.3
multiple models per document a list (many) exactly one entry group = model's identity · §4.1

4. Narrowings and relaxations

This profile diverges from Ossie in two directions. Narrowings are where it is
stricter — rules that can reject an otherwise-valid Ossie document at deploy time.
Relaxations are where it is looser — fields Ossie requires that this profile
makes optional, so a relaxed model is not a valid Ossie document until those
fields are supplied. Both are the reason the reverse compatibility direction in
§1 is conditional.

4.1. Narrowings (stricter than Ossie)

Each rule and its reason:

  • At most one deployment target per model. A model bound for a graph MUST
    declare exactly one deployment target (§7). Why: the
    target names one graph in one store; a model with several has no single place to
    deploy. (A catalog-only push needs no target at all.)

  • Exactly one model per entry group. A push MUST deploy exactly one model to a
    Knowledge Catalog entry group, and a pull expects exactly one. Why: the entry
    group is the model's identity in the catalog; more than one anchor is ambiguous.

  • A graph-bound metric MUST resolve to a single entity. For a BigQuery target,
    every metric's expression MUST reference exactly one entity, so the metric can
    attach to one node table. Why: a graph measure binds to a single element.
    Spanner imposes no such rule (it has no measures). A metric that resolves to no
    entity or spans several is rejected at push.

  • A graph-bound metric MUST reduce to one supported aggregate. For a
    BigQuery target, a metric that is not a single supported aggregate
    (SUM, AVG, COUNT, MIN, MAX) over one operand cannot become a
    MEASURE, and the push is rejected (see Validation).
    COUNT(*) on a keyed node also passes. Why: BigQuery Graph measures are
    single-aggregate.

  • A graph-bound relationship MUST have its join columns bound. For any graph
    target, a non-M:N relationship MUST supply both from_columns and to_columns
    before deploy. Why: the edge table needs both keys.

  • Unknown keys are rejected. Every object is validated closed: an unrecognized
    sibling key is a hard load error, not silently dropped. Combined with the version
    gate, this is how a native key under vanilla — or a custom_extensions block
    under /google — is caught (§6).

  • Duplicate names are rejected. A dataset, field, relationship, or metric name
    repeated within its scope is a hard load error; the generated element would be
    ambiguous.

  • An unknown supertype is rejected. An extends that names a dataset not in the
    model is a hard load error (see §2.1.2).

  • A metric belongs to a concrete leaf. A metric whose entity is abstract, or
    extended by another entity, is rejected on every push.

  • A relationship joins two different datasets in one direction. A
    relationship from a dataset to itself is rejected, and so are relationships
    in both directions between two datasets (§2.2).

  • Only SQL dialects are accepted. An expression entry whose dialect is
    MDX, TABLEAU, MAQL or THOUGHTSPOT is a hard load error
    (§2.5). Why: every accepted dialect names a SQL engine,
    and no engine on the list runs text in those languages.

A diamond in a class hierarchy is allowed when both paths reach one
declaration: the resolver keeps that field once. A cycle in extends is
rejected, and so is a field that two ancestors which do not extend each other
both declare or both rebind. See §2.1.2.

4.2. Relaxations (looser than Ossie)

This profile relaxes Ossie in two kinds, for two different reasons.

Binding fields made optional — to model before binding. Ossie marks these
required; kcmd accepts them as optional so a model can be governed before it is
bound (a purely logical model). At graph deploy a non-abstract dataset must
still declare a source — the corresponding narrowing in
§4.1. An omitted field expression or
relationship join column is not an error: the field is unbound and the
availability pass prunes it, and a relationship with no join columns is a
logical edge.

  • source on a dataset — required in Ossie; optional in kcmd. A non-abstract
    dataset bound for a graph MUST still declare one.
  • expression on a field — required in Ossie; optional in kcmd. A field
    with no expression is unbound and is pruned at graph generation; supply
    the column in a binding profile to bind it.
  • from_columns / to_columns on a relationship — both required in Ossie;
    kcmd allows both omitted (a logical edge).

Dialect names added for Google Cloud databases. Ossie's dialect enum has
no name for Spanner, AlloyDB or the Cloud SQL engines. kcmd accepts SPANNER,
MYSQL, POSTGRES, ALLOYDB and SQLSERVER in both versions
(§2.5). A vanilla document that uses one of them is not a
valid Ossie document.

The operational form of every rule in this section (hard error vs.
skip-with-warning, and the exact message) is in
Reference → Validation.

5. Extensions

Constructs kcmd adds beyond Ossie. Each is carried so an Ossie-only tool still
reads the document (§6).

  • entities as an alias for datasets (extended profile only). Under
    0.2.0.dev0/google a model MAY spell its datasets entities: instead of
    datasets:; setting both is an error. Pure sugar — identical meaning. Under
    vanilla Ossie the key is not accepted; use datasets. kcmd's pull always emits
    entities.

  • extends on a dataset. Class inheritance — a subtype names its supertype
    datasets. Ossie 0.2.0.dev0 has no inheritance construct, so extends is a
    native key only under 0.2.0.dev0/google. A vanilla document writes it in a
    GOOGLE block on the dataset (§6). See
    §2.1.2.

  • abstract: true on a dataset (extended profile only). Marks a concept with
    no physical table (typically an extends supertype). An abstract dataset produces
    no node table and MUST NOT declare a source, keys or field expressions.
    Accepted only under
    0.2.0.dev0/google.

  • deployment_target (extended profile only). The physical destination — one
    graph in one store — as a first-class model-level (or profile-level) key, accepted
    under 0.2.0.dev0/google. Under vanilla Ossie there is no native key; the
    model's deployments are written as a GOOGLE custom_extensions block carrying
    {"deployments": [{"name": "…", "target": "…"}]}, and the older
    {"deploymentTargets": ["…"]} form is still read
    (§6). Grammar in §7.

  • actions (extended profile only). Model-level write operations, the
    write-side counterpart to a metric. An action names an operation, points at the
    executor that performs it, and types each parameter against the ontology. Every
    parameter carries one scalar value and MUST settle on one scalar datatype: it
    either projects a field — naming the concept and field (or the one-key
    shorthand field: Concept.field) whose datatype, description, label and AI
    context it takes — or declares a scalar type (or datatype) of its
    own. Naming an entity or a relationship as a type is a hard load error,
    and so is restating type or datatype beside concept/field — the field
    is the one place the datatype is stated. Its optional guards lists, by
    name, the constraints that gate it; each name MUST resolve to a constraint the
    same model declares, and a repeated name is a hard load error. Its
    optional affects lists what the call changes. Each entry is either a bare
    concept name or a record — {concept, operation?, fields?} — and both forms
    normalize to the same thing. Every concept MUST resolve to an entity or a
    relationship the same model declares; the two are named the same way, because
    the model already records which one a name is. operation is drawn from one
    closed vocabulary, create / modify / delete, whatever the concept is: a
    many-to-many relationship is backed by a junction table with fields of its
    own, so an edge is modified exactly as an entity is. fields MUST name fields
    of the concept and MUST NOT accompany a delete, which takes the whole
    instance. A repeated concept-and-operation pair is a hard load error.
    Accepted only under
    0.2.0.dev0/google. kcmd publishes an action and never calls its executor,
    so affects is the author's declaration and nothing verifies it against what
    the executor does. See Modeling write operations.

    An executor is a physical binding, not part of the declaration: it is
    OPTIONAL on the action, and a binding profile MAY supply or
    replace it, exactly as it supplies an entity's source. An action a profile
    does not mention keeps the model's executor; executor: null in a profile
    withdraws it. An action in a model a profile names MUST NOT declare a sql
    executor; merging such a model is a hard error. The other three kinds MAY
    be declared in the model and replaced per profile. An action left with no
    executor is declared and not performable under that binding, which the
    availability pass reports; it is not an error.

    An executor, where one is written, MUST declare exactly one kind. Three of
    them — mcp, rest and grpc — name a system that performs the write; the
    fourth, sql, carries the
    write itself as an ordered list of statements. Because that one is the only
    kind whose text the model can read, it is the only one with rules about that
    text: each statement MUST be a single INSERT, UPDATE or DELETE, MUST NOT
    contain a ; other than a trailing one, and MUST reference only @parameter
    names the action declares. Those rules are what let every value be bound
    rather than interpolated, so an argument cannot reach the store as SQL. Where
    the key of an inserted row comes from is the statement's own business, and
    affects has no bearing on it.

  • constraints (extended profile only). Model-level named invariants over
    the ontology, accepted only under 0.2.0.dev0/google. Each states exactly one
    condition, in one body.

    judgment is the rule in words, settled by a language model reading the
    attempted call: whether a discount is justified by the reason given, whether a
    refund note explains the exception it claims, whether a credit stays under a
    ceiling. Field names in a judgment are written model-qualified
    (Order.discount_reason rather than "the reason"), so the reference is
    checked against the model and lives in the sentence that uses it. A constraint
    declaring no judgment is a load error.

    judgment is the only body a constraint has, which puts a model call on every
    guarded write and means a threshold a subtraction would settle identically can
    come back differently twice. Actions → What a judgment
    costs
    says where that leaves a rule.
    expression is a reserved key: a document stating one is refused with a
    sentence telling the author to restate the rule in words.

    A policy that branches — a large refund is held for a director, a disguised
    one is refused, a vague reason is only reported — becomes one constraint per
    branch, each with its own name, on_violation and severity, which guards
    on the action lists together. That keeps each branch independently searchable,
    revisable and owned. Actions → A credit policy, worked
    through
    works a credit policy
    through end to end.

    A constraint takes effect only where something references it. Declaring one
    adds a rule to the catalog and refuses nothing, so publishing a rule cannot
    change what an already-working call does. guards on an action is the only
    reference the model defines, and it is where a rule over an action's
    parameters belongs, having no other moment to run. A rule that turns on a
    stored value can be declared there too, but nothing fetches that value: a
    judge is given the rule, the action and the arguments, so such a rule is
    recorded rather than enforced, and its enforcement belongs in your schema.
    Status: authored, validated, published, and settled at run time. Whatever
    dispatches a call puts each guard to a language model before the transaction
    opens and routes the verdict by on_violation; given no judge, the action is
    refused rather than run past its rules. The commerce
    demo
    shows what hiring a judge
    takes, in recorded runs — nothing in this repository embeds one today.
    Rules in Reference → Validation.

    A constraint says two things about a violation, under two separate keys.
    on_violation is what a violation does to the write that tripped it:
    reject refuses it outright and nobody may approve it, escalate holds it
    for a human decision, warn lets it proceed and reports it. It is required.
    severity is how grave a violation is — critical, high, medium or
    low — for ranking and reporting. It is optional, carries no default, and
    nothing ranks or routes on it yet.

    When an action's guards names several constraints and a write violates more
    than one, the strictest outcome among them applies: any reject refuses the
    call, failing that any escalate holds it, failing that any warn lets it
    through with the violations reported. That combination is fixed and no part of
    the model states it, which is why an action can name any number of guards
    without saying how to add them up.

    A constraint MUST state on_violation, and it MAY be any of the three words.
    Omitting the key is the only error: an unmarked constraint rejects, and that
    is too strong a consequence for an author to inherit by silence. Pairing a
    judgment with reject is the riskiest thing the format can express, because a
    language model can decide two identical proposals differently and reject
    leaves no appeal. It is published rather than refused, and made findable: a
    constraint is an entry of its own, so the unappealable rules a model states
    are one query over them.

    They are two keys because they answer different questions, and neither one
    implies the other. A low rule can still be an absolute refusal, and a
    critical one can be a warning because the organization is not yet ready to
    block on it. escalate states that an approver exists; it does not state who,
    because an approver role is not modeled yet.

  • Binding profiles. A separate document that supplies only the physical
    bindings, so one logical model serves several stores. Not part of the Ossie
    document; a kcmd-specific file alongside it (§7).

One construct is reserved rather than added. 0.2.0.dev0 has no authorable
M:N association syntax
. The construct is under consideration for a future
version and is not part of the format today.

6. The extension mechanism

The version selects one of two mutually exclusive extension surfaces (agreed
with Dmitri). A document uses the carrier or the native keys, never both — which
surface applies is a property of the version, not a per-key choice.

Vanilla 0.2.0.dev0 — the custom_extensions carrier. Under vanilla Ossie
every kcmd extension rides in Ossie's own custom_extensions field. An entry is:

yaml
custom_extensions:
  - vendor_name: GOOGLE       # the namespace
    data: "{ … }"             # an opaque, vendor-serialized string (typically JSON)

custom_extensions MAY appear on the model, a dataset, a field, a relationship,
and a metric. data is opaque to Ossie. kcmd reads a GOOGLE block's payload
into the model when it parses the document, and carries any other vendor's block
verbatim. vendor_name matches GOOGLE in any letter case, so Google and
google name the same vendor. A model file that pull rebuilds from the catalog
metadata writes the vendor name as GOOGLE.
An Ossie-only tool treats a GOOGLE block as a vendor extension it does
not recognize and passes it through unchanged — so a vanilla kcmd model is still
a valid Ossie document, and stripping the GOOGLE blocks yields plain Ossie. A
model's deployments ride here as a GOOGLE block carrying
{"deployments": [{"name": "…", "target": "…"}]}. The older form,
{"deploymentTargets": ["…"]}, is still read. The native keys (entities,
deployments, deployment_target, profiles, extends, abstract, metric
entity, relationship description) are not accepted under vanilla — each
is rejected with a message naming the GOOGLE custom_extensions block or
0.2.0.dev0/google (§4.1).

Extended 0.2.0.dev0/google — native keys. Under the extended profile the same
information is written as first-class keys — entities, deployment_target,
extends, abstract — read directly, with no data string to encode. There is
therefore nothing left for the carrier to carry, and a custom_extensions block is
not accepted under /google (also an unknown key). This is the profile kcmd's
own tools emit: readable, but no longer a vanilla-Ossie document.

Converting between the two is the version plus a mechanical fold between
vanilla GOOGLE blocks and /google native keys or profile files:

  • Model GOOGLE block: {"profiles": […], "deployments": […]} (plus legacy
    deploymentTargets on read) folds to <model>.profile.<name>.yaml sidecar
    files and a native deployments: key under /google, and back.
  • Dataset GOOGLE block: {"extends": ["…"]} folds to extends: on the
    entity, and back.
  • Relationship GOOGLE block: {"description": "…"} folds to
    description: on the relationship, and back.
  • Metric GOOGLE block: {"entity": "…"} folds to entity: on the metric,
    and back.

Both flavors describe the same model. The entities spelling, abstract,
actions, and constraints have no vanilla form. What survives a push and a
pull through each surface is in What push and pull preserve.

7. The binding layer

Ossie already binds a model to physical data: a dataset names a source table, a
field names a column expression, and a relationship names its join columns. Those
are Ossie-native, not kcmd additions. What kcmd adds is the layer that says
where a model deploys and lets one logical model serve several stores:

  • deployment_target on the model — where the model deploys. A kcmd
    extension; Ossie has no deployment construct (§7.2).
  • binding profiles — a separate document that varies the physical bindings
    per store. A kcmd extension (§7.3).
  • an unbound field — a field with no column under a given binding, expressed
    by omitting its expression (there is no unbound flag).

kcmd also relaxes the Ossie-native bindings — source, field expression,
and relationship join columns — from required to optional (§4.2),
so a model with none of them is a complete logical model that can be governed in
Knowledge Catalog as-is. The bindings are required only to deploy to a store.

Every physical binding is profile-swappable. source, an entity's key
columns, a field's expression, a relationship's join columns, and an action's
executor
are supplied or overridden by a binding
profile
, so one logical model deploys to several stores,
in each store's own table and column names, and performs its writes by whatever
mechanism each store has. A named profile states every relationship's join
columns itself, and its own keys for each entity whose keys the model file
states (§7.3). Key shapes may differ between profiles.
In every binding, a relationship's join columns cover a key of its target.

7.1. Table sources

source is the GCP resource name of the backing table, with no leading //:

text
bigquery.googleapis.com/projects/<p>/datasets/<d>/tables/<table>
spanner.googleapis.com/projects/<p>/instances/<i>/databases/<db>/tables/<table>

A resource URI, the same name with a leading //, and a Knowledge Catalog name
such as bigquery:.<d>.<t> are rejected, and the error names the resource
name to write. profiles.md lists every accepted store.
Ossie requires source on every dataset; kcmd relaxes it to optional
(§4.2) so a purely logical model loads, but
a non-abstract dataset in a model bound for a graph MUST have one.

7.2. Deployment target

deployment_target is a single GCP resource name, with no leading //, whose
host selects the store. A resource URI or a Knowledge Catalog name is rejected,
and the error names the form to write:

text
# BigQuery Graph — analytical
bigquery.googleapis.com/projects/<p>/datasets/<d>/propertyGraphs/<g>

# Spanner Graph — operational
spanner.googleapis.com/projects/<p>/instances/<i>/databases/<db>/graphs/<g>

Each is the name Dataplex gives the graph's entry: a BigQuery graph is under
propertyGraphs/, a Spanner graph under graphs/. A Spanner graph written with
propertyGraphs/ is rejected, and the error names its graphs/ name.

The name MUST match one of these two shapes. A model bound for a graph MUST declare
exactly one (§4). The identifier segments are
restricted to [A-Za-z0-9_-]. The native deployment_target: key shown here is the
extended-profile form; under vanilla Ossie the same target rides in a GOOGLE
custom_extensions block (§6).

7.3. Binding profiles

A binding profile is a separate document that supplies only the physical
bindings for a model, so one logical model can deploy to several stores from one
definition. A profile:

  • MUST set only physical-binding keys: source, primary_key, unique_keys,
    fields and fields_exclude on an entity; from_columns and to_columns on
    a relationship; expression on a field; executor on an action; and
    metrics_exclude. Setting any logical key (a new field, a datatype, a
    relationship's endpoints, an action's guards, …) in a profile is an error —
    the logical model owns those.
  • binds by name at each level, including a field the entity inherits. It is an
    overlay: a field the profile does not name keeps the model file's binding, and a
    field in fields_exclude is left unbound for that profile; there is no
    unbound flag. A field whose expression reads a column MUST be restated or
    excluded, because that column belongs to the model file's table. A column is
    a reference that no entity name qualifies. An action the profile does not
    mention keeps the model's executor, and executor: null withdraws it.
  • MUST state primary_key for an entity whose primary key the model file
    states, and unique_keys for one whose unique keys it states.
  • MAY give a field any expression the logical model could: a column, a
    computation, or the dialects form.

The model document lives at catalog/EntryGroups/<entryGroup>/<model>.yaml
(sidecar files *.aspects.yaml / *.overview.yaml are not models). Profiles live
beside it as catalog/EntryGroups/<entryGroup>/<model>.profile.<name>.yaml, and
a profile file's name: MUST equal its <name>. The older
<model>.profiles/<name>.yaml directory is still read when a model has no sibling
profile file. A vanilla 0.2.0.dev0 model lists its profiles instead in a
GOOGLE block on the model, as {"profiles": [...]}, each with the keys a
profile file takes. The name default is reserved for the model's own inline
bindings and MUST NOT be used as a profile name. A
default_profile in catalog.yaml selects which profile the default push uses.
The full merge behavior and worked examples are in
Binding profiles.

8. Versioning and compatibility policy

  • What declaring a version guarantees. The constructs in
    §2 and the extensions in §5 are what kcmd
    reads and writes. The version is a gate, not advice: it is required, must be
    0.2.0.dev0 or 0.2.0.dev0/google, and selects the legal extension surface
    (§6); any other value is a hard error.

  • When Ossie changes. A new Ossie construct is added to
    §3 as a row — as-is if adopted unchanged, narrowed
    if constrained. Growth is a table entry, not a rewrite; this document's size
    tracks kcmd's decisions, not Ossie's surface.

  • Forward direction of each extension. A native /google key is either a
    candidate to upstream into Ossie (e.g. deployment_target, entities) or a
    Google-store specific that stays kcmd's own. The vanilla version stays a strict
    Ossie superset: its only extension is the GOOGLE custom_extensions carrier,
    and constructs with no vanilla form (abstract, actions, constraints, the
    entities spelling) are simply unavailable there — a model that needs them
    uses 0.2.0.dev0/google.

  • Reserved constructs. association (M:N) is reserved. It is under
    consideration for a future version and cannot be authored today, so a document
    MUST NOT rely on it in 0.2.0.dev0.

Appendix: annotated example

A logical model plus one binding profile.

yaml
# catalog/EntryGroups/sales/sales.yaml — the logical model (concepts + edge join keys;
# table and column bindings live in the profile below — see §7)
version: "0.2.0.dev0"          # vanilla Ossie: uses no native keys, so this file is portable
semantic_model:
  - name: sales
    datasets:
      - name: orders
        primary_key: [order_id]
        fields:
          - { name: order_id,   datatype: Integer }
          - { name: net_amount, datatype: Decimal, label: "Net amount" }
      - name: customer
        primary_key: [customer_id]
        fields:
          - { name: customer_id, datatype: Integer }
          - { name: name,        datatype: String }
        ai_context:
          synonyms: [client, account]
    relationships:
      - name: placed_by
        from: orders
        to: customer
        from_columns: [customer_id]
        to_columns: [customer_id]
    metrics:
      - name: revenue
        expression: SUM(orders.net_amount)   # resolves to one entity → a BigQuery measure
yaml
# catalog/EntryGroups/sales/sales.profiles/analytical.yaml — physical binding only
version: "0.2.0.dev0/google"   # native deployment_target key -> extended profile
semantic_model:
  - name: sales
    deployment_target: "bigquery.googleapis.com/projects/acme/datasets/sales/propertyGraphs/sales"
    datasets:
      - name: orders
        source: bigquery.googleapis.com/projects/acme/datasets/raw/tables/orders
        fields:
          - { name: order_id,   expression: o_id }
          - { name: net_amount, expression: o_net }
      - name: customer
        source: bigquery.googleapis.com/projects/acme/datasets/raw/tables/customer
        fields:
          - { name: customer_id, expression: c_id }
          - { name: name,        expression: c_name }

Toolbox Mdcode Docs Semantic Model Osi Google Schema.Json (`toolbox/mdcode/docs/semantic-model/osi-google-schema.json`)

{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://cloud.google.com/dataplex/schemas/osi-google-schema-0.2.0.dev0.json",
"title": "Google-Extended Open Semantic Interchange (OSI) Specification Schema (0.2.0.dev0/google)",
"description": "JSON Schema (Draft 2020-12) for Google-flavor Open Semantic Interchange (OSI) documents, version '0.2.0.dev0/google'. The root validates a model document; #/$defs/ProfileDocument validates a profile file. Google extensions are native keys (entities, deployments, extends, abstract, a metric's entity, a relationship's description, actions, constraints). Third-party custom_extensions, whose vendor_name is not 'GOOGLE', are carried verbatim. The vendor_name match ignores letter case, so 'google' and 'Google' are GOOGLE too.",
"type": "object",
"required": [
"version",
"semantic_model"
],
"additionalProperties": false,
"properties": {
"version": {
"type": "string",
"const": "0.2.0.dev0/google",
"description": "Google-extended OSI schema version identifier."
},
"semantic_model": {
"type": "array",
"minItems": 1,
"description": "The semantic model. A document holds exactly one.",
"items": {
"$ref": "#/$defs/SemanticModel"
},
"maxItems": 1
}
},
"$defs": {
"Identifier": {
"type": "string",
"minLength": 1,
"pattern": "^[A-Za-z][A-Za-z0-9_]$",
"description": "An entity, metric or relationship name. A letter, then letters, digits or underscores, up to 500 characters.",
"maxLength": 500
},
"ModelName": {
"type": "string",
"minLength": 1,
"pattern": "^[A-Za-z][A-Za-z0-9_-]
$",
"maxLength": 500,
"description": "A model, profile or deployment name. A letter, then letters, digits, underscores or hyphens, up to 500 characters."
},
"Description": {
"type": "string",
"description": "Human-readable description."
},
"EntryDescription": {
"type": "string",
"maxLength": 2000,
"description": "The description of a model, entity, metric or relationship, stored as a Knowledge Catalog entry description: up to 2000 characters."
},
"NonEmptyText": {
"type": "string",
"minLength": 1,
"description": "A non-empty string with no pattern and no length limit. Used for the names and descriptions of actions, action parameters and constraints."
},
"NonBlankText": {
"type": "string",
"pattern": "\S",
"description": "A string holding at least one non-whitespace character."
},
"Dialect": {
"type": "string",
"enum": [
"ANSI_SQL",
"BIGQUERY",
"SPANNER",
"MYSQL",
"POSTGRES",
"ALLOYDB",
"SNOWFLAKE",
"DATABRICKS",
"SQLSERVER"
],
"description": "SQL dialect, matched exactly."
},
"DataType": {
"type": "string",
"pattern": "^([Ss][Tt][Rr][Ii][Nn][Gg]|[Ii][Nn][Tt][Ee][Gg][Ee][Rr]|[Dd][Ee][Cc][Ii][Mm][Aa][Ll]|[Ff][Ll][Oo][Aa][Tt]|[Bb][Oo][Oo][Ll][Ee][Aa][Nn]|[Dd][Aa][Tt][Ee]|[Tt][Ii][Mm][Ee]|[Dd][Aa][Tt][Ee][Tt][Ii][Mm][Ee]|[Dd][Aa][Tt][Ee][Tt][Ii][Mm][Ee][Tt][Zz]|[Oo][Pp][Aa][Qq][Uu][Ee])$",
"description": "Logical data type: String, Integer, Decimal, Float, Boolean, Date, Time, DateTime, DateTimeTz or Opaque, in any casing."
},
"NonEmptySql": {
"type": "string",
"minLength": 1,
"pattern": "\S",
"description": "Non-empty, non-whitespace SQL expression or bare column name."
},
"DialectExpression": {
"type": "object",
"required": [
"dialect",
"expression"
],
"additionalProperties": false,
"properties": {
"dialect": {
"$ref": "#/$defs/Dialect"
},
"expression": {
"$ref": "#/$defs/NonEmptySql"
}
}
},
"Expression": {
"description": "SQL expression: either a plain string, shorthand for a single ANSI_SQL variant, or an object listing one variant per dialect.",
"oneOf": [
{
"$ref": "#/$defs/NonEmptySql"
},
{
"type": "object",
"required": [
"dialects"
],
"additionalProperties": false,
"properties": {
"dialects": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/DialectExpression"
}
}
}
}
]
},
"AiContext": {
"description": "AI context metadata: either an instructions string or an object with instructions, synonyms and examples. Custom members go inside custom, the one open key.",
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"instructions": {
"type": "string"
},
"synonyms": {
"type": "array",
"items": {
"type": "string"
}
},
"examples": {
"type": "array",
"items": {
"type": "string"
}
},
"custom": {
"type": "object",
"description": "Custom ai_context members, carried verbatim and never interpreted. Any key and any value, except the three names this object defines and proto.",
"propertyNames": {
"not": {
"enum": [
"instructions",
"synonyms",
"examples",
"proto"
]
}
}
}
}
}
]
},
"CustomExtension": {
"type": "object",
"required": [
"vendor_name",
"data"
],
"additionalProperties": false,
"description": "Third-party vendor custom extension, carried verbatim. In '0.2.0.dev0/google', Google extensions are native properties, so vendor_name 'GOOGLE' is not permitted. The match ignores letter case, so 'google' and 'Google' are not permitted either.",
"properties": {
"vendor_name": {
"type": "string",
"minLength": 1,
"not": {
"pattern": "^[Gg][Oo][Oo][Gg][Ll][Ee]$"
}
},
"data": {
"type": "string"
}
}
},
"TableSource": {
"type": "string",
"description": "The GCP resource name, with no leading //, of a BigQuery, BigLake, Spanner, AlloyDB or Cloud SQL table.",
"anyOf": [
{
"title": "BigQuery table",
"pattern": "^bigquery\.googleapis\.com/projects/[^/]+/datasets/[^/.]+/tables/[^/.]+$"
},
{
"title": "BigLake table",
"pattern": "^biglake\.googleapis\.com/projects/[^/]+/catalogs/[^/.]+/namespaces/[^/.]+/tables/[^/.]+$"
},
{
"title": "Spanner table",
"pattern": "^spanner\.googleapis\.com/projects/[^/]+/instances/[^/]+/databases/[^/]+/tables/[^/]+$"
},
{
"title": "AlloyDB table",
"pattern": "^alloydb\.googleapis\.com/projects/[^/]+/locations/[^/]+/clusters/[^/]+/databases/[^/]+/schemas/[^/]+/tables/[^/]+$"
},
{
"title": "Cloud SQL for MySQL table",
"pattern": "^cloudsql\.googleapis\.com/projects/[^/]+/locations/[^/]+/instances/[^/]+/databases/[^/]+/tables/[^/]+$"
},
{
"title": "Cloud SQL for PostgreSQL or SQL Server table",
"pattern": "^cloudsql\.googleapis\.com/projects/[^/]+/locations/[^/]+/instances/[^/]+/databases/[^/]+/schemas/[^/]+/tables/[^/]+$"
}
]
},
"DeploymentTarget": {
"type": "string",
"description": "The GCP resource name, with no leading //, of a BigQuery graph, a Spanner graph or an AlloyDB database.",
"anyOf": [
{
"title": "BigQuery graph",
"pattern": "^bigquery\.googleapis\.com/projects/[A-Za-z0-9_-]+/datasets/[A-Za-z0-9_-]+/propertyGraphs/[A-Za-z0-9_-]+$"
},
{
"title": "Spanner graph",
"pattern": "^spanner\.googleapis\.com/projects/[A-Za-z0-9_-]+/instances/[A-Za-z0-9_-]+/databases/[A-Za-z0-9_-]+/graphs/[A-Za-z0-9_-]+$"
},
{
"title": "AlloyDB database",
"pattern": "^alloydb\.googleapis\.com/projects/[A-Za-z0-9_-]+/locations/[A-Za-z0-9_-]+/clusters/[A-Za-z0-9_-]+/databases/[A-Za-z0-9_-]+$"
}
]
},
"Dimension": {
"type": "object",
"additionalProperties": false,
"properties": {
"is_time": {
"type": "boolean"
}
}
},
"Field": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"$ref": "#/$defs/FieldName"
},
"expression": {
"$ref": "#/$defs/Expression"
},
"datatype": {
"$ref": "#/$defs/DataType"
},
"label": {
"type": "string"
},
"description": {
"$ref": "#/$defs/Description"
},
"dimension": {
"$ref": "#/$defs/Dimension"
},
"ai_context": {
"$ref": "#/$defs/AiContext"
},
"custom_extensions": {
"type": "array",
"items": {
"$ref": "#/$defs/CustomExtension"
}
}
}
},
"Entity": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"$ref": "#/$defs/Identifier"
},
"source": {
"$ref": "#/$defs/TableSource"
},
"abstract": {
"type": "boolean",
"default": false
},
"extends": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/Identifier"
}
},
"primary_key": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/ColumnName"
}
},
"unique_keys": {
"type": "array",
"items": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/ColumnName"
}
}
},
"description": {
"$ref": "#/$defs/EntryDescription"
},
"ai_context": {
"$ref": "#/$defs/AiContext"
},
"fields": {
"type": "array",
"items": {
"$ref": "#/$defs/Field"
}
},
"custom_extensions": {
"type": "array",
"items": {
"$ref": "#/$defs/CustomExtension"
}
}
},
"allOf": [
{
"description": "An abstract entity has no table, so it declares no source, no key and no field expression.",
"if": {
"properties": {
"abstract": {
"const": true
}
},
"required": [
"abstract"
]
},
"then": {
"properties": {
"source": false,
"primary_key": false,
"unique_keys": false,
"fields": {
"items": {
"properties": {
"expression": false
}
}
}
}
}
}
]
},
"BoundEntity": {
"description": "An entity that names its source unless it is abstract.",
"if": {
"properties": {
"abstract": {
"const": true
}
},
"required": [
"abstract"
]
},
"else": {
"required": [
"source"
]
}
},
"Relationship": {
"type": "object",
"required": [
"name",
"from",
"to"
],
"additionalProperties": false,
"properties": {
"name": {
"$ref": "#/$defs/Identifier"
},
"from": {
"$ref": "#/$defs/Identifier"
},
"to": {
"$ref": "#/$defs/Identifier"
},
"from_columns": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/ColumnName"
}
},
"to_columns": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/ColumnName"
}
},
"description": {
"$ref": "#/$defs/EntryDescription"
},
"ai_context": {
"$ref": "#/$defs/AiContext"
},
"custom_extensions": {
"type": "array",
"items": {
"$ref": "#/$defs/CustomExtension"
}
}
},
"dependentRequired": {
"from_columns": [
"to_columns"
],
"to_columns": [
"from_columns"
]
}
},
"Metric": {
"type": "object",
"required": [
"name",
"expression"
],
"additionalProperties": false,
"properties": {
"name": {
"$ref": "#/$defs/Identifier"
},
"entity": {
"$ref": "#/$defs/Identifier"
},
"expression": {
"$ref": "#/$defs/Expression"
},
"datatype": {
"$ref": "#/$defs/DataType"
},
"description": {
"$ref": "#/$defs/EntryDescription"
},
"ai_context": {
"$ref": "#/$defs/AiContext"
},
"custom_extensions": {
"type": "array",
"items": {
"$ref": "#/$defs/CustomExtension"
}
}
}
},
"ProfileField": {
"type": "object",
"required": [
"name",
"expression"
],
"additionalProperties": false,
"properties": {
"name": {
"$ref": "#/$defs/FieldName"
},
"expression": {
"$ref": "#/$defs/Expression"
}
}
},
"ProfileEntity": {
"type": "object",
"required": [
"name",
"source"
],
"additionalProperties": false,
"properties": {
"name": {
"$ref": "#/$defs/Identifier"
},
"source": {
"$ref": "#/$defs/TableSource"
},
"primary_key": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/ColumnName"
}
},
"unique_keys": {
"type": "array",
"items": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/ColumnName"
}
}
},
"fields": {
"type": "array",
"items": {
"$ref": "#/$defs/ProfileField"
}
},
"fields_exclude": {
"description": "Field names left unbound under this profile. Unlike metrics_exclude, '' is not supported; list the field names explicitly.",
"type": "array",
"items": {
"$ref": "#/$defs/FieldName"
},
"uniqueItems": true
}
}
},
"ProfileRelationship": {
"type": "object",
"required": [
"name",
"from_columns",
"to_columns"
],
"additionalProperties": false,
"properties": {
"name": {
"$ref": "#/$defs/Identifier"
},
"from_columns": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/ColumnName"
}
},
"to_columns": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/ColumnName"
}
}
}
},
"Deployment": {
"type": "object",
"required": [
"name",
"target"
],
"additionalProperties": false,
"properties": {
"name": {
"$ref": "#/$defs/ModelName"
},
"target": {
"$ref": "#/$defs/DeploymentTarget"
},
"profile": {
"$ref": "#/$defs/ModelName"
}
}
},
"Executor": {
"type": [
"object",
"null"
],
"additionalProperties": false,
"properties": {
"mcp": {
"type": "object",
"required": [
"server",
"tool"
],
"additionalProperties": false,
"properties": {
"server": {
"$ref": "#/$defs/NonBlankText"
},
"tool": {
"$ref": "#/$defs/NonBlankText"
}
}
},
"rest": {
"type": "object",
"required": [
"endpoint",
"method"
],
"additionalProperties": false,
"properties": {
"endpoint": {
"$ref": "#/$defs/NonBlankText"
},
"method": {
"$ref": "#/$defs/NonBlankText"
}
}
},
"grpc": {
"type": "object",
"required": [
"service",
"method"
],
"additionalProperties": false,
"properties": {
"service": {
"$ref": "#/$defs/NonBlankText"
},
"method": {
"$ref": "#/$defs/NonBlankText"
}
}
},
"sql": {
"type": "object",
"required": [
"statements"
],
"additionalProperties": false,
"properties": {
"statements": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/NonBlankText"
}
}
}
}
},
"minProperties": 1,
"maxProperties": 1,
"description": "How an action is performed: exactly one of mcp, rest, grpc or sql. null means nothing performs it."
},
"ActionParameter": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"$ref": "#/$defs/NonEmptyText"
},
"type": {
"type": "string"
},
"datatype": {
"type": "string"
},
"concept": {
"type": "string"
},
"field": {
"type": "string"
},
"description": {
"$ref": "#/$defs/NonEmptyText"
},
"label": {
"type": "string"
},
"ai_context": {
"$ref": "#/$defs/AiContext"
},
"required": {
"type": "boolean"
},
"default": {}
}
},
"AffectedConcept": {
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"required": [
"concept"
],
"additionalProperties": false,
"properties": {
"concept": {
"type": "string"
},
"operation": {
"type": "string",
"enum": [
"create",
"modify",
"delete"
]
},
"fields": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
]
},
"Action": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"$ref": "#/$defs/NonEmptyText"
},
"description": {
"$ref": "#/$defs/NonEmptyText"
},
"executor": {
"$ref": "#/$defs/Executor"
},
"parameters": {
"type": "array",
"items": {
"$ref": "#/$defs/ActionParameter"
}
},
"guards": {
"type": "array",
"items": {
"type": "string"
}
},
"affects": {
"type": "array",
"items": {
"$ref": "#/$defs/AffectedConcept"
}
},
"ai_context": {
"$ref": "#/$defs/AiContext"
}
}
},
"Constraint": {
"type": "object",
"required": [
"name",
"judgment",
"on_violation"
],
"additionalProperties": false,
"properties": {
"name": {
"$ref": "#/$defs/NonEmptyText"
},
"judgment": {
"$ref": "#/$defs/NonBlankText"
},
"description": {
"$ref": "#/$defs/NonEmptyText"
},
"on_violation": {
"type": "string",
"enum": [
"reject",
"escalate",
"warn"
]
},
"severity": {
"type": "string",
"enum": [
"low",
"medium",
"high",
"critical"
]
},
"ai_context": {
"$ref": "#/$defs/AiContext"
}
}
},
"SemanticModel": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"$ref": "#/$defs/ModelName"
},
"description": {
"$ref": "#/$defs/EntryDescription"
},
"ai_context": {
"$ref": "#/$defs/AiContext"
},
"deployments": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/Deployment"
}
},
"entities": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/Entity"
}
},
"datasets": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/Entity"
}
},
"relationships": {
"type": "array",
"items": {
"$ref": "#/$defs/Relationship"
}
},
"metrics": {
"type": "array",
"items": {
"$ref": "#/$defs/Metric"
}
},
"actions": {
"type": "array",
"items": {
"$ref": "#/$defs/Action"
}
},
"constraints": {
"type": "array",
"items": {
"$ref": "#/$defs/Constraint"
}
},
"custom_extensions": {
"type": "array",
"items": {
"$ref": "#/$defs/CustomExtension"
}
}
},
"oneOf": [
{
"required": [
"entities"
]
},
{
"required": [
"datasets"
]
}
],
"allOf": [
{
"description": "A deployment that names no profile, or the profile 'default', uses the model file's own bindings, so every concrete entity then names its source and every relationship its join columns.",
"if": {
"required": [
"deployments"
],
"properties": {
"deployments": {
"contains": {
"anyOf": [
{
"not": {
"required": [
"profile"
]
}
},
{
"required": [
"profile"
],
"properties": {
"profile": {
"const": "default"
}
}
}
]
}
}
}
},
"then": {
"properties": {
"entities": {
"items": {
"$ref": "#/$defs/BoundEntity"
}
},
"datasets": {
"items": {
"$ref": "#/$defs/BoundEntity"
}
},
"relationships": {
"items": {
"required": [
"from_columns",
"to_columns"
]
}
}
}
}
}
]
},
"FieldName": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]{0,299}$",
"description": "A field name. A BigQuery column name: a letter or underscore, then letters, digits or underscores, up to 300 characters."
},
"ProfileDocument": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"allOf": [
{
"$ref": "#/$defs/ModelName"
},
{
"not": {
"pattern": "^[Dd][Ee][Ff][Aa][Uu][Ll][Tt]$"
}
}
],
"description": "A profile name. 'default', in any casing, is reserved."
},
"entities": {
"type": "array",
"items": {
"$ref": "#/$defs/ProfileEntity"
}
},
"relationships": {
"type": "array",
"items": {
"$ref": "#/$defs/ProfileRelationship"
}
},
"metrics_exclude": {
"description": "Metrics this profile does not make available. "
" covers every metric the model declares, including ones added later. A list names them explicitly, and may not contain "".",
"oneOf": [
{
"const": "
"
},
{
"type": "array",
"items": {
"type": "string",
"not": {
"const": "*"
}
}
}
]
},
"actions": {
"type": "array",
"description": "Per-profile executor bindings for actions the base model declares. An action's executor is a physical binding, like an entity's source, so a profile may set it; null means the action is performed by nothing under this profile. Nothing else about an action may be set here.",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name"
],
"properties": {
"name": {
"$ref": "#/$defs/NonEmptyText"
},
"executor": {
"$ref": "#/$defs/Executor"
}
}
}
}
},
"title": "Profile document",
"description": "The whole of a .profile..yaml file. Validate a profile file against #/$defs/ProfileDocument and a model document against the root."
},
"ColumnName": {
"type": "string",
"minLength": 1,
"pattern": "^([^]+|[^\\s().+\\-*/%=<>!|&^~,]+)$",
"description": "A physical column name in a key or join-column slot, passed through as written. A backtick-quoted name passes whatever it contains. An unquoted name may not contain whitespace, parentheses, a dot, a comma, a backtick or a SQL operator, which is how kcmd tells a column from an expression."
}
}
}


Toolbox Mdcode Docs Semantic Model Owl Import (`toolbox/mdcode/docs/semantic-model/owl-import.md`)

Importing an OWL ontology

An OWL ontology and a semantic model share a backbone: classes ≈ entities,
object properties ≈ relationships, datatype properties ≈ fields. kcmd
does not learn a second format — it converts OWL into a semantic model once,
then the ontology rides the normal kcmd push / kcmd pull
workflow. The semantic model stays the single canonical form.

The converter maps the OWL constructs that have a clean BigQuery Graph shape to
native semantic-model fields — class → node, object property → edge,
datatype property → property, plus class hierarchies (rdfs:subClassOf →
entity extends, see Class hierarchies).
Import is one-way and lossy by design: constructs with no native
semantic-model home — property inheritance (rdfs:subPropertyOf), the inverse /
equivalence / disjointness cross-references, the property characteristics, the
set-level axioms, and per-term annotations (rdfs:seeAlso, owl:deprecated, …)
— are not imported. The result is a clean semantic model, never an OWL
document wrapped in opaque metadata. What maps and what drops is listed in How
each OWL construct maps
and
Limitations.

1. The OWL file

sales.owl.ttl — an ontology header, two classes with keys, datatype
properties across several xsd types, and one object property. Nothing here
needs an OWL reasoner:

text
/* Detailed source-code truncated for AI context efficiency. */

The same ontology as an RDF graph. Every arc is a triple: a class (the subject)
points through a property (the predicate) to its object. Each datatype
property
points to its xsd range — a literal type, drawn as a plain box —
and becomes a field. The object property ex:placedBy points from one class
to another and becomes the relationship. The two classes become the datasets.

mermaid
graph LR
    Customer(["ex:Customer"])
    Order(["ex:Order"])

    Order -- "ex:placedBy" --> Customer

    Customer -- "ex:customerId" --> cid["xsd:string"]
    Customer -- "ex:email (inverse-functional)" --> cem["xsd:string"]
    Customer -- "ex:customerName" --> cnm["xsd:string"]
    Customer -- "ex:signupDate" --> csd["xsd:date"]
    Customer -- "ex:isVip" --> civ["xsd:boolean"]

    Order -- "ex:orderId" --> oid["xsd:string"]
    Order -- "ex:orderAmount" --> oam["xsd:decimal"]
    Order -- "ex:quantity" --> oqt["xsd:integer"]
    Order -- "ex:orderDate" --> odt["xsd:date"]

    classDef cls fill:#dae8fc,stroke:#6c8ebf,color:#000;
    classDef lit fill:#f5f5f5,stroke:#999999,color:#000;
    class Customer,Order cls;
    class cid,cem,cnm,csd,civ,oid,oam,oqt,odt lit;

Classes (rounded) are the resources that become datasets; the xsd boxes are
literal types that become each field's datatype.

2. The command

console
$ kcmd owl import sales.owl.ttl
converted 2 classes, 1 object property, 9 datatype properties
wrote catalog/EntryGroups/<entryGroup>/sales.yaml

The model name comes from the file (sales.owl.ttl → sales). By default the
document is written into the semantic-model layout dir so the next kcmd push
picks it up; pass --out to write it elsewhere. The output uses the
block layout shown below; pass --compact for the compact flow layout
(primary_key: [id], inline { name, datatype } field and relationship maps)
instead. Either way it is a purely logical model — kcmd push publishes it
to Knowledge Catalog as-is, while a BigQuery or Spanner Graph deploy needs each
relationship's join columns added to the model plus a binding
profile
(see §4).

3. The semantic model it produces — `sales.yaml`

The Customer dataset and the placedBy edge (the Order dataset follows the
same shape). The structure, keys, and values are exactly what the converter
emits; the inline # comments are annotations added here for the walkthrough —
the real output has none:

text
/* Detailed source-code truncated for AI context efficiency. */

This is a purely logical model: an ontology declares meaning, not physical
tables, so entities carry no source, fields no expression, and relationships
no join columns. Before a graph deploy a binding profile supplies
the sources and field expressions, and you add each edge's join columns to the
model (they are logical, not a binding). The output carries no term IRIs and no
custom_extensions: a term's identity is its name, so a cleanly-mapped construct
needs nothing more. One kind of ontology goes further — a class hierarchy
(rdfs:subClassOf), shown next as an extension of this same sales domain (see
Class hierarchies). Everything OWL can say
that the semantic model cannot is dropped on import, not carried.

The source namespace is recorded only as a human-readable fallback: when the
ontology header has no comment of its own, the model description names the base
IRI it was imported from. This ontology has a header comment, so the namespace
appears nowhere above.

How each OWL construct maps

OWL Semantic model Notes
owl:Ontology header model description, ai_context, version comment → description; labels → ai_context.synonyms; skos:example → ai_context.examples; owl:versionInfo → appended to description
owl:Class datasets[] entry a logical entity — no source (a binding profile adds one before a graph deploy)
owl:DatatypeProperty fields[] on each domain's dataset a property with several rdfs:domain values lands on each; a logical field — no expression (a binding profile maps it to a column)
owl:ObjectProperty relationships[] from = domain, to = range; a logical edge with no join columns (you add from_columns/to_columns to the model before a graph deploy, see binding); with several rdfs:domain/rdfs:range values only the first of each is kept (a relationship is one source → one destination) and the rest are warned
rdfs:subClassOf (named superclass) dataset extends[] entity-level inheritance only — records the parent(s) in document order; not read on object properties (see Class hierarchies)
rdfs:subPropertyOf, owl:inverseOf, owl:equivalentClass, owl:disjointWith, owl:oneOf, owl:equivalentProperty, owl:propertyDisjointWith, owl:propertyChainAxiom, owl:AllDisjointClasses, owl:AllDisjointProperties, owl:AllDifferent, the property characteristics, rdfs:seeAlso, rdfs:isDefinedBy, owl:deprecated, owl:versionInfo (dropped) no native home — not imported (see Limitations)
rdfs:range xsd:* field datatype see Datatypes
owl:hasKey ( ... ) dataset primary_key single or composite, in list order
owl:InverseFunctionalProperty dataset unique_keys a uniquely-identifying property; omitted when it is already the primary_key; a lone one on a keyless class is promoted to primary_key instead
rdfs:label on a datatype property field label the field's display name (a label slot exists only on fields); dropped when it only respaces/recases the name
rdfs:label on a class / object property ai_context.synonyms no label slot there, so a distinct label becomes an alternate name; dropped when redundant with the name
extra rdfs:label / skos:altLabel / prefLabel / hiddenLabel ai_context.synonyms genuinely alternate names; feed NL search
skos:example ai_context.examples sample questions / values
rdfs:comment, skos:definition, dcterms:/dc:description description first present wins, in that order; on an object property (which has no description slot) the comment rides in ai_context.instructions
term IRIs, @prefix dropped (base IRI named in description only when the header has no comment of its own) a term's identity is its local name; the source namespace is not otherwise carried

A datatype property whose domain is not a class, or an object property missing an
endpoint, cannot be placed; it is skipped with a warning rather than failing
the whole import. An object property that declares more than one rdfs:domain
or rdfs:range is kept — a relationship maps one source to one destination, so
the first of each is used and the extra endpoints are dropped with a warning
(unlike a multi-domain datatype property, which lands on every domain).

Datatypes (`rdfs:range`)

rdfs:range sets a field's logical datatype. Physical width is not carried (it
belongs to the bound table, not the ontology), so several xsd types collapse
onto one logical type:

xsd range datatype
string, normalizedString, token, anyURI, language, Name, NCName String
integer, int, long, short, byte, nonNegativeInteger, positiveInteger, unsignedInt, … (any width/sign) Integer
decimal Decimal
float, double Float
boolean Boolean
date Date
time Time
dateTime DateTime
dateTimeStamp DateTimeTz
anything else, or no rdfs:range none (the field is written with no datatype)

A temporal field (Date / Time / DateTime / DateTimeTz) is additionally
marked a time dimension (dimension: { is_time: true }), so downstream
BigQuery Graph / BI treats it as one.

Keys (`owl:hasKey`, `owl:InverseFunctionalProperty`)

  • owl:hasKey ( ... ) on a class becomes the dataset's primary_key (its
    grain), in list order — single or composite. If any key column names no
    datatype property on the class (undeclared, or declared only on another class)
    it has no field to back it; because keeping only the columns that do exist
    would silently narrow a composite key to a possibly non-unique one, the
    entire primary_key is dropped with a warning rather than left to fail
    later at graph generation.
  • An owl:InverseFunctionalProperty (a datatype property that uniquely
    identifies its subject) becomes a unique_keys constraint — unless it is
    already the primary_key, in which case it is not repeated. If a class
    declares no owl:hasKey but has exactly one single-column
    inverse-functional property, that property is promoted to the primary_key
    (with a warning) so the dataset has a grain; ambiguous cases (several unique
    keys, or a composite one) are left without a primary_key.

Keys are logical grain, not a binding — they are the entity's identity, so
they come across even though the model has no physical binding. They also tell
you which columns an edge's to_columns must reference when you add join columns
to the model; see binding.

Class hierarchies (`rdfs:subClassOf`)

rdfs:subClassOf maps to a dataset's extends — the one keyword borrowed from
Ossie's ontology proposal
onto our existing datasets. Extend the sales domain with a Person base class
that Customer refines (Customer rdfs:subClassOf Person):

turtle
ex:Person a owl:Class ;
    rdfs:comment "A human being." .
ex:fullName a owl:DatatypeProperty ;
    rdfs:domain ex:Person ;
    rdfs:range xsd:string .

ex:Customer a owl:Class ;
    rdfs:subClassOf ex:Person ;
    rdfs:comment "A person or organization that places orders." ;
    owl:hasKey ( ex:customerId ) .
# ... Customer's own datatype properties: customerId, email, customerName, … ...

the Person and Customer entities come out as (Customer carrying
extends: [Person]):

yaml
# Person is its own entity:
  - name: Person
    description: A human being.
    fields:
      - name: fullName
        datatype: String
  # Customer records that it extends Person and keeps ONLY its own fields;
  # Person's fullName is NOT flattened down.
  - name: Customer
    extends:
      - Person
    primary_key:
      - customerId
    description: A person or organization that places orders.
    fields:
      - name: customerId
        datatype: String
      # ... email, customerName, signupDate, isVip (Customer's own) ...

Multiple superclasses are allowed (extends: [Person, Employee], in document
order). A subClassOf whose object is a blank-node axiom (owl:Restriction and
similar) is not a named class, so it is ignored, as are the implicit universal
superclasses owl:Thing / rdfs:Resource (every class subclasses them, so they
carry no inheritance information).

Two boundaries to be clear about:

  • The import records the hierarchy; the BigQuery push resolves it. The
    importer writes Customer with extends: [Person] and only its own fields.
    A BigQuery push then resolves that inheritance: it emits a LABEL Person
    clause on the Customer node table and flattens Person's fields down (fields
    flow down; edges do not) — see Class hierarchies (extends →
    labels)
    . The Knowledge Catalog push is
    unchanged: it still publishes each entry with exactly the fields it declares
    (KC-side inheritance is a separate, later opt-in). Every push accepts a
    relationship only between concrete leaf entities. An object property whose
    rdfs:domain or rdfs:range is a class with subclasses imports as written,
    and push rejects it until you move it onto the leaf classes.
  • Native inheritance is entity-level only. extends lives on entities,
    never on relationships — the boundary is structural. rdfs:subPropertyOf
    (property / relationship inheritance) has no such native slot, so it is not
    imported
    (see Limitations).

4. Going from ontology to a running graph (binding)

The import gives you a logical model: what the domain means, with no physical
binding. That is directly useful — kcmd push publishes it to
Knowledge Catalog as-is, so the ontology becomes catalog metadata (entities,
fields, and keys) with nothing more to fill in. Push rejects a relationship on
a class with subclasses, as described above. It also rejects an object property
whose rdfs:domain and rdfs:range are one class, and two object properties
that join the same two classes in opposite directions:

console
$ kcmd push                  # publishes the logical model to Knowledge Catalog

A relationship publishes as a catalog link only once it has join columns (added
to the model, below); a column-less edge is skipped with a warning, so the
entities and fields still publish while the edge waits.

A BigQuery or Spanner Graph deploy needs two things the import leaves open,
and they belong in different places:

  1. The join columns on each edge — in the model. The import gives every
    relationship its endpoints (from/to) but no join columns; which columns an
    edge joins on is a logical fact the model owns, not a physical binding, so
    you add it to the imported model itself. A key and a join column each name a
    column of the table, and the import names each key after the property it came
    from. So change each imported key to the column its property is bound to, and
    join on that column:

    yaml
    # sales.yaml: key each entity by its column, and add the join columns
    entities:
      - name: Customer
        primary_key: [c_custkey]    # imported as [customerId]
        unique_keys: [[c_email]]    # imported as [[email]]
      - name: Order
        primary_key: [o_orderkey]   # imported as [orderId]
    relationships:
      - name: placedBy
        from: Order
        to: Customer
        from_columns: [o_custkey]   # the Order-side foreign key
        to_columns: [c_custkey]     # Customer's key column
  2. The physical binding — in a profile. Which table each entity reads, which
    column each field reads, and the deployment target go in a
    binding profile: a document in the same schema, kept beside the
    model as <model>.profiles/<name>.yaml, that adds only the binding and
    leaves the logical model untouched. A profile may set source, field
    expression, and deployment_target — nothing logical (a relationships
    block in a profile is rejected). A field the profile does not bind is left
    unbound; there is no separate flag. The model and the profile merge by name at
    push time:

    yaml
    # sales.profiles/warehouse.yaml — physical binding for the sales model
    version: 0.2.0.dev0/google
    semantic_model:
      - name: sales
        deployment_target: bigquery.googleapis.com/projects/myproj/datasets/sales/propertyGraphs/sales
        datasets:
          - name: Customer
            source: bigquery.googleapis.com/projects/myproj/datasets/sales/tables/customers
            fields:
              - { name: customerId, expression: c_custkey }
              - { name: email,      expression: c_email }
              # ... the remaining Customer fields ...
          - name: Order
            source: bigquery.googleapis.com/projects/myproj/datasets/sales/tables/orders
            fields:
              - { name: orderId, expression: o_orderkey }
              # ... the remaining Order fields ...

With the join columns in the model and the profile in place, a single push
deploys the graph:

console
$ kcmd push --profile warehouse   # merges the binding, then CREATE OR REPLACE PROPERTY GRAPH + KC entries

See One logical model, many physical bindings for the full profile
contract — what a profile may and may not set, how merge and prune work, and how
one logical model binds to several stores.

Limitations

Three different situations hide in "not covered": constructs already read but not
yet resolved all the way downstream, constructs not read but reachable next, and
constructs out of scope.

Read now — only downstream resolution is pending. The class hierarchy
(rdfs:subClassOf) maps to entity extends (see Class
hierarchies
); the importer handles it today
and the remaining work is downstream, not in the import:

  • BigQuery push resolves it into node-table labels, inherited fields
    flattened down (see Class hierarchies (extends →
    labels)
    ).
  • Knowledge Catalog is the follow-on — KC push still publishes each entry
    with only its own fields.

Not imported — dropped. OWL can state things the semantic model has no native
slot for. The importer reads them but does not carry them (an earlier version
rode them along as custom_extensions; that was removed so an imported model is a
clean semantic model). To keep any of these, model them natively after import.
Dropped:

  • Property inheritance — rdfs:subPropertyOf.
  • Cross-references — owl:inverseOf, owl:equivalentClass,
    owl:disjointWith, owl:equivalentProperty, owl:propertyDisjointWith.
  • Property characteristics — symmetric / transitive / functional /
    reflexive / irreflexive / asymmetric.
  • Enumerations (owl:oneOf) and property chains
    (owl:propertyChainAxiom).
  • Set-level axioms — owl:AllDisjointClasses, owl:AllDisjointProperties,
    owl:AllDifferent.
  • Per-term annotations — rdfs:seeAlso, rdfs:isDefinedBy,
    owl:deprecated, owl:versionInfo.

Not read yet. Cardinality (owl:minCardinality / owl:maxCardinality)
lives on an anonymous owl:Restriction reached through rdfs:subClassOf — the
last blank-node shape the converter does not yet read. Reading it needs an
owl:Restriction reader joined back to its class.

Not read — out of scope. Richer OWL/RDF beyond the schema-shaped subset this
converter targets:

  • SHACL shapes, class expressions (owl:unionOf / intersectionOf, and any
    blank-node owl:equivalentClass / rdfs:subClassOf), individuals (A-box
    instances), and owl:sameAs / owl:differentFrom (which relate individuals).
  • OWL serializations other than Turtle (.ttl), and the reverse direction —
    semantic model → OWL export. Import is one-way.

Toolbox Mdcode Docs Semantic Model Profiles (`toolbox/mdcode/docs/semantic-model/profiles.md`)

One logical model, many physical bindings

Scope. A deployment_target answers two questions that are easy to
conflate: where the model is published, and where it runs.

kcmd push publishes. It deploys a property graph to the backend the target
names — BigQuery Graph or Spanner Graph — and the logical model to Knowledge
Catalog, which takes it under any profile.

An action's statements run against whatever store the profile binds. All
three backends execute SQL DML, so all three can carry a write. What differs
is the push: an AlloyDB target runs and does not push, because AlloyDB has
no property-graph DDL for a push to deploy, while Spanner and BigQuery do
both.

A profile's sources take the forms listed under Sources. A source
in any other form is rejected.

A semantic model describes a business logically — its entities, the
relationships between them, and the metrics over them — independent of where the
data physically lives. A binding profile maps that one logical model onto a
concrete set of sources. You keep the model as a single canonical definition and
select a profile per scenario; a profile changes only where each entity reads
from, never what the model means. Because every profile shares one model,
Customer, revenue, and each relationship mean the same thing whichever
profile serves them.

Why a model gets more than one binding

The same concept usually lives in more than one system, and a profile binds the
model to one of them. The systems differ along whatever axis matters to you:

  • Different backends for different consumers. A live operational store —
    AlloyDB, Spanner, a SaaS API — holds the Customer an agent reads to check
    current state before it acts, and writes back to. An analytics warehouse such
    as BigQuery holds a copy of that same Customer, scaled for reporting, that
    dashboards and conversational-analytics agents read. One profile binds each,
    and every consumer inherits the same metric definitions, so revenue is
    computed the same way wherever it is asked.
  • Different environments of one backend. A dev, staging, and prod copy of one
    store are three profiles that differ only in the project they point at.
  • Different physical layouts. The same model can bind an Iceberg copy read
    through BigLake, or a partner's differently-named schema.

A profile is a named binding, and its meaning is yours to decide; the cases above
only illustrate the range. You author the model once and choose the profile when
you deploy or query.

How it works: a logical model and its bindings

A model and its profiles form a class hierarchy, the same way an entity
extends a supertype:

  • The logical model (<model>.yaml) is the base: the complete
    declaration — entities, fields, relationships, metrics, the grain, and the
    graph shape — with no physical binding.
  • A binding profile is a subclass: it supplies the physical facets the
    logical model leaves open, such as each entity's source and key columns,
    each field's column, and each relationship's join columns.
  • kcmd push --profile <name> merges the profile onto the logical model —
    matching entities, fields, relationships, and metrics by name — and
    deploys the result. A profile is never deployed alone: the logical model
    supplies every declaration, and the profile supplies the bindings its store
    provides.

A binding may also sit inline in the logical model, as the profile named
default. The layout on this page keeps the logical
model standalone and each binding in its own file, so the split between logical
and physical is visible on disk.

Sources

Each entity names where its data lives with source. A Google Cloud table is
written as its GCP resource name, with no leading //, such as
bigquery.googleapis.com/…, biglake.googleapis.com/…,
spanner.googleapis.com/…, alloydb.googleapis.com/… or
cloudsql.googleapis.com/…, and is read with ambient IAM. A resource URI (the
name with a leading //) or a Knowledge Catalog name such as
bigquery:.<d>.<t> is rejected, and the error names the resource name to
write. kcmd pull returns a model an older push stored that way as stored,
and warns for each such value. The preview
supports Google Cloud tables only, so a third-party table is rejected in every
form: a Knowledge Catalog entry name, or a name under a prefix such as
snowflake:, databricks:table:, mysql: or postgresql:. A Bigtable or
Dataproc Metastore table is rejected too, since the preview does not support
them yet. An entry name in a system entry group, such as @bigquery, is
rejected with an error that says what to write instead. A bare table name, a
query, and any other prefix are rejected. The source also fixes the profile's
SQL dialect, so every field the profile binds needs an expression in that
dialect or in ANSI_SQL.

A Cloud SQL table's resource name is
cloudsql.googleapis.com/projects//locations/<l>/instances//databases/<db>/tables/<t>
for MySQL, or …/databases/<db>/schemas/<s>/tables/<t> for PostgreSQL and SQL
Server. The name cannot tell PostgreSQL from SQL Server, so kcmd reads
the dialect from the table's Knowledge Catalog entry in the @cloudsql entry
group: a cloudsql-mysql-table entry gives MYSQL, cloudsql-postgresql-table
gives POSTGRES and cloudsql-sqlserver-table gives SQLSERVER, and the
matching -view entry types give the same dialects. kcmd push,
kcmd profiles and kcmd skills-generate look each table up once before they
load the model. The entry exists only when the instance has Dataplex
integration turned on (gcloud sql instances patch --enable-dataplex-integration), and the catalog can take a while to list a new
table. When the entry cannot be read, the command stops and says so.

A profile's sources all sit in one database, meaning the system one query can
reach: all of BigQuery, or one Spanner, AlloyDB or Cloud SQL for PostgreSQL
database, or one Cloud SQL for MySQL or SQL Server instance. A profile moves an entity
between stores by swapping its source and, when the two stores shape the data
differently, the column each field reads.

What a profile may change — the contract

A model separates two things. Declaration is logical: which entities,
fields, relationships, and metrics exist, what each means, how each metric
computes, the grain, and the graph shape. The logical model owns all of it.
Binding is physical: which store each entity reads from, which column each
field reads, and how each action performs its write. A profile sets binding and
leaves declaration alone.

A profile may set (physical binding) A profile may not touch (logical, in the model)
an entity's source (its store URI) which entities, fields, relationships, metrics, or actions exist, and what each means
a field's column or computation (its expression), for a field the entity declares or inherits a field's label, description, dimension, datatype
whether a field is bound at all under this profile (fields_exclude) the graph shape (from/to) and the cardinality a relationship's join columns imply
an entity's key columns (primary_key, unique_keys), which a named profile must state itself where the model file states them
a relationship's join columns (from_columns, to_columns), which every named profile must state itself
an action's executor (how the write is performed) an action's parameters, guards, and affects — what it takes, what gates it, what it changes
the deployment target, in the older <model>.profiles/ layout only any metric definition; any ai_context / synonyms; a relationship or its junction source

An element's name is not overridden — it is the key that pairs a profile
element with the model element it binds. Key and join columns name physical
columns, not fields, so whether a field is bound says nothing about them; each
field's column is resolved per profile from its expression.

Why an action's executor is a binding. An action declares what a call does:
its parameters, the constraints that gate it, and the concepts it changes. None
of that changes when the same model is deployed somewhere else. The executor
does, for either of two reasons. An mcp, rest or grpc executor is an
address, and the system it names answers at a different server, endpoint or
method in one environment than in another — nothing about where the data sits
comes into it. A sql executor is the statement itself, and each store binds
its own table and column names and speaks its own dialect, so the same write is
a different statement under each binding. Either way the executor sits with
source and expression on the physical side: the same action and the same blast
radius, performed by whatever the binding points at.

The two kinds sit differently in the files, though. An address may be declared
in the model as a default and replaced per profile. A statement has no
model-level spelling at all: select a named profile whose model declares a
sql executor and the command refuses
, naming the action. The single-file
form, where one document is both the model and its binding, is the exception —
its statements already sit beside the columns they name.

Bindings inherit. A profile is an overlay: whatever it does not restate
keeps what the model file says. A model may declare one default executor, and a
profile overrides it only for the bindings that perform the write differently —
an action the profile does not mention keeps the default. A field's column is
the exception, because it belongs to the table the model file's expression was
written for. A named profile restates each field whose expression reads a
column, or names it in fields_exclude. A column is any reference that no
entity name qualifies. Push rejects a profile that does neither, naming the
field, the profile and the column. A field whose expression reads only other
fields, such as Order.gross - Order.discount, carries over. To withdraw an
inherited executor — a read-only binding
that performs no writes at all — a profile writes executor: null, which leaves
the action declared and unavailable there.

Why metrics never appear in a profile. A metric like
SUM(OrderedAs.extendedPrice * (1 - OrderedAs.discount)) references field
names rather than columns. Field names are stable across profiles — only their
column bindings change — so the metric is correct under every profile where its
fields are bound, without being restated. Where a field it references is not
bound, the profile has to exclude the metric with metrics_exclude; the next
section explains why.

Availability follows the bindings

A store holds what it holds. An operational database keeps a customer's live
credit; a warehouse keeps a modeled lifetime value; neither carries the other's
column. A profile binds whatever subset its store serves, and leaves the rest
unbound.

What a profile can answer is therefore derived from what it binds, by one rule.
A building block is available under a profile when every field it depends on is
bound there. When any input is unbound, the block is unbound too, and so is
anything built on it.
Availability propagates up the dependency graph from the
fields a profile binds. The chain runs as far as the model does:

  • a field is bound when the profile gives its column, or the model file gives
    it an expression that reads only other fields, and the profile does not
    exclude it;
  • an entity and a relationship are always available: their key and join columns
    are physical columns rather than fields, so unbinding a field never removes
    them;
  • a metric is available when every field its expression references is bound and
    the profile does not exclude it; a cross-entity metric also needs a
    relationship connecting its entities;
  • an action is available when a binding supplies its executor. An action with
    no executor is declared and not performable here.

So an operational-only field such as live credit carries its operational-only
metrics with it, and a warehouse-only field such as lifetime value carries its
reports; each is present where its inputs are, and unavailable everywhere else.
The logical model still declares each thing once; a profile answers the part of
it that its store can back.

Unbound is not null. A bound field whose data happens to be empty — a
customer with no phone on file — is null: the field exists and the value is
missing. An unbound field does not exist under that profile at all, and anything
that reads it is unavailable there rather than reading a null. Keeping the two
apart is what lets a query fail against a store that cannot answer it instead of
returning a blank that reads like real data.

A profile restates what differs and excludes what its store lacks. A field
the profile gives an expression is bound to it, a field the profile names in
fields_exclude is unbound, and every other field keeps what the model file
says. A field the model file leaves unbound, or binds with an expression that
reads a column, has to appear in one of the two lists: leaving it out of both
is an error, not a quiet way to unbind it. The
logical model below binds nothing inline, so the operational profile binds
availableCredit and excludes lifetimeValue, and the analytical profile does
the reverse. A metric that reaches an excluded field has to be named in the
profile's metrics_exclude. When the source table a profile binds is missing or
inaccessible, validation fails and names it; a mistyped column name resolves to a
real table and so surfaces at deploy, when BigQuery rejects the generated graph.

File layout

A binding may also sit inline in the logical model — logical and physical in a
single file — which is how the default profile works, and a model with one
binding needs nothing more. Keeping them in separate files, as below, is one
layout among several: it keeps the logical model reusable across bindings and
lets each binding be reviewed and owned on its own.

The logical model is one file, and its bindings live beside it, one file per
profile. A profile file is named after the model and the profile, and holds one
profile:

text
catalog/EntryGroups/commerce_eg/
  commerce.yaml                       # the logical model — declarations only
  commerce.profile.analytical.yaml    # bindings for the analytics warehouse
  commerce.profile.operational.yaml   # bindings for the operational store

The <name> in the file name must match the name: inside the file, and
default is reserved for the inline bindings. A profile file carries name,
entities, relationships, metrics_exclude and actions, and names no
deployment target. kcmd push deploys each deployment with the profile it
names. kcmd profiles --print-store and kcmd skills-generate
take a profile file's store from the deployments that name the profile in
profile. When none does, they report that there is no database to run it
against.

The examples on this page use the older layout, which kcmd push still
deploys: a commerce.profiles/ directory whose files are semantic_model
documents in the logical model's schema, each carrying only physical facets and
its own deployment_target. In either layout, --profile analytical reads
commerce.yaml plus the analytical profile, so nothing in one binding can
affect another. Beside a profile file, commerce.yaml must load on its own
first. A half-bound join in it stops the push even when the profile supplies
the missing join columns, and so does a rejected source the profile replaces.

Example — one model, an analytical and an operational binding

The logical model declares the business and nothing physical: no sources, no
columns, no deployment target. lifetimeValue and availableCredit are both
declared here, though no single store carries both:

yaml
# commerce.yaml — logical model
version: "0.2.0.dev0/google"
semantic_model:
  - name: commerce
    entities:
      - name: Customer
        fields:
          - { name: key,             label: Customer ID }
          - { name: name,            label: Customer Name }
          - { name: lifetimeValue,   label: Lifetime Value }
          - { name: availableCredit, label: Available Credit }
      - name: Order
        fields:
          - { name: key, datatype: Integer }
          - { name: customerKey }
          - { name: orderDate, dimension: {is_time: true} }
    relationships:
      - name: PlacedBy
        from: Order
        to: Customer
    metrics:
      - name: order_count
        expression: COUNT(Order.key)
      - name: avg_lifetime_value
        expression: AVG(Customer.lifetimeValue)
    actions:
      - name: CancelOrder
        description: Cancel an order that has not shipped
        parameters:
          - { name: order, concept: Order, field: key }
        affects:
          - { concept: Order, operation: modify }
        # The default: ask the service that owns orders to cancel one. Any
        # binding that does not hold the rows itself uses this.
        executor:
          mcp:
            server: //agentregistry.googleapis.com/projects/acme-ops/locations/us/mcpServers/orders
            tool: cancel_order

The analytical binding points the model at the BigQuery warehouse. The warehouse
carries the modeled lifetimeValue and does not hold live credit, so
availableCredit is excluded: the model binds nothing inline, and this profile
names it in fields_exclude. It says nothing about
CancelOrder, so the action keeps the default executor: the warehouse reports
on orders but does not own them, and cancelling one means calling the service
that does.

yaml
# commerce.profiles/analytical.yaml — BigQuery bindings
version: "0.2.0.dev0/google"
semantic_model:
  - name: commerce
    deployment_target: bigquery.googleapis.com/projects/acme-analytics/datasets/sales/propertyGraphs/commerce
    entities:
      - name: Customer
        source: bigquery.googleapis.com/projects/acme-analytics/datasets/sales/tables/customer
        primary_key: [c_custkey]
        fields:
          - { name: key,             expression: c_custkey }
          - { name: name,            expression: c_name }
          - { name: lifetimeValue,   expression: c_ltv }
        fields_exclude: [availableCredit]    # the warehouse holds no live credit
      - name: Order
        source: bigquery.googleapis.com/projects/acme-analytics/datasets/sales/tables/orders
        primary_key: [o_orderkey]
        fields:
          - { name: key,         expression: o_orderkey }
          - { name: customerKey, expression: o_custkey }
          - { name: orderDate,   expression: o_orderdate }
    relationships:
      - { name: PlacedBy, from_columns: [o_custkey], to_columns: [c_custkey] }

The operational binding points the same model at the live Spanner store. Spanner
holds the same customers under different table and column names, binds the live
availableCredit, and excludes the modeled lifetimeValue, which it does not
carry. It also owns
the order rows, so it overrides CancelOrder to write them directly instead of
calling out:

text
/* Detailed source-code truncated for AI context efficiency. */

Each binding states the key columns and the PlacedBy join columns in its own
store's column names. Neither changes the labels or the metric definitions;
those live once in the logical model. kcmd push --profile operational merges the operational bindings and deploys to their
Spanner backend, while --profile analytical deploys to BigQuery — each profile
picks its own backend. The two bindings answer different parts of the same model:

  • order_count depends only on Order.key, so it is available analytically.
    The operational profile deploys to Spanner, whose graphs have no measures, so
    it names every metric in metrics_exclude with the wildcard "*".
  • avg_lifetime_value depends on Customer.lifetimeValue. The warehouse binds
    it, so the metric is available analytically. The operational profile excludes
    the field, and a metric that reaches an excluded field has to be named in
    metrics_exclude, which the wildcard already does.
  • availableCredit is bound only operationally, so it — and any metric written
    on top of it — is available under the operational binding and absent under the
    analytical one.
  • CancelOrder is available under both, and performed differently by each: DML
    operationally, an MCP call analytically. One declaration, one blast radius, two
    mechanisms.

Merge rules

  • A profile carries entities with their source, key columns, fields and
    fields_exclude; relationships with their from_columns and to_columns;
    metrics_exclude; and actions with their executor. These merge onto the
    logical model by name. A profile never declares a relationship or a
    metric: it binds a relationship's join columns and may leave metrics out, and
    anything else about either lives once in the model. An entry that sets
    anything else is rejected.
  • A profile entity's fields may name a field the entity inherits as well as
    one it declares, and so may its fields_exclude. Each applies to this
    entity's table only, so an entity that extends it still has an excluded
    field. Where a profile binds a field on a concrete ancestor to a column, each
    concrete descendant restates or excludes that field too.
  • Every named profile must state each relationship's join columns itself. The
    model file's join columns are the inline binding's, and do not stand in for a
    named profile's. In every binding, to_columns must contain every column of
    a primary or unique key of the target, a superset counting.
  • A named profile states its own primary_key for an entity whose primary key
    the model file states, and its own unique_keys for one whose unique keys it
    states. Key shapes may differ between profiles.
  • An entity or field named only in the logical model keeps its declaration and
    any inline column binding; a profile element whose name is not in the
    logical model is rejected.
  • Scalars — source, expression, and deployment_target in the older
    layout — replace.
  • A field the profile does not name keeps the model file's binding, which
    push accepts only when that binding reads no column. A field
    in fields_exclude is unbound under that profile. There is no unbound
    flag; fields_exclude is how a field is left unbound, and naming one field in
    both fields and fields_exclude is rejected.
  • An action the profile does not mention keeps the model's executor, if it
    declared one, so a model can state one default and a profile override only
    where the write differs;
    executor: null withdraws it explicitly. Only an mcp, rest or grpc
    executor may be that default; a sql one is rejected in a model the profile
    names, and belongs in the profile itself.
  • Profiles are binding-only: a profile sets physical facets, may leave a
    field unbound with fields_exclude, and may leave metrics out with
    metrics_exclude. It cannot add entities, fields or metrics, change the
    graph shape, or change what anything means.

Command line

bash
kcmd push                                 # deploy the graph with the default binding + Knowledge Catalog
kcmd push --profile analytical            # deploy the graph with the analytical bindings (BigQuery target)
kcmd push --profile operational           # deploy the graph with the operational bindings (Spanner target)
kcmd push --all-profiles                  # deploy the graph once per binding profile
kcmd push --profile analytical --no-kc    # deploy only the graph, skip Knowledge Catalog
kcmd push --no-profile                    # publish only to Knowledge Catalog, deploy no graph
kcmd profiles                             # list profiles, their resolved sources, what each cannot answer, and what push would reject

--profile chooses which physical binding feeds the graph, and that binding's
deployment_target selects which graph backend the model deploys to
(BigQuery Graph or Spanner Graph). You never name the backend on the command line
— picking the profile is picking the backend. The binding-profile axis (how many
profiles the graph deploys for: --no-profile, the default, --profile, or
--all-profiles) is separate from the Knowledge Catalog axis (--no-kc).
Both default on; --no-profile --no-kc together is an error (nothing to deploy).

Deploying to more than one binding at once. --all-profiles deploys the graph
once for every defined profile — for example the analytical (BigQuery) and
operational (Spanner) graphs in a single run. Knowledge Catalog still records one
canonical view of the logical model — the default binding — so a bare kcmd push
and a kcmd push --all-profiles write the same catalog entries and differ only in
how many graphs deploy. A push in two phases records the
model file as written, whatever default_profile names.

There is always a default binding profile: whatever default_profile names,
or — if it is unset — the model's inline bindings (the implicit default
profile). Because default names those inline bindings, it is reserved: a
<model>.profiles/default.yaml is rejected rather than silently ignored. Set the
default so a bare kcmd push in CI does the right thing, in catalog.yaml:

yaml
scope: semantic-model.acme.us.commerce_eg
default_profile: analytical

Validation

Profiles are checked as part of push; --validate-only runs the checks and
writes nothing.

  • Unknown profile — --profile stg when stg is not defined fails and lists
    the profiles that are.
  • Missing or ambiguous target — a profile that deploys a graph must resolve
    to one deployment_target.
  • Declaration override — a profile that sets a label, dimension,
    ai_context, a metric, or a relationship's from/to is rejected, naming
    the offending path.
  • Unknown name — a profile element whose name is not in the logical model
    is rejected. Profiles bind declarations; they do not add them.
  • Incomplete profile — a push checks every named profile of each model it
    deploys or publishes, whichever binding it deploys: each profile file, and
    each profile a vanilla model carries in its GOOGLE block. A profile that
    leaves an entity without a source or a relationship without join columns
    fails the push.
  • Keys and join columns — every push checks the keys and join columns of
    the model file, each profile file and each GOOGLE block profile. The push
    fails when any binding of a relationship has to_columns that cover no key of
    the target. Where the model file states a primary key or unique keys for an
    entity, the push also fails when a named profile does not state its own.
    Profiles are not compared with each other.
  • Unrestated column — the push fails when a named profile neither restates
    nor excludes a field whose expression reads a column. The error names the
    field, the profile and the column.
  • Deployments and field dialects — every push checks each deployment
    against the rules of its engine, under the model file's own bindings or the
    profile it names, whether or not the push deploys it. Each field the model
    file binds also needs a text its entity's database runs. See
    Validation.
  • Unbound inline bindings — when the model file's own bindings leave an
    entity without a source or a relationship without join columns, and no
    deployment uses them, push warns and still pushes. The warning says
    the model is non-executable when it declares no named profile, and that
    default is unbound when it declares one. kcmd profiles prints it under
    the default binding.
  • Unresolvable source — the BigQuery table a profile binds is probed with a
    dry run; a table that is missing or inaccessible fails and names it. Column
    names are not probed here — a mistyped column resolves to a real table and is
    caught at deploy, when BigQuery rejects the generated graph.
  • Rejected DML — where this profile deploys to Spanner or BigQuery, every
    statement in an action's sql executor is planned against that store without
    running it, and a store that refuses one fails the push and quotes back why.
    The profile is what picks the store, so the same statement can pass under one
    profile and fail under another. A profile deploying to AlloyDB is the
    exception: its statements are checked for shape but never sent anywhere, so
    nothing confirms the names in them. See
    statements use your database names.
  • Availability summary — push resolves the dependency graph and prints, per
    profile, how many fields the binding leaves unbound and how many metrics and
    actions that makes unavailable. Entities and relationships stay available,
    since their key and join columns are physical columns rather than fields. kcmd profiles lists each one with the reason that stops
    it, so withheld coverage is stated rather than discovered later. Actions are
    listed apart, under cannot run: rather than cannot answer:: an action a
    binding drops is not a question it cannot answer, it is a write it cannot
    perform.

Notes

The deployment target is a first-class key. Under the extended
0.2.0.dev0/google profile — the version these binding examples use —
deployment_target: is a native model key, so a profile in the older layout
sets it readably. The
vanilla 0.2.0.dev0 profile has no native key for it; there the target rides in a
GOOGLE custom_extensions block instead. The two are the same target written
two ways, chosen by the document's version (see model
spec
).

Bare-string expressions. A field's expression may be written as a one-line
string (expression: c_name) instead of the full per-dialect object. The two
forms mean the same thing and expand to the same wire representation.

Dialect comes from the store. A profile carries no SQL dialect. The dialect
follows from the store a profile binds to; the engine lowers each expression to
that store's query language when it runs. A profile chooses the data, and the
execution engine chooses the dialect.

An action's statements reach the store as written. Lowering covers a
field's expression and stops there. kcmd passes a sql executor's statements
through unchanged, so they name physical tables and columns and use the dialect
of the store the profile binds. CancelOrder above writes UPDATE Orders SET Status = 'CANCELLED' rather than the model's Order and key, and a profile
that binds a different store restates those statements for it.


Toolbox Mdcode Docs Semantic Model Reference (`toolbox/mdcode/docs/semantic-model/reference.md`)

Reference

Lookup companion to the deploy guide: every flag, exactly what push
creates in each destination, the validation checks, and the permissions each leg
needs. For what of your metadata survives a deploy or a pull, see
What push and pull preserve.

CLI flags

init

bash
kcmd init --semantic-model <projectId>.<locationId>.<entryGroupId>

Provisions the Knowledge Catalog entry group named by the scope (idempotent — an
existing group is fine) and creates its local directory,
catalog/EntryGroups/<entryGroupId>/. Author <model>.yaml there.

push

bash
kcmd push

With no flags, deploys to every destination, the model's graph backends and
Knowledge Catalog, in the order below. You do not choose the graph backend: each
model deploys to whichever its deployment target names (BigQuery Graph or Spanner
Graph). The binding profile selects which physical binding feeds the graph, and
that binding's deployment target selects the backend.

A push has two axes, both defaulted so a bare kcmd push deploys the graph for
the default binding and records to Knowledge Catalog, unless it takes the
two-phase path below. The binding-profile axis
sets how many profiles the graph deploys for; the Knowledge Catalog axis is
whether the catalog leg runs.

Flag Effect
--profile <name> Deploy the graph for one binding profile (<model>.profiles/<name>.yaml); its deployment target selects the backend. Defaults to default_profile from catalog.yaml, else the model's inline bindings, except on the two-phase path below. Mutually exclusive with --all-profiles and --no-profile. See Binding profiles.
--all-profiles Deploy the graph for every defined binding profile (plus the inline bindings when the document itself declares a target), instead of a single one. The Knowledge Catalog leg still records one canonical view — the default binding. Mutually exclusive with --profile and --no-profile.
--no-profile Deploy the graph for no binding profile: publish only the logical model to Knowledge Catalog, leaving any deployed graph untouched.
--no-kc Skip the Knowledge Catalog metadata push and deploy only the graph the selected profile targets. Knowledge Catalog is pushed by default. A push that deploys no graph (a logical model, or --no-profile) can only reach Knowledge Catalog, so --no-kc on it is an error, as is --no-profile --no-kc (nothing left to deploy).
--deployment <names> Deploy only these entries of the model's deployments: list, comma-separated or repeated, in the order given. Push still publishes the model to Knowledge Catalog first.
--no-deployment Publish the model to Knowledge Catalog and deploy nothing, after every check of the two-phase path below. It publishes the model file as written, whereas --no-profile publishes the binding default_profile names. Push refuses it with --deployment, in either order.
--validate-only Run every validation check and report pass/fail, but write nothing.
--print Print each destination's generated artifact (BigQuery or Spanner Graph SQL DDL, Knowledge Catalog entry plan). Combine with --validate-only to preview without deploying.
--force-remove Delete models in the entry group that this push no longer includes (see Updating and removing models).
--emit-expressions Also write the SQL-expression fields (per-field schema.semantics and semantic-metric.expression) to Knowledge Catalog. Off by default: the published system-type templates do not carry them yet. Knowledge Catalog push only.

A model that lists deployments:, and a push with --deployment or
--no-deployment, run in two phases. So does a push of a model with a sibling
profile file, without --profile, --all-profiles or --no-profile. The first
runs every check, for Knowledge Catalog and for each deployment, and writes
nothing. The second publishes the model to Knowledge Catalog, then deploys each
deployment in order with the profile it names. An AlloyDB deployment deploys no
graph. The first phase dry-runs each BigQuery graph's DDL. Spanner cannot check
a graph's DDL without running it, so a Spanner deployment whose DDL Spanner
rejects, for example one that reads a missing column, fails in the second phase,
after Knowledge Catalog is written. On this path --no-kc, --profile,
--all-profiles and --no-profile are errors, and catalog.yaml's
default_profile chooses no binding: Knowledge Catalog receives the model file
as written. The runtime still reads default_profile, so it must name a
profile the model declares, and that profile must apply as the runtime
applies it.

Any other push prepares the catalog's model, checks that it publishes one
model and checks the file copies before the first graph deploys. Its Knowledge
Catalog leg runs its own checks only after the graphs deploy.

pull

bash
kcmd pull

Writes back the model file and profile files a push stored in Knowledge
Catalog. It reconstructs the model document from the catalog entries instead
for a model an older kcmd pushed, and when no stored file is named after the
model. When it finds that the last push stopped partway, it writes nothing.
Reads only from Knowledge Catalog (never BigQuery); its coordinates come from
the same scope you authored under. See Pull for behavior.

Flag Effect
--dry-run Read the catalog and report what would be written, but write or delete no files.
--force-remove Replace a differently-named local model with the catalog's (see Pull); without it, a pull that would leave the entry group holding two models fails. Also deletes a local profile file the catalog has no copy of, when the catalog holds copies of the model's files. With KC_V2_ASPECTS=1, it also deletes a local profile file or overview sidecar the pull did not write.

profiles

bash
kcmd profiles

Reports each binding profile the model declares: its deployment target, the
source each entity binds to, and what the profile cannot answer or cannot run.
Under each profile it then prints every error push
would give for it, after a push rejects: heading. The inline default
binding is checked too. Beside declared profiles it is listed only when push
would reject it. Some errors fail every push of the scope, such as a second
model in the entry group. When push would stop at such an error,
kcmd profiles prints it once above the profiles and still checks each
profile, unless a plain push runs in two phases. A Cloud SQL table whose entry
cannot be read stops the command before the report. When
default_profile names a profile that a model does not declare,
a push that names no profile fails. That error prints above the model's
profiles. When push warns that the model file's own bindings are incomplete,
the report prints that warning under profile 'default', unless --profile
names another profile.

The command is read-only. It applies and prunes each profile and generates each
graph as push --validate-only does, but deploys nothing, so you can compare
coverage before choosing one. Like push, it reads the Knowledge Catalog entry
of each Cloud SQL table written as its resource name, for the table's dialect.
The other checks that need a service run only at push time: the BigQuery source table probe, the dry runs of action
statements and of each BigQuery graph's DDL, the check that a deployment's
BigQuery dataset or Spanner database exists and, on a push without
--validate-only, the Knowledge Catalog
checks on the entry group. Push's lookup of the file-copy type also runs only
at push time, because it calls a service. It exits non-zero when push would
reject a binding. The report checks each profile as
kcmd push --profile <name> does, unless a plain kcmd push of the scope
runs in two phases. In that case it gives the errors of that push and stops
where that push stops. Under each profile it prints the errors of that profile's own
checks and of each deployment that uses it. The errors of a deployment that
names no profile print under default. With --profile <name>, the other
profiles' errors print above the profiles too, since any of them stops the
push. Push applies only the profiles the deployments use and the one
default_profile names, so it can accept a profile that does not apply. When
push gives no error and a profile does not apply, the report prints why on a
not shown: line under that profile.
The report's target: line lists the targets of the deployments the profile
uses, the same ones --print-store reads. For an AlloyDB target named by its
cluster, --print-store calls the AlloyDB API to find the cluster's primary
instance.

Flag Effect
--profile [name] Report only this profile. Naming one the model does not declare is an error, not an empty report. Defaults to every profile.
--print-store Print only the store the profile deploys to, on one line and nothing else, for a script to read rather than parse out of the report: project/instance/database for a Spanner store, and the backend named ahead of the path for any other (alloydb:project/region/cluster/instance/database, bigquery:project/dataset). The store is the one the deployments for the profile name: those that name it in profile, or that name no profile or default for default. A legacy profile in <model>.profiles/ that no deployment names takes the deployments for default, unless it sets its own deployment_target. Of several, it is the single Spanner or AlloyDB store. For an AlloyDB target named by its cluster, the instance printed is the cluster's primary instance. For a legacy instance-based AlloyDB target, it is the instance the target names. Errors when the scope holds more than one model, since those may name different databases, before any AlloyDB call. Also errors when a deployment names a profile the model does not declare, and when no single store results. Like push, it errors when the model's extends do not resolve: one names an entity the model does not declare, they form a cycle, or an entity inherits one field from two unrelated ancestors. A target in no accepted form in any deployment fails it under every profile, and so does a target a GOOGLE block lists twice. It reads every model's profiles first: its profile files, and the profiles a vanilla model carries in its GOOGLE block. So does kcmd push --profile <name>. A plain push that runs in two phases loads the model files before it checks the profile files. It errors on a profile file that breaks one of the layout's file rules, such as a <model>.profile.<name>.yaml file whose name: does not match its file name, on a GOOGLE block profile that does not load, and on a profile named default in any letter case. Like push, it also errors on a profile file that does not load, such as a file in <model>.profiles/ whose name: does not match its file name. It does not run push's checks that every named profile applies to the model, that each is complete, and that each has valid keys and join columns. So a profile that leaves an entity without a source does not stop it. It does not run push's deployment checks, its dialect check of the model file's fields, or its check that each concrete entity restates the inherited fields that read a column either. It does not run push's checks on the copies of the model and profile files that push stores in Knowledge Catalog, such as whether each file is valid UTF-8. For an AlloyDB cluster it also errors when the cluster has no primary instance, when the primary is not READY, or when the instance listing fails.

This is a read of the binding, never a choice of one. Nothing on any kcmd
command line names a store directly; --profile selects a binding and the
binding's deployment target decides where writes land. A script that creates,
seeds or drops that database asks for the name rather than repeating it:

bash
IFS=/ read -r PROJECT INSTANCE DATABASE <<<"$(kcmd profiles --print-store)"

skills-generate

bash
kcmd skills-generate

Writes each model in the scope out as an Agent Skill
folder: a SKILL.md naming the model, routing to its actions and saying what a
call comes back as, and one references/<action>.md per action carrying that
action's arguments, rules and blast radius. It reads the model, touches no
store, calls no judge, and runs nothing. For an AlloyDB target named by its
cluster, it calls the AlloyDB API to find the cluster's primary instance.

A skill names the store only for a model with actions, in its "Running an
action" section. The command fails without writing anything when push rejects
the deployments. That happens when the target of any deployment is in no
accepted form, whichever profile the deployment is for. It also happens when
several targets leave the store ambiguous, or when a deployment names a profile
the model does not declare. It accepts a deployment that names default or a
declared profile, as push does, but also one that names a legacy
semantic_model: profile, which push refuses. It accepts a Spanner deployment
that names no profile in a model with metrics, which push refuses. It fails on
two BigQuery deployments that use one profile, since they leave no single
store, while push deploys both. It accepts a model that binds a table outside
the database a deployment deploys to, which push refuses, unless the model has
actions and that database is the Spanner or AlloyDB store of the selected
profile. It also accepts a profile file that no deployment uses, as push does,
and writes a skill whose store line says there
is none. A vanilla model lists its deployments or targets in a GOOGLE block.
The command fails the same way when a target there is in no accepted form or
listed twice, or when the block's data is not valid JSON. It reads every
model's profiles first: its profile files, and the profiles a vanilla model
carries in its GOOGLE block. So does kcmd push --profile <name>. A plain
push that runs in two phases loads the model files before it checks the
profile files. It fails on a profile file that breaks one of the layout's file
rules, such as a <model>.profile.<name>.yaml file whose name: does not
match its file name, on a GOOGLE block profile that does not
load, and on a profile named default in any letter case. Like push, it also
fails on a profile file that does not load, such as a file in
<model>.profiles/ whose name: does not match its file name. It does not run
push's checks that every named profile applies to the model and that each is
complete. So it still writes a skill when a profile leaves an entity without a
source. It does not run push's checks on the copies of the model and
profile files that push stores in Knowledge Catalog, such as whether each file
is valid UTF-8. It does not check that the entry group holds one model, or that
two models deploy to different graphs. Of push's checks on
the model itself, it runs only the one on inheritance. It fails when an
extends names an entity the model does not declare, when extends forms a
cycle, or when an entity inherits one field from two unrelated ancestors. It
runs none of the others, such as a concrete entity with no source or an action
guarded by a constraint the model lacks.

For a model with actions, it also fails without writing anything when the
profile names a store that cannot be resolved. That happens when the model
binds a table outside its Spanner or AlloyDB store, when the AlloyDB cluster
has no primary instance, when the primary is not READY, or when the instance
listing fails. A model with no actions still gets its skill in those cases,
since the skill does not name the store.

A BigQuery graph may read tables in other datasets, as push allows. For such a
model, and for a profile whose deployments name no target, the store line says
there is none and gives the reason.

A skill's name and the directory it sits in have to match, or a client skips
it. So the directory is named from what was generated rather than from anything
the caller typed, and a name the format does not allow fails before anything is
written.

Everything the binding decides lives in SKILL.md: the store, the executor
kinds, and which actions this deployment cannot run and why, all under one
heading, plus the snippet for reading the store directly. A reference page is
the same bytes under any profile.

A guarded action is always described as runnable, because a rule stated in words
is settled by the runtime before the transaction opens and a guarded action only
ever runs against a runtime that has a judge. There is no flag here to say
otherwise: whether the caller of skills-generate had a judge configured is a
fact about that invocation, not about the deployment the document is read
against.

Flag Effect
--out <dir> Directory the skill directories are written under. Defaults to skills.
--name <name> Name the skill, and so its directory. Defaults to the model's own name. Rejected when the scope holds more than one model, because a name names one skill.
--profile [name] Read the model under this binding profile. Defaults to default_profile, else the model's inline bindings. It is what the one deployment-specific section describes.
--force Replace a skill already at that path. A reference page for an action the model no longer declares is deleted and reported; a file outside references/ is left alone.

Before anything is written, a warning is printed when no action in a model is
runnable under the selected profile, because a skill that can run nothing is
rarely what was meant. Two models in one scope whose names normalize to one
skill name are refused rather than one overwriting the other. See
Generating an Agent Skill.

What gets created in BigQuery

push executes a single CREATE OR REPLACE PROPERTY GRAPH per deployment
target, in the project and dataset the target names. Each part of your model
becomes one part of that graph:

Model element BigQuery construct Notes
Model PROPERTY GRAPH named by the deployment-target URI
Entity NODE TABLE backed by the entity's source table, keyed by its primary key, or by its first unique key when it has none. A measure reads only one of the rows whose key is NULL, and COUNT(*) skips them, so a unique key used this way should hold no NULLs
Relationship EDGE TABLE connects the two entities' node tables
Metric MEASURE on a node table must resolve to a single entity and reduce to one supported aggregate over one operand (otherwise the push is rejected; see Validation)
Entity extends extra LABEL clauses on the subclass node table the subclass also matches its supertypes; the supertypes' fields flatten down (see Class hierarchies)
Action nothing an action reaches Knowledge Catalog only (see below); the BigQuery push deploys none and warns once
Constraint nothing a constraint reaches Knowledge Catalog only (see below); the BigQuery push deploys none and warns once

push reads the target dataset's location (bigquery.datasets.get) so the
statement runs in the right region; without that permission it falls back to
BigQuery's own location inference and warns. Under --validate-only no graph
DDL is executed and nothing is written (the live source-table checks still run —
see Validation); add --print to see the generated DDL.

For which of your descriptive metadata (description, ai_context, field
labels, …) lands in the graph and which is dropped, see
What push and pull preserve.

Class hierarchies (`extends` → labels)

This section is the rules lookup. To model a hierarchy step by step — declaring
it, binding each subtype's table, and keeping supertype counts correct — see
Modeling class hierarchies.

An entity that declares extends: [Parent] is a subclass. BigQuery Graph has
no inheritance keyword, so the push expresses the hierarchy with labels: a
subclass node table declares its own default label plus one LABEL <Ancestor>
per supertype
, walking the full transitive chain. A node then matches its
supertype in a query — MATCH (:Party) returns every Customer and every
Supplier node.

You author each entity's own fields plus the one extends keyword, and the
push flattens the supertype's fields down onto each subclass. The usual
supertype has no table of its own, so mark it abstract: true: it has no
source and no key, produces no node table, and survives only as a label on its
subtypes.

yaml
entities:
  - name: Party
    abstract: true             # no table; becomes a label on every subtype
    fields:
      - { name: id,   datatype: Integer }
      - { name: name, datatype: String }
  - name: Customer
    extends: [Party]           # the one keyword you add
    primary_key: [c_custkey]   # each subclass keeps its OWN key; keys do not inherit
    source: bigquery.googleapis.com/projects/proj/datasets/ds/tables/customer
    fields:
      - { name: id,   expression: c_custkey }
      - { name: name, expression: c_name }                      # the inherited field, bound to this table's column
      - { name: tier, datatype: String, expression: c_tier }
  - name: Supplier
    extends: [Party]
    primary_key: [s_suppkey]
    source: bigquery.googleapis.com/projects/proj/datasets/ds/tables/supplier
    fields:
      - { name: id,     expression: s_suppkey }
      - { name: name,   expression: s_name }
      - { name: rating, datatype: Integer, expression: s_rating }

The supertype's fields flatten down onto each subclass. A supertype
contributes its field names to every subclass, ordered own fields first then
inherited. An abstract supertype binds no columns of its own, so each subtype
supplies the column for every inherited name on its own table — id and name
above are bound on both the customer and the supplier. Under a concrete
supertype, a subtype restates each inherited field whose supertype expression
reads a column. This holds in each named profile, and in the model file when it
gives the subtype a source. A field whose supertype expression reads only other
fields flattens straight down.

A subtype redeclares an inherited field only to bind it. The redeclaration
names the field and gives it a column of the subtype's table. The datatype,
label, dimension, description, ai_context and custom_extensions come from
t