Fixing Satisfiability Errors in Federated Composition

A satisfiability error says something no other composition error does: every subgraph is individually valid, every type merges cleanly, and there is still a field the router could never reach. It is a statement about the graph, not about any one schema, which is why the fix is rarely in the subgraph the error names. This page explains what the checker is actually testing, how to read the error, and the four fixes that resolve it. It sits under Resolving Schema Conflicts in Apollo Federation.

When to use this pattern

  • Use it when composition fails with a satisfiability error and the named subgraph looks perfectly correct — because it usually is.
  • Use it when adding a subgraph that contributes fields to an existing entity, which is the change that most often introduces one.
  • Skip it for merge errors and type mismatches, which are about two declarations disagreeing rather than about reachability; those are covered in resolving field type mismatch composition errors.

Prerequisites

What the checker is actually proving

After merging types, composition asks a question about every field in the composed schema: starting from a root field, is there a sequence of fetches that reaches this field? The router can only move between subgraphs through @keys, so the question reduces to whether a path of keys exists from somewhere queryable to the field in question.

A satisfiability error means the answer is no for at least one path. There are only three ways that happens.

No resolvable key. The subgraph declaring the field has an entity whose key no subgraph can resolve — every declaration is resolvable: false, or the owner never implemented __resolveReference.

Mismatched keys. The subgraph that produces references uses one key shape and the subgraph that resolves them declares another. @key(fields: "id") and @key(fields: "sku") describe two different doors, and having both does not mean either connects.

An unreachable island. The field’s type is only ever returned by fields that are themselves unreachable, so a whole region of the graph has no entrance.

Three ways a path stops existing A key nobody can resolve, two subgraphs using different key shapes, and a region of the graph with no queryable entrance are the three causes of a satisfiability error. Every cause is a missing edge, not a bad type 1. no resolvable key Every declaration of the entity is resolvable: false, or the owner has no __resolveReference. Fix: give exactly one subgraph a resolvable key and a reference resolver. 2. mismatched key shapes One subgraph produces references by sku, the other only resolves by id. Fix: add the missing @key to the resolving side, or align on one shape. 3. unreachable island The type is only returned by fields that are themselves unreachable — no entrance exists. Fix: add a root field, or a field on a reachable type that returns it.

Implementation walkthrough

The error text is verbose and contains one genuinely useful part: the path it tried and the reason each attempt failed.

The following supergraph API query:
{ product(id: "1") { reviews { author { loyaltyTier } } } }
cannot be satisfied by the subgraphs because:
- from subgraph "reviews": cannot find field "User.loyaltyTier".
- from subgraph "loyalty": cannot move to subgraph "loyalty" using
  @key(fields: "id") of "User", the key field(s) cannot be resolved
  from subgraph "reviews".

Read it bottom-up. The second bullet is the actual cause: the router wanted to hop into loyalty using User’s id key, and reviews could not supply it. The field named in the first bullet is a symptom.

The fix depends on which of the three causes applies. Here it is cause two, and the resolution is in reviews — the subgraph the error mentions only in passing:

# reviews subgraph — BEFORE: produces a User reference it cannot key
type Review @key(fields: "id") {
  id: ID!
  author: User!
}

type User @key(fields: "email") {   # wrong door
  email: String! @external
}
# reviews subgraph — AFTER: declare the key the resolving subgraph uses
type User @key(fields: "id") {
  id: ID! @external
}

For cause one, the fix belongs with the owner:

# loyalty subgraph — the entity had no resolvable declaration anywhere
type User @key(fields: "id") {      # note: no resolvable: false
  id: ID!
  loyaltyTier: LoyaltyTier!
}
// and the resolver that makes the key actually usable
export const resolvers = {
  User: {
    __resolveReference: (ref: { id: string }, ctx: Context) =>
      ctx.loaders.user.load(ref.id),
  },
};

Verification steps

Compose locally against files rather than the registry, because the loop is seconds instead of minutes:

# supergraph.yaml pointing at local SDL for every subgraph
rover supergraph compose --config ./supergraph.local.yaml > /dev/null \
  && echo "satisfiable"

Then prove the path exists at runtime, not only in theory. Run the exact operation from the error message against the composed graph and confirm it returns data:

