Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
fe0d631
docs(spec): specify user-facing schema separation [INFP-234]
Jul 1, 2026
3b79693
docs(plan): plan user-facing schema separation [INFP-234]
Jul 1, 2026
7976eb4
docs(critique): critique user-facing schema separation + apply must-a…
Jul 1, 2026
30a158a
docs(tasks): task breakdown for user-facing schema separation [INFP-234]
Jul 1, 2026
0bbdd4a
docs(spec): alignment check for user-facing schema separation [INFP-234]
Jul 1, 2026
234b483
docs(research): SDK schema-model audit + towncrier types [INFP-234]
Jul 3, 2026
c6b42e9
feat(schema): add field visibility axis and generate write/read SDK m…
dgarros Jul 3, 2026
ab5bdf6
feat(schema): reject non-write fields at schema load boundary [INFP-234]
dgarros Jul 3, 2026
6cd7ac3
feat(schema): consume SDK offline write-validation at load boundary […
dgarros Jul 3, 2026
8e774c1
test(schema): verify read-back visibility and id authorization for us…
dgarros Jul 3, 2026
492e6ec
docs(schema): changelog, migration note, and knowledge for write-cont…
dgarros Jul 3, 2026
264fb46
fix(schema): gate extensions, guard settable fields, harden task shell
dgarros Jul 3, 2026
9630b8b
docs(report): implementation report for user-facing schema separation…
dgarros Jul 3, 2026
f9c4c64
docs(changelog): satisfy release-notes style [INFP-234]
dgarros Jul 4, 2026
81bfb24
refactor(schema): name generated SDK models InfrahubSchema{Write,Read…
dgarros Jul 5, 2026
32bd22f
feat(schema): generate typed ComputedAttribute block in SDK write/rea…
dgarros Jul 5, 2026
ae4c7c4
feat(schema): generate typed sub-block models for user-facing SDK schema
dgarros Jul 5, 2026
687373d
feat(schema): generate kind-discriminated attribute SDK models
dgarros Jul 5, 2026
12a22a9
feat(schema): generate SDK extension write models and profile/templat…
dgarros Jul 6, 2026
341b488
feat(schema): declare SDK read/write models on FastAPI schema endpoin…
dgarros Jul 6, 2026
2b1e6a7
test(schema): align extension/parity load tests with discriminated-un…
dgarros Jul 6, 2026
baad9b9
feat(schema): generate dedicated enum classes for SDK schema models
dgarros Jul 6, 2026
4ce7610
fix(schema): generate enum-member defaults for SDK write/read models …
dgarros Jul 6, 2026
154c6ac
chore(schema): regenerate openapi.json for enum-typed schema models […
dgarros Jul 6, 2026
bcf6d31
chore(frontend): regenerate REST types for enum-typed schema models […
dgarros Jul 7, 2026
2cfb3d1
test(schema): accept dedicated enum classes in write-model allowed-va…
dgarros Jul 7, 2026
cf25235
feat(schema): generate kind/hash on SDK read models, drop from API mo…
dgarros Jul 7, 2026
e18cee8
feat(schema): generate kind as a property on SDK write schema nodes […
dgarros Jul 8, 2026
1d9945c
docs(spec): move PRD and field classification into the feature spec d…
dgarros Jul 9, 2026
18d7f09
chore: stop tracking .specify/feature.json (now gitignored) [INFP-234]
dgarros Jul 9, 2026
a4e00a4
ci(schema): validate generated SDK schema and protocols are in sync […
dgarros Jul 9, 2026
b654344
refactor(schema): return SDK enum specs as a dataclass, not a 3-tuple…
dgarros Jul 14, 2026
b53b6ce
fix(schema): classify upstream `ordered` attribute field as write-vis…
dgarros Jul 14, 2026
d0af4ec
fix(frontend): align schema consumers with restructured read-schema t…
dgarros Jul 15, 2026
55fe3d4
chore(sdk): bump submodule for schema-load write-contract normalizati…
dgarros Jul 16, 2026
38f841a
chore(sdk): rebase submodule onto latest infrahub-develop [INFP-234]
dgarros Jul 16, 2026
f67c6ae
fix(schema): tolerate read-only fields at the schema-load boundary [I…
dgarros Jul 16, 2026
4b8ee94
test(schema): cover schema-load read-only tolerance and rejection bou…
dgarros Jul 16, 2026
408ba99
fix(schema): coerce field discriminator to str in write projection [I…
dgarros Jul 16, 2026
439b04a
fix(schema): render internal schema-load errors as field-level messag…
dgarros Jul 16, 2026
8e4e234
fix(schema): only reject unknown fields early, defer other invariants…
dgarros Jul 16, 2026
65cde1b
test(schema): update schema fixtures from removed 'String' attribute …
dgarros Jul 16, 2026
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
3 changes: 0 additions & 3 deletions .specify/feature.json

This file was deleted.

135 changes: 105 additions & 30 deletions backend/infrahub/api/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,29 @@
from typing import TYPE_CHECKING, Any, Sequence

from fastapi import APIRouter, Depends, Query, Request
from infrahub_sdk.schema.generated.read import (
GenericSchemaRead,
NodeSchemaRead,
ProfileSchemaRead,
TemplateSchemaRead,
)
from infrahub_sdk.schema.generated.write import InfrahubSchemaWrite
from infrahub_sdk.schema.validate import validate_schema as validate_write_schema
from pydantic import (
BaseModel,
Field,
PrivateAttr,
computed_field,
create_model,
model_validator,
)
from pydantic import ValidationError as PydanticValidationError
from starlette.responses import JSONResponse

from infrahub import lock
from infrahub.api.dependencies import get_branch_dep, get_context, get_current_user, get_db, get_permission_manager
from infrahub.api.exceptions import SchemaNotValidError
from infrahub.api.schema_write_projection import project_to_write_contract
from infrahub.branch.status_checker import BranchStatusChecker
from infrahub.core import registry
from infrahub.core.account import GlobalPermission
Expand Down Expand Up @@ -75,35 +86,26 @@ def from_schema(cls, schema: MainSchemaTypes) -> Self:
data["relationships"] = [
relationship.model_dump() for relationship in schema.relationships if not relationship.internal_peer
]
# ``kind`` is a computed field on the generated read model (derived from namespace+name);
# only ``hash`` needs to be supplied here since it is computed by the server.
Comment on lines +89 to +90

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 don't think this comment is necessary

data["hash"] = schema.get_hash()
return cls(**data)

@model_validator(mode="before")
@classmethod
def set_kind(cls, values: Any) -> Any:
if isinstance(values, dict):
values["kind"] = f"{values['namespace']}{values['name']}"
return values


class APINodeSchema(NodeSchema, APISchemaMixin):
api_kind: str | None = Field(default=None, alias="kind", validate_default=True)
hash: str
class APINodeSchema(NodeSchemaRead, APISchemaMixin):
pass


class APIGenericSchema(GenericSchema, APISchemaMixin):
api_kind: str | None = Field(default=None, alias="kind", validate_default=True)
hash: str
class APIGenericSchema(GenericSchemaRead, APISchemaMixin):
pass


class APIProfileSchema(ProfileSchema, APISchemaMixin):
api_kind: str | None = Field(default=None, alias="kind", validate_default=True)
hash: str
class APIProfileSchema(ProfileSchemaRead, APISchemaMixin):
pass


class APITemplateSchema(TemplateSchema, APISchemaMixin):
api_kind: str | None = Field(default=None, alias="kind", validate_default=True)
hash: str
class APITemplateSchema(TemplateSchemaRead, APISchemaMixin):
pass


class SchemaReadAPI(BaseModel):
Expand All @@ -115,8 +117,71 @@ class SchemaReadAPI(BaseModel):
namespaces: list[SchemaNamespace] = Field(default_factory=list)


class SchemaLoadAPI(SchemaRoot):
def _format_schema_errors(errors: list[Any]) -> list[str]:
"""Render internal-schema validation errors as field-level messages.

The dotted path indexes list elements with ``[i]`` (``nodes[0].relationships[0].cardinality``)
and appends the offending scalar value so the message names both the field and what was received.
"""
messages: list[str] = []
for error in errors:
parts: list[str] = []
for element in error["loc"]:
if isinstance(element, int) and parts:
parts[-1] = f"{parts[-1]}[{element}]"
else:
parts.append(str(element))
message = f"{'.'.join(parts)}: {error['msg']}"
received = error.get("input")
if isinstance(received, (str, int, float, bool)) or received is None:
message += f" (received: {received!r})"
messages.append(message)
return messages


class SchemaLoadAPI(InfrahubSchemaWrite):
version: str
_internal_schema: SchemaRoot = PrivateAttr()

@model_validator(mode="before")
@classmethod
def validate_write_contract(cls, data: Any) -> Any:
# A submitted schema may legitimately carry read-only/internal fields — a schema read back
# from Infrahub, or a full internal dump. The internal schema accepts those but still
# rejects fields that are unknown or invalid for their context (a typo, or a node-only
# field on an extension), drawing the tolerate/reject line a uniform extra="forbid" cannot.
if isinstance(data, dict):
try:
SchemaRoot.model_validate(data)

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: Malformed schema attributes missing kind or name can now produce an unhandled KeyError rather than a 422 validation response because the internal validation call invokes direct dictionary indexing and the handler catches only PydanticValidationError. Keeping this preflight validation from propagating non-Pydantic exceptions would retain the API's validation-error behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/infrahub/api/schema.py, line 133:

<comment>Malformed schema attributes missing `kind` or `name` can now produce an unhandled `KeyError` rather than a 422 validation response because the internal validation call invokes direct dictionary indexing and the handler catches only `PydanticValidationError`. Keeping this preflight validation from propagating non-Pydantic exceptions would retain the API's validation-error behavior.</comment>

<file context>
@@ -122,11 +124,21 @@ class SchemaLoadAPI(InfrahubSchemaWrite):
+        # field on an extension), drawing the tolerate/reject line a uniform extra="forbid" cannot.
         if isinstance(data, dict):
+            try:
+                SchemaRoot.model_validate(data)
+            except PydanticValidationError as exc:
+                messages = [f"{'.'.join(str(part) for part in err['loc'])}: {err['msg']}" for err in exc.errors()]
</file context>

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.

P2: Schema-load requests that explicitly send extensions: null now fail validation even though InfrahubSchemaWrite accepts that value. Normalizing a null extension to an omitted/empty extension before validating with SchemaRoot would preserve the public write contract.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/infrahub/api/schema.py, line 133:

<comment>Schema-load requests that explicitly send `extensions: null` now fail validation even though `InfrahubSchemaWrite` accepts that value. Normalizing a null extension to an omitted/empty extension before validating with `SchemaRoot` would preserve the public write contract.</comment>

<file context>
@@ -122,11 +124,21 @@ class SchemaLoadAPI(InfrahubSchemaWrite):
+        # field on an extension), drawing the tolerate/reject line a uniform extra="forbid" cannot.
         if isinstance(data, dict):
+            try:
+                SchemaRoot.model_validate(data)
+            except PydanticValidationError as exc:
+                messages = [f"{'.'.join(str(part) for part in err['loc'])}: {err['msg']}" for err in exc.errors()]
</file context>

except PydanticValidationError as exc:
# Only reject genuinely unknown/forbidden fields here. Constraint and enum
# violations, reserved keywords, and other invariants are surfaced with their
# original messages by the write-contract check below or by build_internal_schema.
unknown = [error for error in exc.errors() if error["type"] == "extra_forbidden"]

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.

P2: POST /api/schema/load still cannot accept the /api/schema response that this boundary is intended to tolerate: the response-only root and computed fields are classified as extra_forbidden and rejected before projection. Validating/stripping the known read-contract fields before the unknown-field check would preserve round-tripping while continuing to reject fields unknown to both contracts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/infrahub/api/schema.py, line 160:

<comment>`POST /api/schema/load` still cannot accept the `/api/schema` response that this boundary is intended to tolerate: the response-only root and computed fields are classified as `extra_forbidden` and rejected before projection. Validating/stripping the known read-contract fields before the unknown-field check would preserve round-tripping while continuing to reject fields unknown to both contracts.</comment>

<file context>
@@ -154,10 +154,15 @@ def validate_write_contract(cls, data: Any) -> Any:
+                # Only reject genuinely unknown/forbidden fields here. Constraint and enum
+                # violations, reserved keywords, and other invariants are surfaced with their
+                # original messages by the write-contract check below or by build_internal_schema.
+                unknown = [error for error in exc.errors() if error["type"] == "extra_forbidden"]
+                if unknown:
+                    raise ValueError("; ".join(_format_schema_errors(unknown))) from exc
</file context>

if unknown:
raise ValueError("; ".join(_format_schema_errors(unknown))) from exc

# Drop the read-only/internal fields so the strict write models accept the payload; the
# unknown-field check above has already run, so nothing is hidden. What remains is the
# user-facing write contract, validated for out-of-range constrained values.
data = project_to_write_contract(data)
result = validate_write_schema(schema=data)
if not result.valid:
raise ValueError("; ".join(result.messages))
return data

@model_validator(mode="after")
def build_internal_schema(self) -> Self:
# The published request contract only exposes user-settable fields; the processing
# engine operates on the richer internal SchemaRoot. Building it here surfaces
# internal-only invariants (e.g. reserved keywords) as request-validation errors,
# and lets the endpoints feed the pipeline without re-deriving the conversion.
Comment on lines +175 to +178

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.

4-line comment is overkill
# Build the internal model here to surface validation errors at construction

self._internal_schema = SchemaRoot.model_validate(self.model_dump(exclude_none=True))
return self

@property
def internal_schema(self) -> SchemaRoot:
return self._internal_schema


class SchemasLoadAPI(BaseModel):
Expand Down Expand Up @@ -170,11 +235,11 @@ def _merge_candidate_schemas(schemas: Sequence[SchemaRoot]) -> SchemaRoot:


def evaluate_candidate_schemas(
branch_schema: SchemaBranch, schemas_to_evaluate: SchemasLoadAPI
branch_schema: SchemaBranch, schemas_to_evaluate: Sequence[SchemaRoot]
) -> tuple[SchemaBranch, SchemaUpdateValidationResult]:
candidate_schema = branch_schema.duplicate()
try:
schema = _merge_candidate_schemas(schemas=schemas_to_evaluate.schemas)
schema = _merge_candidate_schemas(schemas=schemas_to_evaluate)

candidate_schema.load_schema(schema=schema)
candidate_schema.process()
Expand Down Expand Up @@ -357,10 +422,13 @@ async def load_schema(

errors: list[str] = []
warnings: list[SchemaWarning] = []
candidate_schemas: list[SchemaRoot] = []
for schema in schemas.schemas:
errors += schema.validate_namespaces()
errors += schema.validate_reserved_suffixes()
warnings += schema.gather_warnings()
internal_schema = schema.internal_schema
candidate_schemas.append(internal_schema)
errors += internal_schema.validate_namespaces()
errors += internal_schema.validate_reserved_suffixes()

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.

looks like these two should be one function on SchemaRoot

warnings += internal_schema.gather_warnings()

if errors:
raise SchemaNotValidError(message=", ".join(errors))
Expand All @@ -369,7 +437,9 @@ async def load_schema(
branch_schema = registry.schema.get_schema_branch(name=branch.name)
original_hash = branch_schema.get_hash()

candidate_schema, result = evaluate_candidate_schemas(branch_schema=branch_schema, schemas_to_evaluate=schemas)
candidate_schema, result = evaluate_candidate_schemas(
branch_schema=branch_schema, schemas_to_evaluate=candidate_schemas
)

if not result.diff.all:
return SchemaUpdate(hash=original_hash, previous_hash=original_hash, diff=result.diff)
Expand Down Expand Up @@ -445,17 +515,22 @@ async def check_schema(

errors: list[str] = []
warnings: list[SchemaWarning] = []
candidate_schemas: list[SchemaRoot] = []
for schema in schemas.schemas:
errors += schema.validate_namespaces()
errors += schema.validate_reserved_suffixes()
warnings += schema.gather_warnings()
internal_schema = schema.internal_schema
candidate_schemas.append(internal_schema)
errors += internal_schema.validate_namespaces()
errors += internal_schema.validate_reserved_suffixes()
warnings += internal_schema.gather_warnings()

if errors:
raise SchemaNotValidError(message=", ".join(errors))

branch_schema = registry.schema.get_schema_branch(name=branch.name)

candidate_schema, result = evaluate_candidate_schemas(branch_schema=branch_schema, schemas_to_evaluate=schemas)
candidate_schema, result = evaluate_candidate_schemas(
branch_schema=branch_schema, schemas_to_evaluate=candidate_schemas
)

# ----------------------------------------------------------
# Validate if the new schema is valid with the content of the database
Expand Down
95 changes: 95 additions & 0 deletions backend/infrahub/api/schema_write_projection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Project a schema-load payload onto the user-facing write contract.

A load payload may carry read-only and internal fields (a schema read back from Infrahub, or a
full internal model dump used by tests). Those fields are legitimate — they are validated against
the internal schema first — but the strict write models reject them. This projection keeps only the
write-settable fields, resolving the per-kind attribute and computed-attribute discriminated unions
so each kind keeps the fields valid for it. It runs only after the internal validation has already
rejected genuinely unknown or forbidden fields, so dropping non-write keys never hides a mistake.
"""

from __future__ import annotations

import functools
import types
import typing
from typing import Any

from infrahub_sdk.schema.generated.write import InfrahubSchemaWrite
from pydantic import BaseModel
from pydantic.fields import FieldInfo

_UNION_ORIGINS = {typing.Union, types.UnionType}
_SEQUENCE_ORIGINS = {list, tuple}


def project_to_write_contract(schema: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of ``schema`` containing only fields accepted by the write contract."""
if not isinstance(schema, dict):
return schema
return _project_model(schema, InfrahubSchemaWrite)


@functools.cache
def _resolved_hints(model: type[BaseModel]) -> dict[str, Any]:
# ``model_fields[...].annotation`` leaves module-level Annotated aliases (the discriminated
# unions) as unresolved forward references; ``get_type_hints`` resolves them with the metadata.
return typing.get_type_hints(model, include_extras=True)


def _project_model(data: Any, model: type[BaseModel]) -> Any:
if not isinstance(data, dict):
return data
hints = _resolved_hints(model)
projected: dict[str, Any] = {}
for name, info in model.model_fields.items():
key = name if name in data else (info.alias if info.alias in data else None)
if key is None:
continue
discriminator = info.discriminator if isinstance(info.discriminator, str) else None
projected[key] = _project_value(data[key], hints.get(name, info.annotation), discriminator)
return projected


def _project_value(value: Any, annotation: Any, discriminator: str | None = None) -> Any:
# Annotated[...] (used for the discriminated unions): unwrap to the base + its discriminator.
if hasattr(annotation, "__metadata__"):
nested = next(
(
m.discriminator
for m in annotation.__metadata__
if isinstance(m, FieldInfo) and isinstance(m.discriminator, str)
),
None,
)
return _project_value(value, annotation.__origin__, nested or discriminator)

origin = typing.get_origin(annotation)
args = typing.get_args(annotation)

if origin in _UNION_ORIGINS:
members = [a for a in args if a is not type(None)]
if discriminator and isinstance(value, dict):
member = _pick_variant(members, discriminator, value)
if member is not None:
return _project_model(value, member)
return _project_value(value, members[0]) if len(members) == 1 else value

if origin in _SEQUENCE_ORIGINS and args and isinstance(value, list):
return [_project_value(item, args[0]) for item in value]

if isinstance(annotation, type) and issubclass(annotation, BaseModel):
return _project_model(value, annotation)

return value


def _pick_variant(members: list[Any], discriminator: str, value: dict[str, Any]) -> type[BaseModel] | None:
target = str(getattr(value.get(discriminator), "value", value.get(discriminator)))
for member in members:
if not (isinstance(member, type) and issubclass(member, BaseModel)):
continue
field = member.model_fields.get(discriminator)
if field is not None and target in {str(getattr(a, "value", a)) for a in typing.get_args(field.annotation)}:
return member
return None
10 changes: 9 additions & 1 deletion backend/infrahub/core/constants/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
from infrahub.exceptions import ValidationError
from infrahub.utils import InfrahubNumberEnum, InfrahubStringEnum

from .schema import FlagProperty, NodeProperty, SchemaElementPathType, UpdateSupport, UpdateValidationErrorType
from .schema import (
FlagProperty,
NodeProperty,
SchemaElementPathType,
UpdateSupport,
UpdateValidationErrorType,
Visibility,
)

__all__ = [
"FlagProperty",
Expand All @@ -16,6 +23,7 @@
"UpdateSupport",
"UpdateValidationErrorType",
"ValidationError",
"Visibility",
]


Expand Down
14 changes: 13 additions & 1 deletion backend/infrahub/core/constants/schema.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from enum import Enum, Flag, auto
from enum import Enum, Flag, IntEnum, auto

PARENT_CHILD_IDENTIFIER = "parent__child"
RESOURCE_POOL_REL_SUFFIX = "_from_resource_pool"
Expand All @@ -21,6 +21,18 @@ class UpdateSupport(Enum):
NOT_APPLICABLE = "not_applicable"


class Visibility(IntEnum):
"""Ordinal classification of who may see or set a schema field.

The ordering is meaningful: a field is included in a given model variant when the
variant's minimum level is at or below the field's visibility (write ⊆ read ⊆ internal).
"""

INTERNAL = 0
READ = 1
WRITE = 2


class UpdateValidationErrorType(Enum):
NOT_SUPPORTED = "not_supported"
VALIDATOR_FAILED = "validator_failed"
Expand Down
Loading