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.
- Define a metric model over tickets, runs or a record type
- Write measures once, with their filters
- Restrict rows and fields by role
- Let agents answer count and trend questions from the model instead of raw SQL
"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 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.
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
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:
- 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.
- The model's row policies for the caller's role and directory groups.
$useris the caller's name and$groupstheir groups. With no person behind the question (an agent working on its own),$userand$groupsmatch 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.