query { product(id: "1") { reviews { author { loyaltyTier } } } }

Finally, capture the query plan and confirm the hop you added is being used. A composition that succeeds because you added a second key, while the planner still prefers a longer path, is worth knowing about — it means the fix worked and there may be a cheaper one available.

Adding the matching key restores the path Before, reviews declares User by email while loyalty resolves by id, so no hop exists. After, reviews declares the id key and the router can move from reviews into loyalty. The hop the planner needs before — no matching door reviews: User by email loyalty: User by id after — the shapes match reviews: User by id loyalty: User by id Both subgraphs were individually valid in both diagrams. Only the pair was broken, which is why a local check on one schema can never catch this. Agreeing on one key shape per entity avoids the whole category.

Because the analysis is graph-wide, the practical difficulty is rarely understanding the fix and almost always finding it. Two habits make that search short.

The first is composing locally against files. A supergraph.local.yaml that references each subgraph’s SDL on disk turns a five-minute registry round trip into a one-second loop, which changes the debugging strategy entirely: you can bisect by commenting out subgraphs until the error disappears, and the last one you removed is where the missing edge lives. That technique resolves most satisfiability errors faster than reading the message carefully.

The second is keeping a written record of every entity’s canonical key. One table — entity, key fields, resolving subgraph — makes cause two visible at a glance, because the mismatch is a row that disagrees with itself. Teams that maintain this table stop hitting satisfiability errors almost entirely, not because the errors become easier but because the condition that produces them becomes visible during review rather than at composition.

Both habits share a property worth noticing: they move the graph-wide knowledge somewhere a single person can see it. That is the actual difficulty with satisfiability, and no amount of care within one subgraph substitutes for it.

When the message is long and the cause is not obvious, working the error mechanically beats reading it carefully. Four steps resolve almost every case.

Work it, do not read it Reading the last bullet, identifying the entity and key, listing declarations, and bisecting locally resolves nearly every case. Working a satisfiability error mechanically step do this what it tells you 1 read the LAST bullet, not the first which hop failed 2 find the entity named in that hop which key is involved 3 list every subgraph declaring it who has the wrong shape 4 compose locally, bisect subgraphs the exact pair at fault Step one alone resolves most cases, because the first bullet names a symptom and the last names a cause. Step four is a one-second loop once a local supergraph config exists, and it never guesses wrong.

Common mistakes & gotchas

Editing the subgraph named first in the error. The first bullet names where the router gave up, not where the missing edge is. Read the last bullet, which describes the hop it could not make.

Adding a second key to make the error go away. It often works and leaves the entity with two doors that must both stay resolvable forever. Prefer aligning on the existing key unless a second entry point is genuinely wanted.

Using resolvable: false everywhere. It is correct on stubs that only produce references, and if every declaration has it, nothing can resolve the entity at all. Exactly one subgraph must be able to.

Frequently Asked Questions

Why did this appear when I only added a field?

Because adding a field can create a new path the checker must satisfy. Your field returns a type, that type must be reachable from where your field is, and if the intervening keys do not line up the error surfaces on your change even though the missing edge predates it. It is a real problem your change exposed rather than caused.

Can I suppress it?

Only by removing the unreachable field from the API, which @inaccessible does — and that is a legitimate fix when the field genuinely has no client. Treat it as a decision to withdraw the field rather than as a way to silence the checker, because the field will still be unreachable, just no longer promised.

Does a satisfiability error mean the graph is broken at runtime?

It means it would be, which is why composition refuses to produce the supergraph. Nothing ships, the routers keep serving the previous version, and the failure is a build failure rather than an outage. That is the whole value of the check.

How do I avoid these entirely?

Agree on one key shape per entity across the whole graph, and require that exactly one subgraph declares it resolvably. Nearly every satisfiability error traces back to a violation of one of those two rules, and both are checkable by a linter rather than discovered by a composition failure.

Can the error name a query no client would ever send?

Yes, frequently, and it is still a real finding. The checker proves reachability for every field the schema promises, not only for the operations you happen to run today. A field nobody currently selects is still part of your API, and leaving it unreachable means the first client to try it gets an error rather than data. Either restore the path or withdraw the promise.