Using @tag and @inaccessible to Build Contract Variants

@tag and @inaccessible look like a pair of similar directives for hiding things, and treating them that way is the fastest route to a broken contract. This page shows exactly what each one does to a composed schema, how a contract filter reads them, and the tagging discipline that keeps a filtered variant composable as the graph grows. It sits under Contracts and Schema Variants for API Consumers.

When to use this pattern

  • Use @tag when different audiences should see different subsets of the same graph, and the number of audiences is small and stable — a public API, a partner API, an internal API.
  • Use @inaccessible when a schema element must exist for composition but must never appear in any API schema, such as a key field a subgraph needs but nobody should query, or a field being introduced before its consumers are ready.
  • Skip both when the difference is per caller. A field one customer may see and another may not is an authorization concern, handled by directive patterns for cross-service authorization, not by a schema filter.

Prerequisites

The two directives are not alternatives

@tag is metadata and nothing else. Adding it changes no behaviour whatsoever: the field still composes, still appears in every API schema, and still resolves exactly as before. The tag is recorded in the supergraph so that something else — a contract filter — can act on it later. A graph with tags and no contracts behaves identically to a graph with no tags at all.

@inaccessible is behaviour. It removes the element from the API schema of the supergraph, in every variant, unconditionally. The element remains in the subgraph schema and remains usable by the router internally, which is precisely why it exists: a field can participate in a @key or a @requires while being invisible to clients.

The consequence is that the two operate at different times and answer different questions. @tag answers “which audiences care about this?” and defers the decision. @inaccessible answers “should anyone be able to query this?” and settles it immediately.

A tag defers a decision; @inaccessible settles one Across a source variant and two contract variants, a tagged field appears wherever its tag is included, while an inaccessible field is absent from every variant including the source. Same subgraph, three API schemas field as declared source partner public name @tag("public") present present present wholesaleCost @tag("partner") present present filtered out internalMargin @inaccessible absent absent absent untagged field present filtered out filtered out The last row is the surprise: with an include-list filter, untagged means excluded everywhere but the source.

Implementation walkthrough

Everything below lives in one subgraph and needs no coordination beyond agreeing the tag names. Import both directives, then tag transitively: the type, the fields that audience needs, and the types those fields return.

# catalog/schema.graphql
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.9",
        import: ["@key", "@tag", "@inaccessible", "@shareable"])

type Query {
  # Root fields need tagging too, or the audience has no entry point.
  product(id: ID!): Product @tag(name: "public") @tag(name: "partner")
}

type Product @key(fields: "id") @tag(name: "public") @tag(name: "partner") {
  id: ID!                @tag(name: "public") @tag(name: "partner")
  name: String!          @tag(name: "public") @tag(name: "partner")
  # Money is returned by a tagged field, so Money must be tagged as well.
  price: Money!          @tag(name: "public") @tag(name: "partner")
  wholesaleCost: Money!  @tag(name: "partner")
  # Needed by the pricing subgraph's @requires; never queryable by anyone.
  internalMargin: Float! @inaccessible
}

type Money @tag(name: "public") @tag(name: "partner") {
  amount: Int!           @tag(name: "public") @tag(name: "partner")
  currencyCode: String!  @tag(name: "public") @tag(name: "partner")
}

enum ProductStatus @tag(name: "public") @tag(name: "partner") {
  ACTIVE       @tag(name: "public") @tag(name: "partner")
  DISCONTINUED @tag(name: "public") @tag(name: "partner")
  # An internal-only state that partners must not learn about.
  EMBARGOED    @inaccessible
}

Three details in that snippet do real work. Tagging Query.product matters because a contract with no reachable root field is a schema nobody can query, and composition will happily produce it. Tagging Money matters because price returns it, and a retained field returning a filtered type is a composition error. Tagging the individual enum values matters because enum values are filterable elements in their own right — leaving EMBARGOED accessible in a partner contract leaks a business state through nothing more than introspection.

The repetition is real and it is the main practical complaint about tagging. Two mitigations help. Generate the tags where the audience is derivable from a naming convention or a directive you already apply, and lint for the transitive rule so a missing tag on a returned type fails locally rather than in contract composition.

The tag has to reach all the way down A contract keeps a path only if every element along it is tagged: the root field, the object type, the field itself, and the type that field returns. A gap anywhere breaks the chain. A contract keeps whole paths, not individual fields Query.product tagged ✓ type Product tagged ✓ field price tagged ✓ type Money untagged ✗ Result: contract composition fails Product.price survives the filter but returns Money, which the filter removed. The same chain applies to interfaces, unions, input types and enum values. Anything reachable from a retained field must itself be retained, which is why a linter is a better tool than a review. Scalars are the exception — built-in scalars are always available.

