# Overview

Lynk is a semantic layer for AI — a brain organized by concept that teaches an agent how your business thinks.

A Lynk project is the business context an AI agent needs to answer data questions the way a senior insider would — your entities, metrics, vocabulary, and rules, encoded as files and organized by concept.

You are not configuring a system; you are teaching an agent how your business thinks. Lynk gives every piece of that knowledge one obvious home, so a builder opening a project recognizes the structure before reading a single file.

***

## The mental model

A Lynk project is **a brain organized by concept**. Six concept-shaped drawers hold what the agent needs:

| Drawer                               | What it holds                                                                                 |
| ------------------------------------ | --------------------------------------------------------------------------------------------- |
| [`LYNK.md`](/concepts/lynk-md)       | **Orientation.** Who the business is, who a team is, how they think.                          |
| [`GLOSSARY.yml`](/concepts/glossary) | **Vocabulary.** The terms a team uses and what they refer to.                                 |
| [Domains](/concepts/domain)          | **The agents themselves.** One per team, one per audience.                                    |
| [Entities](/concepts/entity)         | **What exists.** Customers, orders, campaigns. Each entity owns everything true about itself. |
| [Skills](/concepts/skill)            | **How to reason.** Procedures for recurring kinds of analysis.                                |
| [Policies](/concepts/policy)         | **Protocol.** How the agent operates and presents.                                            |

Four ideas hold it together:

**Domains are agents.** Each domain is one team's analytical agent — marketing's agent, sales' agent, finance's agent. Each speaks that team's language and answers in that team's voice. A user always talks to one agent at a time. Designing a domain *is* designing an agent.

**Concepts are the unit of organization, not forms.** Everything true about orders — its definitions, its quirks, its conventions, its analytical patterns — lives in the orders entity. Not split across a knowledge file and an instructions file and a metrics file. One concept, one home.

**Some content is always loaded; some is loaded on demand.** Orientation, vocabulary, and policies are always in the agent's context. Entities and skills are lazy — the agent indexes them by description and loads only what a question needs.

**The shape never changes.** A project with one domain has the same shape as a project with twelve. Adding a domain is adding a folder, not a restructure.

***

## The `.lynk/` tree

The whole semantic layer lives under a `.lynk/` directory at your repo root. Up to three files sit at the root — `lynk.yml` is required; root `LYNK.md` and `GLOSSARY.yml` are optional. Domains hang off `domains/`, and shared [reference files](/concepts/reference-files) can sit at the root alongside them.

```
.lynk/
├── lynk.yml            # project settings
├── LYNK.md             # who the business is
├── GLOSSARY.yml        # shared vocabulary
└── domains/
    ├── core/           # the shared domain others build on (set shared_domain in lynk.yml)
    │   ├── LYNK.md
    │   ├── GLOSSARY.yml
    │   ├── entities/
    │   │   └── customer/
    │   │       ├── ENTITY.md      # prose: quirks, conventions
    │   │       └── schema.yml     # structure: features, metrics, relationships
    │   ├── skills/
    │   └── policies/
    └── marketing/      # one team's agent
        ├── LYNK.md
        ├── entities/
        └── skills/
            └── attribution-analysis/
                └── SKILL.md
```

The smallest project worth querying is one domain with one entity and a `LYNK.md`. See [Layout and naming](/reference/layout-and-naming) for the full tree and the rules.

***

## How a project is consumed

A Lynk project is a self-contained git repository — version-controlled, reviewable, editable in any IDE. Agents don't reason against in-progress edits. You push to a branch, the push triggers a build, the build validates the whole layer, and if it passes it deploys and becomes queryable. If it fails, the build is rejected and the last good build keeps serving. Answers are reproducible because they are bound to a specific build of a specific branch and domain.

See [Project](/concepts/project) for the full lifecycle.

***

## Find your way

<table data-view="cards"><thead><tr><th>Start here</th><th></th><th data-card-target data-type="content-ref">Target</th></tr></thead><tbody><tr><td><strong>The project</strong></td><td>The repo, the build lifecycle, branches</td><td><a href="/pages/tEvdPt4D8Hq3WwdICdaJ">/pages/tEvdPt4D8Hq3WwdICdaJ</a></td></tr><tr><td><strong>Domains</strong></td><td>Designing an agent for a team</td><td><a href="/pages/sKoO3Sm2pOZfFu5k0BIU">/pages/sKoO3Sm2pOZfFu5k0BIU</a></td></tr><tr><td><strong>Entities</strong></td><td>Modeling what exists — ENTITY.md + schema.yml</td><td><a href="/pages/pfR2q6eC7E0ijK3bW97b">/pages/pfR2q6eC7E0ijK3bW97b</a></td></tr><tr><td><strong>Querying</strong></td><td>The Lynk SQL dialect</td><td><a href="/pages/DkE0ymt0S9X5edcGd1JX">/pages/DkE0ymt0S9X5edcGd1JX</a></td></tr></tbody></table>

**Guides** — the judgment layer: how to decide, what good looks like, and the anti-patterns to avoid:

