How to Query ServiceNow Data with AI When the Join Isn't a Foreign Key

DRAFT. Incident extends task, so the join between them is not a foreign key. Introspection has nothing to infer and the governance analyzer never sees the join.

ServiceNow incident task join: incident extends task through inheritance, so the two tables share a sys_id and no foreign key connects them
A foreign key is in the database. Inheritance is in ServiceNow, and replication leaves it there. Source: Agami original diagram.

Ask replicated ServiceNow for open Sev-1 incidents by customer and you get an answer. It's even the right answer. It just arrives with a warning attached:

This result wasn't produced entirely from your organization's approved definitions, so it may differ from your official reports. Use with care.

That warning is the interesting part. The SQL is correct, the numbers are correct, and the system is still telling you it can't vouch for them. Working out why leads somewhere specific about how ServiceNow stores data, and it isn't a quirk of one table. It's true of nine of them.

The question

"Which customers have the most open critical incidents?" It's the first thing a service owner asks once ServiceNow lands in a warehouse, and it's checkable: one row per company, one count, ordered.

Three tables answer it. incident.incident holds severity. task.task holds whether the record is still active. core.core_company holds the customer name.

What breaks

Here's the query. It runs against a ServiceNow estate replicated into Redshift. The numbers below come from a synthetic demo org we maintain for exactly this, so the company names are invented, but the schema, the join, and the governance output are the real thing:

SELECT cc.name AS company,
       COUNT(DISTINCT CASE WHEN i.severity = 1 THEN t.sys_id END) AS open_sev1
FROM incident.incident i
JOIN task.task t ON i.sys_id = t.sys_id
JOIN core.core_company cc ON cc.sys_id = t.company
WHERE t.active = true
GROUP BY cc.name
ORDER BY open_sev1 DESC;

It runs, and it returns exactly what you'd want:

Company Open Sev-1
Helios Aerospace 7
Pinecrest Avionics 3
Maplewood Diagnostics 2
Norwood Machinery 2
Yellowstone Devices 2

Look at the join on the second line, though. i.sys_id = t.sys_id. Not a foreign key pointing at a parent. The two tables are joined on the same primary key value, because an incident and its task are the same record.

That's the shape that breaks things, and it broke this exact query before anyone declared it. The live logs show this chain coming back flagged:

ungoverned_joins: [
  { from: "incident.incident", to: "task.task",       reason: "undefined_relationship" },
  { from: "task.task",         to: "core.core_company", reason: "undefined_relationship" }
]

Two flags, two different causes, which is what makes this worth an article rather than a bug report.

Why it breaks

Inheritance isn't a relationship anything can read

ServiceNow's data model is built on table inheritance. incident extends task, and so do eight other things:

The task hierarchy: nine tables extend task.task, each sharing its parent's sys_id, with no foreign key between any of them

Nine children, one shared primary key, zero declared relationships.

Every one of those children shares its parent's sys_id. The parent carries the fields they all have (state, priority, active, assigned_to, opened_at, and about twenty more) and each child carries only what's specific to it. On incident, that's severity, incident_state, category, caller_id, resolved_at and a dozen others.

It's a good design. It's why you can report across every work item in one query without a union.

It also means there is no foreign key between incident and task. Point introspection at the replicated schema and it finds two tables, each with a primary key, and nothing declaring that they're related. There's no constraint to read, no column named task_id, nothing to infer from. The relationship exists in ServiceNow's application metadata, and replication doesn't bring that along.

So the governance analyzer never sees the join. Not because it's wrong, but because nothing told it the join exists.

The second flag is the ordinary kind

task.company points at core.core_company through a real foreign key. That one introspection can see. It was simply missing from the model's relationships: block.

Worth separating, because the fixes are different. One is a declaration nobody could have inferred. The other is a declaration somebody skipped.

The failure mode is a warning, not a wrong number

This is where ServiceNow differs from a fan-out problem. When a Salesforce query joins across a one-to-many and sums the parent's amount, you get a confidently wrong number and nothing tells you. Here the number is right, and the system says it can't stand behind it.