Verification steps

Confirm the subgraph still composes with the directives present:

rover subgraph check my-graph@current \
  --name catalog --schema ./catalog/schema.graphql

Then compose the contract and compare its API schema against what you intended:

rover contract publish my-graph@public \
  --source-variant my-graph@current --include-tag public

rover graph fetch my-graph@public > public.graphql
grep -c "wholesaleCost\|internalMargin\|EMBARGOED" public.graphql   # expect 0
grep -c "type Money" public.graphql                                 # expect 1

The second grep is the one that catches transitive mistakes. A contract missing an internal field is what you asked for; a contract missing Money means price went with it, and the API you just published is quietly smaller than you think.

Finally, query the contract router for a field that should not exist and confirm the error is a validation error rather than an authorization error:

query { product(id: "P-1") { wholesaleCost { amount } } }
# expected: Cannot query field "wholesaleCost" on type "Product"

That exact message is the signal that filtering worked. An authorization-style denial would mean the field is still in the schema and something else rejected it, which is a different — and weaker — guarantee.

One habit is worth adopting before the first contract exists: write the tag vocabulary down somewhere every subgraph team reads, with one sentence per tag describing the audience it names. Tags are strings compared exactly, they are referenced by filters nobody in a given repository can see, and a typo produces a silently smaller API rather than an error. A three-line document and a lint rule that rejects unknown tag names removes an entire category of incident for effectively no cost, and it also makes the review question concrete — instead of “is this field sensitive?”, the reviewer asks “which of these four audiences needs it?”, which is a question with a checkable answer.

Before tagging a large schema it is worth knowing which kinds of element a filter can act on at all, because that determines how carefully you have to read each type. Object types, fields, enum values and arguments are all independently filterable — more surface than most people expect — while built-in scalars never are. The argument row is the one that causes quiet breakage, since removing an argument leaves the field in place and changes its signature underneath callers who were passing it.

What a contract filter can act on Object types, fields, enum values and arguments are all filterable. Built-in scalars are always present. Removing an argument changes the signature of a field that otherwise survives. Where each element type can carry a tag schema element taggable and filterable? what its removal does object type yes takes all its fields with it field yes disappears alone enum value yes value vanishes from the enum argument yes changes a surviving field's signature built-in scalar no always available Arguments are the sharp edge: the field survives, its signature changes, and callers passing it break. Read a type's arguments as carefully as its fields when deciding what an audience needs.

Common mistakes & gotchas

Tagging the field but not the type. A field survives the filter only if its parent type does. Tagging Product.name while leaving type Product untagged removes both, and the resulting contract is missing an entire type for a reason that reads as unrelated.

Assuming @inaccessible hides data. It removes an element from the API schema of the supergraph. The subgraph still declares and serves it, so a caller who can reach the subgraph directly is unaffected. Network isolation of subgraphs is what makes @inaccessible a boundary rather than a convention.

Forgetting enum values and arguments. Both are independently filterable. An untagged argument on a retained field disappears from the contract, which changes the field’s signature for that audience — usually breaking whoever was passing it.

Frequently Asked Questions

Can one element carry several tags?

Yes, and it is the normal case. @tag(name: "public") @tag(name: "partner") marks a field for two audiences, and a filter that includes either tag keeps it. There is no ordering or precedence between tags on the same element; membership is a simple union.

Does an exclude tag beat an include tag?

Always. If an element carries both an included and an excluded tag, it is removed. That precedence makes exclusion useful as a safety net: tag anything experimental with a single internal-only tag, exclude that tag from every contract, and no include-list mistake can leak it.

Should key fields be tagged?

They do not need to be — a surviving entity keeps its key fields automatically, because the router cannot resolve the entity without them. Tagging them anyway is harmless and some teams do it for consistency. What you must not do is mark a key field @inaccessible and expect the entity to still resolve across subgraph boundaries in that variant.

Can I introduce a field to the graph before any audience should see it?

Yes, and this is one of the better uses of @inaccessible. Ship the field marked inaccessible so it composes and can be wired up and tested internally, then swap the directive for the appropriate tags when the audience is ready. Composition is exercised the whole time, so the enabling change is a one-line edit rather than a new field landing untested.

How do I audit what a contract actually exposes?

Fetch the variant’s API schema and read it, rather than reasoning about the filter. rover graph fetch my-graph@public returns exactly what that audience sees, which is the only artifact that reflects every tag, every exclusion, and every transitive removal at once. Diff that file between releases in CI and you have a reviewable record of your public surface changing over time — far more useful than reviewing tag diffs across a dozen subgraph repositories.