Skip to content

feat: report the schema fields the write contract does not apply - #1223

Merged
dgarros merged 3 commits into
infrahub-developfrom
dg-schema-load-extra-field-feedback
Aug 2, 2026
Merged

feat: report the schema fields the write contract does not apply#1223
dgarros merged 3 commits into
infrahub-developfrom
dg-schema-load-extra-field-feedback

Conversation

@dgarros

@dgarros dgarros commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What changed

The generated write models set extra="ignore", so a field the user may not set never reaches the server. That settled whether such a field has an effect, but not whether the author hears about it — a misspelled key produced a schema quietly different from the one they wrote.

Every extra key in a submitted payload is now classified:

  • a name the contract knows at that location but the user may not set is dropped and reported as a warning, so a schema read back from Infrahub, edited and loaded again keeps working;
  • any other name is an error, since the only ways to produce one are a typo and a field that no longer exists.
nodes[0].attributes[0].inherited: Read-only field, the submitted value is ignored (received: True)
nodes[0].attributes[0].optionl: Unknown field, it is not part of the schema (received: True)

Implementation notes

A generated table, not a third model family. infrahub_sdk/schema/generated/contract.py holds the non-settable field names of each write class (17 entries). It is produced by the Infrahub-side generator from two diffs: the read variant of a class against its write variant, and the internal pydantic counterpart of a value model against its generated write model. The second diff matters — the parameters/choice/computed-attribute/extension models are hand-declared in the generator, so they omit the id/state bookkeeping every internal schema model carries, and each computed-attribute variant omits its siblings' fields. Those names all appear in a schema dumped from Infrahub's own models, so treating them as unknown would have broken the round trip.

Applying the table needs to know which model governs each place in the payload. _collect_extra_fields walks the raw payload alongside the validated write document: the document resolves the model at every location, including which member of a discriminated union an attribute matched, so raw keys are compared against the fields that location actually accepts. This is why the walk gives exact dotted paths and the owning kind/element for free, and it avoids both a second generated model family and a hand-rolled discriminator resolver.

One consequence, documented in the docstring: extra fields surface only once the payload is otherwise valid, because the validated document is what resolves the contract. A payload rejected for another reason names its extra fields on the next run.

API changes

  • SchemaValidationResult gains warnings (list[SchemaValidationWarningDetail]) and warning_messages. Each warning carries the dotted path, the bare field name, and the schema kind / element that set it.
  • client.schema.validate() returns the verdict instead of None, and raises ValueError rather than a pydantic ValidationError. ValidationError subclasses ValueError, so except ValueError callers are unaffected; a caller catching ValidationError specifically needs updating.
  • validate_schema_content_and_exit() no longer takes client — it never needed a server.
  • infrahubctl validate schema no longer builds a client, so it works with no server configured. It reports warnings and errors; infrahubctl schema load / schema check report errors locally and leave warnings to the server response, which already carries them, to avoid printing each one twice.

Two shapes that previously passed and now fail

Both were silently dropped before, so the loaded schema differed from the authored one:

  1. a misspelled field name;
  2. attribute parameters belonging to a different attribute kind — for example start_range on a Number attribute, which configured nothing.

Testing

  • tests/unit/test_schema_offline_validation.py restructured: read-only cases assert the exact set of warning paths, unknown-field cases assert the exact set of error paths. Covers every nesting level (root, node, generic, attribute, relationship, parameters, choices, computed_attribute, extensions.nodes[*].attributes), the value-model bookkeeping fields, the sibling-variant field, and the reporting-order consequence above.
  • Full unit suite: 1735 passed. Three pre-existing failures in test_repository_app.py / test_task_app.py (rich table width mismatches) fail identically on a clean tree.
  • invoke format and invoke lint-code (ruff, ty, mypy) clean.

Companion PR

contract.py is generated by invoke backend.generate in the Infrahub repo. The Infrahub side, which generates it and consumes the warnings on POST /api/schema/load, is opsmill/infrahub#10095.

Note this branch is based on the commit the Infrahub release-1.11 submodule currently pins, so it sits behind infrahub-develop; the changes to ctl/schema.py on develop are additive, in a different part of the file.