That sounds milder. It's arguably worse in practice, because an ungoverned answer that happens to be correct today teaches the team to ignore the warning. The next one won't be correct.

The fix, named

The declaration that closes the first gap is not a special inheritance feature. It's an ordinary relationship, saying out loud what no constraint implies:

- from_table: incident
  from_column: sys_id
  to_table: task
  to_column: sys_id
  relationship: one_to_one
  description: incident extends task; the two rows share a sys_id

Once that's in the model, the analyzer knows i.sys_id = t.sys_id is a sanctioned path rather than an unexplained equality between two primary keys. Adding the task.company foreign key closes the second.

Run the same SQL against the model with both declared, and the governance block comes back like this:

governed_joins: [
  { from: "incident.incident",  to: "task.task", on: "i.sys_id = t.sys_id" },
  { from: "core.core_company",  to: "task.task", on: "cc.sys_id = t.company" }
]
ungoverned_joins: []

That's the state the model is in today, and it's locked in by a regression test: if either relationship is removed, the test fails with the join chain named. Building it, and the choices inside it, is the next post in this series.

One thing that fix does not cover, and the reason the warning at the top of this article is still there: the metric itself. COUNT(DISTINCT CASE WHEN i.severity = 1 THEN t.sys_id END) is still hand-rolled SQL rather than a definition anyone signed off. The joins are governed; the number isn't. Those are separate declarations and they fail separately.

Reproduce it yourself

You don't need a warehouse to see the shape.

  1. Get a Personal Developer Instance. Free, full admin, pre-loaded with demo data. It's reclaimed after ten days of inactivity, so do this when you have an hour rather than bookmarking it.
  2. Open any incident record, then look at the same record through the task table. Same sys_id, different visible fields. That's the inheritance, in the UI.
  3. List what extends task. In the platform's table configuration, filter tables by their parent. You'll get the nine above.
  4. Check for a foreign key between them. There isn't one. That's the whole article in one observation.

If you've already replicated ServiceNow into a warehouse, the faster version: introspect it and read what came back. Count how many relationships were inferred between incident and task. The answer is zero, and now you know why.

What carries over

The specifics here are ServiceNow. The shape isn't.

Every enterprise application encodes relationships somewhere its schema can't express. Salesforce hides meaning in __c custom fields whose names say nothing. NetSuite uses a polymorphic transactions table where the row's type decides what the row means. ServiceNow puts the relationship in application metadata that replication leaves behind.

In each case introspection does what it can, then stops at the boundary of what the schema actually states. The rest is a declaration somebody has to write down once.

Bring us the join your schema doesn't declare.

agami-core is source-available. Point it at your replicated ServiceNow, and see which relationships come back inferred and which come back empty.

Get agami-core or tell us which report nobody trusts →

Frequently asked questions

Why can't introspection just detect the inheritance? Because the schema doesn't record it. In a replicated warehouse, incident and task are two ordinary tables that happen to share primary key values. The parent-child relationship lives in ServiceNow's application metadata, which replication tools don't carry across. There's nothing in the target database for introspection to read.

Does a database view fix this? It fixes the query, not the governance. A view can pre-join incident and task so analysts don't have to, and plenty of teams do exactly that. But the view is another object that has to be described before anything can reason about what it means, and it multiplies once you want the same treatment for problem, change, and the request tables.

Is this specific to Redshift? No. The example here runs on Redshift because that's where the replica we tested against lives, but the cause is upstream of the warehouse. Snowflake, BigQuery and Postgres all receive the same two tables with the same missing relationship.

How many of these declarations will I need? Nine, for the task hierarchy, plus one more if you use hardware assets, which extend alm_asset the same way. It's a bounded list you write once, not per-query work.

References

  1. ServiceNow: table extension and inheritance
  2. ServiceNow Community: relationship between the task and incident tables
  3. ServiceNow Personal Developer Instance
  4. agami-core semantic model format specification
  5. agami-core on GitHub