-
Notifications
You must be signed in to change notification settings - Fork 56
docs: document how query targeting scopes artifact regeneration [IFC-2504] #10293
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: stable
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| # 20. Analyzer is the single source of truth for query targeting | ||
|
|
||
| **Status:** Accepted | ||
| **Date:** 2026-08-14 | ||
| **Author:** @opsmill-team | ||
|
|
||
| ## Context | ||
|
|
||
| Whether a proposed change regenerates one artifact or every artifact under a definition hinges on a | ||
| single verdict: is the definition's GraphQL query guaranteed to resolve to one object? That verdict is | ||
| computed by `GraphQLQueryReport.only_has_unique_targets` in the GraphQL query analyzer, and consumed by | ||
| the proposed change pipeline to choose between a specific and a full regeneration scope. | ||
|
|
||
| The rules behind the verdict are not obvious from a query alone. They depend on the branch's uniqueness | ||
| constraints, on whether a filter argument is a literal, a required variable, or a list, and on every root | ||
| operation in the document rather than just the first. Users had no way to reach the verdict: they | ||
| discovered it at runtime, as an unexpectedly slow pipeline, and could only explain it by reading backend | ||
| source. | ||
|
|
||
| Exposing the verdict meant choosing where the rules live. The tempting shape is to state the rules in | ||
| user documentation and let a lighter-weight check - in the CLI, in the frontend, or in a separate | ||
| validation helper - reproduce them for users. That check would be cheap, would work offline, and would | ||
| not require a server round trip. | ||
|
|
||
| ## Decision | ||
|
|
||
| The analyzer holds the rules, and every consumer reads them from it. Nothing reimplements or restates | ||
| the targeting logic as executable rules. | ||
|
|
||
| - The proposed change pipeline calls `only_has_unique_targets` as it already did. | ||
| - The root GraphQL field `InfrahubGraphQLQueryReport` exposes the same property as | ||
| `targets_unique_nodes`, resolving branch and schema from the request so the answer is computed against | ||
| the branch the query will actually run on. | ||
| - `infrahubctl graphql query-report` calls that GraphQL field. It resolves a query by name and prints the | ||
| verdict; it does not analyze the query locally. | ||
|
|
||
| Documentation describes the rules for comprehension and points at the command for the answer. It is not | ||
| a specification a second implementation is written against. | ||
|
|
||
| ## Consequences | ||
|
|
||
| ### Positive | ||
|
|
||
| - A user's answer and the pipeline's decision cannot disagree, because they are the same computation on | ||
| the same branch schema. | ||
| - Broadening the rules stays a single change. The rules were extended after the introspection query | ||
| shipped - `hfid`, cardinality-one relationships, and composite uniqueness constraints were added - and | ||
| the exposed verdict followed automatically with no second implementation to update. | ||
| - The verdict is reachable before a definition is saved, which is when it is actionable. | ||
|
|
||
| ### Negative | ||
|
|
||
| - Checking a query requires a reachable Infrahub instance and a branch. There is no offline linting of a | ||
| `.gql` file, and none can be added without reintroducing the divergence this decision avoids. | ||
| - Every consumer pays a round trip for a computation that is pure and in-memory on the server. | ||
|
|
||
| ### Neutral | ||
|
|
||
| - The verdict is branch-dependent by construction. The same query can report differently on two branches, | ||
| because uniqueness constraints live in the schema. | ||
| - Documentation of the rules is explanatory and can drift from the analyzer without anything failing. | ||
| Behavior does not drift; only the prose can, so it needs review whenever the rules change. | ||
|
|
||
| ## Alternatives Considered | ||
|
|
||
| ### Reimplement the targeting rules in the CLI or SDK | ||
|
|
||
| Would give offline checks with no server dependency. Rejected: the rules read the branch's uniqueness | ||
| constraints, so an offline implementation would need the schema anyway, and any drift between the two | ||
| implementations produces the worst possible failure - a tool that confidently reports `true` while the | ||
| pipeline regenerates everything. | ||
|
|
||
| ### Document the rules and ship no tooling | ||
|
|
||
| Cheapest option, and where the feature started. Rejected because the rules are subtle enough that reading | ||
| them is not the same as applying them correctly to a specific query, and the failure mode is silent: a | ||
| user gets no signal that a query is expensive until they observe the pipeline. | ||
|
|
||
| ### Return the verdict as a bare `Boolean` field | ||
|
|
||
| Simpler schema for the one question being asked. Rejected in favor of an object type, so the other | ||
| properties the analyzer already computes (`requested_read`, `variables`, `impacted_models`) can be | ||
| surfaced later without a breaking change. | ||
|
|
||
| ## Implementation Notes | ||
|
|
||
| - Rules and consumers: [`dev/knowledge/backend/query-target-uniqueness.md`](../knowledge/backend/query-target-uniqueness.md). | ||
| - Spec: [`dev/specs/archive/ifc-2504-graphql-query-report/research.md`](../specs/archive/ifc-2504-graphql-query-report/research.md) (RES-001). | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| # GraphQL API Standards | ||
|
|
||
| > Part of: `dev/guidelines/backend/` | Related: [Python Standards](python.md), [Python Testing Standards](testing.md) | ||
|
|
||
| <!-- Extracted from specs/ifc-2504-graphql-query-report on 2026-08-14 --> | ||
|
|
||
| Conventions for hand-written additions to Infrahub's GraphQL API. Schema-driven node queries and | ||
| mutations are generated from the schema and are not covered here. | ||
|
|
||
| ## Root-level query fields | ||
|
|
||
| A root-level query field is a custom entry point on the root `Query` type, as opposed to the node | ||
| queries generated from the schema. Adding one takes four pieces, in one new module under | ||
| `backend/infrahub/graphql/queries/`. | ||
|
|
||
| 1. A graphene `ObjectType` describing the response. | ||
| 2. A standalone `async` resolver function, not a method on a class. | ||
| 3. A module-level `Field(...)` bound to that resolver, named exactly as the field should appear in the | ||
| schema. | ||
| 4. Registration: export the `Field` from `queries/__init__.py` (import plus `__all__`), then assign it as | ||
| a class attribute on `InfrahubBaseQuery` in `backend/infrahub/graphql/schema.py`. | ||
|
|
||
| ```python | ||
| class QueryStatistics(ObjectType): | ||
| node_count = Field(Int, required=True, description="Number of nodes matched by the query.") | ||
|
|
||
|
|
||
| async def resolve_query_statistics(_root: None, info: GraphQLResolveInfo, query: str) -> dict[str, int]: | ||
| graphql_context: GraphqlContext = info.context | ||
| ... | ||
|
|
||
|
|
||
| InfrahubQueryStatistics = Field( | ||
| QueryStatistics, | ||
| query=String(required=True, description="The raw GraphQL query string to analyze."), | ||
| description="Return statistics describing how Infrahub will execute a query.", | ||
| resolver=resolve_query_statistics, | ||
| required=True, | ||
| ) | ||
| ``` | ||
|
|
||
| Use a `graphene.Mutation`-style class only for operations that mutate state. | ||
|
|
||
| ### Return a container, not a bare scalar | ||
|
|
||
| Even when the field answers a single yes/no question today, return an `ObjectType` holding that one field | ||
| rather than a bare `Boolean`. Adding a second field to an object type is backward compatible; changing a | ||
| scalar field into an object type is not. | ||
|
|
||
| ### Mark fields required and describe them | ||
|
|
||
| Every field a resolver always populates is declared `required=True`. Give each field and each argument a | ||
| `description` - these strings are the API reference, published in the exported GraphQL schema and read by | ||
| API consumers who cannot see the resolver. Describe what the value means to a caller, not how it is | ||
| computed. | ||
|
|
||
| ## Resolver conventions | ||
|
|
||
| - **Branch comes from the context, never from an argument.** Read `info.context` as `GraphqlContext` and | ||
| use `graphql_context.branch`. Do not add a `branch` argument to a new field; the request already | ||
| carries branch context and every other query resolves it the same way. | ||
| - **Reach the schema branch through the registry.** `GraphqlContext` does not expose `SchemaBranch` | ||
| directly. Use `registry.schema.get_schema_branch(name=graphql_context.branch.name)`. Deriving it from | ||
| `info.schema` does not work: the graphene/graphql-core schema does not carry the Infrahub | ||
| `SchemaBranch`. | ||
| - **Keep the resolver thin.** A resolver adapts the request to a component and shapes the response. When | ||
| it grows real logic, move that logic into a component under `backend/infrahub/` and call it - the | ||
| resolver is not a place where behavior should accumulate. Never reimplement analysis or business rules | ||
| that already exist elsewhere; call the existing owner so the API and the internal consumer cannot | ||
| diverge. | ||
|
|
||
| ## Invalid user input | ||
|
|
||
| Input that a caller controls and can get wrong - a query string, an identifier, a filter expression - | ||
| must fail loudly. | ||
|
|
||
| - Raise a `GraphQLError` so the failure surfaces in the response's `errors` array rather than as an | ||
| unhandled Python exception. | ||
| - Never absorb invalid input into a default or falsy result. A caller who submits a malformed query and | ||
| receives `false` cannot tell a real answer from a swallowed error. | ||
| - Validate before analyzing. Where a helper already exposes a validity check, run it and surface its | ||
| errors rather than letting a downstream call fail in a less legible place. | ||
| - Keep the message about the input. Do not let stack traces or internal paths reach the caller. | ||
|
|
||
| ## Testing | ||
|
|
||
| Resolvers that touch the schema registry or a `SchemaBranch` belong in | ||
| `backend/tests/component/graphql/queries/`, executed through the full GraphQL stack with | ||
| `prepare_graphql_params` rather than by calling the resolver function directly - the wiring into | ||
| `InfrahubBaseQuery` is part of what the test needs to cover. Every input-error path gets its own test | ||
| case; assert on the exact error message, as required by | ||
| [Python Testing Standards](testing.md). | ||
|
|
||
| Resolver logic that operates purely on in-memory inputs still belongs in a unit test. Pick the cheapest | ||
| tier the logic actually needs. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| # Query target uniqueness | ||
|
|
||
| > Part of: `dev/knowledge/backend/` | Related: [display-labels-and-hfid.md](display-labels-and-hfid.md), [schema-definitions.md](schema-definitions.md) | ||
|
|
||
| <!-- Extracted from specs/ifc-2504-graphql-query-report on 2026-08-14 --> | ||
|
|
||
| Infrahub decides how much work a proposed change has to redo by asking one question of every artifact | ||
| definition and generator definition query: **is this query guaranteed to resolve to a single object?** | ||
| The answer is a single boolean, `only_has_unique_targets`, computed by the GraphQL query analyzer. | ||
|
|
||
| When the answer is yes, Infrahub can map a changed node back to the exact artifacts or generator | ||
| instances that depend on it, and regenerate only those. When the answer is no, a changed node cannot be | ||
| attributed to any particular target, so every target of the definition is reprocessed. Users experience | ||
| the difference as either a quick, surgical pipeline or a full regeneration of every artifact under the | ||
| definition. | ||
|
|
||
| ## Where it is computed | ||
|
|
||
| `GraphQLQueryReport.only_has_unique_targets` in `backend/infrahub/graphql/analyzer.py` is the single | ||
| source of truth. It is a pure function of the parsed query document plus the branch's `SchemaBranch` | ||
| (needed to read uniqueness constraints); it runs in memory and issues no database queries. | ||
|
|
||
| `only_has_unique_targets` is `True` only when **every** root operation in the document pins a single | ||
| object. One unfiltered root query anywhere in the document makes the whole report `False`, even if the | ||
| other operations are fully pinned. | ||
|
|
||
| ## The pinning rules | ||
|
|
||
| A root operation pins a single object when either condition holds. | ||
|
|
||
| ### 1. Pinned by identifier | ||
|
|
||
| The operation carries an `ids` or `hfid` filter argument that provides a single value. | ||
|
|
||
| ### 2. Pinned by uniqueness constraint | ||
|
|
||
| Every component of at least one of the model's uniqueness constraints is pinned by a single-valued | ||
| filter argument. Constraint groups are read via | ||
| `model.get_unique_constraint_schema_attribute_paths(...)`, and any group being fully pinned is enough. | ||
|
|
||
| - Attribute component: pinned by `<attribute>__<property>`, where the property defaults to `value`. | ||
| - Relationship component: pinned by `<relationship>__ids` or `<relationship>__hfid`, and **only for | ||
| cardinality-one relationships**. A cardinality-many relationship can never pin a target. | ||
|
|
||
| ### What counts as a single value | ||
|
|
||
| An argument provides a single value when it is one of: | ||
|
|
||
| - A static literal, for example `name__value: "red"`. | ||
| - A required, non-list variable, for example `$name: String!` used as `name__value: $name`. | ||
| - A single-element list literal whose element is either a static literal or a required variable, for | ||
| example `ids: [$id]` with `$id: ID!`. | ||
|
|
||
| A required **list-typed** variable is treated differently depending on where it appears: | ||
|
|
||
| | Position | `$ids: [ID!]!` used directly | Why | | ||
| |----------|------------------------------|-----| | ||
| | Root `ids` / `hfid` filter | Accepted | The target selector is driven once per target member, so at execution time the list carries exactly that member. | | ||
| | Relationship component of a uniqueness constraint (`<rel>__ids`) | Rejected | Nothing constrains the list to one element, so it can match several objects. | | ||
|
|
||
| An optional variable never pins, whatever its type. `$ids: [ID!]` (optional list of required elements) | ||
| is a common near-miss: the elements are non-null but the argument itself may be omitted, so the query | ||
| reports `false`. | ||
|
|
||
| ## What consumes the result | ||
|
|
||
| `get_field_level_impacted_subscribers` in `backend/infrahub/proposed_change/tasks.py` combines the | ||
| uniqueness verdict with the branch diff and returns an `ImpactScope`: | ||
|
|
||
| | Scope | When | Effect | | ||
| |-------|------|--------| | ||
| | `SPECIFIC` | The query pins unique targets. | Only the subscribers linked to the changed nodes are reprocessed, possibly none. | | ||
| | `ALL` | The query does not pin unique targets, but a field the query reads did change. | Every target of the definition is reprocessed. | | ||
| | `NONE` | No node of a queried kind had any of its queried fields modified. | Nothing is reprocessed, regardless of the uniqueness verdict. | | ||
|
|
||
| Two separate gates therefore apply, and uniqueness is only the second one. Field-level relevance comes | ||
| first: `query_report.requested_read` limits "relevant change" to the attributes and relationships the | ||
| query actually reads, so a query that reads `name` is untouched by a change to `description`. Only once | ||
| a relevant change exists does the uniqueness verdict decide between `SPECIFIC` and `ALL`. | ||
|
|
||
| The same helper serves both subscriber kinds: `CoreArtifact` for artifact definitions and | ||
| `CoreGeneratorInstance` for generator definitions. A generator query with unpinned targets pays the same | ||
| full-reprocessing cost an artifact query does. | ||
|
|
||
| ## Inspecting a query | ||
|
|
||
| The verdict is exposed so users never have to reason about the rules above from source code. | ||
|
|
||
| The root GraphQL field `InfrahubGraphQLQueryReport` takes a raw query string and returns | ||
| `targets_unique_nodes`. Branch context is resolved from the request like any other query, and the | ||
| submitted string is validated against that branch's schema before analysis, so an empty string, | ||
| malformed GraphQL, or a reference to an unknown node kind comes back as a GraphQL error rather than a | ||
| default `false`. | ||
|
|
||
| ```graphql | ||
| query ($q: String!) { | ||
| InfrahubGraphQLQueryReport(query: $q) { | ||
| targets_unique_nodes | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| `infrahubctl graphql query-report <name>` wraps that field for the common case. It resolves the query by | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Custom agent: Flag AI Slop and Fabricated Changes This documentation references Prompt for AI agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not a fabrication - the command exists and is already shipped.
So the command is available to anyone running the pinned SDK today, which is why the docs can reference it without a pointer bump. The companion PR (opsmill/infrahub-sdk-python#1253) only rewrites the command's The reason the pointer is deliberately not bumped here is the reverse of what the review assumes: bumping it would make this PR depend on an unmerged SDK commit. |
||
| name from the local `.infrahub.yml`, or from the server's `CoreGraphQLQuery` nodes with `--online`, and | ||
| prints the verdict. This is the check to run against an artifact definition's query before saving it. | ||
|
|
||
| User-facing documentation calls this property a **single-target query**, and | ||
| `docs/docs/development-resources/graphql/single-target-queries.mdx` is where the criteria and the command | ||
| are documented for users. Keep that page in step when the rules change. | ||
|
|
||
| The response type is intentionally a container rather than a bare boolean, so further fields already | ||
| computed by the analyzer (`requested_read`, `variables`, `impacted_models`) can be surfaced later | ||
| without breaking callers. | ||
|
|
||
| ## Gotchas | ||
|
|
||
| - Adding a second, unfiltered root operation to an otherwise well-pinned query silently flips the verdict | ||
| to `false`. This is the most common cause of an unexpected full regeneration. | ||
| - The verdict depends on the branch's schema, because uniqueness constraints live in the schema. The same | ||
| query can report differently on two branches, and changing a model's uniqueness constraints changes how | ||
| its existing queries are scoped. | ||
| - `false` is never incorrect behavior, only expensive behavior: Infrahub falls back to reprocessing every | ||
| target, which is safe but slow. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| # Extraction Record | ||
|
|
||
| **Extracted on**: 2026-08-14 | ||
| **Extracted by**: speckit.opsmill.extract | ||
|
|
||
| ## ADRs Created | ||
|
|
||
| - `dev/adr/0020-analyzer-single-source-of-truth-for-query-targeting.md` (from RES-001) | ||
|
|
||
| ## Knowledge Updated | ||
|
|
||
| - `dev/knowledge/backend/query-target-uniqueness.md` (new file - pinning rules, impact scopes, how to inspect a query) | ||
|
|
||
| ## Guidelines Updated | ||
|
|
||
| - `dev/guidelines/backend/graphql.md` (new file - root-level query fields, resolver conventions, invalid user input, testing) | ||
|
|
||
| ## User-Facing Documentation Updated | ||
|
|
||
| - `docs/docs/artifacts/overview.mdx` (When artifacts regenerate - new "Targeted regeneration and your query" section) | ||
| - `python_sdk/infrahub_sdk/ctl/graphql.py` and the regenerated `python_sdk/docs/docs/infrahubctl/infrahubctl-graphql.mdx` (`query-report` help text). Separate repository - needs its own commit and PR. | ||
|
|
||
| ## Notes | ||
|
|
||
| The uniqueness rules described in `research.md` (RES-001) and `data-model.md` are the original, | ||
| narrower semantics. They were broadened after this spec shipped to cover `hfid`, cardinality-one | ||
| relationships, and composite uniqueness constraints. The extracted documentation describes the | ||
| current behavior, not the spec text. | ||
|
|
||
| ## Archive | ||
|
|
||
| Spec directory moved to `dev/specs/archive/ifc-2504-graphql-query-report/` as a historical record. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
an ADR feels a bit overkill for this feature but I guess our current guidelines to define what should be an ADR or not aren't clearly defined