How to Build a ServiceNow Semantic Model That Governs Inherited Tables
Declare the inheritance join and the missing company FK, sign them off, and the incidents-by-customer chain comes back governed instead of flagged.
Point an AI at replicated ServiceNow and ask for open Sev-1 incidents by customer. You'll get the right answer with a warning stapled to it, because the join between incident and task is on a shared sys_id and nothing in the warehouse records that the two tables are related at all. The previous post in this series works through why.
This one closes it. The ServiceNow semantic model needs three declarations, and the first is the inheritance itself. Write them once and the same question comes back governed.
Before you start
- ServiceNow replicated into a warehouse. This walkthrough runs on Redshift. Snowflake, BigQuery, and Postgres are identical for these steps.
- Read access. A read-only role is the right one.
- agami-core installed, which is source-available and runs locally.
The work is bounded in a way that's unusual. ServiceNow's hierarchy is a fixed, documented list, so this isn't per-query work or a discovery exercise. It's nine declarations you write once.
Step 1. Introspect, then read what's missing
/agami-connectIt drafts subject areas, tables, columns and types, primary keys, foreign keys, and the relationships it can infer.
Then read what came back for the task hierarchy. On the ServiceNow model we tested against, a replicated estate in a synthetic demo org, the enrichment pass declared 61 relationships, every one of them verified by value overlap and signed off automatically. It found the foreign keys from assets to locations, from catalog tasks to catalog items, from tasks to users, and dozens more.
It declared exactly zero relationships between incident and task.
The only edge in the whole model that points at task is task.parent to task.sys_id, the self-reference. That absence isn't a bug in the enrichment. It's correct behaviour: there's no constraint in the database to read, so there's nothing to infer. Sixty-one successes and one systematic blind spot, in the same pass.
Step 2. Declare the inheritance
Here's the part worth saying plainly, because it's less exotic than it sounds: there is no special inheritance feature to reach for. You declare an ordinary relationship. What makes it unusual is only that both sides are primary keys.
incident and task live in different subject areas, so the edge goes in cross_subject_area_relationships.yaml:
edges:
- from_table: incident
from_column: sys_id
to_table: task
to_column: sys_id
from_schema: incident
to_schema: task
from_subject_area: incident
to_subject_area: task
relationship: one_to_one
join_type: LEFT
confidence: confirmed
review_state: approved
description: incident extends task via table inheritance; the two rows
are the same record and share a sys_id
signed_off_by: dana@example.com
signed_off_role: data_leadrelationship: one_to_one is the line doing the work, and it's the honest cardinality: one incident is exactly one task. Not many_to_one, which is what you'd reach for out of habit on anything that looks like a parent.
The description matters more here than on an ordinary foreign key. Six months from now, sys_id = sys_id will look like a mistake to whoever reads it. The sentence is what stops someone deleting it.
Repeat for the other eight children: incident_task, problem, problem_task, change_request, change_task, sc_request, sc_req_item, and sc_task. If you track hardware assets, alm_hardware extends alm_asset the same way.
Optionally, record the inheritance as metadata too. agami-core's table model carries parent_table and child_table fields for exactly this. They document the intent; the relationship above is what the analyzer acts on.

