From f627ba31b6ee9b82d4c76c92a36e184a9a79bc46 Mon Sep 17 00:00:00 2001 From: Matt Mahoney Date: Tue, 9 Jun 2026 17:50:57 -0400 Subject: [PATCH 1/6] Draft Identity GAP: @strong (GAP-0) --- gaps/GAP-0/DRAFT.md | 142 ++++++++++++++++++++++++++++++++++++++++ gaps/GAP-0/README.md | 65 ++++++++++++++++++ gaps/GAP-0/metadata.yml | 15 +++++ 3 files changed, 222 insertions(+) create mode 100644 gaps/GAP-0/DRAFT.md create mode 100644 gaps/GAP-0/README.md create mode 100644 gaps/GAP-0/metadata.yml diff --git a/gaps/GAP-0/DRAFT.md b/gaps/GAP-0/DRAFT.md new file mode 100644 index 0000000..886f115 --- /dev/null +++ b/gaps/GAP-0/DRAFT.md @@ -0,0 +1,142 @@ +# Identity: @strong + +> [!NOTE] +> This is one of a pair of companion proposals. **Identity: @fetchable** +> ([GAP-00](../GAP-00/README.md) — placeholder number) builds on `@strong` to +> describe types that can be independently (re-)fetched given their identity. The +> two are designed to be read together but may be adopted independently. + +``` +directive @strong(field_name: String) on OBJECT | INTERFACE +``` + +## Introduction + +:: This document specifies the `@strong` schema directive, which marks a type as +having a _strong identity_: an identifier that is unique across all values of the +same type, such that any two values of the type that share that identity denote +the same entity, wherever they appear in a response. + +GraphQL responses frequently describe the same underlying entity in more than one +place — the same user as the `author` of a post and as a `friend` of the viewer, +for example. Whether two such positions denote the _same_ entity is information +that clients today must infer from convention (an `id` field, the `Node` +interface, a Federation `@key`). + +`@strong` makes that explicit: two values of a `@strong` type that share the same +identity are the same entity, wherever they appear. + +Additionally, we add a meta-field to _all_ types, `strong_id__: ID`. When a type +is `@strong`, `strong_id__` is `@semanticNonNull`. If `@strong(field_name: )` +is specified, then `strong_id__` returns the same value as `.`. + +This is an alternative form of identity to the +[Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm). +In particular, `@strong` is particularly useful when: + +- You cannot guarantee globally unique IDs for all objects in the schema. +- You have already created an expensive or non-identifying `id` field that you + cannot migrate away from. +- You need to have transient elements with an identity that cannot be reliably + fetched. +- You need to be able to migrate types from being weak, without a known identity, + to strong, with a specified identity. +- You need to be able to request a _potential_ identity, to ensure abstract types + with mixed strong and weak implementations merge across requests correctly + within a normalized cache. + +The companion `@fetchable` directive (see +[Identity: @fetchable](../GAP-00/README.md)) builds on `@strong` to describe how +a strong identity can be used to (re-)fetch an object from a type-specific root +field. + +**Example** + +```graphql +# `Story` is guaranteed to have an identity, but that identity may be expressed +# by a different field across implementations. +interface Story @strong { + text: String +} + +# `EphemeralStory` has an identity (so it can be merged in a normalized cache) +# but is not, on its own, fetchable. +type EphemeralStory implements Story @strong(field_name: "cache_id") { + cache_id: ID! + text: String +} + +# `PhotoStory` has a strong identity backed by its `id` field. +type PhotoStory @strong(field_name: "id") { + id: ID! + text: String + photo_url: String +} +``` + +**Use Cases** + +- Normalized client caches may merge and deduplicate objects of a `@strong` type + by identity, and may store them in a single canonical location. +- Abstract types with mixed strong and weak implementations may request the + `strong_id__` meta-field to merge correctly across requests. +- Code generators may emit identity-aware types (e.g. cache keys) only for the + types that declare identity. + +With the above example, if we want to write a query that guarantees we can de-duplicate +stories across responses, we might have: +```graphql +query UserStories { + user { + strong_id__ + stories { + strong_id__ + + ... on PhotoStory { + id + photo_url + } + } + } +} +``` +Even though the identity field for `PhotoStory` and `EphemeralStory` are different, +because `Story` is `@strong` we know we can create a key-value store of all Story instances using just `strong_id__` as the key. + +## The strong_id\_\_ meta-field + +A `strong_id__: ID` meta-field is available on every type. + +- If a type is `@strong`, `strong_id__` must be either `strong_id__: ID @semanticNonNull` or `strong_id__: ID!`. +- `<__typename> + strong_id__` forms a globally unique value. +- If a type is not `@strong`, `strong_id__` is `ID` and is always {null}. +- If `@strong(field_name: "")` is specified, `strong_id__` returns the same + value as `.`. + +## @strong on Object types + +For Object types, `@strong(field_name: "")` is non-nullable: the referenced field +must exist and be typed `: ID @semanticNonNull` or `: ID!`. + +## @strong on Interface types + +For Interface types, `@strong(field_name: "")` is optional. If provided, the referenced field +must exist and be typed `: ID @semanticNonNull` or `: ID!`. + +- All types implementing an `@strong` interface must themselves be `@strong`. +- All types implementing an interface with `@strong(field_name: "")` set must + provide the same value `@strong(field_name: "")` value. + + +## New Implementation Recommendations + +This specification _matches behavior in existing implementations_: as this specification is +a result of iterative, in-production implementations, it describes what _is_ +rather than what _ought to be_. + +If you're creating a new GraphQL implementation, you should, if possible: +- Ensure the `@strong(field_name:)` is guaranteed to be a globally-unique value. +- Otherwise, ensure via automation or validation, that `__typename` is fetched wherever necessary to ensure globally-unique identity values can be created. +- Always fetch `strong_id__` on all abstract selection sets, i.e. underneath Union or Interface fields, in order to ensure every + instance of a given concrete Object can be merged, regardless of whether it's fetched underneath a Concrete or Abstract field. + - A non-`@strong` Interface or Union may include `@strong` Object types, which is why `strong_id__` is available as a nullable meta-field on *all* types. diff --git a/gaps/GAP-0/README.md b/gaps/GAP-0/README.md new file mode 100644 index 0000000..35949c7 --- /dev/null +++ b/gaps/GAP-0/README.md @@ -0,0 +1,65 @@ +# GAP-0: Identity: @strong + +> [!NOTE] +> This is a scaffold. The number `GAP-0` is a placeholder until the introducing +> PR is filed (see [CONTRIBUTING.md](../../CONTRIBUTING.md)). Sections marked +> _TODO_ still need to be written. +> +> This proposal has a companion: **Identity: @fetchable** +> ([GAP-00](../GAP-00/README.md) — placeholder number), which builds on `@strong`. + +## Overview + +This proposal defines the **`@strong`** schema directive, which marks a type as +having a _strong identity_: an identifier that is unique across all values of the +same type, such that any two values sharing that identity denote the same entity, +wherever they appear in a response. + +It also defines a `strong_id__: ID` meta-field on every type. For `@strong` +types, `strong_id__` is semantically non-null and, combined with `__typename`, +forms a globally unique value. + +`@strong` is the foundation for the companion **`@fetchable`** directive (see +[Identity: @fetchable](../GAP-00/README.md)): a type must be `@strong` before it +can be `@fetchable`. + +## Motivation + + + +_TODO._ + +## Relationship to prior art + +This is an alternative form of identity to the +[Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm) +(the `Node` interface and `node(id:)` root field). + +Related discussions and prior art: + +- [Global Object Identification](https://relay.dev/graphql/objectidentification.htm). +- [Apollo Federation entities and `@key`](https://www.apollographql.com/docs/federation/entities/). +- [GAP-33 — Set Extensions for Type System Documents](../GAP-33/README.md). +- Companion proposal: [Identity: @fetchable](../GAP-00/README.md). + +## Status + +**Proposal.** Initial draft; not yet sponsored. + +## Challenges and drawbacks + + + +_TODO._ diff --git a/gaps/GAP-0/metadata.yml b/gaps/GAP-0/metadata.yml new file mode 100644 index 0000000..35579b7 --- /dev/null +++ b/gaps/GAP-0/metadata.yml @@ -0,0 +1,15 @@ +id: 0 +title: "Identity: @strong" +summary: > + A schema directive marking a type as having a strong identity — an identifier + unique across all values of the same type — plus a `strong_id__` meta-field on + every type. Foundation for the companion `@fetchable` directive. +status: proposal +authors: + - name: "Matt Mahoney" + email: "mahoney.mattj@gmail.com" + githubUsername: "@mjmahone" +sponsor: "@TBD" +discussion: "https://github.com/graphql/gaps/pull/0" +related: + - 33 From 30d9732148640879a1efad03cae8911cab0b8dd3 Mon Sep 17 00:00:00 2001 From: Matt Mahoney Date: Tue, 9 Jun 2026 17:57:16 -0400 Subject: [PATCH 2/6] Renumber to GAP-54 and update companion references to GAP-55 --- gaps/{GAP-0 => GAP-54}/DRAFT.md | 4 ++-- gaps/{GAP-0 => GAP-54}/README.md | 12 +++++------- gaps/{GAP-0 => GAP-54}/metadata.yml | 5 +++-- 3 files changed, 10 insertions(+), 11 deletions(-) rename gaps/{GAP-0 => GAP-54}/DRAFT.md (97%) rename gaps/{GAP-0 => GAP-54}/README.md (83%) rename gaps/{GAP-0 => GAP-54}/metadata.yml (86%) diff --git a/gaps/GAP-0/DRAFT.md b/gaps/GAP-54/DRAFT.md similarity index 97% rename from gaps/GAP-0/DRAFT.md rename to gaps/GAP-54/DRAFT.md index 886f115..824b9df 100644 --- a/gaps/GAP-0/DRAFT.md +++ b/gaps/GAP-54/DRAFT.md @@ -2,7 +2,7 @@ > [!NOTE] > This is one of a pair of companion proposals. **Identity: @fetchable** -> ([GAP-00](../GAP-00/README.md) — placeholder number) builds on `@strong` to +> ([GAP-55](../GAP-55/README.md)) builds on `@strong` to > describe types that can be independently (re-)fetched given their identity. The > two are designed to be read together but may be adopted independently. @@ -46,7 +46,7 @@ In particular, `@strong` is particularly useful when: within a normalized cache. The companion `@fetchable` directive (see -[Identity: @fetchable](../GAP-00/README.md)) builds on `@strong` to describe how +[Identity: @fetchable](../GAP-55/README.md)) builds on `@strong` to describe how a strong identity can be used to (re-)fetch an object from a type-specific root field. diff --git a/gaps/GAP-0/README.md b/gaps/GAP-54/README.md similarity index 83% rename from gaps/GAP-0/README.md rename to gaps/GAP-54/README.md index 35949c7..a5bf011 100644 --- a/gaps/GAP-0/README.md +++ b/gaps/GAP-54/README.md @@ -1,12 +1,10 @@ -# GAP-0: Identity: @strong +# GAP-54: Identity: @strong > [!NOTE] -> This is a scaffold. The number `GAP-0` is a placeholder until the introducing -> PR is filed (see [CONTRIBUTING.md](../../CONTRIBUTING.md)). Sections marked -> _TODO_ still need to be written. +> This is a scaffold. Sections marked _TODO_ still need to be written. > > This proposal has a companion: **Identity: @fetchable** -> ([GAP-00](../GAP-00/README.md) — placeholder number), which builds on `@strong`. +> ([GAP-55](../GAP-55/README.md)), which builds on `@strong`. ## Overview @@ -20,7 +18,7 @@ types, `strong_id__` is semantically non-null and, combined with `__typename`, forms a globally unique value. `@strong` is the foundation for the companion **`@fetchable`** directive (see -[Identity: @fetchable](../GAP-00/README.md)): a type must be `@strong` before it +[Identity: @fetchable](../GAP-55/README.md)): a type must be `@strong` before it can be `@fetchable`. ## Motivation @@ -47,7 +45,7 @@ Related discussions and prior art: - [Global Object Identification](https://relay.dev/graphql/objectidentification.htm). - [Apollo Federation entities and `@key`](https://www.apollographql.com/docs/federation/entities/). - [GAP-33 — Set Extensions for Type System Documents](../GAP-33/README.md). -- Companion proposal: [Identity: @fetchable](../GAP-00/README.md). +- Companion proposal: [Identity: @fetchable](../GAP-55/README.md). ## Status diff --git a/gaps/GAP-0/metadata.yml b/gaps/GAP-54/metadata.yml similarity index 86% rename from gaps/GAP-0/metadata.yml rename to gaps/GAP-54/metadata.yml index 35579b7..595bb64 100644 --- a/gaps/GAP-0/metadata.yml +++ b/gaps/GAP-54/metadata.yml @@ -1,4 +1,4 @@ -id: 0 +id: 54 title: "Identity: @strong" summary: > A schema directive marking a type as having a strong identity — an identifier @@ -10,6 +10,7 @@ authors: email: "mahoney.mattj@gmail.com" githubUsername: "@mjmahone" sponsor: "@TBD" -discussion: "https://github.com/graphql/gaps/pull/0" +discussion: "https://github.com/graphql/gaps/pull/54" related: - 33 + - 55 From 41d4e73fdca3aa1c6cdb3d7e0d6b491e87a952e5 Mon Sep 17 00:00:00 2001 From: Matt Mahoney Date: Tue, 9 Jun 2026 18:03:02 -0400 Subject: [PATCH 3/6] Remove unrelated GAP-33 from related/prior art --- gaps/GAP-54/README.md | 1 - gaps/GAP-54/metadata.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/gaps/GAP-54/README.md b/gaps/GAP-54/README.md index a5bf011..afd9c53 100644 --- a/gaps/GAP-54/README.md +++ b/gaps/GAP-54/README.md @@ -44,7 +44,6 @@ Related discussions and prior art: - [Global Object Identification](https://relay.dev/graphql/objectidentification.htm). - [Apollo Federation entities and `@key`](https://www.apollographql.com/docs/federation/entities/). -- [GAP-33 — Set Extensions for Type System Documents](../GAP-33/README.md). - Companion proposal: [Identity: @fetchable](../GAP-55/README.md). ## Status diff --git a/gaps/GAP-54/metadata.yml b/gaps/GAP-54/metadata.yml index 595bb64..39e7a0a 100644 --- a/gaps/GAP-54/metadata.yml +++ b/gaps/GAP-54/metadata.yml @@ -12,5 +12,4 @@ authors: sponsor: "@TBD" discussion: "https://github.com/graphql/gaps/pull/54" related: - - 33 - 55 From 16973ea6d0e476f18565bb185ae44d6de116c0f4 Mon Sep 17 00:00:00 2001 From: Matt Mahoney Date: Wed, 10 Jun 2026 11:15:53 -0400 Subject: [PATCH 4/6] Flesh out @strong Motivation and Challenges sections --- gaps/GAP-54/DRAFT.md | 11 ++-- gaps/GAP-54/README.md | 114 +++++++++++++++++++++++++++++++++++------- 2 files changed, 102 insertions(+), 23 deletions(-) diff --git a/gaps/GAP-54/DRAFT.md b/gaps/GAP-54/DRAFT.md index 824b9df..86d5aa3 100644 --- a/gaps/GAP-54/DRAFT.md +++ b/gaps/GAP-54/DRAFT.md @@ -19,20 +19,19 @@ the same entity, wherever they appear in a response. GraphQL responses frequently describe the same underlying entity in more than one place — the same user as the `author` of a post and as a `friend` of the viewer, -for example. Whether two such positions denote the _same_ entity is information -that clients today must infer from convention (an `id` field, the `Node` -interface, a Federation `@key`). +for example. Reconciling two response values is often done via convention, for +instance by assuming two values with the same `id` field value are the same. -`@strong` makes that explicit: two values of a `@strong` type that share the same +`@strong` makes expectations explicit: two values of a `@strong` type that share the same identity are the same entity, wherever they appear. -Additionally, we add a meta-field to _all_ types, `strong_id__: ID`. When a type +We add a meta-field to _all_ types, `strong_id__: ID`. When a type is `@strong`, `strong_id__` is `@semanticNonNull`. If `@strong(field_name: )` is specified, then `strong_id__` returns the same value as `.`. This is an alternative form of identity to the [Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm). -In particular, `@strong` is particularly useful when: +In particular, `@strong` may be useful when: - You cannot guarantee globally unique IDs for all objects in the schema. - You have already created an expensive or non-identifying `id` field that you diff --git a/gaps/GAP-54/README.md b/gaps/GAP-54/README.md index afd9c53..f97e08b 100644 --- a/gaps/GAP-54/README.md +++ b/gaps/GAP-54/README.md @@ -23,16 +23,28 @@ can be `@fetchable`. ## Motivation - +This proposal documents how `@strong` is already defined and used, in production. +`@strong` enabled Meta to solve specific performance and behavioral issues, and at the time +acted as a foundation for more advanced features. -_TODO._ +Specifically, `@strong` enabled production schemas to safely evolve while preserving +long-lived, compiled clients' store behavior, in a way that was not possible with [Global Object Identification](https://relay.dev/graphql/objectidentification.htm). + +The Global Object Identification Spec conflates *identity* with an object's *retrieval token*. + +If an object is ephemeral and not retrievable from the service, there is either no way to retrieve the object via `node(id: $id)`, or the `id` must encode the entirety of the object's data. In practice, +this led to either producing `id` values that were kilobytes in size, or failing to fulfill the +[Global Object Identification Spec](https://relay.dev/graphql/objectidentification.htm) by having types that +implement `Node` but return responses with `"id": null`. + +The key insight for `@strong` was that we needed a single meta-field, `strong_id__: ID`, that can be requested within *any* selection set, which does *not* guarantee we can retrieve the same object from the service. We need to be able to request `strong_id__` on *abstract* (Union and Interface) type selections. Unions and Interfaces may mix strong and weak types, and we want to ensure that strong values are always consistent. + +`strong_id__` allows clients using a consistent, normalized store to: +- Always request a single field, and know whether to treat a given object in the response as "strong" or "weak" based on whether `strong_id__` is null or not. +- If `strong_id__` is always requested, weak objects (say, an Address type), can become `@strong`, and get a consistency "fix" on old clients. +- For objects that have large retrieval keys, storing the retrieval key as the *key in the store* can cause performance degradation on store publish and lookups. + - If the `id` field is incredibly large, we can create a new field, `cache_id`, that is a one-way hash of `id`'s value. + - By migrating from `type HasLargeKey @strong(field_name: "id")` to `type HasLargeKey @strong(field_name: "cache_id")`, we can ship the new more-performant keys to existing clients using `strong_id__`, provided the client does not treat `strong_id__` as an alias for `id`, or vice versa. ## Relationship to prior art @@ -52,11 +64,79 @@ Related discussions and prior art: ## Challenges and drawbacks - - -_TODO._ +`@strong` has some thorny issues that require thoughtful adoption. + +**Reader Confusion**: When people see an `id: ID!` field in a selection set, they tend to assume that's the field that will return a retrievable, global identity. `@strong` upends that: it may be true in almost all cases, but if there is any divergence it will typically be for your *most important* cases. If you *can* make `id` the cheap identity field for every object in your schema, you should, whether or not you adopt `@strong`. + +**Non-global identity**: `@strong` does not *require* an object's `strong_id__` be globally unique. +In practice, this means clients need to request another field, such as `__typename`, which is merged +with `strong_id__` to create the object's key for the client normalized store. + +The reason to *not* make `strong_id__` a hash of `__typename` and `field_name:` was specifically so that `strong_id__` and the `field_name:` could +be considered the same value. In retrospect, this is dangerous behavior to rely on, as described below. + +**Using `strong_id__` as the value for `id` lookups is not safe for schema evolution**: +Even if a type is `@strong(field_name: "id")`, while it is extremely tempting for performance reasons, it is not safe to de-duplicate the `id` and `strong_id__` in the selection set: the `field_name` may change over time, and if `id` is used for the purpose of making a follow-up query, `strong_id__` is not guaranteed to match forever into the future. + +If you de-duplicate the `` and `strong_id__`, or use `strong_id__` for client logic, +it becomes a breaking change for your existing clients to change the `field_name:` value. + +**Treating a `"strong_id__": null` as a single shared object**: +It is easy to assume that, whenever you see a `strong_id__` field, that it will have a non-null value. +The `@strong` spec *specifically* allows for `null` values for weak, non-identifiable object types. + +For a query like: +``` +query { + __typename + strong_id__ + node(id: 123) { + __typename + strong_id__ + ... on User { + address { + __typename + strong_id__ + street + } + } + } +} +``` +with a response like: +``` +{ + "data": { + "__typename": "Query", + "strong_id__": "Query", + "node": { + "__typename": "User", + "strong_id__": "123", + "address": { + "__typename": "Address", + "strong_id__": null, + "street": "Hacker Way" + } + } + } +} +``` + +The client consistency store should probably look something like: +``` +{ + "Query:Query": { + "node(id:123)": "User:123" + }, + "User:123": { + "address": { + "street": "Hacker Way" + } + } +} +``` + +As the `Address` type is a weak object, it can't have an identity independent of the parent strong `User`. + +Note in this example, `Query` has an identity: it's extremely convenient to treat every instance of the `Query` type as a single, shared `@strong` type. +Similarly, it may be convenient for the other root types, `Mutation` and `Subscription`, to each have a singular identity value. From d5ad0928444f72ace3615fd84335f67b73a08ba4 Mon Sep 17 00:00:00 2001 From: Matt Mahoney Date: Wed, 10 Jun 2026 15:52:45 -0400 Subject: [PATCH 5/6] Add GAP-49 (@semanticNonNull) to @strong related --- gaps/GAP-54/metadata.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/gaps/GAP-54/metadata.yml b/gaps/GAP-54/metadata.yml index 39e7a0a..421ca9b 100644 --- a/gaps/GAP-54/metadata.yml +++ b/gaps/GAP-54/metadata.yml @@ -12,4 +12,5 @@ authors: sponsor: "@TBD" discussion: "https://github.com/graphql/gaps/pull/54" related: + - 49 - 55 From a10ad5d3215f7d519279abde615a93742f811b19 Mon Sep 17 00:00:00 2001 From: Matt Mahoney Date: Wed, 10 Jun 2026 16:10:23 -0400 Subject: [PATCH 6/6] Add Relay reference-implementation links to @strong prior art --- gaps/GAP-54/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/gaps/GAP-54/README.md b/gaps/GAP-54/README.md index f97e08b..b844be1 100644 --- a/gaps/GAP-54/README.md +++ b/gaps/GAP-54/README.md @@ -58,6 +58,20 @@ Related discussions and prior art: - [Apollo Federation entities and `@key`](https://www.apollographql.com/docs/federation/entities/). - Companion proposal: [Identity: @fetchable](../GAP-55/README.md). +**Reference implementation.** Relay already carries (partial, partly +undocumented) support for these annotations: + +- Relay's directive docs name both annotations under + [`@refetchable(..., preferFetchable:)`](https://relay.dev/docs/api-reference/graphql/graphql-directives/): + it is "useful for schemas that have adopted the `@strong` and `@fetchable` + server annotations". +- The `strong_id__` meta-field is defined in the Relay compiler — see + `strongid_field_name: "strong_id__"` in + [`compiler/crates/schema/src/in_memory.rs`](https://github.com/facebook/relay/blob/main/compiler/crates/schema/src/in_memory.rs). +- "Strong" objects are recognized as those implementing `Node` — see the + reserved-`id` error in + [`compiler/crates/relay-transforms/src/errors.rs`](https://github.com/facebook/relay/blob/main/compiler/crates/relay-transforms/src/errors.rs). + ## Status **Proposal.** Initial draft; not yet sponsored.