| If you're deciding…                           | Go to                                                               |
| --------------------------------------------- | ------------------------------------------------------------------- |
| Where a piece of knowledge belongs            | [Placing knowledge in a layer](/guides/where-knowledge-goes)        |
| What becomes an entity, and its grain         | [Choosing and shaping entities](/guides/designing-entities)         |
| How to model balances, windows, cohorts, KPIs | [Modeling metrics, time, and state](/guides/metrics-time-and-state) |
| How many domains, what goes in core           | [Designing domains](/guides/designing-domains)                      |
| What loads when — @ vs link, splitting files  | [Budgeting the agent's context](/guides/context-engineering)        |
| How to rename, deprecate, and change safely   | [Evolving a live layer](/guides/evolving-the-layer)                 |
| What a whole correct layer looks like         | [Reading a complete layer](/guides/complete-example)                |

**Reference** — the shared mechanics every concept relies on:

| If you need…                                                          | Go to                                                    |
| --------------------------------------------------------------------- | -------------------------------------------------------- |
| The directory tree and naming rules                                   | [Layout and naming](/reference/layout-and-naming)        |
| Frontmatter and the `@` injection operator                            | [Markdown format](/reference/markdown-format)            |
| The `sql:` grammar inside `schema.yml`                                | [SQL expressions](/reference/sql-expressions)            |
| Whether Lynk supports X — testing, parameters, templating, scheduling | [What Lynk doesn't do](/reference/what-lynk-does-not-do) |
| The query dialect                                                     | [Lynk SQL](/api-reference/api/lynk-sql)                  |


# Overview

The router — every page in these docs and what it answers. Ground vocabulary here, then read the narrowest page.

The building blocks of a `.lynk` project, and the map of every page in these docs. Lynk distinguishes primitives that general analytics vocabulary blurs — a *metric* is entity-local, a *feature* is row-grain, a *skill* is reasoning, a *policy* is behavior. Ground your vocabulary in the map below, then read the narrowest page that answers the question. For the mental model of how the primitives fit together, start with the [Overview](/).

## Every page, and what it answers

### Concepts

* [Project](/concepts/project) — A Lynk project is a self-contained git repository — one repo, one customer — consumed as versioned, validated builds.
* [lynk.yml](/concepts/lynk-yml) — The required project settings file at the .lynk/ root — schema\_version, topology, and name.
* [LYNK.md](/concepts/lynk-md) — LYNK.md is the agent's orientation and identity — who the business is, who a team is, and how they think. Always loaded.
* [GLOSSARY.yml](/concepts/glossary) — GLOSSARY.yml is the team's vocabulary — the terms they use and what they mean, always loaded and merged across scopes.
* [Domain](/concepts/domain) — A domain is an agent — one team's analytical surface, with its own vocabulary, entities, skills, and policies. A user talks to one at a time.
  * [LYNK.md (domain)](/concepts/domain/lynk-md) — A domain's LYNK.md — who this team is. Appended to the root LYNK.md; extends it, never replaces it.
  * [GLOSSARY.yml (domain)](/concepts/domain/glossary) — A domain's GLOSSARY.yml — the team's vocabulary, merged over the root glossary with the domain winning on a key collision.
* [Entity](/concepts/entity) — An entity is a lazy, encapsulated representation of a thing in the business — a folder holding an ENTITY.md prose file and a schema.yml definition.
  * [ENTITY.md](/concepts/entity/entity-md) — ENTITY.md is the prose side of an entity — quirks, conventions, and business context the agent reads. Short by design, frontmatter required.
  * [schema.yml](/concepts/entity/schema-yml) — schema.yml is the structured definition of an entity — identity, keys, imports, features, metrics, and relationships.
    * [Identity and Imports](/concepts/entity/schema-yml/identity-and-imports) — identity roots an entity in a warehouse table or view, or another entity; keys identify its rows; imports cherry-pick definitions from a parent entity.
    * [Feature](/concepts/entity/schema-yml/feature) — A feature is a queryable, row-grain attribute of an entity — a column, a derivation, or a value pulled across a relationship.
    * [Metric](/concepts/entity/schema-yml/metric) — A metric is an aggregation defined on the entity it aggregates — sum, count, average, conditional aggregates — invoked with metric().
    * [Relationships](/concepts/entity/schema-yml/relationships) — Relationships declare a path from an entity to a physical table or another entity — directional, per-step joins with cardinality.
* [Policy](/concepts/policy) — Policies are eager, always-apply behavioral commitments — output format, clarification behavior, and other rules for how the agent operates.
* [Skill](/concepts/skill) — Skills are lazy prose procedures for classes of analytical reasoning — how the agent thinks through an analysis, not what exists in the data.
* [Reference Files](/concepts/reference-files) — Reference files — user-organized files outside the domain primitives, the escape hatch for cross-cutting content, reached by absolute /.lynk/ references.

### Guides

* [Placing knowledge in a layer](/guides/where-knowledge-goes) — How to decide where a piece of knowledge belongs — entity prose, schema, glossary, metric, skill, policy, LYNK.md, or a reference file.
* [Choosing and shaping entities](/guides/designing-entities) — How to decide what becomes an entity and how to shape it — the entity test, grain, promoting table relationships to entities, the upstream boundary, and base-row hygiene. Reach for it before adding or restructuring entities.
* [Modeling metrics, time, and state](/guides/metrics-time-and-state) — How to place a computation — feature, metric, skill, or glossary term — and how to model balances, time windows, cohorts, and ratio KPIs correctly.
* [Designing domains](/guides/designing-domains) — How to decide how many domains to create, what belongs in the shared domain, and when to promote, extend, split, or merge.
* [Budgeting the agent's context](/guides/context-engineering) — How to budget the agent's context — deciding @ injection vs a link, splitting a growing ENTITY.md, placing content at root vs domain, and when a rule earns a policy.
* [Evolving a live layer](/guides/evolving-the-layer) — How to change a live semantic layer — logging definition changes, renaming a shared feature or metric safely, deprecating with enabled false, and landing breaking changes across domains.
* [Reading a complete layer](/guides/complete-example) — A complete, annotated .lynk/ project for Grove (B2B SaaS) — every file of a small correct layer, end to end, with the reasoning behind each choice. Read when you want the whole shape at once instead of per-concept fragments.

### Reference

* [Layout and Naming](/reference/layout-and-naming) — The .lynk/ directory tree, the rules for naming folders and files, and the settings that control how a project is built.
* [Markdown Format](/reference/markdown-format) — The frontmatter contract shared by every Markdown primitive, the reference grammar (@ injection, markdown links, bare paths), and how supporting files are organized.
* [SQL Expressions](/reference/sql-expressions) — The reference grammar inside schema.yml sql fields — segment-count path resolution, the metric/first/last functions, filters, and join binding.
* [What Lynk doesn't do](/reference/what-lynk-does-not-do) — The boundaries in one place — testing and evaluations, metric parameterization, templating, tags, scheduling, cross-domain queries — what Lynk doesn't do by design, what's planned, and what belongs upstream, with the supported alternative for each.

### API Reference

* [API Reference](/api-reference/api) — The programmatic interfaces — the Lynk SQL query dialect and the REST endpoints for validation and catalog inspection.
  * [Lynk SQL](/api-reference/api/lynk-sql) — The query dialect — warehouse SQL plus metric(.) and USING('\<join\_name>'); entity references, joins, CTEs, windows, supported statements, pitfalls.
  * [REST API](/api-reference/api/rest-api) — HTTP endpoints for validating the semantic layer and inspecting the data catalog — full documentation in progress.


# Project

A Lynk project is a self-contained git repository — one repo, one customer — consumed as versioned, validated builds.

A Lynk project is a self-contained git repository: one repo, one project, one customer. It holds the entire semantic layer and is consumed as versioned, validated builds.

## What it is

A project is the unit of everything in Lynk. It contains the [domains](/concepts/domain), [entities](/concepts/entity), [skills](/concepts/skill), and [policies](/concepts/policy) that make up a customer's semantic layer. Multi-tenancy lives at the account level, not inside the project — a customer with genuinely independent business units (a holding company with separate analytics teams) has multiple projects, one per repo.

A project is **not**:

* the data warehouse — the layer points at tables, it doesn't contain them;
* the dashboard layer — skills describe reasoning, not pre-built charts;
* access control — governed elsewhere;
* the agent — the agent reads the project; it isn't part of it.

## Where it lives

A project is a git repository. The semantic layer lives under a `.lynk/` directory at the repo root, with `lynk.yml`, `LYNK.md`, and `GLOSSARY.yml` at the root of that tree, domains under `domains/`, and any shared [reference files](/concepts/reference-files) at the root alongside them. The full shape is in [Layout and naming](/reference/layout-and-naming).

## Format

### Project settings

The project's settings live in [`lynk.yml`](/concepts/lynk-yml) at the `.lynk/` root: `schema_version` (required), `topology`, and an optional `name`. These are settings *for* the semantic layer — they tell Lynk how to interpret the rest — not customer business content.

### Lifecycle

Projects are consumed as **versioned, validated builds**, never as live edits. The loop:

1. The customer edits the repo — locally, via the Lynk UI, or in a PR.
2. The customer pushes to a branch.
3. The push triggers a build.
4. The build validates the entire semantic layer.
5. If validation passes, the build deploys and becomes queryable.
6. If validation fails, the build doesn't deploy; the previous good build keeps serving.

Agents always reason against a deployed build of a specific branch — never against in-progress or unvalidated edits. When a user asks a question, that question is scoped to a branch's build and a single [domain](/concepts/domain) inside it. The triple **domain + branch + build** addresses one queryable agent, which is why answers are reproducible.

The build validates *structure* — definitions compile, references resolve — never result values. Tooling that tests expected query results (evaluations) is planned, not yet available (see [Lynk SQL](/api-reference/api/lynk-sql)).

### The minimum project

The smallest project that can be productively queried is one [domain](/concepts/domain) with at least one [entity](/concepts/entity), plus a `LYNK.md` with basic orientation. Below that, no agent has anything to reason about. Below-minimum projects pass validation with warnings; they just can't be queried usefully.

## Examples

**A single-domain project.**

```
.lynk/
├── lynk.yml
├── LYNK.md
└── domains/
    └── core/
        └── entities/
            └── customer/
                ├── ENTITY.md
                └── schema.yml
```

**A project's `lynk.yml`.**

```yaml
# .lynk/lynk.yml
schema_version: "v2"
name: Grove
topology:
  pattern: medallion
  shared_domain: core
```

## Validation

The build validates the entire semantic layer as one unit; a project deploys only if validation passes. Validation covers, among other rules:

* `lynk.yml` exists and declares `schema_version`.
* Every primitive's `name` matches its folder (see [Layout and naming](/reference/layout-and-naming#validation)).
* Every cross-domain reference — in structured fields (`identity:`, `imports:`) and in `@`/file references — resolves and obeys the project [topology](/concepts/lynk-yml#topology). (`sql:` is same-domain only, so it is never a cross-domain reference.)
* A project below the productive minimum (a domain, an entity, a `LYNK.md`) passes with **warnings**, not errors.

Each topic's own page lists the specific rules its build errors cite.

## Related

* [lynk.yml](/concepts/lynk-yml) — the settings file
* [Domain](/concepts/domain) — the agents a project contains
* [Entity](/concepts/entity) — what a domain models
* [Layout and naming](/reference/layout-and-naming) — the on-disk shape
* Guides: [Evolving a live layer](/guides/evolving-the-layer)


# lynk.yml

The required project settings file at the .lynk/ root — schema\_version, topology, and name.

The required settings file at the project root. It governs how Lynk interprets the rest of the semantic layer — format version, domain topology, and the project name.

## What it is

`lynk.yml` holds settings *for* the semantic layer, not customer business content. Where [`LYNK.md`](/concepts/lynk-md) and [`GLOSSARY.yml`](/concepts/glossary) carry what the agent knows, `lynk.yml` carries the rules Lynk uses to read everything else: which format version the project targets, how its [domains](/concepts/domain) may reference each other, and what to call the project.

It is the one place the project's **topology** is declared. Topology is the only architectural axis a customer can configure — everything else about how the layer composes is a fixed product property.

## Where it lives

One file, at the root of the `.lynk/` tree:

```
.lynk/lynk.yml
```

It is required. A project with no `lynk.yml` does not build.

## Format

| Field            | Required | Type   | Notes                                                                                                                                              |
| ---------------- | -------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `schema_version` | ✓        | string | Pins the project to a Lynk format version, so the format can evolve without breaking existing projects.                                            |
| `topology`       | –        | object | The domain reference pattern. See below. Defaults to medallion with **no** shared domain — set `shared_domain` to enable cross-domain composition. |
| `name`           | –        | string | Human-readable project name.                                                                                                                       |

### `topology`

`topology` declares which [domains](/concepts/domain) may reference which. It is the single authority for **every** cross-domain reference — structured composition (`identity:` and `imports:`) and file references alike (`@` injection, markdown links, and bare `/.lynk/…` paths in prose). It is enforced at build time: a reference the topology forbids is a build error.

```yaml
topology:
  pattern: medallion       # the default
  shared_domain: core      # the domain others may reference
```

| Field           | Required | Type   | Notes                                                                                                                                                                                              |
| --------------- | -------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pattern`       | –        | string | `medallion`. Defaults to `medallion`.                                                                                                                                                              |
| `shared_domain` | –        | string | Under `medallion`, the domain other domains may import from and reference. Conventionally `core`. **Optional — unset means there is no shared domain, so no cross-domain composition is allowed.** |

**By default there is no shared domain: until you set `shared_domain`, a domain may reference only its own files and the root reference files — no cross-domain composition at all.** Once a `shared_domain` is set under **medallion**, a domain may reference:

* its **own** files;
* the **root reference files** ([reference files](/concepts/reference-files) at the `.lynk/` root, outside `domains/`, belonging to no domain);
* the **shared domain** (e.g. `core`).

Domains may **not** reference each other. So a `marketing` entity may declare `identity: core.customer` and import from it, or `@`-inject `/.lynk/domains/core/…`, but it cannot reach a `sales` peer; to share content across peers, promote it into the shared domain or a root reference file.

## Examples

**Version only.** Topology defaults to medallion with no shared domain — every domain is self-contained until a `shared_domain` is declared.

```yaml
# .lynk/lynk.yml
schema_version: "v2"
```

**Named project with an explicit topology.**

```yaml
# .lynk/lynk.yml
schema_version: "v2"
name: Grove
topology:
  pattern: medallion
  shared_domain: core
```

## Validation

* `lynk.yml` exists at the `.lynk/` root and declares `schema_version`. Missing or unreadable fails the build.
* `topology.pattern`, if set, is `medallion`; other values are rejected.
* The `shared_domain`, **if set**, names an existing domain under `domains/`. When unset there is no shared domain and no cross-domain composition is allowed.
* Cross-domain references that violate the declared topology are build errors; questionable-but-legal patterns surface as warnings.

## Related

* [Project](/concepts/project) — what a project is and how it builds
* [Domain](/concepts/domain) — domains and the cross-domain reference rules topology produces
* [Layout and naming](/reference/layout-and-naming) — where `lynk.yml` sits in the tree


# LYNK.md

LYNK.md is the agent's orientation and identity — who the business is, who a team is, and how they think. Always loaded.

The agent's orientation and identity — who the business is, who a team is, how they think, what makes their voice their voice. Always loaded, before any specific reasoning happens.

## What it is

`LYNK.md` is the closest thing in Lynk to what `CLAUDE.md` or `AGENTS.md` is in agent-IDE ecosystems: always loaded, prepended to the agent's context before it reasons about anything. It carries orientation, system-prompt-level personality, and identity — the business at the root level, the team at the [domain](/concepts/domain) level.

It is what an agent reads to know *who it is and where it's working*, before it knows anything else. It is deliberately **not**:

* protocol — output format and clarification behavior live in [policies](/concepts/policy);
* vocabulary lookups — terms live in [`GLOSSARY.yml`](/concepts/glossary);
* facts about specific entities — those live in [`ENTITY.md`](/concepts/entity/entity-md).

## Where it lives

One `LYNK.md` at the project root, and optionally one inside each domain:

```
.lynk/LYNK.md                       # the business
.lynk/domains/marketing/LYNK.md     # the marketing team
```

It is always loaded, eagerly, in scope order: root first, then the domain's, appended.

## Format

A single Markdown file of pure prose — no structure is imposed. Unlike the other primitives, `LYNK.md` requires no frontmatter contract; it is orientation, not an indexed primitive. It may use the [`@` operator](/reference/markdown-format#references) to inject glossary terms, entity descriptions, or shared files.

**Composition is additive.** The agent's effective orientation is the root `LYNK.md` followed by the domain's `LYNK.md` when present. Domain content *extends* root content; it never replaces it.

**What goes where:**

| File             | Carries                                                                                              |
| ---------------- | ---------------------------------------------------------------------------------------------------- |
| Root `LYNK.md`   | Who the company is — the business, fiscal year, top-level conventions every agent should know.       |
| Domain `LYNK.md` | Who this team is — their analytical lens, their voice, vocabulary nuances that differ from the root. |

The test for placement: *would every agent in the project benefit from reading this?* If yes, root. If only this team's agent, the [domain's LYNK.md](/concepts/domain/lynk-md).

One more boundary: a rule about **one entity's rows** — "exclude test accounts from customer analyses" — lives on that entity's [`ENTITY.md`](/concepts/entity/entity-md), not here. `LYNK.md` may point at it, but the entity is its single home; orientation carries only what no single entity owns.

Lynk ships no default — the file is empty until you author it.

## Examples

**A root `LYNK.md`.**

```markdown
# Grove

Grove sells subscription-based business software to other companies. Revenue is
recurring; ARR is the headline metric. The fiscal year starts February 1
(Q1 = Feb–Apr). All revenue reporting is in USD.
```

**A domain `LYNK.md` that extends the root.**

```markdown
# Marketing

This is the marketing team's agent. We think in funnels and attribution, not
contracts. When someone says "lead," they mean a marketing-qualified lead —
the sales team uses the word differently.

Lead with conversion and pipeline contribution. The team's working definition
of "qualified" is in @glossary.mql.description.
```

## Validation

* `LYNK.md` is optional at both levels but recommended at the root — a project with no root orientation passes with a **warning** (see the [project minimum](/concepts/project#the-minimum-project)).

## Related

* [Domain → LYNK.md](/concepts/domain/lynk-md) — the domain-scoped orientation that extends this file
* [GLOSSARY.yml](/concepts/glossary) — vocabulary, which `LYNK.md` is not
* [Policy](/concepts/policy) — operational protocol, which `LYNK.md` is not
* [Markdown format](/reference/markdown-format) — the `@` operator
* Guides: [Budgeting the agent's context](/guides/context-engineering) · [Placing knowledge in a layer](/guides/where-knowledge-goes)


# GLOSSARY.yml

GLOSSARY.yml is the team's vocabulary — the terms they use and what they mean, always loaded and merged across scopes.

The team's vocabulary, structured for the agent to read. Each entry is a term the team uses and what it means.

## What it is

When a user mentions a term in a question, the agent resolves it against the glossary to understand what they mean. When the agent writes an answer, it draws on the glossary for the names the team actually uses. The glossary is how the agent speaks the team's language.

**A glossary entry has no structured pointer to an entity or metric — the agent resolves a term only by reading its prose, so the description must stand on its own.** It is always loaded as part of orientation, and the agent reads the whole merged glossary to resolve each term.

A term that is really *a way of computing* — "customer health" combining engagement, payment, and support signals — usually wants to be a [skill](/concepts/skill), not a glossary entry. The glossary defines what words mean; skills define how to reason.

## Where it lives

One `GLOSSARY.yml` at the project root, and optionally one inside each domain:

```
.lynk/GLOSSARY.yml                      # shared vocabulary
.lynk/domains/marketing/GLOSSARY.yml    # marketing's terms
```

## Format

A YAML file. Each top-level key is a term, in snake\_case — the stable address the [`@` operator](/reference/markdown-format#references) points at (`@glossary.expansion.description`).

```yaml
expansion:
  name: Expansion
  description: Additional recurring revenue from an existing customer — upsells, seat growth, or plan upgrades. Counted separately from new-logo revenue.
```

| Field         | Required | Type        | Notes                                                                                                     |
| ------------- | -------- | ----------- | --------------------------------------------------------------------------------------------------------- |
| key           | ✓        | snake\_case | The stable address. `@glossary.<key>` resolves here.                                                      |
| `name`        | ✓        | string      | The term as the team writes it.                                                                           |
| `description` | ✓        | string      | What the term means, in prose. Long forms, abbreviation expansions, and clarifying context all live here. |

**Composition merges across scopes.** The effective glossary in a domain is the root glossary merged with the domain's, with the **domain winning** on a key collision. A glossary reference never needs a domain prefix — it is already resolved against the merged result.

Lynk ships no default — the glossary is empty until you author it.

## Examples

**A single term.**

```yaml
active_customer:
  name: Active Customer
  description: A customer who has logged in within the last 90 days.
```

**A domain glossary (Grove, B2B SaaS) that overrides and extends the root.**

```yaml
logo_churn:
  name: Logo Churn
  description: A customer fully cancelling, counted as one lost logo regardless of contract size. Distinct from revenue churn, which weights by ARR.

ndr:
  name: NDR
  description: Net Dollar Retention. Revenue retained from existing customers over a period including expansion, contraction, and churn — excluding new logos.

at_risk:
  name: At Risk
  description: An active customer showing churn signals — declining usage, a pending cancellation, or an NPS detractor score in the last quarter.
```

## Validation

* Each entry declares `name` and `description`. A missing required field fails the build.
* Keys are snake\_case. On a root/domain key collision, the domain entry wins (this merge is expected, not an error).

## Related

* [Domain → GLOSSARY.yml](/concepts/domain/glossary) — the domain-scoped vocabulary that merges over this file
* [LYNK.md](/concepts/lynk-md) — orientation, which the glossary is not
* [Skill](/concepts/skill) — where *ways of computing* live, rather than the glossary
* Guides: [Placing knowledge in a layer](/guides/where-knowledge-goes) · [Modeling metrics, time, and state](/guides/metrics-time-and-state)


# Domain

A domain is an agent — one team's analytical surface, with its own vocabulary, entities, skills, and policies. A user talks to one at a time.

A domain is an agent. Each domain is one team's analytical agent — marketing's agent, sales' agent, finance's agent — with its own vocabulary, its own data of interest, and its own way of reasoning.

## What it is

When a user picks a domain, they are picking which agent answers their question. A user always talks to one agent at a time. Designing a domain *is* designing an agent — every choice about what entities to include, what to call them, what skills to write, and what policies to set is a choice about how that team's agent thinks and answers.

The triple **domain + branch + build** addresses one queryable agent. See [Project](/concepts/project) for how builds and branches scope a query.

**Isolation is the definition, not a constraint.** Each domain's agent sees only its own world — sales' agent doesn't know marketing's definitions unless sales pulled them in. When a question can't be answered in the active agent's world, the moves are: ask a different agent, expand this agent's world, or promote shared content to a domain multiple agents can pull from (typically `core`). Cross-domain reasoning is never a runtime mode — it's a modeling decision. Whether a domain may *reference* another is governed by the project [topology](/concepts/lynk-yml#topology), declared once in `lynk.yml` — under medallion a domain reaches its own files, the root reference files, and the configured shared domain; peers never reach each other.

Create a new domain when a new *audience* arrives whose vocabulary or definitions would be hurt by another team's bleeding in — not per entity. When and how to split, size, and share is [Designing domains](/guides/designing-domains).

## Where it lives

One folder per domain under `domains/`. The folder name is the domain's name — the domain is derived from the path, with no `domain:` field anywhere.

```
.lynk/domains/<domain>/
├── LYNK.md
├── GLOSSARY.yml
├── entities/
├── skills/
└── policies/
```

Folder names are lowercase alphanumeric with underscores (`marketing`, `core`, `customer_success`) — a domain name can appear in a cross-domain reference (`core.customer` in an [`identity:`/`imports:`](/concepts/entity/schema-yml/identity-and-imports)). `core` is the conventional shared domain under medallion but is not a reserved name. See [Layout and naming](/reference/layout-and-naming).

## Format

A domain is a container, so its format is the folder contract — everything its agent needs to answer questions in its team's language:

* [Entities](/concepts/entity) the team thinks about — some native to this domain, some pulled in from elsewhere.
* [Skills](/concepts/skill) for the team's recurring analytical reasoning.
* [Policies](/concepts/policy) for how the team wants the agent to communicate.
* The team's [`GLOSSARY.yml`](/concepts/domain/glossary) and [`LYNK.md`](/concepts/domain/lynk-md).

What goes in is the team's choice. Marketing's agent might pull in `deals` (a sales concept) because attribution requires it; sales' agent might not. The structure provides the primitives; each team composes their agent.

## Examples

**A single domain.** The whole project is one agent.

```
.lynk/domains/core/
└── entities/
    └── customer/
        ├── ENTITY.md
        └── schema.yml
```

**A team domain building on `core`.** Marketing reuses `core.customer` and adds its own skill.

```
.lynk/domains/marketing/
├── LYNK.md
├── GLOSSARY.yml
├── entities/
│   └── customer/        # identity: core.customer
│       ├── ENTITY.md
│       └── schema.yml
└── skills/
    └── attribution-analysis/
        └── SKILL.md
```

## Validation

* A domain folder name is lowercase alphanumeric with underscores.
* A domain with no entities passes with a **warning** — an agent with no entities can't answer anything.
* Cross-domain references that violate the declared topology are build errors.

## Related

* [LYNK.md](/concepts/domain/lynk-md) · [GLOSSARY.yml](/concepts/domain/glossary) — the domain's orientation and vocabulary
* [Entity](/concepts/entity) · [Skill](/concepts/skill) · [Policy](/concepts/policy) — what a domain holds
* [lynk.yml → topology](/concepts/lynk-yml#topology) — where the reference pattern is declared
* [Project](/concepts/project) — how domain + branch + build address an agent
* Guides: [Designing domains](/guides/designing-domains)


# LYNK.md (domain)

A domain's LYNK.md — who this team is. Appended to the root LYNK.md; extends it, never replaces it.

A domain's orientation file — who this team is, their analytical lens and voice. It extends the [root `LYNK.md`](/concepts/lynk-md) rather than standing alone.

## What it is

The shared definition of what `LYNK.md` is — its role, what belongs in it, and what doesn't — lives on the [root `LYNK.md`](/concepts/lynk-md) page. This page covers only the **scope behavior** at the domain level.

A domain `LYNK.md` describes who *this team* is: their analytical lens, their voice, and the vocabulary nuances that differ from the company-wide root. It is what makes a [domain's](/concepts/domain) agent *theirs*.

## Where it lives

Optionally, one per domain:

```
.lynk/domains/<domain>/LYNK.md
```

## Format

Pure prose, same as the root file — see [LYNK.md → Format](/concepts/lynk-md#format).

**Composition is additive.** The agent's effective orientation is the root `LYNK.md` followed by the domain's, appended in scope order. The domain file *extends* the root; it never replaces it. Put content here only when it is specific to this team — if every agent in the project would benefit, it belongs in the root.

## Examples

**A domain voice note.**

```markdown
# Finance

This is the finance team's agent. Default to GAAP definitions and the fiscal
calendar. When a number could be cash or accrual, say which.
```

**A domain that redefines a shared term for its audience.**

```markdown
# Marketing

This is the marketing team's agent. We think in funnels and attribution.
"Lead" here means a marketing-qualified lead — narrower than the company-wide
usage in the root. The team's definition is in @glossary.mql.description.

Lead answers with conversion and pipeline contribution, not contract value.
```

## Validation

* Optional; a domain with no `LYNK.md` simply inherits the root orientation.

## Related

* [LYNK.md](/concepts/lynk-md) — the root concept and the shared definition
* [Domain](/concepts/domain) — the agent this orientation belongs to
* [Domain GLOSSARY.yml](/concepts/domain/glossary) — the team's vocabulary


# GLOSSARY.yml (domain)

A domain's GLOSSARY.yml — the team's vocabulary, merged over the root glossary with the domain winning on a key collision.

A domain's vocabulary file. It merges with the [root `GLOSSARY.yml`](/concepts/glossary), with the domain winning when a term collides.

## What it is

The shared definition of what `GLOSSARY.yml` is — its role, its entry format, and when a term should be a [skill](/concepts/skill) instead — lives on the [root `GLOSSARY.yml`](/concepts/glossary) page. This page covers only the **scope behavior** at the domain level.

A domain glossary holds the terms this team uses, and overrides any root term whose meaning differs for this audience.

## Where it lives

Optionally, one per domain:

```
.lynk/domains/<domain>/GLOSSARY.yml
```

## Format

Same entry format as the root — key → `name` / `description`. See [GLOSSARY.yml → Format](/concepts/glossary#format).

**Composition merges across scopes.** The effective glossary in a domain is the root glossary merged with the domain's. On a key collision, the **domain entry wins** — this is how a team gives a company-wide term its own meaning. Terms the domain doesn't redefine are inherited from the root unchanged.

## Examples

**One domain-specific term.**

```yaml
mql:
  name: MQL
  description: Marketing Qualified Lead. A lead that has met the marketing team's engagement-score threshold and is ready to pass to sales.
```

**Overriding a shared term for this audience.** The root defines `lead` broadly; marketing narrows it.

```yaml
lead:
  name: Lead
  description: A marketing-qualified lead — narrower than the company-wide definition. Excludes self-serve signups, which marketing does not source.

cac:
  name: CAC
  description: Customer Acquisition Cost. Total marketing and sales spend in a period divided by new customers acquired in that period.
```

## Validation

* Optional; a domain with no glossary inherits the root vocabulary.
* Each entry declares `name` and `description`.
* A root/domain key collision resolves to the domain entry (expected, not an error).

## Related

* [GLOSSARY.yml](/concepts/glossary) — the root concept and the shared definition
* [Domain](/concepts/domain) — the agent this vocabulary belongs to
* [Domain LYNK.md](/concepts/domain/lynk-md) — the team's orientation


# Entity

An entity is a lazy, encapsulated representation of a thing in the business — a folder holding an ENTITY.md prose file and a schema.yml definition.

A lazy, encapsulated representation of a thing in the business — customers, orders, campaigns, games. Each entity owns everything true about itself.

## What it is

An entity is a concept in the business, not a database table. Everything true about orders — its definitions, its quirks, its conventions, its metrics — lives in the orders entity. That is the core of Lynk's "one concept, one home" model: a quirk in the orders table goes in the orders entity, not in a separate knowledge file or in [`LYNK.md`](/concepts/lynk-md).

Entities are **lazy**. The agent reads an index of entity names and descriptions, decides which entities a question touches, and loads only those. This keeps the brain large while the agent's working memory stays focused — which is why an entity's `description` is load-bearing.

An entity is two files in a folder:

* [`ENTITY.md`](/concepts/entity/entity-md) — the prose side: quirks, conventions, business context the agent reads to understand the entity's character.
* [`schema.yml`](/concepts/entity/schema-yml) — the structured side: the [features](/concepts/entity/schema-yml/feature), [metrics](/concepts/entity/schema-yml/metric), and [relationships](/concepts/entity/schema-yml/relationships) the agent queries to compose SQL.

## Where it lives

A folder per entity inside a [domain](/concepts/domain):

```
.lynk/domains/<domain>/entities/<entity>/
├── ENTITY.md          # prose (required frontmatter; body optional)
├── schema.yml         # structure
└── ...                # optional supporting files
```

The two named files are required; the folder may hold any [supporting files](/reference/markdown-format#supporting-files) the prose injects.

## Format

The split is consistent across every entity:

| File         | Carries                                                                                              | Page                                      |
| ------------ | ---------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `ENTITY.md`  | Frontmatter (`name`, `description`) + prose: framing, conventions, quirks.                           | [ENTITY.md](/concepts/entity/entity-md)   |
| `schema.yml` | `identity`, `keys`, `imports`, `features`, `metrics`, `table_relationships`, `entity_relationships`. | [schema.yml](/concepts/entity/schema-yml) |

An entity is rooted in its [`identity`](/concepts/entity/schema-yml/identity-and-imports) — either a physical warehouse table (the standalone case) or another entity (the extending case, which shares grain and imports definitions). See [identity and imports](/concepts/entity/schema-yml/identity-and-imports).

## Examples

**A standalone entity.**

```
.lynk/domains/core/entities/customer/
├── ENTITY.md
└── schema.yml
```

```yaml
# schema.yml
identity: maindb.public.customers
keys:
  - id

features:
  - name: company_name
    description: The customer's company name
    sql: maindb.public.customers.company_name
    data_type: string

metrics:
  - name: count_customers
    description: Count of customers
    sql: COUNT(*)
    data_type: number
```

**An entity folder with supporting content.** Grove's `customer`, with prose injecting a fiscal-year note.

```
.lynk/domains/core/entities/customer/
├── ENTITY.md
├── schema.yml
└── instructions/
    └── fiscal-year.md
```

## Validation

* Both `ENTITY.md` and `schema.yml` are present, and `ENTITY.md` carries valid [frontmatter](/reference/markdown-format#frontmatter-contract).
* `schema.yml` declares a valid `identity` (see [identity and imports](/concepts/entity/schema-yml/identity-and-imports#validation)).
* If `ENTITY.md` is marked `enabled: false`, the whole entity is disabled — `schema.yml` included — and can't be queried, referenced, or imported; a reference to it fails the build like a reference to a missing entity.
* **One concept, one home.** A fact lives on the entity it belongs to and nowhere else — a quirk about orders is not also restated in [`LYNK.md`](/concepts/lynk-md), a policy, or another entity; everything else points to that single home.
* A domain with no entities passes with a [warning](/concepts/domain#validation).

## Related

* [ENTITY.md](/concepts/entity/entity-md) — the prose side
* [schema.yml](/concepts/entity/schema-yml) — the structured side
* [Domain](/concepts/domain) — what entities belong to
* [Skill](/concepts/skill) — reasoning that *uses* entities, versus facts that live *on* them
* Guides: [Choosing and shaping entities](/guides/designing-entities) · [Reading a complete layer](/guides/complete-example)


# ENTITY.md

ENTITY.md is the prose side of an entity — quirks, conventions, and business context the agent reads. Short by design, frontmatter required.

The prose side of an [entity](/concepts/entity) — what the agent reads to understand the entity's character: quirks, conventions, gotchas, business context. The kind of thing a senior teammate tells a new hire.

## What it is

Where [`schema.yml`](/concepts/entity/schema-yml) defines structure, `ENTITY.md` carries prose. It's the orientation an analyst needs before running any analysis on this thing — not exhaustive documentation, just the few things every analysis must know.

It is **not** the place for:

* definitions of features or metrics — those are in [`schema.yml`](/concepts/entity/schema-yml);
* domain-wide context — that's [`LYNK.md`](/concepts/lynk-md);
* vocabulary — that's [`GLOSSARY.yml`](/concepts/glossary);
* operational behavior like output format — that's a [policy](/concepts/policy).

## Where it lives

One per entity, alongside `schema.yml`:

```
.lynk/domains/<domain>/entities/<entity>/ENTITY.md
```

## Format

Frontmatter (the shared [contract](/reference/markdown-format#frontmatter-contract)) over an optional prose body.

**Frontmatter is required.** Lazy loading depends on it: the agent reads each entity's `name` and `description` at index time to decide whether to load the entity for a given question. Write the `description` to signal what the entity is *for* — what kinds of questions it answers.

**`enabled: false` disables the whole entity.** The flag lives on `ENTITY.md`, but it governs the entire entity — `schema.yml` included. A disabled entity can't be queried, referenced, or imported; it's as if it weren't there, so a reference to it fails the build like a reference to a missing entity. See [Markdown format](/reference/markdown-format#frontmatter-contract).

**The body should be short.** `ENTITY.md` loads as a unit whenever the entity is activated, so a long body taxes every analysis that touches the entity. A typical body covers:

* a brief framing of what the entity represents;
* conventions the team uses (vocabulary nuances, how they talk about this thing);
* quirks or gotchas in the data that affect most analyses;
* pointers to deeper content via [`@` injection](/reference/markdown-format#references).

The body is optional — an entity with no quirks worth flagging can have an empty body; the frontmatter alone makes it loadable. Keep `ENTITY.md` lean and inject only what every analysis needs; leave deeper content un-injected so the load cost stays honest.

## Examples

**Frontmatter only.**

```markdown
---
name: subscription
description: Active and historical subscriptions. One row per subscription. Use for MRR, billing cycle, and cancellation analysis.
enabled: true
---
```

**Grove's `customer`, with conventions, a quirk, and an injected file.**

```markdown
---
name: customer
description: Grove accounts. One row per company. Use for ARR, churn, and plan-tier analysis.
enabled: true
---

# Customer

One row per company that has signed up. The team uses "customer" and "account"
interchangeably.

**Conventions.** Most analyses exclude test and deleted accounts
(`is_test_account = false`, `is_deleted = false`). "Churned" is defined in
@glossary.logo_churn.description.

**Quirk.** `first_paid_at` is null for trials — filter it out when measuring
time-to-paid, or the cohort skews.

@/.lynk/domains/core/entities/customer/instructions/fiscal-year.md
```

## Validation

* `ENTITY.md` exists and carries valid frontmatter — `name` (matching the folder) and `description`. A missing required field fails the build.

## Related

* [Entity](/concepts/entity) — the entity folder and the two-file split
* [schema.yml](/concepts/entity/schema-yml) — the structured side, where definitions live
* [Markdown format](/reference/markdown-format) — the frontmatter contract and `@` injection
* Guides: [Budgeting the agent's context](/guides/context-engineering)


# schema.yml

schema.yml is the structured definition of an entity — identity, keys, imports, features, metrics, and relationships.

The structured side of an [entity](/concepts/entity). It defines what the entity *is* and what the agent can query on it — [identity](/concepts/entity/schema-yml/identity-and-imports), keys, [imports](/concepts/entity/schema-yml/identity-and-imports), [features](/concepts/entity/schema-yml/feature), [metrics](/concepts/entity/schema-yml/metric), and [relationships](/concepts/entity/schema-yml/relationships).

## What it is

`schema.yml` is what the agent reads to compose SQL: the columns and derivations it can select ([features](/concepts/entity/schema-yml/feature)), the aggregations it can apply ([metrics](/concepts/entity/schema-yml/metric)), and the paths it can traverse to other entities ([relationships](/concepts/entity/schema-yml/relationships)). Where [`ENTITY.md`](/concepts/entity/entity-md) is prose the agent reads to understand the entity, `schema.yml` is structure the agent queries.

Everything rests on one rule: **grain is preserved by construction.** The entity always has exactly one row per base instance — `one_to_many` and `many_to_many` sources are handled through aggregation in [metrics](/concepts/entity/schema-yml/metric) or row-selection in [features](/concepts/entity/schema-yml/feature), never through references that would multiply rows. Joins default to LEFT; a [relationship](/concepts/entity/schema-yml/relationships) step can set its own `join_type` when needed.

## Where it lives

One per entity, alongside `ENTITY.md`:

```
.lynk/domains/<domain>/entities/<entity>/schema.yml
```

## Format

The top-level fields:

```yaml
identity: maindb.public.customers   # required — a physical table OR another entity
keys:                               # required when identity is a physical table; inherited otherwise
  - id

features: [...]
metrics: [...]
table_relationships: [...]
entity_relationships: [...]
imports: [...]                    # only when identity points at another entity
```

| Field                  | Required    | Type   | Page                                                                                                                                                        |
| ---------------------- | ----------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `identity`             | ✓           | path   | [identity and imports](/concepts/entity/schema-yml/identity-and-imports)                                                                                    |
| `keys`                 | conditional | list   | required when `identity` is a physical table; inherited when it's another entity — [identity and imports](/concepts/entity/schema-yml/identity-and-imports) |
| `imports`              | –           | object | only valid when `identity` is another entity — [identity and imports](/concepts/entity/schema-yml/identity-and-imports)                                     |
| `features`             | –           | list   | [feature](/concepts/entity/schema-yml/feature)                                                                                                              |
| `metrics`              | –           | list   | [metric](/concepts/entity/schema-yml/metric)                                                                                                                |
| `table_relationships`  | –           | list   | [relationships](/concepts/entity/schema-yml/relationships)                                                                                                  |
| `entity_relationships` | –           | list   | [relationships](/concepts/entity/schema-yml/relationships)                                                                                                  |

**One namespace.** Feature, metric, and relationship `name`s are unique within an entity, combined — a feature and a metric can't both be called `total_points`. The single namespace makes every reference unambiguous.

Expressions inside `sql:` and `filter:` follow the [SQL expressions](/reference/sql-expressions) grammar.

## Examples

**A standalone entity with one feature and one metric.**

```yaml
identity: maindb.public.orders
keys:
  - order_id

features:
  - name: net_amount
    description: Order total after discounts and refunds, in USD
    sql: maindb.public.orders.net_amount
    data_type: number

metrics:
  - name: count_orders
    description: Count of orders
    sql: COUNT(*)
    data_type: number
```

**An entity with a relationship feeding a cross-entity feature.** Grove's `customer` pulls total MRR from its subscriptions.

```yaml
identity: maindb.public.customers
keys:
  - id

features:
  # keys are not features — declared here because the relationship below joins on it
  - name: id
    description: Unique customer identifier
    sql: maindb.public.customers.id
    data_type: number

  - name: company_name
    description: The customer's company name
    sql: maindb.public.customers.company_name
    data_type: string

  - name: arr
    description: Annual recurring revenue for this customer, in USD
    sql: maindb.public.customers.arr
    data_type: number

  # cross-entity: pulled from subscription across the relationship, so it's a feature
  - name: total_mrr
    description: Total MRR across this customer's active subscriptions
    sql: metric(subscription.total_mrr)
    data_type: number
    join_name: customer_to_subscription

metrics:
  # local: aggregates this entity's own rows, so it's a metric
  - name: total_arr
    description: Total ARR across customers
    sql: SUM(customer.arr)
    data_type: number

entity_relationships:
  - name: customer_to_subscription
    description: Subscriptions belonging to this customer
    entity: subscription
    cardinality: one_to_many
    steps:
      - target: subscription
        join_type: left
        sql: customer.id = subscription.customer_id
```

Both sides of the step's join are declared features — `id` above, and `customer_id` on `subscription`'s own `schema.yml`. [Keys are not features](/concepts/entity/schema-yml/identity-and-imports#identity-and-keys); a step that references an undeclared column fails the build.

## Validation

* `identity` is present and valid; `keys` are authored when `identity` is a physical table — see [identity and imports](/concepts/entity/schema-yml/identity-and-imports#validation).
* The field tables are exhaustive — an unknown key anywhere in `schema.yml` (a `tags:`, `meta:`, or `time_grain:` carried over from another tool) fails the build. There is no dimension/measure split and no time grain on a metric; time grouping is a query-time `GROUP BY`.
* `name`s are unique across features, metrics, and relationships combined.
* Every feature and metric `sql` resolves to real columns **and compiles at the Lynk build** — the authoritative surface, not a raw-warehouse check (which is only a proxy and can be false-green). Unbacked columns or fabricated values fail the build.
* The feature/metric dependency graph is **acyclic** — no feature or metric may transitively depend on itself. A definition on entity A can reference one on B, and a definition on B can reference back into A — that's fine, as long as the *same* definition never reappears in the chain (`a.feature_a → b.feature_a → a.feature_a` is the illegal case). Break a cycle by sourcing the looping value from the entity's own columns.
* Each fact has **one home** — define it on the entity it belongs to and reference it elsewhere rather than restating it.
* Each sub-definition validates per its own page: [feature](/concepts/entity/schema-yml/feature#validation), [metric](/concepts/entity/schema-yml/metric#validation), [relationships](/concepts/entity/schema-yml/relationships#validation).

## Related

* [identity and imports](/concepts/entity/schema-yml/identity-and-imports) — what the entity is, and extending another entity
* [Feature](/concepts/entity/schema-yml/feature) · [Metric](/concepts/entity/schema-yml/metric) · [Relationships](/concepts/entity/schema-yml/relationships) — the sub-definitions
* [Entity](/concepts/entity) — the folder and the `ENTITY.md` / `schema.yml` split
* [SQL expressions](/reference/sql-expressions) — the `sql:` grammar


# Identity and Imports

identity roots an entity in a warehouse table or view, or another entity; keys identify its rows; imports cherry-pick definitions from a parent entity.

`identity` is an [entity](/concepts/entity)'s foundation — it declares *what this entity is*. Combined with `keys` and `imports`, it covers both standalone entities and entities that extend another.

## What it is

Every entity points its `identity` at one of two things, and the parser tells them apart by segment count:

* **A warehouse relation** (3 segments — `database.schema.table`): a physical table or a view — anything the warehouse can query at that address, never an inline SQL query. **Always prefix the database: a 2-segment `identity` is always read as `domain.entity`, never as a table** — even when your warehouse addresses tables as `schema.table`. The entity is rooted in this relation's rows; one row is one entity instance — the **standalone** case.
* **Another entity** (2 segments — `domain.entity`, in a **different** domain). **Extension is cross-domain only and requires a configured** [**`shared_domain`**](/concepts/lynk-yml#topology)**; under the default topology an entity can only root in a warehouse table or view.** The entity is the same conceptual thing as the named entity, sharing its grain and able to import its definitions — the **extending** case (typically a leaf domain extending the shared domain). There is no same-domain `import`: two entities in one domain are always independent objects, even on the same physical table.

When marketing's `customer` is the same underlying thing as core's `customer`, marketing declares `identity: core.customer` and imports the specific definitions it wants. This is the explicit version of saying "these two are the same object." Without identity, two entities that happen to share columns are independent objects.

## Where it lives

The top of an entity's [`schema.yml`](/concepts/entity/schema-yml):

```
.lynk/domains/<domain>/entities/<entity>/schema.yml
```

## Format

### `identity` and `keys`

```yaml
identity: maindb.public.customers   # a table or view (3 segments)
keys:
  - id
```

| Field      | Required    | Notes                                                                                                                                                               |
| ---------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `identity` | ✓           | A warehouse table or view (3-segment `database.schema.table`) or another entity (2-segment `domain.entity`). Never an inline query.                                 |
| `keys`     | conditional | The primary keys identifying rows uniquely. **Required** when `identity` is a table or view; **inherited** (and not re-authored) when `identity` is another entity. |

**Keys are not features.** `keys` only declare row identity. To reference a key column anywhere else — a [relationship](/concepts/entity/schema-yml/relationships) step's `sql`, a [feature](/concepts/entity/schema-yml/feature) expression, or a [Lynk SQL](/api-reference/api/lynk-sql) query — declare a feature for it, like any other column. An undeclared key is invisible outside this block.

### `imports` — extending another entity

When `identity` points at another entity, the entity becomes an *extension* of it. It shares the parent's grain, inherits its keys, and imports specific definitions:

```yaml
identity: core.customer

imports:
  features:
    - core.customer.company_name
    - core.customer.first_paid_at
  metrics:
    - core.customer.total_arr
  entity_relationships:
    - core.customer.customer_to_subscription
```

The rules:

* **Imports are explicit cherry-picks.** You list exactly which features, metrics, and entity relationships to bring in. Anything not imported is not present — there is no auto-inheritance.
* **Imports are by reference, not by copy.** If the parent later changes an imported definition, this entity follows automatically. The schema never duplicates definitions.
* **Imports cannot be renamed.** An imported feature keeps its name. To expose it under a different name, define a new local feature whose `sql` references the imported one.
* **Imports come only from the `identity` parent.** To pull values from other entities, declare a [relationship](/concepts/entity/schema-yml/relationships) and define a feature whose SQL references it — the standard cross-entity pattern.
* **Local additions are unconstrained.** On top of imports, an extending entity adds its own [features](/concepts/entity/schema-yml/feature), [metrics](/concepts/entity/schema-yml/metric), and [relationships](/concepts/entity/schema-yml/relationships) exactly like a standalone entity. Local names cannot collide with imported names — the single namespace spans both.

Import paths use the 3-segment `domain.entity.name` form; the kind (feature, metric, relationship) is implied by the section header. Imports respect the project [topology](/concepts/lynk-yml#topology) — under medallion, an entity can import from `core` but not from a peer domain.

## Examples

**A standalone entity.**

```yaml
identity: maindb.public.customers
keys:
  - id
```

**Marketing extends `core.customer`.** It reuses core's definitions and adds a marketing-specific feature.

```yaml
# .lynk/domains/marketing/entities/customer/schema.yml
identity: core.customer

imports:
  features:
    - core.customer.company_name
    - core.customer.first_paid_at
  metrics:
    - core.customer.total_arr

features:
  - name: signup_year
    description: Calendar year the customer first paid, for cohort analysis
    sql: EXTRACT(YEAR FROM customer.first_paid_at)
    data_type: number
```

The local `signup_year` derives from the imported `customer.first_paid_at` — a marketing-specific addition on top of what `core` already defines.

## Validation

* When `identity` is a warehouse table or view, `keys` is authored. When `identity` is another entity, `keys` is **not** re-authored — it's inherited.
* A reference to a key column that isn't declared as a feature fails — keys are not features. Declare a feature for any key a relationship step, expression, or query needs.
* When `identity` is another entity, the build validates that the target is in a **different** domain (extension is cross-domain — extending an entity in your own domain fails), exists, is reachable under [topology](/concepts/lynk-yml#topology), and that every item in `imports` is actually defined on the target.
* No circular identity chains — entity A extending B which extends A fails.
* Local names don't collide with imported names (the single namespace spans imports and local definitions).

## Related

* [schema.yml](/concepts/entity/schema-yml) — the top-level fields
* [Feature](/concepts/entity/schema-yml/feature) · [Metric](/concepts/entity/schema-yml/metric) · [Relationships](/concepts/entity/schema-yml/relationships) — what gets imported or added locally
* [lynk.yml → topology](/concepts/lynk-yml#topology) — which domains an entity may import from
* Guides: [Designing domains](/guides/designing-domains) · [Evolving a live layer](/guides/evolving-the-layer)


# Feature

A feature is a queryable, row-grain attribute of an entity — a column, a derivation, or a value pulled across a relationship.

A queryable, row-grain attribute of an [entity](/concepts/entity) — a column, a derivation, or a value pulled across a [relationship](/concepts/entity/schema-yml/relationships).

## What it is

Features answer *"what is this attribute?"* at the entity's grain — one value per entity instance. Use a feature for row-level values; use a [metric](/concepts/entity/schema-yml/metric) when you need an aggregation across rows.

A feature's `sql` can be a direct column read, a formula over other features, a function call, or a value pulled from a related entity. The expression follows the [SQL expressions](/reference/sql-expressions) grammar — segment-counted paths, `metric()` / `first()` / `last()`, and the join-binding rule.

## Where it lives

`.lynk/domains/<domain>/entities/<entity>/schema.yml`, under `features:`.

## Format

**For any ratio, state the scale (`0–1` vs `0–100`) in the `description`, and keep every threshold in that scale** — a `0–1` value compared against `>= 55` is always false, and nothing errors.

| Field         | Required    | Type                                            | Notes                                                                                                                                                                                                                                                                                     |
| ------------- | ----------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`        | ✓           | string                                          | Unique within the entity across features, metrics, and relationships.                                                                                                                                                                                                                     |
| `description` | ✓           | string                                          | Load-bearing — the agent reasons from it, so the `sql` must produce **exactly** what it describes.                                                                                                                                                                                        |
| `sql`         | ✓           | SQL expression                                  | The expression after `SELECT` that produces this value. [SQL expressions](/reference/sql-expressions) grammar.                                                                                                                                                                            |
| `data_type`   | ✓           | `number` \| `string` \| `datetime` \| `boolean` | The type of the resulting value.                                                                                                                                                                                                                                                          |
| `join_name`   | conditional | string                                          | A [relationship](/concepts/entity/schema-yml/relationships) name. Required unless `sql`/`filter` reference only the entity's own identity source and/or its own features — you never need a `join_name` to "join" an entity to itself. Anything reached through a relationship needs one. |
| `filter`      | –           | SQL predicate                                   | A WHERE clause that narrows source rows *before* the `sql` evaluates. Grain-preserving.                                                                                                                                                                                                   |

The full rule for when `join_name` is required, and how one `join_name` binds every cross-entity reference in the expression, lives in [SQL expressions → join binding](/reference/sql-expressions#join-binding).

## Examples

**A formula over the entity's own features.** No `join_name`: every reference is local.

```yaml
- name: discount_value
  description: Amount discounted off this order, in USD
  sql: order.gross_amount - order.net_amount
  data_type: number
```

**A value pulled across a relationship, combined with a metric.** On Grove's `customer`, the latest subscription's MRR as a share of the customer's total.

```yaml
- name: latest_subscription_share
  description: The customer's most recent subscription MRR as a share of their total
  sql: last(subscription.mrr, order_by=subscription.started_at) / metric(subscription.total_mrr)
  data_type: number
  join_name: customer_to_subscription
```

A few more shapes, for reference:

```yaml
# Direct column read — physical column on the entity's own identity table (no join_name)
- name: net_amount
  description: Order total after discounts and refunds, in USD
  sql: maindb.public.orders.net_amount
  data_type: number

# Physical column across a TABLE relationship — order_items is a table, not an entity,
# so the join_name reaches its raw columns directly
- name: primary_category
  description: Category of this order's highest-value line item
  sql: last(maindb.public.order_items.category, order_by=maindb.public.order_items.item_total)
  data_type: string
  join_name: order_to_items

# Cross-entity reference — semantic path through an ENTITY relationship
- name: customer_email
  description: Email of the customer who placed this order
  sql: customer.email
  data_type: string
  join_name: order_to_customer

# Filtered cross-entity reference
- name: ios_spend_usd
  description: Net USD revenue from this player's iOS purchases
  sql: metric(purchase.sum_net_revenue_usd)
  data_type: number
  join_name: player_to_purchase
  filter: purchase.store = 'ios'
```

## Validation

* `name` is unique within the entity (features, metrics, and relationships share one namespace).
* The `sql` computes what the `description` says, and the feature **compiles and field-probes at the Lynk build** — the authoritative surface where every column must resolve to real data. A raw-warehouse check alone is a proxy; fabricated values or columns fail the build.
* `data_type` is one of `number`, `string`, `datetime`, `boolean`.
* `join_name` is required unless `sql`/`filter` reference only the entity's own identity source and/or own features; a missing required `join_name` fails.
* Every reference in `sql`/`filter` is reachable through the declared `join_name` (the local entity plus the join's steps); what each relationship type exposes is the [SQL expressions → join binding](/reference/sql-expressions#join-binding) rule.
* Grammar errors are detailed in [SQL expressions → validation](/reference/sql-expressions#validation).

## Related

* Parent: [schema.yml](/concepts/entity/schema-yml) · [Entity](/concepts/entity)
* Siblings: [Metric](/concepts/entity/schema-yml/metric) · [Relationships](/concepts/entity/schema-yml/relationships)
* [SQL expressions](/reference/sql-expressions) — the `sql` grammar and join binding
* [Lynk SQL](/api-reference/api/lynk-sql) — how features appear as columns at query time
* Guides: [Modeling metrics, time, and state](/guides/metrics-time-and-state)


# Metric

A metric is an aggregation defined on the entity it aggregates — sum, count, average, conditional aggregates — invoked with metric().

An aggregation defined on an [entity](/concepts/entity) — sum, count, average, conditional aggregate — anything that produces a single value across many rows.

## What it is

Where a [feature](/concepts/entity/schema-yml/feature) answers *"what is this attribute?"* at row grain, a metric answers *"across many rows of this entity, what's the aggregate?"*

A metric is **entity-local**. It is defined exactly once, on the entity whose rows it aggregates — `sum_net_revenue` lives on `order` because `order` is the entity whose rows it sums. Its `sql` references that entity's own features (entity-qualified) and **cannot** reference other entities; there is no `join_name` on a metric.

To use an aggregate across an entity boundary — `customer` wanting total revenue across all its orders — you don't define a metric on `customer`. You define a [feature](/concepts/entity/schema-yml/feature) on `customer` whose `sql` references the cross-entity metric path through a [relationship](/concepts/entity/schema-yml/relationships), and the engine handles the join and grain alignment. This keeps the agent's mental model simple: **metrics aggregate the current entity; everything else is a feature**, however it's computed underneath.

## Where it lives

`.lynk/domains/<domain>/entities/<entity>/schema.yml`, under `metrics:`.

## Format

| Field         | Required | Type                                            | Notes                                                                                                                                                                                                    |
| ------------- | -------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`        | ✓        | string                                          | Unique across the **whole domain**, not just this entity — the domain's metric namespace is flat (see [Validation](#validation)). Also shares the entity's namespace with features and relationships.    |
| `description` | ✓        | string                                          | What the metric represents. The `sql` must compute **exactly** this — the agent reasons from the description, so a mismatch misleads every query. State the scale (e.g. `0–1` vs `0–100`) for any ratio. |
| `sql`         | ✓        | aggregation expression                          | References this entity's features, entity-qualified. No cross-entity references. [SQL expressions](/reference/sql-expressions) grammar.                                                                  |
| `data_type`   | ✓        | `number` \| `string` \| `datetime` \| `boolean` | The type of the aggregated value.                                                                                                                                                                        |
| `filter`      | –        | SQL predicate                                   | A WHERE clause that narrows rows before the aggregation runs.                                                                                                                                            |

### Invoking a metric

A metric is invoked with `metric(<entity>.<metric_name>)` — and that is the **entire** call. **A metric takes no arguments: no parameters, no per-call filters, no date ranges.** A differently-scoped aggregate is a *second* metric with its own `filter:`, or manual SQL at query time (see [Lynk SQL → CTEs](/api-reference/api/lynk-sql#ctes-and-subqueries)).

* **Inside `schema.yml`** — a feature's `sql` can call `metric()` to compose with an aggregate (see [SQL expressions](/reference/sql-expressions#functions)).
* **At query time** — the agent writes `metric(<entity>.<metric_name>)` in Lynk SQL; when the entity is aliased, it uses the alias. Full rules in [Lynk SQL](/api-reference/api/lynk-sql#metricentitymetric_name).

### Computing the right value

Two mistakes pass every structural check but still produce the wrong number, so they are called out here:

* **Aggregate ratios as a ratio of sums — never an average of per-row ratios.** A rate or percentage is `SUM(numerator) / NULLIF(SUM(denominator), 0)`. `AVG(per_row_pct)` weights every row equally and is wrong whenever the denominators differ — a career shooting % computed by averaging per-game percentages is off by exactly this.
* **State the scale and keep thresholds in it.** Say whether a ratio is `0–1` or `0–100` in the `description`, and write every comparison constant in that same scale. A `0–1` value compared against `>= 55` is always false.

## Examples

**A count.**

```yaml
- name: count_customers
  description: Count of customers
  sql: COUNT(*)
  data_type: number
```

**A conditional aggregate.** Counts churned customers without filtering the rest out.

```yaml
- name: churned_customers
  description: Count of customers who have churned
  sql: SUM(CASE WHEN customer.status = 'churned' THEN 1 ELSE 0 END)
  data_type: number
```

**An aggregate with a filter.** On Bly's `order`, revenue from completed orders only.

```yaml
- name: completed_revenue
  description: Net revenue from completed orders
  sql: SUM(order.net_amount)
  data_type: number
  filter: order.status = 'completed'
```

**A weighted ratio.** A percentage is a ratio of sums, not an average of per-row ratios.

```yaml
- name: completion_rate
  description: Share of orders completed, 0–1 (weighted by order count)
  sql: SUM(CASE WHEN order.status = 'completed' THEN 1 ELSE 0 END) * 1.0 / NULLIF(COUNT(*), 0)
  data_type: number
```

## Validation

* `name` is unique within the entity (features, metrics, and relationships share one namespace).
* `name` is unique across the **whole domain**, not just within its entity — a `player_game` and a `team_game` cannot both define a metric named `total_points`. References are always entity-qualified (`player_game.total_points`), so you'd expect that to disambiguate, but the domain's metric namespace is flat: the bare `name` must be globally unique. Give each a distinct name by prefixing its subject — `player_total_points`, `team_total_points`.
* The metric **compiles and field-probes at the Lynk build** — the authoritative surface where every column must resolve to real data. A raw-warehouse check alone is a proxy that can pass while the build fails; fabricated values or columns fail the build.
* Quality bars the build can't check — the `sql` matching the `description`, descriptions distinguishable enough for the agent to choose between similar metrics — live in [Modeling metrics, time, and state](/guides/metrics-time-and-state#the-bar).
* `sql` references only this entity's own features (entity-qualified); cross-entity references and `join_name` are not allowed on a metric.
* `data_type` is one of `number`, `string`, `datetime`, `boolean`.
* Grammar errors are detailed in [SQL expressions → validation](/reference/sql-expressions#validation).

## Related

* Parent: [schema.yml](/concepts/entity/schema-yml) · [Entity](/concepts/entity)
* Siblings: [Feature](/concepts/entity/schema-yml/feature) — how cross-entity aggregates are exposed · [Relationships](/concepts/entity/schema-yml/relationships)
* [SQL expressions](/reference/sql-expressions) — `metric()` and the `sql` grammar
* [Lynk SQL](/api-reference/api/lynk-sql) — invoking `metric()` at query time
* Guides: [Modeling metrics, time, and state](/guides/metrics-time-and-state)


# Relationships

Relationships declare a path from an entity to a physical table or another entity — directional, per-step joins with cardinality.

A relationship declares a path from an [entity](/concepts/entity) to a target — either a physical table or another entity. Features use them to pull values across boundaries; the agent uses them to navigate between entities.

## What it is

Two kinds of relationships exist, distinguished by what they target:

* **Table relationships** target a physical table. Used only at build time, only by the entity that declares them — invisible to the agent. If two entities pull from the same physical table, each declares its own.
* **Entity relationships** target another entity. Used at build time (pulling features or metrics) and at query time (the agent navigating between entities) — visible to the agent.

Both share the same interface shape. The split into two sections reflects the agent's view — entities are the source of truth it reasons in; physical tables are scaffolding below its vision.

Three properties shape how relationships are written:

* **Each relationship is flat.** Two ways to connect the same pair of things means two relationships, not one with branching joins. The `name` is the relationship's full identity, and multiple relationships between the same pair are allowed.
* **Relationships are directional.** Each side declares its own relationship in its own vocabulary — `player_to_session` from the player's perspective, `session_to_player` from the session's. The SQL is direction-specific. AI descriptions matter, so each direction gets its own.
* **Cardinality is per-direction.** Going from `player` to `session` is `one_to_many`; the reverse, `session` to `player`, is `many_to_one`. Each side states what it sees; there is no symmetric cardinality.

Features reference a relationship by its `name` through their [`join_name`](/concepts/entity/schema-yml/feature) — the single namespace means the name resolves whether it's a table or entity relationship.

## Where it lives

`.lynk/domains/<domain>/entities/<entity>/schema.yml`, under `table_relationships:` and `entity_relationships:`.

## Format

| Field              | Required | Type   | Notes                                                                                                         |
| ------------------ | -------- | ------ | ------------------------------------------------------------------------------------------------------------- |
| `name`             | ✓        | string | Unique within the entity across all relationships, features, and metrics. Convention: `{source}_to_{target}`. |
| `description`      | ✓        | string | What this relationship represents, from this entity's perspective. Load-bearing for the agent.                |
| `table` / `entity` | ✓        | path   | `table:` in `table_relationships`; `entity:` in `entity_relationships`. The target.                           |
| `cardinality`      | ✓        | enum   | `one_to_one`, `one_to_many`, `many_to_one`, or `many_to_many` — from this entity to the target.               |
| `steps`            | ✓        | list   | Ordered hops along the path. Single-step relationships have one entry.                                        |
| `default`          | –        | `true` | Marks one relationship as the default when several share the same target.                                     |

The `default` flag is a query-time navigation hint — it tells the agent which relationship to use when an entity pair has several. It has no build-time consumer, since every feature names its relationship explicitly through `join_name`.

### Per step

| Field       | Required | Notes                                                                                                                                                                                                                                                                                                                                                   |
| ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `target`    | ✓        | The table or entity reached by this hop. In a **table relationship**, every step (intermediates and the endpoint) is a physical table. In an **entity relationship**, every step is an entity — a multi-hop path models its bridges as entities, never as raw physical tables. The final step's target matches the relationship's `table:` / `entity:`. |
| `join_type` | –        | `left` (default), `inner`, or `full_outer`. Per-step, so multi-hop paths can mix join types.                                                                                                                                                                                                                                                            |
| `sql`       | ✓        | The join condition — qualified references, no templating. **Entity-relationship** steps use `entity.feature` paths (`player.player_id`, never a physical table); **table-relationship** steps use physical columns. See [Validation](#validation) for the full source/target rule.                                                                      |

Per-step `join_type` is where the LEFT-default is expressed. The entity's grain is still preserved by construction — `one_to_many` and `many_to_many` targets are consumed through aggregation or row-selection in [features](/concepts/entity/schema-yml/feature) and [metrics](/concepts/entity/schema-yml/metric), never through references that multiply rows.

**Join columns must be declared features.** An entity-relationship step's `sql` references only declared features — and [keys are not features](/concepts/entity/schema-yml/identity-and-imports#identity-and-keys). If a step joins on `player.player_id`, both `player` and the entity on the other side must declare that column as a feature.

**When a pair has several relationships, mark exactly one `default: true`.** A bare `JOIN` with no default to resolve it is ambiguous — the agent must fall back to naming the relationship with `USING('<join_name>')`, and near-identical descriptions make that choice a coin flip.

## Examples

**A single-step entity relationship.** On Arcadia's `player`, the purchases a player has made.

```yaml
entity_relationships:
  - name: player_to_purchase
    description: Purchases this player has made
    entity: purchase
    cardinality: one_to_many
    steps:
      - target: purchase
        join_type: left
        sql: player.player_id = purchase.player_id
```

**Two relationships to the same target, plus a multi-step relationship through a bridge entity.** All declared on `player`, so all sit in one entity's `entity_relationships:`. `player_to_session` is the default; `player_to_meaningful_session` narrows the join to real play sessions; `player_to_achievement` hops through `player_achievement`, a bridge modeled as its own entity (the join table `maindb.public.player_achievements` is its identity).

```yaml
entity_relationships:
  - name: player_to_session
    description: All sessions this player has played
    entity: session
    cardinality: one_to_many
    default: true
    steps:
      - target: session
        join_type: left
        sql: player.player_id = session.player_id

  - name: player_to_meaningful_session
    description: Sessions longer than 5 seconds (excludes crash and load sessions)
    entity: session
    cardinality: one_to_many
    steps:
      - target: session
        join_type: left
        sql: player.player_id = session.player_id AND session.duration_seconds > 5

  - name: player_to_achievement
    description: Achievements this player has unlocked
    entity: achievement
    cardinality: many_to_many
    steps:
      - target: player_achievement       # bridge modeled as an entity
        join_type: left
        sql: player.player_id = player_achievement.player_id
      - target: achievement
        join_type: left
        sql: player_achievement.achievement_id = achievement.id
```

A table relationship looks the same, with `table:` instead of `entity:` — here Bly's `order` reaching its line-item rows:

```yaml
table_relationships:
  - name: order_to_items
    description: Per-line-item rows backing this order
    table: maindb.public.order_items
    cardinality: one_to_many
    steps:
      - target: maindb.public.order_items
        join_type: left
        sql: maindb.public.orders.order_id = maindb.public.order_items.order_id
```

## Validation

* `name` is unique within the entity (relationships, features, and metrics share one namespace).
* In an `entity_relationship`, **every step target is an entity** — a physical-table intermediate fails the build (model the bridge as its own entity). In a `table_relationship`, every step target is a physical table.
* An **entity-relationship** step's `sql` references only entities and their features (the owning entity by name, e.g. `player.player_id`) — a physical-table column (`db.schema.table.col`) in an entity relationship fails the build. A **table-relationship** step references physical table columns. In both, the first step's source is the owning entity (resolved from [`identity`](/concepts/entity/schema-yml/identity-and-imports)) — its features in an entity relationship, its identity-table columns in a table relationship; each subsequent step's source is the previous step's target; the final step's target matches the relationship's `table:` / `entity:`.
* All referenced columns exist on their respective tables/entities. In an entity relationship this means **declared features** — a step that joins on an undeclared key column fails (see [identity and imports](/concepts/entity/schema-yml/identity-and-imports#identity-and-keys)).
* At most one relationship per target pair is marked `default: true`.

## Related

* Parent: [schema.yml](/concepts/entity/schema-yml) · [Entity](/concepts/entity)
* Siblings: [Feature](/concepts/entity/schema-yml/feature) — references relationships via `join_name` · [Metric](/concepts/entity/schema-yml/metric)
* [identity and imports](/concepts/entity/schema-yml/identity-and-imports) — the base table a relationship's first step anchors to
* [Lynk SQL](/api-reference/api/lynk-sql) — `USING('<join_name>')` at query time
* Guides: [Choosing and shaping entities](/guides/designing-entities)


# Policy

Policies are eager, always-apply behavioral commitments — output format, clarification behavior, and other rules for how the agent operates.

An eager, always-apply behavioral commitment that governs how the agent operates and presents. Output format and clarification behavior are policies.

## What it is

Policies are eager prose rules for *how the agent behaves* — not what the data is, not how to reason through an analysis, but how to operate and present. The defining test: a policy is an **eager, always-apply behavioral commitment**. That rule is what keeps `policies/` coherent and stops it from becoming a catch-all.

Policies come in two layers that look identical structurally:

* **Lynk policies** — types Lynk defines and ships defaults for (`output-format` and `clarification` at launch, more over time). You override a default by creating a policy of that type; your file fully replaces Lynk's default.
* **Custom policies** — types you author for needs Lynk hasn't covered (compliance disclosures, regulatory caveats, team-specific behavior). No Lynk default — pure customer content.

The distinction matters at authoring time (am I overriding or extending?), not at runtime — the agent loads both the same way.

## Where it lives

A folder per policy inside a [domain](/concepts/domain):

```
.lynk/domains/<domain>/policies/<name>/POLICY.md
```

## Format

`POLICY.md` follows the shared [frontmatter contract](/reference/markdown-format#frontmatter-contract) — `name` (matching the folder), `description`, optional `enabled` — over a prose body describing the behavior.

```markdown
---
name: output-format
description: How the agent presents query results to the user
enabled: true
---

# Output Format

When presenting query results:

- Lead with the answer, not the methodology.
- Show numbers with appropriate precision — never more decimal places than the data supports.
- For comparisons, always say which direction the change goes (up/down, gained/lost).
- When data is missing or partial, name it explicitly rather than presenting incomplete numbers as complete.
```

**Overriding a Lynk default fully replaces it — it does not merge.** Name the policy folder after the Lynk type (`output-format`, `clarification`) and your file becomes the whole policy; the default bullets are gone, not extended. Setting `enabled: false` on an override falls back to Lynk's default.

**Composition is per domain.** Policies don't merge across scopes. For behavior shared across domains, put the prose in a [reference file](/concepts/reference-files) (outside `domains/`, reachable by every domain) and inject it from each domain's policy with an [`@` file reference](/reference/markdown-format#references); a policy can also inject from the shared `core` domain, per [topology](/concepts/lynk-yml#topology).

**Naming collisions.** If Lynk later ships a policy type whose name you already use for a custom policy, the build surfaces a collision; rename your file or treat the new Lynk type as an override target.

## Examples

**A custom policy.**

```markdown
---
name: compliance-disclaimer
description: Required disclaimer appended to any answer involving financial projections
---

# Compliance Disclaimer

When an answer includes a forward-looking financial projection, append:
"Projections are estimates, not guarantees, and are not financial advice."
```

**A `sales`-domain clarification policy** overriding the Lynk default and referencing another policy in the same domain.

```markdown
---
name: clarification
description: When the agent asks a clarifying question before answering
enabled: true
---

# Clarification

Ask one clarifying question before answering when:

- a time range is implied but not stated ("recently", "lately");
- a metric name maps to more than one definition in the glossary;
- the question spans entities this domain doesn't contain.

Otherwise, state your assumption inline and proceed. For how to present the
answer once resolved, see @/.lynk/domains/sales/policies/output-format/POLICY.md.
```

## Validation

* `POLICY.md` declares `name` and `description`; `name` matches the folder.
* A custom policy whose name later collides with a new Lynk type surfaces a build collision.

## Related

* [Markdown format](/reference/markdown-format) — the frontmatter contract and `@` operator
* [Skill](/concepts/skill) — *how to reason*, versus a policy's *how to operate*
* [LYNK.md](/concepts/lynk-md) — identity and orientation, which is not protocol
* [Domain](/concepts/domain) — the scope a policy applies within
* Guides: [Placing knowledge in a layer](/guides/where-knowledge-goes)


# Skill

Skills are lazy prose procedures for classes of analytical reasoning — how the agent thinks through an analysis, not what exists in the data.

A lazy prose procedure for a class of analytical reasoning — root-cause analysis, churn investigation, pipeline review. Skills capture *how the agent thinks*, not *what exists in the data*.

## What it is

A skill is the runbook you'd hand a junior analyst for a recurring kind of analysis. It is verb-shaped — "how to investigate churn" — not noun-shaped. That distinction is the rule that keeps `skills/` from becoming a garbage drawer:

* A **fact** about an entity goes on the [entity](/concepts/entity).
* A **way of reasoning** across an analysis goes in a skill.

Two further rules follow:

* **Skills don't define new schema.** If a skill needs a value the schema doesn't have, add a [feature](/concepts/entity/schema-yml/feature) or [metric](/concepts/entity/schema-yml/metric) — don't compute it inside the skill. Skills *use* features and metrics; they don't define them.
* **Skills don't compose with other skills.** A skill is an encapsulated procedure. It may inject content from entities, glossary terms, or supporting files via `@`, but skills don't merge or extend one another.

## Where it lives

A folder per skill inside a [domain](/concepts/domain), with optional supporting files alongside the primary file:

```
.lynk/domains/<domain>/skills/<name>/
├── SKILL.md
└── examples/
    └── enterprise-churn-2024-q3.md
```

**Loaded lazily.** The agent indexes skills by their frontmatter `description` and loads a skill's body only when it's relevant to the question. Skills that don't apply stay unloaded — the brain is large, the agent's working memory is focused.

## Format

`SKILL.md` follows the shared [frontmatter contract](/reference/markdown-format#frontmatter-contract) — `name` (matching the folder), `description`, optional `enabled` — over a prose body.

The `description` is load-bearing: it's what the agent reads at index time to decide relevance. Write it so the agent can tell, from one line, whether a question calls for this skill.

The body is whatever helps the agent reason — procedures, decision trees, references to specific entities and metrics. Use the [`@` operator](/reference/markdown-format#references) to inject the exact definitions the procedure leans on, so the prose stays focused while the specifics are pulled in at load time.

## Examples

**Frontmatter and a short procedure.**

```markdown
---
name: pipeline-review
description: How to review sales pipeline health — stage coverage, aging, and slippage risk
enabled: true
---

# Pipeline Review

1. Coverage: compare open pipeline to quota by stage.
2. Aging: flag deals past the median days-in-stage for their stage.
3. Slippage: list deals whose close date moved more than once this quarter.
```

**A churn investigation skill (in the `core` domain) that injects definitions.** The `@` paths stay within `core`, the skill's own domain.

```markdown
---
name: churn-investigation
description: How to investigate customer churn — identify signals, segment by cohort, surface patterns
enabled: true
---

# Churn Investigation

When investigating churn, start with these signals:

1. Usage decline — see @customer.active_subscription_count.description
2. Pending cancellation — see @subscription.is_pending_cancellation.description
3. Support escalation — see @glossary.at_risk.description

Cohort the analysis by signup quarter. The team's working definition of churn
is in @glossary.logo_churn.description.

For a past investigation:
- @/.lynk/domains/core/skills/churn-investigation/examples/enterprise-churn-2024-q3.md
```

## Validation

* `SKILL.md` declares `name` and `description`; `name` matches the folder.
* A skill that references an undefined feature or metric fails — skills use schema, they don't define it.

## Related

* [Entity](/concepts/entity) — where facts live, versus a skill's reasoning
* [Feature](/concepts/entity/schema-yml/feature) · [Metric](/concepts/entity/schema-yml/metric) — what skills use, not define
* [Policy](/concepts/policy) — *how to operate*, versus a skill's *how to reason*
* [Markdown format](/reference/markdown-format) — the frontmatter contract and `@` operator
* Guides: [Placing knowledge in a layer](/guides/where-knowledge-goes)


# Reference Files

Reference files — user-organized files outside the domain primitives, the escape hatch for cross-cutting content, reached by absolute /.lynk/ references.

User-organized files that sit outside the domain primitives — the escape hatch for cross-cutting content that doesn't fit an entity, skill, policy, or glossary.

## What it is

Most knowledge has an obvious home: facts go on an [entity](/concepts/entity), vocabulary in the [glossary](/concepts/glossary), reasoning in a [skill](/concepts/skill), behavior in a [policy](/concepts/policy). Reference files are for the rare content that fits none of them — onboarding notes, internal documentation, a shared data dictionary — that an author wants to point at without inventing a primitive.

They are any files or folders outside the domain primitives (a `docs/` folder is the usual convention, but the name and layout are yours), kept at one of two scopes:

* **At the `.lynk/` root** (outside `domains/`) — shared content that belongs to no domain. The project [topology](/concepts/lynk-yml#topology) makes these reachable from every domain.
* **Inside a domain** — a domain's own docs or references, scoped to that domain like the rest of its content.

Use the root for cross-domain material; a domain folder for material only that domain needs.

The escape hatch is deliberately low-status. Heavy use is a signal the primitives are wrong — something that should be an entity, skill, or policy is being parked here. Reach for it sparingly.

## Where it lives

At the `.lynk/` root for shared content, or inside a domain for domain-scoped content:

```
.lynk/
├── docs/                         # root: shared, reachable from every domain
│   └── onboarding.md
├── reference/
│   └── data-dictionary.md
└── domains/
    └── marketing/
        └── docs/                 # domain: scoped to marketing
            └── channel-taxonomy.md
```

## Format

Plain files — no frontmatter contract, no required structure (these aren't primitives). A reference file never loads on its own; it enters context only when something points at it, via an absolute [`/.lynk/` reference](/reference/markdown-format#references) — `@` injection to pull its content in, or a markdown link / bare path to navigate to it.

## Examples

**A root onboarding note injected into the root `LYNK.md`.**

```markdown
# In /.lynk/LYNK.md

New to this project? Start here: @/.lynk/docs/onboarding.md
```

**A shared data dictionary linked from an entity (navigation, not injection).**

```markdown
# In /.lynk/domains/core/entities/customer/ENTITY.md

Column lineage and source notes: [data dictionary](/.lynk/reference/data-dictionary.md)
```

## Validation

* A reference file nothing points at is unreachable — it never loads. Not an error, but it earns its keep only when referenced.
* References to it are absolute (`/.lynk/…`) and resolve to a real file.
* Root reference files (outside `domains/`) are reachable from every domain (they belong to none); reference files inside a domain are scoped to that domain. See [topology](/concepts/lynk-yml#topology).

## Related

* [Markdown format](/reference/markdown-format) — the reference grammar (`@` injection, markdown links, bare paths)
* [lynk.yml → topology](/concepts/lynk-yml#topology) — what each domain may reach
* [Entity](/concepts/entity) · [Skill](/concepts/skill) · [Policy](/concepts/policy) — the primitives reference files are a last resort behind


# Placing knowledge in a layer

How to decide where a piece of knowledge belongs — entity prose, schema, glossary, metric, skill, policy, LYNK.md, or a reference file.

Given one piece of knowledge, this guide produces its single home in the layer.

## When you need this

* An analyst just told you something true ("we exclude test accounts", "NDR means...") and you need to record it somewhere.
* You're migrating a wiki, dbt docs, or tribal knowledge into a layer and every page could plausibly go three places.
* Two files already carry the same fact and you have to pick the home before they drift apart.
* A term feels like it could be a glossary entry, a metric, or a skill — and you keep going back and forth.
* You're reviewing a layer and one file has quietly become a catch-all.

## The principle

One concept, one home. Don't ask *where would this be handy* — an agent with a large brain finds it wherever it lives. Ask *what kind of thing is it*: a fact about data, a word, a number, a procedure, a behavior, or orientation. The kind determines the home; everything else points at that home instead of restating it. Duplication is the failure mode — the copy that isn't the home stops being true first, and nothing tells you.

## Patterns

### The placement table

Situation: any piece of knowledge, before you write it anywhere. The move: classify it by kind and place it by row. Deviate only when the row's own page says so — the rows below link to the owning specs.

| You have                                                  | It goes in                                                                                                                    |
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| A fact about one thing's data — quirk, convention, gotcha | That entity: prose in [ENTITY.md](/concepts/entity/entity-md); a queryable value in [schema.yml](/concepts/entity/schema-yml) |
| A word or term the team uses                              | [GLOSSARY.yml](/concepts/glossary)                                                                                            |
| A number the layer should compute                         | A [metric](/concepts/entity/schema-yml/metric) — define it; a glossary entry may point at it                                  |
| A multi-step way of reasoning                             | A [skill](/concepts/skill)                                                                                                    |
| An eager, always-apply behavioral rule                    | A [policy](/concepts/policy)                                                                                                  |
| Who-we-are orientation                                    | [LYNK.md](/concepts/lynk-md) — root if every agent benefits, the domain's if one team does                                    |
| Cross-cutting material that fits no primitive             | A [reference file](/concepts/reference-files), sparingly                                                                      |

Example: Arcadia's "`is_active` lags 24h — use `last_session_at`" is row one (fact about `player`'s data → its ENTITY.md); Arcadia's `whale` is row two (a word → glossary).

### The noun/verb test

Situation: content that could be entity knowledge or a skill. The move: ask whether it's noun-shaped (*what is true about X*) or verb-shaped (*how to work through Y*). Nouns land on the entity or in the glossary; verbs become skills. Example: Grove's "`first_paid_at` is null for trials" is a noun — `customer`'s ENTITY.md. Grove's "when investigating churn, check usage decline, pending cancellations, then cohort by signup quarter" is a verb — the `churn-investigation` skill.

### The computed-term rule

Situation: a team term that names a number — `ndr`, `arpdau`. The move: if the layer should compute it, define the metric; the glossary entry says what the word means and may point at the metric. Vocabulary points at computation, never carries it. Deviation: a cross-entity ratio like Arcadia's `arpdau` has no entity-local home ([metrics](/concepts/entity/schema-yml/metric) are entity-local), so its glossary entry names the two defined metrics to divide (`sum_net_revenue_usd / count_dau`) — still pointing at defined computation, not carrying raw SQL.

### The entity-row rule

Situation: a rule about one entity's rows — "exclude test accounts from customer analyses." The move: it lives on that entity, in `customer`'s ENTITY.md conventions (or as a feature `filter` if it should shape queries). LYNK.md may point at it; it never restates it. Example: Grove's `is_test_account = false` / `is_deleted = false` convention sits in `customer`'s ENTITY.md, and nowhere else.

### The every-agent test

Situation: orientation or vocabulary that could sit at the root or in a domain. The move: ask *would every agent in the project benefit from reading this?* Yes → root; only one team's agent → that domain. Example: Grove's fiscal year (starts February 1) goes in the root LYNK.md — every analysis needs it. Marketing's "we think in funnels and attribution, not contracts" goes in marketing's LYNK.md.

## Anti-patterns

### The glossary shadow metric store

Wrong:

```yaml
# GLOSSARY.yml — wrong
ndr:
  name: NDR
  description: Net Dollar Retention. Compute as (starting ARR + expansion
    - contraction - churn ARR) / starting ARR, using total_arr snapshots.
```

Why it fails: glossary prose is unvalidated and unexecutable, so the agent re-derives the formula from the description — slightly differently per question. Two askers get two NDRs and the build catches neither, because nothing here references schema.

The fix: define the computation where it's validated — a metric (or a skill if it's genuinely multi-step) — and let the `ndr` entry define the *word* and point at it.

### The skill-as-schema-smuggler

Wrong: a SKILL.md step reading "compute at-risk MRR as `SELECT SUM(amount_cents)/100 FROM maindb.public.subscriptions WHERE cancelled_at IS NOT NULL ...`" because no metric exists for it.

Why it fails: raw SQL in prose bypasses build validation entirely — a skill that references an undefined metric fails the build, but embedded SQL sails through, then rots silently when the table changes. See [Skill](/concepts/skill): skills use schema, they don't define it.

The fix: add the missing piece to schema — Grove already has `mrr_at_risk` on `subscription` — and have the skill reference `metric(subscription.mrr_at_risk)` by name.

### Policy creep

Wrong: a POLICY.md named `churn-methodology` saying "when investigating churn, always cohort by signup quarter and check NPS detractors first."

Why it fails: policies are eager — this loads into every question in the domain, taxing the many that have nothing to do with churn. And it isn't a behavioral commitment: you can't check an arbitrary answer against it, only churn answers.

The fix: situational, multi-step, verb-shaped — that's a [skill](/concepts/skill). It loads only when a churn question arrives. Policies stay for always-apply behavior like output format.

### Orientation dumping

Wrong: Arcadia's root LYNK.md carrying "note: players who installed before 2022-03-01 have `install_date` set to that date."

Why it fails: the quirk now has a duplicated home. The next correction lands on `player`'s ENTITY.md (where anyone analyzing players looks), LYNK.md keeps the stale version, and every agent pays to load a `player` quirk on questions that never touch `player`.

The fix: the quirk lives in `player`'s ENTITY.md — the entity-row rule. LYNK.md may point at it if it truly needs mentioning, but the entity is the single home.

## The bar

* You can name the single home of any fact in the layer — and every other mention points at it rather than restating it.
* No formula lives in glossary prose; every number the layer computes is a defined metric or feature.
* No raw SQL hides in SKILL.md or POLICY.md — skills and policies reference schema by name.
* A good policy is testable from the transcript alone — you can check any answer against it without knowing what was asked.
* Every line of the root LYNK.md passes the every-agent test.
* Reference files are rare; if they're growing, a primitive is being dodged.

## Related

* [Entity](/concepts/entity) · [ENTITY.md](/concepts/entity/entity-md) · [schema.yml](/concepts/entity/schema-yml) — where facts and definitions live
* [GLOSSARY.yml](/concepts/glossary) · [Skill](/concepts/skill) · [Policy](/concepts/policy) · [LYNK.md](/concepts/lynk-md) · [Reference files](/concepts/reference-files) — the homes this guide routes to
* [Designing domains](/guides/designing-domains) — the sibling decision: which domain the home sits in


# Choosing and shaping entities

How to decide what becomes an entity and how to shape it — the entity test, grain, promoting table relationships to entities, the upstream boundary, and base-row hygiene. Reach for it before adding or

Whether a concept becomes an entity, what its grain is, and what stays a table relationship or moves upstream.

## When you need this

* A new source table landed and someone wants it "added to Lynk."
* You can't decide whether line items, raw events, or a join table deserve entities.
* An entity's feature list has grown past what anyone can scan.
* Analyses disagree because some queries include test rows and some don't.
* You're wishing `identity:` could take a SELECT statement.

## The principle

An entity is a concept the business asks questions *of* directly: it has a stable identity, a grain you can state in one sentence, and people argue about it in meetings. Model those, and only those. Everything else is scaffolding — reached through a relationship, or reshaped upstream before Lynk sees it. Lynk owns meaning; the warehouse pipeline owns shape.

## Patterns

### Apply the entity test

Ask: does anyone address questions *to* this thing? "How many customers churned this quarter" makes Grove's `customer` an entity. Nobody asks questions of `maindb.public.order_items` — questions about items are really questions about orders or products — so at Bly it is not an entity; it's a table that `order` reaches through a relationship. A table you happen to have is not a concept the business owns. When in doubt, start it as a table relationship and let the promotion ladder below decide.

### State the grain in the first line

One row per *what*? Say it in the first line of every entity description: "Active and historical subscriptions. One row per subscription." An entity whose grain you can't state in one sentence isn't an entity yet — it's a table that still needs shaping. The description is what the agent reads at index time to decide whether to load the entity, so grain-first descriptions are also what make lazy loading work ([ENTITY.md](/concepts/entity/entity-md)).

### Climb the promotion ladder deliberately

Start every supporting table as a `table_relationship` on the entity that uses it — Bly's `order_to_items` feeds the `primary_category` feature with `last(...)` and never appears in the agent's view. Promote it to an entity when any of these holds:

1. **Users ask questions of it directly.** "Which categories have the highest margin" is a question *of* products — so Bly's `product` is an entity even while `order_items` stays a table.
2. **Two or more entities need paths through it.** Entity-relationship steps must be entities, so a shared bridge must be one — Arcadia's `player_achievement`, whose identity is the physical join table, exists so `player_to_achievement` can step through it ([Relationships](/concepts/entity/schema-yml/relationships)).
3. **It needs its own features or metrics.** The moment the thing carries definitions, it needs a `schema.yml` to put them in.

Event streams get the same treatment: raw events stay a table relationship under `player`; the entity is `session` — materialized upstream, one row per session. Raw events are never an entity (see the anti-pattern below).

### Keep shaping upstream of the identity

An entity's `identity` is one physical table or another entity — never a query ([identity and imports](/concepts/entity/schema-yml/identity-and-imports)). So identity stitching, dedup, SCD flattening, and snapshot building happen upstream, in the pipeline that produces the table the entity roots in. If the rows aren't yet one-per-instance of the concept, that's warehouse work, not schema work.

### Enforce base-row hygiene structurally

Grove excludes `is_test_account` and `is_deleted` rows from most analyses. The convention's single home is the entity's [ENTITY.md](/concepts/entity/entity-md) — written once, as prose. Enforcement is structural: preferred, an upstream filtered view that `identity` points at, so test rows never enter the entity; otherwise a `filter:` on each definition that must exclude them. A prose reminder alone is probabilistic — followed on most queries, missed on some (see the anti-pattern below).

## Anti-patterns

### The god entity

```yaml
# wrong — customer as the home of every number in the company
features:
  - name: refund_rate_paid_search
    description: Refund rate of this customer's paid-search orders, 0–1
    sql: metric(order.refund_rate)
    data_type: number
    join_name: customer_to_order
    filter: order.channel = 'paid_search'
  # …plus refund_rate_email, refund_rate_direct, avg_margin_of_items_bought,
  # and 190 more — one per question anyone ever asked
```

Every feature is individually legal; the pile is the failure. The entity loads as a unit, so every customer question pays for all 200 definitions; the descriptions of near-duplicates blur together; retrieval degrades until the agent picks the wrong variant. Keep each value on the entity that owns it — the agent reaches `order.refund_rate` through the relationship at query time — and promote onto `customer` only what customer-grain questions consume repeatedly.

### Entity-per-table strip-mining

The wrong form: `entities/` as a mirror of the warehouse — `order_items`, `order_status_history`, `currency_rates`, each dutifully wrapped as an entity. Their descriptions can only say "the X table", which answers no question — so the index the agent scans fills with entries that never help it choose, and every pseudo-entity still needs keys, features, and maintenance. Apply the entity test; supporting tables are table relationships on the entities that use them.

### The raw-events entity

```yaml
# wrong — one row per client event
identity: maindb.public.events
keys:
  - event_id
```

"One row per click-or-pageview-or-heartbeat" is not a grain anyone reasons at, and every real question — activity, engagement, retention — needs sessionization, which `schema.yml` cannot express: Lynk cannot create a grain that doesn't exist. Materialize `session` upstream (Arcadia: one row per session, with `duration_seconds`, `level_reached`) and model that; keep raw events reachable as a table relationship if some feature needs them.

### The table-relationship reach-around

```yaml
# wrong — order reads customer's data raw to skip the entity path
- name: customer_email
  description: Email of the customer who placed this order
  sql: maindb.public.customers.email
  data_type: string
  join_name: order_to_customers_table   # a table relationship aimed at customer's identity table
```

It builds — table relationships legitimately expose physical columns. But the value bypasses `customer`, the entity that owns it: the definition is duplicated into every consumer, drifts when `customer` changes, and the table relationship is invisible to the agent, hiding cross-entity structure it navigates by. Another entity's value has one form — its declared feature: `sql: customer.email` with `join_name: order_to_customer` ([Feature](/concepts/entity/schema-yml/feature)).

### Correctness by prose reminder

```markdown
<!-- wrong — an ENTITY.md line as the only enforcement -->
**Convention.** Always remember to exclude test accounts.
```

Prose is advisory. Most queries comply; some don't; the same question returns different counts depending on whether the reminder was heeded on that run — and nothing errors, because rows with `is_test_account = true` are valid rows. Enforce structurally (upstream filtered view, or `filter:` on the affected definitions) and keep the ENTITY.md line as documentation of *why*, pointing at the enforcement.

## The bar

* The grain is stated in the first line of the entity description: "one row per …".
* The description says what the entity is *for* — the questions it answers.
* Every column a relationship step joins on or a query selects is a declared feature, keys included.
* Quirks that affect most analyses are in ENTITY.md — and restated nowhere else.
* Conventions that must always hold are enforced structurally, not by prose alone.
* Nothing on this entity restates a fact whose home is another entity.

## Related

* [Entity](/concepts/entity) · [ENTITY.md](/concepts/entity/entity-md) · [schema.yml](/concepts/entity/schema-yml)
* [Relationships](/concepts/entity/schema-yml/relationships) — table vs entity relationships, bridges, defaults
* [Identity and imports](/concepts/entity/schema-yml/identity-and-imports) — what an entity roots in; keys are not features
* Sibling: [Modeling metrics, time, and state](/guides/metrics-time-and-state)


# Modeling metrics, time, and state

How to place a computation — feature, metric, skill, or glossary term — and how to model balances, time windows, cohorts, and ratio KPIs correctly.

Where each computation lives, and how time-varying state gets a grain it can be aggregated on correctly.

## When you need this

* You're defining "total MRR" and the source table holds historical and cancelled rows.
* Someone asks "MRR in March" or "NDR for the Q1 cohort" and you're unsure what to define versus what to query.
* A KPI divides one entity's aggregate by another's — ARPDAU, CAC — and has no obvious home.
* You're about to add `revenue_7d` next to an existing `revenue_30d`.
* The same rate comes out different depending on who computes it.

## The principle

Model state at the grain where it is true, and put each computation in the narrowest primitive that owns it. A balance like MRR is true *per subscription per month* — that grain must exist as physical rows before any metric can aggregate it, because Lynk cannot create a grain that doesn't exist. And definitions carry only what is always true: a window or segment that is a parameter of the question stays out of the schema and goes into query-time `WHERE`.

## Patterns

### Place the computation by its shape

Run this decision tree before writing anything:

* A row-grain value of one entity → a [feature](/concepts/entity/schema-yml/feature). Grove: `subscription.mrr`.
* An aggregate over an entity's own rows → a [metric](/concepts/entity/schema-yml/metric) on that entity: `subscription.total_mrr`.
* An aggregate consumed across an entity boundary → a feature wrapping the metric. Grove's `customer.total_mrr` is `sql: metric(subscription.total_mrr)` with `join_name: customer_to_subscription` — never a metric on the consuming entity, because metrics are entity-local.
* A way of computing — multi-step, opinionated — → a [skill](/concepts/skill).
* A word the team uses → a [glossary](/concepts/glossary) entry.

Deviate only at a domain boundary, where the move is an [import](/concepts/entity/schema-yml/identity-and-imports), not a new definition.

### Model semi-additive state on a snapshot entity

A balance — MRR, headcount, inventory — is semi-additive: it sums *within* one point in time, never across time. If `maindb.public.subscriptions` holds historical and cancelled rows, `SUM(subscription.mrr)` adds March's balance to February's; nothing errors, the number is just several times too large. **The snapshot grain must already exist upstream as a table or view — an entity's identity is never an inline query.** With `subscription_months` (one row per subscription per active month) in the warehouse, model it:

```yaml
identity: maindb.public.subscription_months   # a table or view, built upstream
keys:
  - subscription_id
  - month

features:
  # keys are not features — declare any key that joins or queries need.
  # subscription_id and customer_id are declared the same way as month.
  - name: month
    description: First day of the calendar month this row snapshots
    sql: maindb.public.subscription_months.month
    data_type: datetime
  - name: mrr
    description: This subscription's MRR during this month, in USD
    sql: maindb.public.subscription_months.mrr
    data_type: number

metrics:
  - name: sum_mrr
    description: Total MRR across the subscription-month rows in scope — always constrain to a single month
    sql: SUM(subscription_month.mrr)
    data_type: number
```

"MRR in March" is now query-time: `WHERE month = '2026-03-01'`. The same grain answers end-of-period and average-of-period balances, with no new definitions.

### Compute cohorts on the snapshot; canonicalize in a skill

**Aggregate&#x20;*****inside*****&#x20;each CTE, before any join — the shape that is correct by construction (**[**CTEs and subqueries**](/api-reference/api/lynk-sql#ctes-and-subqueries)**).** NDR compares the same customers' MRR across two windows: compute each window in its own CTE at the snapshot grain, then join aggregate to aggregate.

```sql
WITH start_mrr AS (
  SELECT customer_id, metric(subscription_month.sum_mrr) AS mrr
  FROM subscription_month WHERE month = '2025-02-01' GROUP BY customer_id
), end_mrr AS (
  SELECT customer_id, metric(subscription_month.sum_mrr) AS mrr
  FROM subscription_month WHERE month = '2026-02-01' GROUP BY customer_id
)
SELECT SUM(e.mrr) / NULLIF(SUM(s.mrr), 0) AS ndr
FROM start_mrr s LEFT JOIN end_mrr e ON e.customer_id = s.customer_id
```

The window edges and who counts as "starting" are opinions, so this query lives in a Grove skill (say `ndr-analysis`) — the reproducible home of the computation. The glossary entry `ndr` stays one sentence of vocabulary and points at the skill.

### Give cross-entity ratio KPIs a skill, not a home they don't have

Arcadia's ARPDAU divides `metric(purchase.sum_net_revenue_usd)` by `metric(player.count_dau)`. Two entities' aggregates: not a metric (entity-local), not a feature (no row owns it). Don't invent a KPI entity — the schema has no primitive for this, by design. The pattern is a skill carrying the canonical Lynk SQL — one CTE per aggregate, joined on the shared key or date — with the glossary term defining the word. CAC-style ratios follow the same shape.

### Bake a time boundary only when it is a business definition

Arcadia bakes `duration_seconds > 5` into `player_to_meaningful_session` because the boundary *is* the definition of a meaningful session. "Last 30 days" is a parameter — it belongs in query-time `WHERE`, not in a definition. Bake when removing the filter changes what the word means; defer when it only changes which question was asked.

### Choose the filter mechanism by scope

| Mechanism              | Scope                                     | Reach for it when                                                                |
| ---------------------- | ----------------------------------------- | -------------------------------------------------------------------------------- |
| `filter:` on a feature | one feature's source rows                 | the narrowing is part of this value's meaning — `player.ios_spend_usd`           |
| `filter:` on a metric  | one aggregate's rows                      | a differently-scoped aggregate is its own definition — `order.completed_revenue` |
| filtered relationship  | every definition and query using the join | the narrowed set is itself a concept — `player_to_meaningful_session`            |
| query-time `WHERE`     | one query                                 | the boundary is a parameter — dates, segments, "in March"                        |

## Anti-patterns

### Summing a balance across periods

```yaml
# wrong — subscription_months holds every month; this sums them all
- name: current_mrr
  description: Current total MRR, in USD
  sql: SUM(subscription_month.mrr)
  data_type: number
```

With twelve months of history the result is roughly 12× actual MRR — every month's balance added to every other's. It compiles and returns a number; the number is wrong. The fix is the snapshot pattern above: name the metric for what it computes, say in its description that time must be constrained, and pin the period at query time.

### Averaging per-row ratios across an entity boundary

The single-entity rule — a rate is a ratio of sums — is owned by [Metric](/concepts/entity/schema-yml/metric#computing-the-right-value). The cross-entity variant sneaks past it: aggregate a per-customer `refund_rate` feature on Bly's `customer`:

```yaml
# wrong — a customer with 1 order weighs the same as one with 1,000
- name: avg_refund_rate
  description: Company-wide refund rate, 0–1
  sql: AVG(customer.refund_rate)
  data_type: number
```

The number moves when the customer mix moves, not when refunds do. The company-wide rate is `order.refund_rate` — the metric on the entity whose rows carry both the numerator and the denominator.

### Measure explosion

```yaml
# wrong — near-duplicates the agent cannot tell apart
- name: revenue_7d
  description: Recent net revenue
  sql: SUM(order.net_amount)
  data_type: number
  filter: order.order_date >= CURRENT_DATE - INTERVAL '7 days'
  # …and revenue_30d, revenue_mtd, revenue_qtd, revenue_ytd
```

Every window multiplies the metric list, and the descriptions are indistinguishable — the agent's choice between them is a coin flip. The window is a parameter: keep the one canonical `sum_net_revenue` and put the window in query-time `WHERE`.

### Glossary-as-computation

```yaml
# wrong — the formula's only home is prose
ndr:
  name: NDR
  description: (starting MRR + expansion − contraction − churn) ÷ starting MRR, trailing 12 months, excluding new logos.
```

Prose is not executable. Each time the question comes up, the agent re-derives the CTEs slightly differently, and two askers get two numbers. The glossary defines words ([GLOSSARY.yml](/concepts/glossary)); the canonical query lives in a skill, and the glossary entry points at it.

## The bar

* Every metric aggregates only the entity it is defined on; every cross-boundary aggregate is a feature wrapping `metric()` with a `join_name`.
* Every balance-like value is modeled on a snapshot grain that physically exists, and its metric's description says how to constrain time.
* No definition encodes a parameter the question should supply; every baked filter is a business definition you can name.
* Every rate is a ratio of sums, computed on the entity that owns the rows.
* Any two metrics the agent must choose between are distinguishable from their names and descriptions alone, and every `sql` computes exactly what its description says.
* Every KPI built from two entities' aggregates has a skill holding its canonical Lynk SQL; the glossary term points at it.
* Query-time cross-grain math aggregates inside CTEs before joining.

## Related

* [Metric](/concepts/entity/schema-yml/metric) · [Feature](/concepts/entity/schema-yml/feature) · [Relationships](/concepts/entity/schema-yml/relationships)
* [Skill](/concepts/skill) · [GLOSSARY.yml](/concepts/glossary)
* [Lynk SQL](/api-reference/api/lynk-sql) — query-time `WHERE`, CTEs, `metric()` · [SQL expressions](/reference/sql-expressions) — the authoring grammar
* Sibling: [Choosing and shaping entities](/guides/designing-entities)


# Designing domains

How to decide how many domains to create, what belongs in the shared domain, and when to promote, extend, split, or merge.

This guide produces a domain layout: how many domains, what sits in the shared one, and how a definition moves between them.

## When you need this

* You're starting a layer and don't know whether to make one domain or five.
* A second team wants to use the layer and their vocabulary doesn't match the first team's.
* Two teams define the same term — ARR, lead, active — differently, and both are "right."
* You're tempted to copy an entity's YAML from one domain into another.
* A domain feels wrong-sized: bloated and contradictory, or empty and pointless.

## The principle

A [domain is an agent](/concepts/domain) — one audience's analytical surface. So domains map to audiences, never to data, dashboards, or org charts you merely anticipate. The shared domain holds only what multiple audiences consume *and* describe the same way; a contested definition stays in the leaf that owns it until the teams reconcile. Everything else follows: when to create a domain (a new audience), what to promote (agreed, twice-consumed), when to split (one audience turns out to be two).

## Patterns

### Start with one domain

Situation: a new layer, one team asking questions. The move: one domain, until the second *audience* arrives — not the second entity. `customer` and `subscription` in one domain is normal; a second domain with nobody behind it is not. Deviate only when two teams are onboarding on day one — then start with the shared domain plus two leaves. Example: Grove's arc — a `core`-only layer serving everyone; finance arrives with its own collections vocabulary → `core` + `finance`; marketing follows → `core` + `finance` + `marketing`.

### What lives in the shared domain

Situation: deciding whether a definition belongs in `core` or a leaf. The move: promote only what passes the two-consumers-AND-one-agreed-description test — at least two domains consume it, and both sign off on a single description. Contested definitions stay in leaves until reconciled. Example: Grove's finance defines ARR as contract value; marketing computes a run-rate ARR from current MRR. Those stay split — `finance.customer` and `marketing.customer` each carry their own — until the company reconciles on one description. The reconciled one moves to `core`; the leaf-specific variant that survives keeps a distinct name.

### The promotion protocol

Situation: a leaf definition just found its second consumer. The move, in order:

1. It was defined in the leaf (say, finance's `customer` with `total_arr`).
2. A second team needs it — marketing wants ARR for segmentation.
3. Reconcile the description: one sentence both teams accept. This is the slow step; don't skip it.
4. Move the entity (or the definition) to `core`.
5. The leaf re-declares itself as an extension — `identity: core.customer` plus explicit `imports` of what it uses (see [identity and imports](/concepts/entity/schema-yml/identity-and-imports)).
6. **Imports can't be renamed** — so if the leaf's old local name differed, keep a deprecated local feature whose `sql` references the import (the alias is how old queries keep working while they migrate), and delete it after a stated window.

### Extend vs duplicate

Situation: a leaf needs an entity that exists elsewhere. The move: if it's the same conceptual thing — same grain, same meaning — extend it: `identity: core.customer` + imports, which share definitions by reference. If the grain or meaning genuinely differs, define an independent entity rooted in its own table. Never copy-paste YAML between peer domains — sameness must be declared, not implied by similar columns. Example: Grove marketing's `customer` is the same company as core's → extension. Grove's `subscription` is a different grain from `customer` → its own entity, no relation to extension.

### Vocabulary layering

Situation: deciding where a term's glossary entry lives. The move: company-wide terms in the root [GLOSSARY.yml](/concepts/glossary); a [domain glossary](/concepts/domain/glossary) entry only for a genuine meaning shift for that audience. Collision-domain-wins is the feature: marketing overrides `lead` to mean a marketing-qualified lead, narrower than the company-wide root definition, and marketing's agent resolves the word marketing's way. Deviate when the "override" is really a correction — then fix the root entry so every agent benefits.

### Sizing signals

Situation: a domain feels wrong-sized. The move — read the signals: **split** when sub-teams inside one domain disagree on what a term means (that's two audiences sharing one agent); **merge** when a domain is more than \~90% imports — it's a filtered view of `core`, not an agent with its own vocabulary or reasoning; and treat a one-entity, no-skill domain as a placeholder, not an agent — fold it back until its audience shows up. Example: a Grove `marketing` domain that only imports `core.customer` and adds nothing has no reason to exist yet.

## Anti-patterns

### Core as dumping ground

Wrong: at Grove, every new definition is promoted to `core` "so it's there if anyone needs it" — marketing's `mql`, finance's collections aging, all of it.

Why it fails: `core` is what every agent can see, so its vocabulary decays to the lowest common denominator, and every definition change now needs every team's sign-off — each definition fight becomes a core fight. Leaves lose the room to mean things their own way.

The fix: the two-consumer test. Nothing enters `core` without two named consumers and one agreed description; everything else stays in its leaf.

### Copy-paste peer coupling

Wrong: Grove's marketing needs finance's `customer` definitions; peers can't reference each other under [topology](/concepts/lynk-yml#topology), so someone pastes finance's `schema.yml` into `marketing/entities/customer/`.

Why it fails: the copies fork at the first correction. Finance fixes `total_arr`; marketing's paste doesn't move; two agents now report different ARR and no build error fires — they are, by definition, independent entities.

The fix: the peer barrier is a prompt to promote. Move the shared definition to `core`, and have both leaves extend it via `identity` + `imports` — imports are by reference, so corrections propagate.

### Domain-per-dashboard

Wrong: a `board_deck` domain at Grove holding whatever the quarterly deck needs.

Why it fails: a domain is an audience's agent, and "the board deck" isn't an audience — it's a set of questions. The dashboard domain has no vocabulary or reasoning of its own, duplicates entities that already have homes, and users picking an agent are now picking a report.

The fix: the questions belong to an existing audience's domain; if walking through them is a recurring procedure, that's a [skill](/concepts/skill) there — not a domain.

### The ghost domain

Wrong: an `operations` domain created "for when ops onboards" — one entity, no skills, no glossary, no LYNK.md, no one asking it anything.

Why it fails: it appears in the domain list as a pickable agent but can't answer like one, and whatever gets parked there is invisible to every real agent — peers can't reach into it, so the content is worse than homeless.

The fix: don't create a domain before its audience exists. Put the entity in the domain that actually consumes it (or `core`, once two do), and create `operations` the day ops shows up with questions.

## The bar

* Each domain names its audience — you can say whose agent it is in one sentence.
* Every `core` entry has two consumers you can name today, and one description both accepted.
* No two domains define the same term with silently different meanings — every glossary collision is a deliberate, documented domain override.
* No YAML is duplicated between peers; every "same thing" is declared with `identity` + `imports`.
* No domain is >90% imports, and none is a one-entity, no-skill placeholder.

## Related

* [Domain](/concepts/domain) — the primitive this guide shapes
* [lynk.yml → topology](/concepts/lynk-yml#topology) — the reference rules that make promotion the sharing mechanism
* [Identity and imports](/concepts/entity/schema-yml/identity-and-imports) — the extension mechanics the promotion protocol lands on
* [GLOSSARY.yml](/concepts/glossary) · [Domain GLOSSARY.yml](/concepts/domain/glossary) — the vocabulary layering pattern's owners
* [Placing knowledge in a layer](/guides/where-knowledge-goes) — the sibling decision: which primitive, before which domain


# Budgeting the agent's context

How to budget the agent's context — deciding @ injection vs a link, splitting a growing ENTITY.md, placing content at root vs domain, and when a rule earns a policy.

Decide where a sentence lives — and therefore who pays to load it — before you write it.

## When you need this

* You're deciding whether to `@`-inject a supporting file or just link it.
* An `ENTITY.md` keeps growing and every analysis of that entity is paying for it.
* You're unsure whether a convention belongs in the root `LYNK.md`, a domain `LYNK.md`, or on one entity.
* An entity or skill isn't getting loaded for the questions it should answer — or is loaded for ones it shouldn't.
* You're about to add a policy for a rule that only applies sometimes.

## The principle

Every sentence in the layer has a **load class**:

* **Always** — root and domain `LYNK.md`, `GLOSSARY.yml`, policies. Taxes every question in scope.
* **On activation** — an entity's `ENTITY.md` body plus everything it `@`-injects; a skill's body. Taxes every question touching that primitive.
* **On index** — entity and skill frontmatter descriptions. Read for every routing decision.
* **On demand** — linked supporting files. Free until summoned.

Know the class before you write. The cost of a sentence is set by its class, not its length.

## Patterns

### The three-tier entity split

An entity accumulates knowledge — quirks, procedures, worked examples. Split it into three tiers:

* **Index tier** — the `description`: one line, grain plus use-for. Read on every routing decision.
* **Activation tier** — the `ENTITY.md` body: only what *every* analysis of this entity needs.
* **JIT tier** — supporting files (`instructions/`, `examples/`): linked, not injected. Loaded only when a question summons them.

Grove's `customer` `ENTITY.md` had grown to \~600 words: fiscal-year mechanics, the closing-period procedure, two past-analysis walkthroughs. Every ARR or churn question paid all 600 before a line of SQL was written. After the split:

```markdown
---
name: customer
description: Grove accounts. One row per company. Use for ARR, churn, and plan-tier analysis.
---

# Customer

One row per company that has signed up. "Customer" and "account" are interchangeable.

**Conventions.** Most analyses exclude test and deleted accounts
(`is_test_account = false`, `is_deleted = false`). "Churned" is defined in
@glossary.logo_churn.description.

Fiscal-year mechanics: [fiscal-year](/.lynk/domains/core/entities/customer/instructions/fiscal-year.md).
Past investigations: [examples](/.lynk/domains/core/entities/customer/examples/).
```

Cost: activation drops from \~600 words to \~80. The fiscal-year detail now costs nothing except on the questions that need it. Deviate only when a supporting file is genuinely needed by most loads — then `@`-inject it and accept the cost as part of the activation tier.

### `@` vs link

Inject with `@` when the content is **short and needed by most loads of the host**. Link when it's situational or long. And prefer a **conceptual path** over a whole file: `@glossary.logo_churn.description` injects one sentence that stays in sync with its source; `@/.lynk/...` injects an entire file. Grammar in [Markdown format](/reference/markdown-format#references).

Grove's `customer` injects `@glossary.logo_churn.description` — one sentence, relevant to nearly every customer analysis — and *links* the closing-period procedure, which only month-end questions touch.

### Descriptions that route

The `description` is the index tier: it's all the agent sees when deciding whether to load an entity or skill. Write it as **what-it-is + grain + "use for X, Y"** — and make it discriminate against its *siblings*, not just describe its subject. Arcadia has both `player` and `player_cohort`; the description is what routes retention questions to the right one:

```yaml
description: Pre-calculated D1/D7/D30 retention per install cohort. One row per cohort. Use for retention curves — never re-derive retention from sessions.
```

A description can be accurate and still fail — see [the vague description](#the-vague-description) below for the wrong form.

### The placement ladder

Root `LYNK.md` → domain `LYNK.md` → entity → supporting file. Each step down means fewer questions pay for the sentence; promoting a sentence up multiplies who pays. Place at the *lowest* rung whose audience still covers everyone who needs it.

Grove: the fiscal year (starts February 1) is root `LYNK.md` — every domain reports on it. The test-account exclusion is `customer`'s `ENTITY.md` — it's a rule about one entity's rows ([LYNK.md](/concepts/lynk-md) is explicit that orientation carries only what no single entity owns). The closing-period procedure is a supporting file — even most customer analyses never need it.

## Anti-patterns

### The encyclopedia ENTITY.md

**Wrong:** a 600-word `ENTITY.md` body — history, edge cases, worked examples, all inline.

**Why it fails:** the body loads as a unit on every activation, so every analysis of the entity pays the full 600 words, including the vast majority that need none of it. The waste is invisible — nothing errors, context just fills.

**Fix:** the [three-tier split](#the-three-tier-entity-split). Body keeps only what every analysis needs; the rest moves to linked supporting files.

### Whole-file @ of another entity

**Wrong:** `@/.lynk/domains/core/entities/subscription/ENTITY.md` inside `customer`'s `ENTITY.md`.

**Why it fails:** `@` is eager — every load of `customer` now drags in all of `subscription`'s prose, plus anything *it* injects, whether or not the question involves subscriptions.

**Fix:** link the file, or inject just the fact you need via a conceptual path — `@subscription.is_pending_cancellation.description`.

### The vague description

**Wrong:** `description: Customer data`.

**Why it fails:** the agent routes on descriptions. This one loads the entity for wrong questions and skips it for right ones — and no build check catches it, because the field is present and non-empty. The failure only shows up as bad answers.

**Fix:** grain + use-for: `Grove accounts. One row per company. Use for ARR, churn, and plan-tier analysis.`

### Policy creep

**Wrong:** a situational rule — "when analyzing refunds, break out by channel" — written as a policy.

**Why it fails:** policies are eager, always-apply commitments ([Policy](/concepts/policy)); every question in the domain now carries a rule that applies to one kind of analysis. Each addition seems small; the always-loaded tier only ever grows.

**Fix:** put it where it's lazy — a skill body if it's how to reason through refund analyses, entity prose if it's a fact about `order`.

### Injection chains

**Wrong:** `ENTITY.md` injects `instructions/a.md`, which injects `instructions/b.md`, which injects a third file.

**Why it fails:** injection *cycles* fail the build ([Markdown format](/reference/markdown-format#validation)), but chains pass silently — and every activation of the host loads the whole transitive closure. The author of each link sees one small `@`; the agent pays for all of them.

**Fix:** flatten. The host injects at most one level; anything deeper becomes a link the agent follows on demand.

## The bar

* Every entity and skill `description` states grain and use-for, and discriminates against its siblings.
* `ENTITY.md` bodies stay under \~150 words unless you can say why every analysis needs more.
* Nothing is `@`-injected that isn't needed by most loads of its host file.
* For any sentence in the layer, you can name its load class — and it's the cheapest class that still reaches everyone who needs it.

## Related

* [Markdown format](/reference/markdown-format) — the `@`/link/bare-path grammar and supporting files
* [ENTITY.md](/concepts/entity/entity-md) · [LYNK.md](/concepts/lynk-md) · [GLOSSARY.yml](/concepts/glossary) · [Skill](/concepts/skill) · [Policy](/concepts/policy) — the primitives whose load behavior this guide budgets
* [Reference files](/concepts/reference-files) — the on-demand tier's home for cross-cutting content
* [Evolving a live layer](/guides/evolving-the-layer) — changing what's already loaded without breaking consumers


# Evolving a live layer

How to change a live semantic layer — logging definition changes, renaming a shared feature or metric safely, deprecating with enabled false, and landing breaking changes across domains.

Decide how to change a definition that others already rely on — without silently changing their answers.

## When you need this

* A metric's `sql` needs correcting and the numbers will move.
* You want to rename a feature or metric in a shared domain that other domains import.
* You're retiring or pausing an entity and aren't sure who references it.
* Someone asks "why is this number different from last month?"
* A breaking change in `core` has to land without stranding the leaf domains.

## The principle

The build validates the whole layer as one unit, so *structural* breakage — a dangling import, a reference to a disabled entity — surfaces at the next build, never at query time. What the build cannot see is *meaning*: a `sql` change under a stable name, a description drifting from its computation. So split the work accordingly: let the build catch structure — make breaks loud and fix them in the same change — and catch meaning yourself, with a changelog entry and a re-verified description on every consequential definition change.

## Patterns

### Keep a definition changelog

A metric's meaning can change even when its name doesn't — a denominator is corrected, a filter is added, a scale is fixed. When a number moves, the team needs to know why. Keep a short record of consequential definition changes: **what changed, when, and why.**

* It answers "why is this number different from last month?" without a forensic dig.
* It lets you re-run the value checks that guarded the old definition, so an already-fixed correctness bug doesn't silently regress.

A changelog is a maintenance record, not a schema primitive — keep it wherever the team already tracks change (a `CHANGELOG` file in the project, commit messages, or a supporting doc), close to the layer it describes. When Grove corrects `churn_rate` to exclude test accounts from the denominator, the entry says so — and next quarter, the check that caught the original bug can run again against the new definition.

### Rename with add → migrate → remove

There is no rename mechanism. [Imports are by reference](/concepts/entity/schema-yml/identity-and-imports), and an imported feature keeps its name — to expose one under a different name, you define a local feature whose `sql` references it. So renaming `total_arr` on `core.customer` breaks every extending entity's `imports` at their **next build** — loudly, not silently at query time. That's the safety net; work with it:

1. **Add** the new name on the parent (its `sql` can reference the old definition, so there's one computation).
2. **Migrate** consumers — point each importing entity and each `sql` reference at the new name.
3. **Deprecation window** — both names live; the changelog marks the old one deprecated.
4. **Remove** the old name. Any straggler you missed fails the next build, which is the point.

When Grove renames `core.customer.total_arr` to `arr_usd`, marketing's `customer` keeps building through steps 1–3 and its import line changes once, in a PR marketing can review.

### Deprecate with `enabled: false` — for drafts, not for hiding

`enabled: false` on an `ENTITY.md` disables the **whole entity** — `schema.yml` included — and every reference to it [fails the build like a reference to a missing entity](/reference/markdown-format#frontmatter-contract). That makes it the right tool for in-progress or experimental work: Arcadia can land a half-built `player_cohort_v2` with `enabled: false` and iterate without it being queryable or referenced.

It is the wrong tool for soft-hiding something others import — flipping the flag doesn't hide the entity, it breaks every consumer's build. To retire something shared, treat it as a removal: migrate importers first (previous pattern), then disable or delete.

### Coordinate breaking changes across domains

When a breaking change lands in the shared domain, make it in one PR and let the build enumerate the damage: [the build validates the entire semantic layer as one unit](/concepts/project#validation), so every leaf entity whose import or reference breaks fails *that* build, with the failures listed. Fix the leaf imports in the same PR. The layer is never deployed half-migrated — validation gates the deploy, and the previous good build keeps serving until the whole change passes.

## Anti-patterns

### Silent redefinition

**Wrong:** changing `churn_rate`'s `sql` — adding a filter, fixing a denominator — while its `name` and `description` stay as they were.

**Why it fails:** nothing errors. The build checks that the sql compiles and its columns resolve, not that it still computes what the description says. Every consumer's mental model is now wrong, dashboards shift with no recorded cause, and the description actively misleads the agent on every query ([Metric](/concepts/entity/schema-yml/metric) makes the description the agent's source of truth).

**Fix:** the changelog entry and the description update ship in the same edit as the `sql` change. If the description didn't need to change, say so explicitly in the changelog — that's the re-verification.

### The meaning-shift rename

**Wrong:** reusing an existing name for a different computation — repointing Bly's `sum_net_revenue` at `gross_amount` because "that's what the exec team means by revenue."

**Why it fails:** every existing query, feature `sql`, and import that references the name keeps compiling and now silently computes something else. A name in a live layer is a contract with everyone who ever read it.

**Fix:** a different computation gets a different name (`sum_gross_revenue`), a description that discriminates it from the old one, and — if the old one is retiring — the add → migrate → remove path.

### Disable-without-checking

**Wrong:** setting `enabled: false` on `core.customer` to "pause" it while other domains extend or reference it.

**Why it fails:** a disabled entity behaves exactly like a missing one — marketing's `identity: core.customer` and every `imports` line under it fail at their next build. You've turned a pause into an outage for every downstream domain.

**Fix:** enumerate importers and references first (the build will list them if you're unsure — disable on a branch and read the failures). Migrate them, then disable.

## The bar

* Every consequential definition change has a changelog entry: what changed, when, why.
* No `sql` change ships without its `description` re-verified against the new computation.
* Renames follow add → migrate → remove; no name is ever reused for a different computation.
* Before `enabled: false` or deletion on anything shared, importers are enumerated and migrated.

## Related

* [Identity and imports](/concepts/entity/schema-yml/identity-and-imports) — why imports break loudly and renames need the migration path
* [Project](/concepts/project) — the build/validate/deploy lifecycle that gates every change
* [Markdown format](/reference/markdown-format) — the `enabled` flag and what "disabled" means per primitive
* [Metric](/concepts/entity/schema-yml/metric) — the description–sql contract that silent redefinition violates
* [Budgeting the agent's context](/guides/context-engineering) — deciding where layer content lives in the first place


# Reading a complete layer

A complete, annotated .lynk/ project for Grove (B2B SaaS) — every file of a small correct layer, end to end, with the reasoning behind each choice. Read when you want the whole shape at once instead o

What a small, correct `.lynk/` project looks like end to end — every file of Grove's layer, and why each choice was made.

## When you need this

The concept pages each explain one file type in isolation. This page shows them fitting together: one project, one domain, two entities, a skill, and a policy — small enough to read in one sitting, complete enough that every cross-reference resolves. Read it when you're building your first layer or reviewing someone else's.

## The layer at a glance

Two entities carry Grove's model — `customer` (the account) and `subscription` (the billing object) — in a single `core` domain.

```
.lynk/
├── lynk.yml
├── LYNK.md
├── GLOSSARY.yml
└── domains/
    └── core/
        ├── entities/
        │   ├── customer/
        │   │   ├── ENTITY.md
        │   │   └── schema.yml
        │   └── subscription/
        │       ├── ENTITY.md
        │       └── schema.yml
        ├── skills/
        │   └── churn-investigation/
        │       └── SKILL.md
        └── policies/
            └── output-format/
                └── POLICY.md
```

## File by file

### `lynk.yml`

```yaml
schema_version: "v2"
name: Grove
```

No `topology` block: with one domain there is nothing to compose across, and the default (medallion, no shared domain) already says "each domain references only itself and the root files." Adding `shared_domain: core` becomes worthwhile only when a second domain appears. See [lynk.yml](/concepts/lynk-yml).

### `LYNK.md`

```markdown
# Grove

Grove sells subscription-based business software to other companies. Revenue is
recurring; ARR is the headline metric. The fiscal year starts February 1
(Q1 = Feb–Apr). All revenue reporting is in USD.
```

Orientation only — facts no single entity owns. Notice what is *not* here: the test-account exclusion is a rule about `customer` rows, so it lives on that entity's `ENTITY.md`, not in orientation. See [LYNK.md](/concepts/lynk-md).

### `GLOSSARY.yml`

```yaml
expansion:
  name: Expansion
  description: Additional recurring revenue from an existing customer — upsells, seat growth, or plan upgrades. Counted separately from new-logo revenue.
logo_churn:
  name: Logo Churn
  description: A customer fully cancelling, counted as one lost logo regardless of contract size. Distinct from revenue churn, which weights by ARR.
```

Vocabulary only — each entry is `key → name / description`, no formulas, no pointers to entities. The keys are stable `@` addresses: the skill below injects `@glossary.logo_churn.description`. See [GLOSSARY.yml](/concepts/glossary).

### `domains/core/entities/customer/ENTITY.md`

```markdown
---
name: customer
description: Grove accounts. One row per company. Use for ARR, churn, and plan-tier analysis.
---

# Customer

One row per company that has signed up. The team uses "customer" and "account"
interchangeably.

**Convention.** Most analyses exclude test accounts (`is_test_account = false`). This entity is that rule's single home — other files may point here, never restate it.

**Quirk.** `first_paid_at` is null for trials — filter it out when measuring time-to-paid, or the cohort skews.
```

The `description` states the grain and what the entity is *for* — that line is what the agent reads at index time to decide whether to load the entity. The body carries only what every analysis of this entity needs. See [ENTITY.md](/concepts/entity/entity-md).

### `domains/core/entities/customer/schema.yml`

```yaml
identity: maindb.public.customers
keys:
  - id

features:
  # keys are not features — id is declared because customer_to_subscription joins on it
  - name: id
    description: Unique customer identifier
    sql: maindb.public.customers.id
    data_type: number
  - name: company_name
    description: The customer's company name
    sql: maindb.public.customers.company_name
    data_type: string
  - name: status
    description: Lifecycle status — 'active', 'churned', or 'trial'
    sql: maindb.public.customers.status
    data_type: string
  - name: arr
    description: Annual recurring revenue for this customer, in USD
    sql: maindb.public.customers.arr
    data_type: number
  - name: is_test_account
    description: True for internal test accounts, which most analyses exclude
    sql: maindb.public.customers.is_test_account
    data_type: boolean
  # derived — a sql expression over the entity's own features
  - name: customer_tier
    description: Size tier from ARR — 'Enterprise' (>= 250k USD), 'Mid-Market' (>= 25k), else 'SMB'
    sql: CASE WHEN customer.arr >= 250000 THEN 'Enterprise' WHEN customer.arr >= 25000 THEN 'Mid-Market' ELSE 'SMB' END
    data_type: string
  # cross-entity aggregate — a feature wrapping a metric on subscription, bound by join_name
  - name: total_mrr
    description: Total normalized monthly recurring revenue across this customer's subscriptions, in USD
    sql: metric(subscription.total_mrr)
    data_type: number
    join_name: customer_to_subscription

metrics:
  - name: count_customers
    description: Count of customers
    sql: COUNT(*)
    data_type: number
  - name: total_arr
    description: Total ARR across customers, in USD
    sql: SUM(customer.arr)
    data_type: number
  - name: churned_customers
    description: Count of customers who have churned
    sql: SUM(CASE WHEN customer.status = 'churned' THEN 1 ELSE 0 END)
    data_type: number

entity_relationships:
  - name: customer_to_subscription
    description: Subscriptions belonging to this customer
    entity: subscription
    cardinality: one_to_many
    steps:
      - target: subscription
        join_type: left
        sql: customer.id = subscription.customer_id
```

Three feature shapes in one file: direct column reads, a derived `sql` expression (`customer_tier`), and a cross-entity aggregate (`total_mrr`) — which is a *feature*, not a metric, because the rows it aggregates belong to `subscription`. Every feature has exactly the fields `name` / `description` / `sql` / `data_type`, plus `join_name` only where the expression crosses the relationship. The relationship step joins on `customer.id` and `subscription.customer_id`, so both are declared features on their entities. See [schema.yml](/concepts/entity/schema-yml).

### `domains/core/entities/subscription/ENTITY.md`

```markdown
---
name: subscription
description: Active and historical subscriptions. One row per subscription. Use for MRR, billing cycle, and cancellation analysis.
enabled: true
---
```

Frontmatter only — this entity has no quirks worth a body, and the body is optional. The `description` alone makes it loadable.

### `domains/core/entities/subscription/schema.yml`

```yaml
identity: maindb.public.subscriptions
keys:
  - subscription_id

features:
  # joined on by both relationships — so it must be a declared feature
  - name: customer_id
    description: The customer this subscription belongs to
    sql: maindb.public.subscriptions.customer_id
    data_type: number
  - name: status
    description: Subscription status — 'active' or 'cancelled'
    sql: maindb.public.subscriptions.status
    data_type: string
  - name: billing_cycle
    description: Billing cycle — 'monthly' or 'annual'
    sql: maindb.public.subscriptions.billing_cycle
    data_type: string
  - name: amount_cents
    description: Billed amount per cycle, in US cents
    sql: maindb.public.subscriptions.amount_cents
    data_type: number
  - name: mrr
    description: Normalized monthly recurring revenue for this subscription, in USD
    sql: CASE WHEN subscription.billing_cycle = 'annual' THEN subscription.amount_cents / 100.0 / 12 ELSE subscription.amount_cents / 100.0 END
    data_type: number
  - name: is_pending_cancellation
    description: True when the subscription is active but a cancellation takes effect at period end
    sql: subscription.status = 'active' AND maindb.public.subscriptions.cancelled_at IS NOT NULL AND maindb.public.subscriptions.current_period_end > CURRENT_DATE
    data_type: boolean

metrics:
  - name: count_subscriptions
    description: Count of subscriptions
    sql: COUNT(*)
    data_type: number
  - name: total_mrr
    description: Total normalized monthly recurring revenue across subscriptions, in USD
    sql: SUM(subscription.mrr)
    data_type: number

entity_relationships:
  - name: subscription_to_customer
    description: The customer this subscription belongs to
    entity: customer
    cardinality: many_to_one
    steps:
      - target: customer
        join_type: left
        sql: subscription.customer_id = customer.id
```

`customer_id` is declared because a relationship step joins on it; the key `subscription_id` is *not* declared, because nothing references it — keys are not features, and only touched columns earn a declaration. `total_mrr` lives here, on the entity whose rows it sums; `customer` exposes it across the boundary as a feature. The relationship is `customer_to_subscription` seen from the other side: its own name, its own `many_to_one` cardinality, its own `sql`. See [relationships](/concepts/entity/schema-yml/relationships).

### `domains/core/skills/churn-investigation/SKILL.md`

```markdown
---
name: churn-investigation
description: How to investigate customer churn — quantify lost logos and ARR, then surface early signals
---

# Churn Investigation

The team counts churn in logos — see @glossary.logo_churn.description.

1. Quantify: lost logos via `metric(customer.churned_customers)`; lost ARR — see @customer.arr.description.
2. Early signals still in the base — see @subscription.is_pending_cancellation.description.
3. Segment by tier — see @customer.customer_tier.description. Enterprise and SMB churn are different problems.
```

A skill uses the schema, it never defines it — every `@` reference here resolves to a feature or glossary entry that already exists, and no value is computed inside the prose. The `description` is what the agent reads to decide whether to load the skill. See [Skill](/concepts/skill).

### `domains/core/policies/output-format/POLICY.md`

```markdown
---
name: output-format
description: How the agent presents query results to the user
---

# Output Format

- Lead with the answer, then the numbers behind it.
- Label ARR and MRR explicitly — never a bare "revenue". All figures in USD.
- For period comparisons, use Grove's fiscal calendar (Q1 = Feb–Apr) and say so.
```

The folder is named after the Lynk policy type `output-format`, so this file fully replaces Lynk's shipped default. It carries operating behavior, not data facts or reasoning procedures. See [Policy](/concepts/policy).

## What to notice

* **Keys are not features — touched columns are.** `customer.id` and `subscription.customer_id` are declared because a relationship step joins on them; `subscription_id` is not, because nothing references it. [Relationships](/concepts/entity/schema-yml/relationships)
* **Metrics are entity-local and take no arguments.** `total_mrr` is defined once, on `subscription`; `customer` reaches it as a feature whose `sql` wraps `metric()` with a `join_name`. [Metric](/concepts/entity/schema-yml/metric)
* **Feature fields are exactly six.** `name` / `description` / `sql` / `data_type`, plus optional `join_name` and `filter` — no types, no templating. And every `description` states what the value is (grain, units, enums), because the agent reasons from it. [Feature](/concepts/entity/schema-yml/feature)
* **Each rule has one home.** The test-account exclusion lives on `customer`'s `ENTITY.md`; `LYNK.md` carries only what no entity owns. [ENTITY.md](/concepts/entity/entity-md) · [LYNK.md](/concepts/lynk-md)
* **The glossary is vocabulary, not computation.** `key → name / description` only; anything formula-shaped belongs in schema or a skill. [GLOSSARY.yml](/concepts/glossary)
* **Names match folders, everywhere.** `customer`, `churn-investigation`, `output-format` — frontmatter `name` equals folder name, and the domain is the folder under `domains/`. [Layout and naming](/reference/layout-and-naming)

## Related

* [Layout and naming](/reference/layout-and-naming) — the tree and naming rules this layer follows
* [Entity](/concepts/entity) — the `ENTITY.md` / `schema.yml` split
* [Skill](/concepts/skill) · [Policy](/concepts/policy) — reasoning versus operating behavior
* [SQL expressions](/reference/sql-expressions) — the grammar every `sql:` above follows


# Layout and Naming

The .lynk/ directory tree, the rules for naming folders and files, and the settings that control how a project is built.

The on-disk shape of a Lynk project: where every file lives, how folders and primitives are named, and the settings that govern the build.

## What it is

A Lynk project is a self-contained git repository. Everything the agent reads lives under a single `.lynk/` directory at the repo root. The shape is uniform: the same layout describes a project with one domain and a project with twelve. Adding a domain is adding a folder, never a restructure.

The layout is built from a small set of primitives that all share one folder-shape — a folder per item, a primary file inside, standard frontmatter. A builder who learns the shape once uses it everywhere: [entities](/concepts/entity), [skills](/concepts/skill), and [policies](/concepts/policy) all follow it.

This page is the reference for the tree and the naming rules. Each primitive's own page covers what goes *inside* its files.

## Where it lives

The semantic layer lives under `.lynk/` at the repo root. The root of that tree holds up to three files — `lynk.yml` (required) plus an optional root `LYNK.md` and `GLOSSARY.yml`; domains hang off `domains/`, and shared [reference files](/concepts/reference-files) can sit at the root alongside them.

```
.lynk/
├── lynk.yml            # project settings (required)
├── LYNK.md             # root orientation (the business)
├── GLOSSARY.yml        # root vocabulary
└── domains/
    └── <domain>/       # one folder per domain — the domain name IS the folder name
        ├── LYNK.md
        ├── GLOSSARY.yml
        ├── entities/
        │   └── <entity>/
        │       ├── ENTITY.md
        │       └── schema.yml
        ├── skills/
        │   └── <skill>/
        │       └── SKILL.md
        └── policies/
            └── <policy>/
                └── POLICY.md
```

The domain is derived **from the path** — the folder name under `domains/` is the domain's name. There is no `domain:` field to set anywhere.

## Format

### The three root files

| File           | Required | Role                                                                                 |
| -------------- | -------- | ------------------------------------------------------------------------------------ |
| `lynk.yml`     | ✓        | Project settings — see below. Governs how Lynk interprets the rest.                  |
| `LYNK.md`      | –        | Root [orientation](/concepts/lynk-md): who the business is. Applies to every domain. |
| `GLOSSARY.yml` | –        | Root [vocabulary](/concepts/glossary). Merged into every domain.                     |

### `lynk.yml` settings

`lynk.yml` holds the project settings — `schema_version` (required), `topology`, and an optional `name`. The fields are documented in full on the [lynk.yml](/concepts/lynk-yml) page.

### Naming rules

| Rule                                            | Applies to                                                     | Detail                                                                                                                   |
| ----------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Lowercase, alphanumeric, underscores            | Domain & entity folders; feature / metric / relationship names | `core`, `player_game`, `total_points`. These appear in `sql:` references.                                                |
| Lowercase, alphanumeric, hyphens or underscores | Skill & policy folders                                         | `churn-investigation`, `output-format`.                                                                                  |
| `name` matches the folder                       | Every primitive with frontmatter                               | The `name` in `ENTITY.md` / `SKILL.md` / `POLICY.md` frontmatter must equal the folder name.                             |
| Primary file is uppercase                       | Every primitive                                                | `ENTITY.md`, `SKILL.md`, `POLICY.md`, `LYNK.md` — fixed names, uppercase. `schema.yml` and `GLOSSARY.yml` are fixed too. |
| No reserved names                               | Domains                                                        | `core` is the conventional shared domain under medallion, but it is a convention, not a requirement.                     |

### `enabled`

Every entity, skill, and policy carries an optional `enabled` flag (defaults to `true`) in its frontmatter that removes it from the build without deleting it — useful for in-progress or deprecated content. The flag is part of the shared frontmatter contract; see [Markdown format → frontmatter contract](/reference/markdown-format#frontmatter-contract) for what "removed" means per primitive.

## Examples

**One domain, one entity.** The smallest project worth querying: a single domain with one entity and a root `LYNK.md`.

```
.lynk/
├── lynk.yml
├── LYNK.md
└── domains/
    └── core/
        └── entities/
            └── customer/
                ├── ENTITY.md
                └── schema.yml
```

**A shared `core` plus a team domain (Grove, B2B SaaS).** Marketing builds its agent on top of `core`, with `core` set as the medallion `shared_domain` (see the `lynk.yml` below).

```
.lynk/
├── lynk.yml
├── LYNK.md
├── GLOSSARY.yml
└── domains/
    ├── core/
    │   ├── LYNK.md
    │   ├── GLOSSARY.yml
    │   ├── entities/
    │   │   ├── customer/
    │   │   │   ├── ENTITY.md
    │   │   │   └── schema.yml
    │   │   └── subscription/
    │   │       ├── ENTITY.md
    │   │       └── schema.yml
    │   └── policies/
    │       └── output-format/
    │           └── POLICY.md
    └── marketing/
        ├── LYNK.md
        ├── GLOSSARY.yml
        ├── entities/
        │   └── customer/          # marketing's view of core.customer (identity)
        │       ├── ENTITY.md
        │       └── schema.yml
        └── skills/
            └── attribution-analysis/
                └── SKILL.md
```

```yaml
# .lynk/lynk.yml
schema_version: "v2"
name: Grove
topology:
  pattern: medallion
  shared_domain: core
```

## Validation

* `lynk.yml` exists at the `.lynk/` root and declares `schema_version`. A missing or unreadable `lynk.yml` fails the build.
* Every primitive's `name` frontmatter matches its folder name. Mismatches fail.
* Domain and entity folders — and feature / metric / relationship names — are lowercase alphanumeric with underscores. Skill and policy folders may also use hyphens.

Beyond the layout itself, the build also warns when the project is below the [project minimum](/concepts/project#the-minimum-project) (a domain, an entity, a root `LYNK.md`) and on [empty domains](/concepts/domain#validation).

## Related

* [Project](/concepts/project) — the repo, the build lifecycle, and branches
* [lynk.yml](/concepts/lynk-yml) — the settings file in full
* [Markdown format](/reference/markdown-format) — the shared frontmatter contract and `@` injection
* [Domain](/concepts/domain) · [Entity](/concepts/entity) · [Skill](/concepts/skill) · [Policy](/concepts/policy)


# Markdown Format

The frontmatter contract shared by every Markdown primitive, the reference grammar (@ injection, markdown links, bare paths), and how supporting files are organized.

The shared rules for every Markdown file in a Lynk project: the frontmatter contract, the references that connect files (`@` injection, markdown links, and bare paths), and how supporting files are organized.

## What it is

Lynk's prose primitives — [`LYNK.md`](/concepts/lynk-md), [`ENTITY.md`](/concepts/entity/entity-md), [`SKILL.md`](/concepts/skill), [`POLICY.md`](/concepts/policy) — are Markdown files that share two mechanics:

* **Frontmatter** — a small, uniform YAML header. For the lazy-loaded primitives (entities and skills), the frontmatter is what the agent reads at index time to decide whether to load the file at all.
* **References** — `@` injection (eager content), plus markdown links and bare paths (navigation), connecting one file to another. File paths are absolute from the repo root (`/.lynk/…`).

Both are documented here once, so each primitive page can link here instead of repeating the rules.

## Where it lives

These rules apply to every `.md` primitive under `.lynk/` — root and domain `LYNK.md`, every `ENTITY.md`, `SKILL.md`, and `POLICY.md`. Paths and the overall tree are covered in [Layout and naming](/reference/layout-and-naming).

## Format

### Frontmatter contract

`ENTITY.md`, `SKILL.md`, and `POLICY.md` carry the same frontmatter:

```yaml
---
name: orders                 # required — must match the folder name (charset per naming rules)
description: ...             # required — one-line summary
enabled: true               # optional — defaults to true
---
```

| Field         | Required | Type    | Notes                                                                                                                                                                                          |
| ------------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`        | ✓        | string  | Lowercase alphanumeric — entities use underscores; skills/policies may use hyphens or underscores. Must match the folder name (see [naming rules](/reference/layout-and-naming#naming-rules)). |
| `description` | ✓        | string  | One line. Load-bearing for lazy primitives — the agent indexes entities and skills by their `description` to decide relevance.                                                                 |
| `enabled`     | –        | boolean | Defaults `true`. `false` removes the primitive from the build — see below.                                                                                                                     |

`LYNK.md` is the exception — it is pure prose with no required frontmatter. GitBook page frontmatter (`description`, `icon`) is separate from this contract and used only for the published docs site, not by the build.

**`enabled: false`** removes a primitive from the build without deleting it — for in-progress, deprecated, or experimental content. What "removed" means depends on the primitive:

* A [skill](/concepts/skill) is not loaded at all.
* An [entity](/concepts/entity) is disabled entirely. `enabled: false` on its `ENTITY.md` disables the **whole entity** — its `schema.yml` included — so it can't be queried, referenced, or imported. A reference to a disabled entity fails the build, exactly like a reference to one that doesn't exist.
* A [policy](/concepts/policy) that overrides a Lynk default falls back to Lynk's default.

### References

Files reference each other in three forms. All **file** paths are **absolute from the repo root, beginning `/.lynk/`** — there are no relative reference paths.

| Form          | Syntax                                    | What it does                                                               |
| ------------- | ----------------------------------------- | -------------------------------------------------------------------------- |
| `@` injection | `@glossary.mrr.description` · `@/.lynk/…` | Pulls content in and composes it into the prose, **eagerly** at load time. |
| Markdown link | `[text](/.lynk/…)`                        | A **navigation** target the agent can follow — not a substitution.         |
| Bare path     | `/.lynk/…` in prose                       | A navigation target written inline.                                        |

**`@` injection** takes two path shapes:

*Conceptual paths* point at a primitive's field — usually `name` or `description` — and resolve in the host file's **own domain** (or the merged glossary):

```
@glossary.<term>.<field>          # the domain's merged glossary
@<entity>.<field>                 # an entity in the host file's domain
@<entity>.<sub>.<field>           # a feature/metric/relationship on that entity
```

The sub-primitive segment (`email` in `@customer.email.description`) is a feature, metric, or relationship name. Names are unique within an entity, so no type marker is needed.

*Whole-file injection* uses an absolute path:

```
@/.lynk/domains/core/entities/customer/ENTITY.md
@/.lynk/docs/onboarding.md
```

**`@` is eager; links and bare paths are not.** When the host file loads, everything it injects with `@` loads with it — the cost is the size of what's injected. Markdown links and bare paths don't inject anything; they're navigation the agent follows on demand. Keep host files lean and inject only what every use needs.

**Scope is topology-governed.** A file may reference its **own domain**, the **root** [**reference files**](/concepts/reference-files) (those at the `.lynk/` root, outside `domains/`), and — when a [shared domain](/concepts/lynk-yml#topology) is configured — that shared domain (conventionally `core`). With no shared domain set, a file reaches only its own domain and the root reference files. A reference to a peer domain fails the build. File references obey the same [topology](/concepts/lynk-yml#topology) as structured (`identity:`, `imports:`) references — topology is the single authority for what any file may reach.

### Supporting files

Beyond the required primary files, a primitive folder can hold any other files — examples, reference notes, longer instructions — organized however the author wants. They are not formal primitives; they are auxiliary content that a primary file injects with `@` when relevant.

```
.lynk/domains/core/entities/customer/
├── ENTITY.md
├── schema.yml
├── instructions/
│   └── closing-period.md
└── examples/
    └── refund-flows.md
```

## Examples

**Frontmatter only.** An entity with nothing worth flagging in prose: the frontmatter alone makes it loadable.

```markdown
---
name: subscription
description: Active and historical subscriptions. One row per subscription. Use for MRR, billing cycle, and cancellation analysis.
enabled: true
---
```

**Prose with injected supporting content.** Grove's `customer` entity injects a glossary term and a supporting file.

```markdown
---
name: customer
description: Grove accounts. One row per company. Use for ARR, churn, and plan-tier analysis.
enabled: true
---

# Customer

One row per company that has signed up. The team uses "customer" and "account" interchangeably.

**Conventions.** Most analyses exclude test accounts (`is_test_account = false`). "Churned" is defined in @glossary.logo_churn.description.

@/.lynk/domains/core/entities/customer/instructions/closing-period.md
```

## Validation

* `name` and `description` are present on every `ENTITY.md`, `SKILL.md`, and `POLICY.md`. A missing required field fails the build.
* `name` matches the folder name. Mismatches fail.
* Every reference resolves to an existing target — a known glossary term, a defined entity/feature/metric/relationship, or a real file. Dangling references fail.
* File reference paths are absolute (`/.lynk/…`); a relative reference path fails.
* References obey the project [topology](/concepts/lynk-yml#topology): a file may reach its own domain, the root reference files, and the shared domain when one is configured. A reference to a peer domain fails.
* A reference to a disabled entity fails the build (see `enabled`, above).
* Injection cycles fail the build — if A injects B and B injects A, the build rejects it rather than looping.

## Related

* [Layout and naming](/reference/layout-and-naming) — the tree, folder rules, and the `enabled` flag
* [SQL expressions](/reference/sql-expressions) — the reference grammar used inside `schema.yml` (a separate path syntax from `@`)
* [LYNK.md](/concepts/lynk-md) · [ENTITY.md](/concepts/entity/entity-md) · [Skill](/concepts/skill) · [Policy](/concepts/policy) — the primitives that use this contract


# SQL Expressions

The reference grammar inside schema.yml sql fields — segment-count path resolution, the metric/first/last functions, filters, and join binding.

The grammar for the `sql:` and `filter:` fields inside [`schema.yml`](/concepts/entity/schema-yml). It governs how a [feature](/concepts/entity/schema-yml/feature) or [metric](/concepts/entity/schema-yml/metric) references columns, other definitions, and related entities.

## What it is

When you author a feature or metric, its `sql:` field holds the expression that produces the value. That expression is mostly ordinary SQL, with one Lynk-specific rule: every reference inside it is a **path**, and the parser tells path types apart by counting segments.

This is the *authoring* grammar — the SQL you write inside `schema.yml`. It is distinct from the [Lynk SQL query dialect](/api-reference/api/lynk-sql), which is what the agent emits to query a built layer. This page is about the former.

## Where it lives

Inside `.lynk/domains/<domain>/entities/<entity>/schema.yml`, in the `sql:` and `filter:` fields of features and metrics, and in the `sql:` field of [relationship](/concepts/entity/schema-yml/relationships) steps.

## Format

### The two reference forms

Every reference inside `sql:` is one of two things, distinguished by segment count:

| Segments | Form                       | Resolves to                                         | Example                           |
| -------- | -------------------------- | --------------------------------------------------- | --------------------------------- |
| 4+       | Physical path              | A column in the warehouse                           | `maindb.public.orders.net_amount` |
| 2        | Entity-local semantic path | A feature or metric on an entity **in this domain** | `order.net_amount`                |

Anything else is SQL syntax around those references — formulas, function calls, `CASE WHEN`, casts.

**Expressions are literal SQL.** There is no templating of any kind — no variables, no macros, no `{{ … }}` or `{% … %}` syntax, no `ref()`. What you write is exactly what compiles.

A physical path inside `sql:` always names a **column** (4+ segments). A bare 3-segment table like `maindb.public.orders` is not a valid `sql:` reference — that 3-segment form belongs to [`identity:`](/concepts/entity/schema-yml/identity-and-imports), not to expressions.

**`sql` is same-domain only.** A semantic path may reference only entities in the *same* domain; it cannot name an entity in another domain. To use a value from another domain (e.g. `core`), [`import`](/concepts/entity/schema-yml/identity-and-imports) it onto this entity and reference it by its local name. Cross-domain composition is an `imports`/topology concern, not a `sql` one.

### References are always entity-qualified

Inside `sql:`, a reference to this entity's own feature is written `<entity>.<feature>` — `order.net_amount`, never a bare `net_amount`. Every name is qualified, so a reader of any expression knows exactly what each token refers to without outside context.

### Reaching across a boundary

A 2-segment path resolves to a *declared feature or metric* on the target entity — not to a raw warehouse column. You reach another entity only through its declared features and metrics; writing its physical path to dodge that is rejected — `maindb.public.customers.region` from an `order` feature fails even though the column exists. A value owned by another entity has exactly one form: that entity's feature or metric (`customer.region`), reached through a `join_name`. (When a 4+ segment physical column *is* legal instead, see [join binding](#join-binding).)

### Functions

| Function                                     | Purpose                                                                                                                                          |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `metric(<entity>.<metric_name>)`             | Invokes a [metric](/concepts/entity/schema-yml/metric) defined on an entity. The engine substitutes the metric's aggregation.                    |
| `first(<field>, order_by=<field>, offset=N)` | Picks a row from a `one_to_many` source ordered **ascending** by `order_by`; `offset` defaults to `0` — the **smallest** `order_by` (the first). |
| `last(<field>, order_by=<field>, offset=N)`  | Picks a row ordered **descending** by `order_by`; `offset` defaults to `0` — the **largest** `order_by` (the most recent / highest).             |

**`first()`/`last()` pick a single row, so the `order_by` must order deterministically** — break ties on a unique field and account for NULLs, or the chosen row is arbitrary and nothing errors.

A **feature** reference needs no wrapper — `customer.email` resolves directly. A **metric** is always invoked with `metric()` — write `metric(customer.total_arr)`, never bare `customer.total_arr` — so every aggregation is explicit in the expression.

`first()` and `last()` belong to this authoring grammar only — they do **not** exist in the [Lynk SQL](/api-reference/api/lynk-sql) query dialect. At query time, use window functions and `QUALIFY` instead.

### Path shapes by context

The same segment count means different things in different fields — don't carry a shape from one context to another:

| Where              | Segments | Means                          | Example                           |
| ------------------ | -------- | ------------------------------ | --------------------------------- |
| `identity:`        | 3        | physical table                 | `maindb.public.customers`         |
| `identity:`        | 2        | another entity (extension)     | `core.customer`                   |
| `sql:` / `filter:` | 2        | entity-local feature or metric | `order.net_amount`                |
| `sql:` / `filter:` | 4+       | physical column                | `maindb.public.orders.net_amount` |
| `imports:`         | 3        | `domain.entity.name`           | `core.customer.total_arr`         |

### `filter`

**A `filter:` is grain-preserving: it narrows&#x20;*****source*****&#x20;rows before the `sql:` evaluates but never adds or drops the entity's own rows.** For an aggregation it limits which rows are aggregated; for a row-level feature it behaves like a join condition that nullifies non-matching rows rather than removing them. References in `filter` are entity-qualified, exactly like in `sql`, and bound by the same `join_name`.

### Join binding

When a feature's `sql:` or `filter:` references another entity, the feature declares a single `join_name` naming the [relationship](/concepts/entity/schema-yml/relationships) to traverse. That one `join_name` binds **every** cross-entity reference in the expression. For a multi-step relationship, the features and metrics of *any* entity along the path's steps are reachable.

A feature must declare a `join_name` **unless** its `sql` references only the entity's own `identity` source (physical columns) and/or its own features. This rule is the single source of truth for when a `join_name` is required.

What a `join_name` exposes depends on the relationship's type: a **table relationship** joins physical tables, so the expression may read their physical columns (4+ segment paths); an **entity relationship** joins entities, so it may read their features and metrics (2-segment paths), never raw columns. So a raw column is reachable only on the entity's own `identity` table or through a table relationship; another entity's value is always its feature or metric, reached through an entity relationship.

## Examples

**A formula over the entity's own features.** No `join_name`: every reference is local.

```yaml
- name: discount_value
  description: Amount discounted off this order, in USD
  sql: order.gross_amount - order.net_amount
  data_type: number
```

**A cross-entity reference with a function and a metric.** On Grove's `customer`, pulls a windowed value across a relationship and divides by a metric on the related entity.

```yaml
- name: latest_subscription_share
  description: The customer's most recent subscription MRR as a share of their total
  sql: last(subscription.mrr, order_by=subscription.started_at) / metric(subscription.total_mrr)
  data_type: number
  join_name: customer_to_subscription
```

```yaml
# Filtered cross-entity reference — only iOS purchases count
- name: ios_spend_usd
  description: Net USD revenue from this player's iOS purchases
  sql: metric(purchase.sum_net_revenue_usd)
  data_type: number
  join_name: player_to_purchase
  filter: purchase.store = 'ios'
```

## Validation

* A 2-segment path is resolved as an entity-local semantic path (`entity.thing`); a 4+ segment path as a physical column. An ambiguous or malformed path is rejected.
* Templating or variable syntax anywhere in `sql:` / `filter:` (`{{ }}`, `{% %}`, `ref()`, macros) is rejected — expressions are literal SQL.
* A reference that names an entity in another domain is rejected — `sql` is same-domain only; bring the value in via [`imports`](/concepts/entity/schema-yml/identity-and-imports) and reference its local name.
* Every reference must be reachable through the declared `join_name` — the local entity plus the join's step entities. A reference to an entity not on the path fails.
* A `join_name` is required unless the `sql`/`filter` references only the entity's own identity source and/or its own features. A missing required `join_name` fails.
* Cross-entity references must point at a *declared* feature or metric on the target — not a raw column.
* A metric is referenced with `metric(entity.metric_name)`; a bare metric path is rejected. A feature is referenced bare.
* A 4+ segment physical path may read a column only on the entity's own `identity` table or on a table reached through a **table relationship**. A raw column reached through an **entity relationship** (another entity's column) is rejected — reference that entity's feature or metric instead.

## Related

* [Feature](/concepts/entity/schema-yml/feature) — the fields that carry `sql`, `join_name`, and `filter`
* [Metric](/concepts/entity/schema-yml/metric) — entity-local aggregations and `metric()`
* [Relationships](/concepts/entity/schema-yml/relationships) — what `join_name` points at
* [Lynk SQL](/api-reference/api/lynk-sql) — the query dialect (distinct from this authoring grammar)


# What Lynk doesn't do

The boundaries in one place — testing and evaluations, metric parameterization, templating, tags, scheduling, cross-domain queries — what Lynk doesn't do by design, what's planned, and what belongs up

Every boundary of the model, in one place. Each entry states whether the boundary is **by design**, **planned**, or **upstream's job** — and the supported way to get the outcome. The owning page carries the full rule; this page exists so "does Lynk support X?" has one home.

## By design

These are the model's load-bearing decisions. They will not change; asking for them differently means using the supported alternative.

* **Metrics take no arguments.** No parameters, no per-call filters, no date ranges — `metric(entity.metric_name)` is the entire call. A parameter of the question goes in query-time `WHERE`; a different business definition is a second metric with its own `filter:`. → [Metric](/concepts/entity/schema-yml/metric)
* **No templating.** No Jinja, no variables, no macros anywhere in `sql:` — expressions are literal. → [SQL expressions](/reference/sql-expressions)
* **No unknown YAML keys.** The field tables are exhaustive; `tags:`, `meta:`, `time_grain:` or any other carried-over key fails the build. → [schema.yml](/concepts/entity/schema-yml)
* **No time grains on metrics.** Time grouping happens at query time via `GROUP BY`/`WHERE`, never in the definition. → [Metric](/concepts/entity/schema-yml/metric)
* **No query-backed identity.** An entity roots in a warehouse **table or view** (3 segments) or another entity — never an inline SQL query. If the grain doesn't exist, create a view or materialize a table upstream. → [Identity](/concepts/entity/schema-yml/identity-and-imports)
* **No same-domain imports.** Two entities in one domain are always independent; extension (`identity: <domain>.<entity>` + `imports`) is cross-domain only and requires a configured `shared_domain`. → [Identity and imports](/concepts/entity/schema-yml/identity-and-imports)
* **No renaming imports.** An imported definition keeps its name; expose a different name via a local feature whose `sql` references the import. → [Identity and imports](/concepts/entity/schema-yml/identity-and-imports)
* **No policy inheritance or merging.** Policies are per-domain; overriding a Lynk default fully replaces it. Share behavior across domains with a reference file injected via `@`. → [Policy](/concepts/policy)
* **No structured glossary pointers.** A term carries no link field to an entity or metric — its description prose must stand on its own. → [GLOSSARY.yml](/concepts/glossary)
* **No cross-domain queries.** A query runs against one domain of one build; cross-domain composition is a modeling decision (promote to the shared domain), not a query-time mode. → [Lynk SQL](/api-reference/api/lynk-sql)
* **No writes from the query surface.** Lynk SQL is read-only — a single `SELECT`; the agent never creates tables or modifies data. → [Lynk SQL](/api-reference/api/lynk-sql)

## Planned, not yet available

* **Evaluations — testing expected query results.** The build validates *structure* (definitions compile, references resolve), never result values. Tooling that asserts expected outputs for metrics and queries is planned; today there is no test file type, assertion syntax, or testing folder. → [Project](/concepts/project) · [Lynk SQL](/api-reference/api/lynk-sql)

## Upstream's job

* **Materialization and refresh scheduling.** The layer defines query-time semantics against tables that already exist; creating a grain (snapshot tables, views) and refreshing it on a schedule happens upstream — dbt, a scheduled job, the warehouse itself. → [Modeling metrics, time, and state](/guides/metrics-time-and-state)
* **Storing data.** A project points at warehouse tables; it never contains or copies them. → [Project](/concepts/project)

## Related

* [Project](/concepts/project) — what a project is and is not
* [Metric](/concepts/entity/schema-yml/metric) · [Identity and imports](/concepts/entity/schema-yml/identity-and-imports) · [Policy](/concepts/policy) · [GLOSSARY.yml](/concepts/glossary) — the owning specs
* [SQL expressions](/reference/sql-expressions) · [Lynk SQL](/api-reference/api/lynk-sql) — the grammars whose boundaries appear above


# API Reference

The programmatic interfaces — the Lynk SQL query dialect and the REST endpoints for validation and catalog inspection.

**Interfaces for querying and inspecting your Lynk semantic layer.**

This section documents the programmatic interfaces exposed by Lynk — how to write queries against your semantic layer, and how to call Lynk's HTTP endpoints to validate the semantic layer and inspect the data catalog.

***

## What's in this section

| Page                                    | What it covers                                                                                                                                                                                                                  |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Lynk SQL](/api-reference/api/lynk-sql) | The SQL dialect the agent uses internally — single-domain scope, entity references, `metric(<entity>.<metric_name>)`, joins (default, `USING('<join_name>')`, `USING(<common_feature_name>)`, `ON`), CTEs, supported statements |
| [REST API](/api-reference/api/rest-api) | HTTP endpoints for validating the semantic layer and inspecting the data catalog (tables and columns)                                                                                                                           |

***

**Where to go next:**

* Need to understand how entities and metrics are defined? → [Entity](/concepts/entity)
* Want the authoring grammar behind `sql:` fields? → [SQL expressions](/reference/sql-expressions)


# Lynk SQL

The query dialect — warehouse SQL plus metric(\<entity>.\<name>) and USING('\<join\_name>'); entity references, joins, CTEs, windows, supported statements, pitfalls.

Lynk exposes a SQL interface for querying your semantic layer. The dialect is your warehouse's SQL with two engine-specific abstractions: `metric(<entity>.<name>)` for applying a pre-defined aggregation, and `USING('<join_name>')` for joining along a relationship defined in an entity's `schema.yml`. Everything else — `SELECT`, `WHERE`, `GROUP BY`, `HAVING`, `ORDER BY`, CTEs, subqueries, window functions, every scalar and aggregate function your warehouse exposes — is standard SQL.

The agent uses this syntax internally when generating queries. As an engineer, you can also write it directly against a deployed build. (Evaluation tooling that consumes Lynk SQL as expected query outputs is planned, not yet available.)

***

## Single-domain scope

A query targets exactly one [domain](/concepts/domain). The domain is selected alongside the branch — in the UI, or carried on the connection. Every entity, feature, metric, and relationship the query references resolves within that one domain's world.

Cross-domain queries are not supported. A query that references an entity the active domain doesn't contain fails with a clear error rather than silently reaching into another domain. To combine concepts from different domains, model them in a shared domain (typically `core`) and query that domain — see [Domains](/concepts/domain).

***

## How it works

Lynk SQL is compiled into your warehouse's native SQL before execution. The engine resolves `metric()` calls to their aggregation expressions, expands `USING('<join_name>')` into the relationship's `ON` clause from `schema.yml`, and rewrites entity references to the underlying source tables. Everything else passes through to the warehouse.

Two consequences worth knowing:

* **The dialect is your warehouse's.** `FILTER (WHERE ...)` works on Postgres; `IFF()` and `QUALIFY` work on Snowflake; `PERCENTILE_CONT(...) WITHIN GROUP (...)` works on most modern warehouses. If your warehouse doesn't expose a function, neither does Lynk SQL.
* **Some constructs depend on how the engine emits SQL.** `WITH RECURSIVE`, for example, isn't supported on every engine because of how Lynk generates CTEs. If a construct fails compilation, fall back to a form the engine can express.

**Read-only.** Lynk SQL compiles to a single `SELECT` statement. `CREATE`, `INSERT`, `UPDATE`, `DELETE`, `DROP`, `ALTER`, and other DDL/DML are not supported.

***

## Entity references

Entities appear as identifiers in `FROM` and `JOIN` — no wrapper, no quoting. The engine resolves the entity to its underlying source table.

```sql
SELECT
  o.order_id,
  o.status,
  o.net_amount
FROM order o
WHERE o.status = 'completed'
ORDER BY o.order_date DESC
```

**Names in `SELECT` and `WHERE` are feature names from the entity's `schema.yml`, not raw warehouse columns — key columns included: `c.id` works only when `id` is a declared feature on `customer` (**[**keys are not features**](/concepts/entity/schema-yml/identity-and-imports#identity-and-keys)**).** One row is returned per entity instance — one row per order above. Aliases (`FROM order o`) work as in any SQL query.

***

## `metric(<entity>.<metric_name>)`

`metric()` applies a pre-defined [metric](/concepts/entity/schema-yml/metric) from an entity. The reference is entity-qualified — name the entity, then the metric. Use it anywhere a standard aggregate (`SUM`, `COUNT`, `AVG`) is legal — `SELECT`, `HAVING`, arithmetic expressions, CTEs, subqueries, window aggregates.

```sql
SELECT
  status,
  metric(order.count_orders)     AS count_orders,
  metric(order.sum_net_revenue)  AS sum_net_revenue
FROM order
WHERE order_date >= '2026-01-01'
GROUP BY status
ORDER BY sum_net_revenue DESC
```

**When the entity is aliased, the reference uses the alias:**

```sql
SELECT
  c.customer_tier,
  metric(c.total_arr) AS total_arr
FROM customer c
GROUP BY c.customer_tier
```

**Rules:**

* The argument is an entity-qualified metric path, unquoted: `metric(order.count_orders)`.
* When the entity carries an alias in the query, use the alias: `FROM customer c` → `metric(c.total_arr)`.
* Every `metric()` in the `SELECT` list must carry an alias: `metric(order.count_orders) AS count_orders`. Inside `HAVING`, `OVER(...)`, or a larger expression, no alias is needed — alias the surrounding select item instead.
* A metric is defined on the entity it aggregates. To aggregate from an entity other than the one you're selecting, push that aggregation into a CTE or subquery (see [CTEs and subqueries](#ctes-and-subqueries)).
* Apply `GROUP BY` to any non-aggregated features in the `SELECT` — same rule as standard SQL aggregates.

When the question needs the metric's logic over a filtered subset the metric definition doesn't capture, or combined with a non-aggregate expression, fall back to writing the aggregation manually.

***

## Joins

Lynk SQL supports the full set of standard SQL join types — `INNER JOIN`, `LEFT JOIN`, `RIGHT JOIN`, `FULL OUTER JOIN`, `CROSS JOIN`. Pick whichever the question requires. The join *condition* can be expressed in four forms:

| Form                                         | Use when                                                                                                                                                                  |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `JOIN <entity>` (no `ON`, no `USING`)        | The default relationship between the two entities is what you want. The engine uses the pair's single relationship, or the one marked `default: true` when several exist. |
| `JOIN <entity> USING('<join_name>')`         | A named relationship exists in the entity's `schema.yml` and you want that specific one — typically because the pair has more than one relationship.                      |
| `JOIN <entity> USING(<common_feature_name>)` | Standard SQL: the two sides share a feature name and you want a join on equality of that column. The argument is an unquoted identifier, not a string literal.            |
| `JOIN <entity> ON <expr>`                    | No relationship matches, you need extra predicates beyond the relationship's keys, or you're joining a CTE or subquery (where relationships don't apply).                 |

The two `USING` forms are distinguished by the argument: a **single-quoted string literal** names a relationship `join_name` from `schema.yml`; an **unquoted identifier** names a common column.

### Default join — no `ON`, no `USING`

When two entities have a single relationship defined (or one of several is marked `default: true`), join them by name alone. The engine fills in the `ON` clause from that relationship.

```sql
SELECT
  o.order_id,
  o.net_amount,
  c.email
FROM order o
LEFT JOIN customer c
WHERE o.status = 'completed'
```

### `USING('<join_name>')`

When the entity pair has more than one relationship, name the one you want with `USING()` and a string literal. The engine looks up the relationship by `join_name` and expands its `ON` clause at compile time.

```sql
SELECT
  p.player_id,
  s.duration_seconds
FROM player p
LEFT JOIN session s USING('player_to_meaningful_session')
WHERE p.country = 'US'
```

**Rules:**

* The `join_name` is a single-quoted string literal.
* This form is only valid for relationships predefined in `schema.yml`.
* `USING()` cannot be combined with additional predicates. `USING('rel') AND extra_predicate` is invalid — switch to a manual `ON` clause when you need extra filters baked into the join.

### `USING(<common_feature_name>)`

Standard SQL `USING` — the unquoted identifier names a feature that exists on both sides, and the engine joins on equality of that column.

```sql
SELECT
  p.player_id,
  s.duration_seconds
FROM player p
LEFT JOIN session s USING(player_id)
WHERE s.duration_seconds > 5
```

### `ON <expr>`

Use a manual `ON` clause when no relationship matches, when the join needs extra predicates, or when joining a CTE or subquery.

```sql
SELECT
  o.order_id,
  o.net_amount,
  c.email
FROM order o
LEFT JOIN customer c
  ON c.id = o.customer_id
 AND c.is_test_account = false
WHERE o.status = 'completed'
```

`ON` (or column-based `USING`) is the only join form available when one side is a CTE or subquery, since relationships are defined between entities, not against derived tables.

***

## CTEs and subqueries

CTEs (`WITH ... AS`) and subqueries are supported. Two situations make them useful:

1. **Applying a `metric()` to a filtered subset** the metric definition itself doesn't capture (e.g., the same metric over two distinct time windows in one query).
2. **Aggregating from an entity other than the one you're selecting** — isolate that entity in a CTE, aggregate there, and expose the value to the outer query.

```sql
WITH refunded AS (
  SELECT
    customer_id,
    metric(order.sum_net_revenue) AS refunded_revenue
  FROM order
  WHERE status = 'refunded'
    AND order_date >= '2026-01-01'
  GROUP BY customer_id
)
SELECT
  c.id,
  c.email,
  c.total_orders,
  r.refunded_revenue
FROM customer c
LEFT JOIN refunded r
  ON r.customer_id = c.id
```

Here `refunded` aggregates from `order` — a different entity than the `customer` we're selecting — and the outer query joins the result back by a plain `ON` clause.

**Joins to a CTE or subquery need a manual `ON` clause (or a column-based `USING(<column>)`): the relationship-name `USING('<join_name>')` and the no-clause default join work only between entities** — a derived table has no relationship to resolve.

Reach for a CTE when it earns its place — clearer grain transitions, isolating a filtered metric scope, or splitting a query into named stages. A CTE that exists because you *could* write one is just noise.

***

## Window functions and `QUALIFY`

Window functions are supported, and `metric()` can appear inside the window — both as the aggregated expression and inside `OVER (ORDER BY ...)`. The `QUALIFY` clause filters rows by a window function result, the way `HAVING` filters by an aggregate.

```sql
SELECT
  country,
  metric(player.count_players) AS count_players,
  DENSE_RANK() OVER (ORDER BY metric(player.count_players) DESC) AS country_rank
FROM player
GROUP BY country
QUALIFY country_rank <= 5
```

All standard window forms work: `ROW_NUMBER`, `RANK`, `DENSE_RANK`, `NTILE`, `PERCENT_RANK`, `LAG`, `LEAD`, `FIRST_VALUE`, `LAST_VALUE`, aggregates as windows (`SUM(x) OVER (PARTITION BY ...)`), and `ROWS BETWEEN ... PRECEDING/FOLLOWING` frames.

***

## Set operations and subqueries

`UNION`, `UNION ALL`, `INTERSECT`, and `EXCEPT` are supported between any two Lynk SQL queries. Subqueries pass through as standard SQL — scalar subqueries in `SELECT`/`WHERE`/`HAVING`, `IN (subquery)`, and `EXISTS` / `NOT EXISTS`.

***

## SQL functions

Every scalar, aggregate, and window function your warehouse supports is available. Date math, string operations, conditional expressions, casts (`CAST`, `::`, `TRY_CAST`) — write them as you would in plain SQL. The engine only intercepts `metric()` and `USING('<join_name>')`; everything else passes through to the warehouse.

***

## Supported statements

| Statement                                                    | Supported        |
| ------------------------------------------------------------ | ---------------- |
| `SELECT` (incl. `DISTINCT`)                                  | Yes              |
| `FROM <entity>`                                              | Yes              |
| `JOIN <entity>` (default relationship)                       | Yes              |
| `JOIN <entity> USING('<join_name>')`                         | Yes              |
| `JOIN <entity> USING(<common_feature_name>)`                 | Yes              |
| `JOIN <entity> ON <expr>`                                    | Yes              |
| `INNER` / `LEFT` / `RIGHT` / `FULL OUTER` / `CROSS JOIN`     | Yes              |
| `WHERE`                                                      | Yes              |
| `GROUP BY` (including by position: `GROUP BY 1`)             | Yes              |
| `HAVING` (with `metric()` or raw aggregates)                 | Yes              |
| `QUALIFY` (window-result filter)                             | Yes              |
| `ORDER BY` (with `NULLS FIRST` / `NULLS LAST`)               | Yes              |
| `LIMIT` / `OFFSET`                                           | Yes              |
| CTEs (`WITH`)                                                | Yes              |
| `WITH RECURSIVE`                                             | Engine-dependent |
| Subqueries (scalar, `IN`, `EXISTS`)                          | Yes              |
| Window functions (`OVER`, `PARTITION BY`, `ROWS BETWEEN`)    | Yes              |
| Set operations (`UNION`, `UNION ALL`, `INTERSECT`, `EXCEPT`) | Yes              |
| DDL / DML                                                    | No               |

***

## Common pitfalls

**Querying across domains.** A query resolves within one domain. Referencing an entity the active domain doesn't contain fails — model shared concepts in `core` instead.

**Using raw warehouse table names.** Lynk SQL operates on entities. `FROM db_prod.core.orders` bypasses the semantic layer — write `FROM order` and let the engine resolve the table.

**Combining `USING('<join_name>')` with extra predicates.** `USING('rel') AND extra_predicate` is invalid. Switch to a manual `ON` clause when you need extra filters baked into the join.

**Using authoring-grammar functions.** `first()` and `last()` belong to the [`schema.yml` authoring grammar](/reference/sql-expressions#functions) and do not exist at query time — the engine intercepts only `metric()` and `USING('<join_name>')`; anything else passes through to the warehouse and fails. Use window functions and `QUALIFY` instead.

***

## Related reference

* [Metric](/concepts/entity/schema-yml/metric) — how metrics are defined and what `metric()` invokes
* [Feature](/concepts/entity/schema-yml/feature) — entity attributes referenced as columns
* [Relationships](/concepts/entity/schema-yml/relationships) — how `join_name` paths are defined and defaulted
* [SQL expressions](/reference/sql-expressions) — the authoring grammar inside `schema.yml` (distinct from this query dialect)


# REST API

HTTP endpoints for validating the semantic layer and inspecting the data catalog — full documentation in progress.

The Lynk REST API provides HTTP endpoints for working with your semantic layer programmatically — validating a build and inspecting the data catalog (the warehouse tables and columns the layer points at) without using the Lynk UI. Schemas themselves are authored in the `.lynk/` git repository and deployed through the [build](/concepts/project), not edited over the API.

{% hint style="info" %}
Full REST API documentation is in progress. For current API access and endpoint details, contact your Lynk account team or visit [app.getlynk.ai](https://app.getlynk.ai).
{% endhint %}

***

## Related reference

* [Lynk SQL](/api-reference/api/lynk-sql) — the query syntax the agent uses, which you can also use directly
* [Project](/concepts/project) — how a build is validated and deployed before it serves queries