Same SQL, same rows, different standing.
Step 3. Declare the foreign key that was simply missed
The second flag on that chain has an ordinary cause. task.company points at core.core_company through a real foreign key, and it was absent from the relationships: block:
relationships:
- from_table: task.task
from_column: company
to_table: core.core_company
to_column: sys_id
relationship: many_to_oneWorth doing separately from step 2, and worth noticing that the two failures looked identical in the output and had nothing in common underneath. One was undeclarable by any tool. The other was a gap in review. A model can flag the second class for you; only a person supplies the first.
Step 4. Define the metric, on the parent
With both relationships declared, that query's joins are governed and the answer still arrives ungoverned, because the number itself is hand-rolled SQL that nobody approved.
Here's where ServiceNow's inheritance stops being a problem and starts paying you back. Define the metric on the parent and it covers every child at once:
name: task_made_sla_rate
calculation: Fraction of task where made_sla is true
bindings:
PostgreSQL: AVG(CASE WHEN made_sla THEN 1.0 ELSE 0.0 END)
source_tables: [task]
primary_table: task
confidence: proposed
review_state: approved
signed_off_by: dana@example.com
signed_off_role: data_leadThat's the real metric from the model, with the signer replaced by an illustrative one. Read primary_table. It's task, the parent. One definition, and SLA attainment is answerable for incidents, problems, changes, and catalog requests without writing it four times or reconciling four versions later.
made_sla is a parent field, which is why this works. Anything living on task can be defined once at the top. Anything living on a child, like severity, has to be defined per child, because only incidents have it.

Declared once on the parent, answerable for all nine children.
That's the argument for declaring inheritance rather than working around it with a view per child table. A view gets you the join. The declaration gets you the join and makes the parent a place you can define things once.
Step 5. Verify the chain is governed
This is where ServiceNow differs from the Salesforce pair. There, verification was arithmetic: total the breakdown, compare it against the unjoined measure, and the two numbers had to match. Here nothing was ever numerically wrong, so there's nothing to reconcile. What changed is standing, and standing is what you check.
Run the original question again and read the governance block, not the rows:
lineage.governed_joins:
incident.incident -> task.task on i.sys_id = t.sys_id
core.core_company -> task.task on cc.sys_id = t.company
ungoverned_joins: []
ungoverned_metrics: []
state: governedungoverned_joins: [] is the line that matters. Before the declarations it held two entries, both reading undefined_relationship.
On our model this is locked in by a regression test rather than left to drift: it loads both relationships from the ServiceNow YAML and asserts the analyzer governs the chain. Remove either one and the test fails naming the join. If you're building this for your own estate, that pattern is worth copying. A declaration nobody tests is a declaration somebody deletes.
When it's still flagged
An undefined_relationship after all of the above usually means one of three things. Work down the list:
- A child you didn't declare. Nine is easy to miscount, and
incident_taskandsc_taskare the two most often missed because they sound like sub-objects rather than task children. They aren't. - A join that skips the parent. If a query goes from
incidentstraight tocore_company, there's no declared path, because the company lives ontask. The route is through the parent. - A metric still hand-rolled. Governed joins and a governed number are separate declarations that fail separately, which is exactly what step 4 was for.
What carries over
The specifics are ServiceNow. The pattern isn't.
Every enterprise application encodes at least one relationship its schema can't state. Salesforce hides meaning in __c fields whose names carry none. NetSuite makes one transactions table mean four things depending on a type column. ServiceNow keeps the relationship in application metadata that replication leaves behind.
The response is the same each time. Introspection gets what the schema states. A person states the rest, once, and every answer after that inherits it.
Build one against your own ServiceNow replica.
agami-core is source-available. Point it at your warehouse, declare the hierarchy, and check whether the joins come back governed.
Get agami-core or tell us which report nobody trusts →
Frequently asked questions
How long does declaring the hierarchy take? It's a bounded list rather than an open-ended review. Nine children on task, plus alm_hardware if you track hardware assets. Each is three fields. The time goes into deciding which metrics belong on the parent, which is a modelling question rather than a typing one.
Why declare inheritance instead of building a view per child? A view gives you the join and nothing else. The declaration gives you the join and makes the parent somewhere you can define a metric once for every child, as in step 4. Views also multiply: nine children means nine views to keep in step with the source schema.
What happens when ServiceNow adds a table? Re-introspect. A new child appears as an ordinary table with no relationships, exactly as the existing ones did. Add its three-field declaration. Existing sign-offs are preserved, so you review the delta.
Does this work the same on Snowflake or BigQuery? Yes. The missing relationship is a property of what replication carries, not of the destination. The declarations are identical; only the metric's SQL dialect changes.