Skip to content
Open
Show file tree
Hide file tree
Changes from 36 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.

97 changes: 67 additions & 30 deletions backend/infrahub/api/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,18 @@
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,
Expand Down Expand Up @@ -75,35 +84,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(NodeSchemaRead, APISchemaMixin):
pass

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

class APIGenericSchema(GenericSchemaRead, APISchemaMixin):
pass

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

class APIProfileSchema(ProfileSchemaRead, APISchemaMixin):
pass

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


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 +115,35 @@ class SchemaReadAPI(BaseModel):
namespaces: list[SchemaNamespace] = Field(default_factory=list)


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

@model_validator(mode="before")
@classmethod
def validate_write_contract(cls, data: Any) -> Any:
# The user-facing "write" contract lives in the SDK; each submitted node/generic is
# validated against it so that fields the user may not set (read-level, internal, or
# unknown) and out-of-range constrained values are rejected at the boundary with a
# field-level message, before the payload is coerced into the richer internal models.

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 think this comment is unnecessary. There's no information here that is not covered by either where validate_write_schema is imported from or the names of the functions.

if isinstance(data, dict):
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 +197,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 +384,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 +399,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 +477,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
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
Loading