# Metric layer

Governed metric models that people and agents ask data questions of, compiled to SQL with your tenant boundary and per-role row policies applied.

"How many P1 tickets did the network team close last month?" is a data question. A model writing its own SQL answers it differently every time: a join that double-counts, a status nobody agreed counts as closed, another tenant's rows in the total. The **metric layer** answers it from a definition someone owns, the same way every time.

## Metric models

A metric model describes one subject: tickets, pipeline runs, or one type of [structured record](/docs/connect/erp-sql) such as `order`.

| Part | What it is |
|---|---|
| **Source** | `tickets`, `runs`, or `records` with a `record_type` and its typed `fields` |
| **Dimensions** | What results are grouped and filtered by. A time column can take a grain: `day`, `week`, `month`, `quarter` or `year` |
| **Measures** | `count`, `count_distinct`, `sum`, `avg`, `min` or `max` of a column, each with an optional filter, so "open tickets" is defined once |
| **Joins** | Many-to-one links to another model (a run to its ticket), so its dimensions can slice this model's measures without double-counting |
| **Allowed roles** | Who may ask the model at all (empty: anyone with `metrics.read`) |
| **Row policies** | Conditions added for callers in a role, such as `requester = $user` for requesters or `team in $groups` for agents |

A dimension or measure can also name the roles that may see it. Anyone else is refused that field by name.

Columns come from a fixed catalogue, never from the definition: a model names `priority` or `resolution_hours`, and the SQL for each column is Liyagent's. Record fields are read by name as bound values. Nothing a person or a model writes ever becomes SQL text.

```json
PUT /api/metrics/models/tickets
{
  "source": {"table": "tickets"},
  "dimensions": {"priority": {}, "team": {}, "created_month": {"column": "created_at", "grain": "month"}},
  "measures": {
    "count": {"agg": "count"},
    "open_count": {"agg": "count", "filter": [{"dimension": "status", "op": "in", "values": ["new", "open"]}]},
    "avg_resolution_hours": {"agg": "avg", "column": "resolution_hours", "roles": ["owner", "analyst"]}
  },
  "row_policies": [{"roles": ["requester"], "dimension": "requester", "op": "eq", "value": "$user"}]
}
```

Every save is a new version and the latest is in force; an identical save is not a new version. Deleting a model keeps its history.

## Asking a model

```json
POST /api/metrics/query
{"model": "tickets", "measures": ["count", "open_count"], "dimensions": ["priority"],
 "filters": [{"dimension": "team", "op": "eq", "value": "network"}],
 "order_by": [{"field": "count", "desc": true}], "limit": 20}
```

Filter operators: `eq`, `ne`, `in`, `not_in`, `gt`, `gte`, `lt`, `lte`, `between`, `is_null`, `not_null` and `contains`. Add `"explain": true` to see the compiled SQL, with its values bound rather than inlined.

## Row-level security

Two layers apply to every query:

1. **The tenant boundary, always.** A ticket belongs to its source's tenant, a record to the tenant stamped on it, a run to its ticket's. A joined model's boundary is applied to the join. An instance-wide model serves each tenant its own rows.
2. **The model's row policies** for the caller's role and directory groups. `$user` is the caller's name and `$groups` their groups. With no person behind the question (an agent working on its own), `$user` and `$groups` match nothing.

A record its source restricts with its own permissions is never counted in an aggregate. The answer says how many such rows were left out (`withheld_restricted_rows`), so a partial total is never presented as the whole.

## Agents

When a tenant has at least one model, its managed agents are offered two tools: `metrics_list` (the models and fields open to them) and `metrics_query`. An agent acting for a person asks as that person, with their role and groups, plus the roles `agent` and `agent:<name>` a model can name. Cedar decides each call as `Tool::"ticketiq.metrics/query"`. Agents on an instance with no models see no change, and the existing SQL tools (Jira SQL, the SQL connectors) keep working beside the metric layer. Switch the tools off with `TICKETIQ_SEMANTIC_LAYER_AGENT_TOOLS=false`.

## Permissions and audit

| Permission | Allows | Included in |
|---|---|---|
| `metrics.read` | List and ask metric models | `analytics.read` |
| `metrics.write` | Define and retire metric models | `sources.write` |

Every query is audited as `metrics.query` with the model, its version, the measures, dimensions, number of filters, the row policies applied and the row count, never the rows themselves. A refused field or model is `metrics.query.denied`; saves are `metrics.model.save`.