🤖 Generated with Claude Code

@dgarros
dgarros requested a review from a team as a code owner July 31, 2026 06:28
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying infrahub-sdk-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: c257f7c
Status: ✅  Deploy successful!
Preview URL: https://e36ea6a2.infrahub-sdk-python.pages.dev
Branch Preview URL: https://dg-schema-load-extra-field-f.infrahub-sdk-python.pages.dev

View logs

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.88889% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
infrahub_sdk/ctl/schema.py 80.00% 3 Missing ⚠️
infrahub_sdk/schema/validate.py 93.87% 2 Missing and 1 partial ⚠️
infrahub_sdk/ctl/validate.py 81.81% 1 Missing and 1 partial ⚠️
infrahub_sdk/schema/__init__.py 66.66% 1 Missing ⚠️
@@                 Coverage Diff                  @@
##           infrahub-develop    #1223      +/-   ##
====================================================
- Coverage             85.21%   84.00%   -1.21%     
====================================================
  Files                   146      147       +1     
  Lines                 15605    13063    -2542     
  Branches               2631     1932     -699     
====================================================
- Hits                  13298    10974    -2324     
+ Misses                 1642     1522     -120     
+ Partials                665      567      -98     
Flag Coverage Δ
integration-tests 38.95% <8.64%> (-2.60%) ⬇️
python-3.10 56.83% <65.43%> (-1.33%) ⬇️
python-3.11 56.85% <65.43%> (-1.31%) ⬇️
python-3.12 56.83% <65.43%> (-1.31%) ⬇️
python-3.13 56.83% <65.43%> (-1.33%) ⬇️
python-3.14 56.85% <65.43%> (-1.31%) ⬇️
python-filler-3.12 23.61% <23.45%> (-0.96%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
infrahub_sdk/schema/generated/__init__.py 100.00% <100.00%> (ø)
infrahub_sdk/schema/generated/contract.py 100.00% <100.00%> (ø)
infrahub_sdk/schema/__init__.py 73.50% <66.66%> (-5.92%) ⬇️
infrahub_sdk/ctl/validate.py 49.29% <81.81%> (-2.82%) ⬇️
infrahub_sdk/ctl/schema.py 72.51% <80.00%> (-1.33%) ⬇️
infrahub_sdk/schema/validate.py 94.44% <93.87%> (-0.91%) ⬇️

... and 23 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

All reported issues were addressed across 8 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread infrahub_sdk/schema/validate.py Outdated

@ajtmccarty ajtmccarty left a comment

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.

one idea for a different approach while I review the tests and the other PR. there's also a cubic comment that I'm not really sure about

console.print(f"[red]Unable to find {schema}")
raise typer.Exit(1)

client = initialize_client()

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.

nice to see the client removed here

Comment thread infrahub_sdk/schema/validate.py Outdated
"""
model = type(instance)
fields = model.model_fields
read_only = _read_only_fields(model)

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.

I would think that model.model_fields would include all fields defined on the model including those inherited, in which case passing fields into _read_only_fields would let you skip the __mro__ loop. but I could definitely be missing something

except PydanticValidationError as exc:
_collect_validation_errors(exc=exc, errors=errors)
else:
_collect_extra_fields(payload=schema, instance=validated, errors=errors, warnings=warnings)

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.

this looks like it works, but it is hard to follow and uses some patterns that aren't quite code smells, but are a little suspicious

  • __mro__ access
  • a lot of isinstance, getattr, and .get() that are brittle

I have an idea for another approach that might be worth. I haven't tested it and could definitely be wrong about it.

  1. first deserialize the request into the Read model, capturing any errors. I think this would cover the removed field and typo cases
  2. transfer the Read instance to the corresponding Write model, capturing anything dropped/rejected as warnings

again, not sure if this would work, but it would be nice to let Pydantic do this work for us

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.

Fair on all three, and two of them are pushed in c257f7c.

__mro__ is gone — the generator now resolves the parent chain when it emits the table, so each entry is complete per class and the lookup is a plain dict access. The defensive isinstance checks on the raw payload have collapsed into a single contract guard at the top of the walk; the ones that remain dispatch on the validated value's shape rather than second-guessing the input. Doing that surfaced a latent bug I'd otherwise have shipped: _descend_context reads keys off the raw value, so it had to move inside the guard instead of running before it.

I did try the read→write idea. It has a hole worth knowing about: the read root only declares nodes and generics, so with extra="ignore" step 1 silently eats version and the entire extensions block, and step 2 then fails version: Field required on every payload — with extension attributes never validated or loaded. Step 1 also only catches typos if the read models are extra="forbid", and that breaks the read path, since kind is a computed field on read and a forbid-read model rejects the exact body GET /api/schema emits ([('kind',)] ['extra_forbidden']).

read root fields : ['generics', 'nodes']
write root fields: ['extensions', 'generics', 'nodes', 'version']

dgarros and others added 2 commits August 2, 2026 12:28
The write models set extra="ignore", so a field the user may not set never
reaches the server. That decided the field has no effect but left the author
with no feedback, so a misspelled key produced a schema quietly different from
the one they wrote.

Classify every extra key instead. A name the contract knows at that location
but the user may not set is reported as a warning and still dropped, so a
schema read back from Infrahub keeps loading; any other name is an error.

The split is driven by a new generated artifact, schema/generated/contract.py,
holding the non-settable field names of each write class. Applying it needs to
know which model governs each place in the payload, so _collect_extra_fields
walks the raw payload alongside the validated write document: the document
resolves the model at every location, including which member of a discriminated
union an attribute matched. One consequence is that extra fields surface only
once the payload is otherwise valid.

validate_schema() now returns warnings alongside errors, and client.schema.validate()
reaches the same verdict -- raising ValueError rather than a pydantic
ValidationError, and returning the verdict when the payload is accepted.
infrahubctl validate schema reports both offline; schema load/check report errors
locally and leave the warnings to the server response, which already carries them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A finding carried the bare key, so `parameters.id` was reported as `id`
against the owning attribute -- claiming a field is read-only that is in fact
settable there -- and collided with an `id` reported from another block when
consumers group findings by name.

Qualify the name with the fields walked since the last kind or element, which
re-anchor the identity a finding is reported against. `inherited` on an
attribute is unchanged; `parameters.id` and `extensions.id` now say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dgarros
dgarros force-pushed the dg-schema-load-extra-field-feedback branch from 758065b to 83b52d2 Compare August 2, 2026 10:32
The table held each class's own fields and the lookup unioned them across the
model's MRO, reaching into the generated hierarchy from the consumer side.
Resolve the inheritance in the generator instead, so the emitted table is
already complete per class and the lookup is a plain dict access.

Fold the paired defensive isinstance checks on the raw payload into one contract
guard at the top of the walk, which also covers the context resolution now that
it happens there. Every remaining isinstance dispatches on the validated value's
shape rather than second-guessing the input.

Record on the walk why it pairs the payload with the validated model: neither
side alone carries both the dropped keys and the model governing each location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="infrahub_sdk/schema/validate.py">

<violation number="1" location="infrahub_sdk/schema/validate.py:168">
P1: Custom agent: **Flag AI Slop and Fabricated Changes**

The new exact class-name lookup for read-only fields (`READ_ONLY_FIELDS.get(type(instance).__name__, frozenset())`) removes the previous MRO-based safety net, but the generated `contract.py` map is incomplete for concrete parameter subclasses. `AttributeParametersWrite` is listed with `{"id", "state"}`, yet `TextAttributeParametersWrite`, `ListAttributeParametersWrite`, and other subclasses that inherit from it are absent from `READ_ONLY_FIELDS`. Because those subclasses do not declare `id` or `state` themselves, a round-trip payload containing those bookkeeping keys under `parameters` will be classified as hard errors instead of warnings, breaking the documented round-trip guarantee that read-only fields are warned and dropped. Consider restoring the MRO walk or ensuring the generator emits entries for every concrete subclass that can appear in a validated instance.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

)

fields = type(instance).model_fields
read_only = READ_ONLY_FIELDS.get(type(instance).__name__, frozenset())

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

The new exact class-name lookup for read-only fields (READ_ONLY_FIELDS.get(type(instance).__name__, frozenset())) removes the previous MRO-based safety net, but the generated contract.py map is incomplete for concrete parameter subclasses. AttributeParametersWrite is listed with {"id", "state"}, yet TextAttributeParametersWrite, ListAttributeParametersWrite, and other subclasses that inherit from it are absent from READ_ONLY_FIELDS. Because those subclasses do not declare id or state themselves, a round-trip payload containing those bookkeeping keys under parameters will be classified as hard errors instead of warnings, breaking the documented round-trip guarantee that read-only fields are warned and dropped. Consider restoring the MRO walk or ensuring the generator emits entries for every concrete subclass that can appear in a validated instance.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At infrahub_sdk/schema/validate.py, line 168:

<comment>The new exact class-name lookup for read-only fields (`READ_ONLY_FIELDS.get(type(instance).__name__, frozenset())`) removes the previous MRO-based safety net, but the generated `contract.py` map is incomplete for concrete parameter subclasses. `AttributeParametersWrite` is listed with `{"id", "state"}`, yet `TextAttributeParametersWrite`, `ListAttributeParametersWrite`, and other subclasses that inherit from it are absent from `READ_ONLY_FIELDS`. Because those subclasses do not declare `id` or `state` themselves, a round-trip payload containing those bookkeeping keys under `parameters` will be classified as hard errors instead of warnings, breaking the documented round-trip guarantee that read-only fields are warned and dropped. Consider restoring the MRO walk or ensuring the generator emits entries for every concrete subclass that can appear in a validated instance.</comment>

<file context>
@@ -140,24 +132,40 @@ def _descend_context(
+        )
+
+    fields = type(instance).model_fields
+    read_only = READ_ONLY_FIELDS.get(type(instance).__name__, frozenset())
 
     for key in sorted(set(payload) - set(fields)):
</file context>
Suggested change
read_only = READ_ONLY_FIELDS.get(type(instance).__name__, frozenset())
read_only = frozenset()
for klass in type(instance).__mro__:
read_only |= READ_ONLY_FIELDS.get(klass.__name__, frozenset())

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.

False positive — the premise is wrong. TextAttributeParametersWrite and ListAttributeParametersWrite are both in READ_ONLY_FIELDS, and were before this change too. From contract.py at the reviewed commit:

"AttributeParametersWrite": frozenset({"id", "state"}),
"ListAttributeParametersWrite": frozenset({"id", "state"}),
"NumberAttributeParametersWrite": frozenset({"id", "state"}),
"NumberPoolParametersWrite": frozenset({"id", "state"}),
"TextAttributeParametersWrite": frozenset({"id", "state"}),

Each parameters class is mapped to its own internal counterpart when the table is built, so it gets its own entry rather than relying on the base. The behaviour predicted to break does not:

Text        valid=True  warnings=['parameters.id', 'parameters.state']  errors=0
Number      valid=True  warnings=['parameters.id', 'parameters.state']  errors=0
List        valid=True  warnings=['parameters.id', 'parameters.state']  errors=0
NumberPool  valid=True  warnings=['parameters.id', 'parameters.state']  errors=0
Dropdown    valid=True  warnings=['parameters.id', 'parameters.state']  errors=0

The general concern behind it is reasonable, though: with a lookup by exact class name there is nothing to catch a generator that forgets a class. That is now covered by a test asserting no generated write class inherits a read-only field its own entry omits — it passes on the current table, and fails with TextAttributeWrite: ['inherited'], NodeSchemaWrite: ['hash', 'kind'] and five more if the generator regresses to emitting own-fields-only.

Not taking the suggested MRO walk: it was removed deliberately in this commit, and resolving the inheritance once in the generator is what makes the lookup a plain dict access.

@dgarros
dgarros merged commit f9e28cf into infrahub-develop Aug 2, 2026
21 checks passed
@dgarros
dgarros deleted the dg-schema-load-extra-field-feedback branch August 2, 2026 13:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants