Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Mark deprecated docs clearly. Don't delete—update with pointers to replacement

- **Repository Organization**: [guidelines/repository-organization.md](guidelines/repository-organization.md) - How to organize content in dev/
- **Python Backend**: [guidelines/backend/python.md](guidelines/backend/python.md)
- **GraphQL API**: [guidelines/backend/graphql.md](guidelines/backend/graphql.md) - Conventions for hand-written GraphQL queries and resolvers
- **TypeScript Frontend**: [guidelines/frontend/typescript.md](guidelines/frontend/typescript.md)
- **Git Workflow**: [guidelines/git-workflow.md](guidelines/git-workflow.md)
- **Markdown Formatting**: [guidelines/markdown.md](guidelines/markdown.md)
Expand All @@ -61,6 +62,11 @@ Backend architecture documentation in [knowledge/backend/](knowledge/backend/):
- [events.md](knowledge/backend/events.md) - Events system
- [async-tasks.md](knowledge/backend/async-tasks.md) - Asynchronous tasks (Prefect)
- [message-bus.md](knowledge/backend/message-bus.md) - Message bus system
- [query-target-uniqueness.md](knowledge/backend/query-target-uniqueness.md) - How query targeting scopes artifact and generator regeneration

## Current ADRs

Architecture Decision Records in [adr/](adr/) - see the [ADR index](adr/README.md) for the full list.

## Current Guides

Expand Down
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

Copy link
Copy Markdown
Collaborator

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


**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).
3 changes: 2 additions & 1 deletion dev/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ We document significant architectural decisions using ADRs.
| [0003](0003-asynchronous-tasks.md) | Asynchronous Tasks Execution with Prefect | Accepted | 2024-12-26 |
| [0004](0004-message-bus.md) | Message Bus Architecture | Accepted | 2024-12-26 |
| [0005](0005-account-group-origin-attribute.md) | `origin` Attribute for `CoreAccountGroup` Provenance Tracking | Accepted | 2025-05-13 |
| [0020](0020-analyzer-single-source-of-truth-for-query-targeting.md) | Analyzer is the single source of truth for query targeting | Accepted | 2026-08-14 |

## Creating a New ADR

1. Copy `template.md` to `NNNN-short-title.md` (use next sequential number)
1. Copy `template.md` to `NNNN-short-title.md`. Pick the next number that is free on **every** long-lived branch, not only the one you are on - `develop` and the `release-*` branches usually carry ADRs that have not reached `stable` yet, so the index here can have gaps.
2. Fill in all sections
3. Submit as PR for review
4. Update this index when merged
Expand Down
95 changes: 95 additions & 0 deletions dev/guidelines/backend/graphql.md
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.
123 changes: 123 additions & 0 deletions dev/knowledge/backend/query-target-uniqueness.md
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Flag AI Slop and Fabricated Changes

This documentation references infrahubctl graphql query-report, but the command does not exist in this repository after the change. The PR description confirms the python_sdk submodule pointer was deliberately not bumped, and the spec archive notes the SDK change needs a separate commit and PR. Per Rule 1, docs should not reference commands that are not implemented by the changed code. Either remove the reference until the SDK PR lands, or add a note that the command requires the companion SDK update.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At dev/knowledge/backend/query-target-uniqueness.md, line 103:

<comment>This documentation references `infrahubctl graphql query-report`, but the command does not exist in this repository after the change. The PR description confirms the `python_sdk` submodule pointer was deliberately not bumped, and the spec archive notes the SDK change needs a separate commit and PR. Per Rule 1, docs should not reference commands that are not implemented by the changed code. Either remove the reference until the SDK PR lands, or add a note that the command requires the companion SDK update.</comment>

<file context>
@@ -0,0 +1,123 @@
+}
+```
+
+`infrahubctl graphql query-report <name>` wraps that field for the common case. It resolves the query by
+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.
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a fabrication - the command exists and is already shipped.

infrahubctl graphql query-report was added to the SDK in opsmill/infrahub-sdk-python#976 (c26809b), which is an ancestor of the submodule commit this repo currently pins:

$ git -C python_sdk merge-base --is-ancestor c26809b 13f26b0 && echo yes
yes

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 --help text; it does not introduce the command.

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.
32 changes: 32 additions & 0 deletions dev/specs/archive/ifc-2504-graphql-query-report/EXTRACTED.md
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.
Loading
Loading