Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a7a5fd2
Carry consumed schema semantics across the destination accessor
estivate Aug 31, 2026
20d2fd7
Build DiffSync model classes in memory from one schema snapshot
estivate Aug 31, 2026
16ed790
Fingerprint the schema semantics a configuration consumes
estivate Aug 31, 2026
1276dde
Resolve the destination branch through one shared rule
estivate Aug 31, 2026
cd211b7
Separate installed adapter resolution from generated-wrapper precedence
estivate Aug 31, 2026
c4be105
Bind registered execution to runtime models and migrate the validatio…
estivate Aug 31, 2026
3c19972
Prove structural isolation and the single read of a composed sync
estivate Aug 31, 2026
45e4af2
Refuse non-string identity paths instead of stringifying them
estivate Aug 31, 2026
abea2ff
Keep the pylint baseline at engine assembly
estivate Aug 31, 2026
faad5be
Make registered resolution structurally unable to load filesystem plu…
estivate Aug 31, 2026
61c2841
Admit an installed source adapter declaration into registered packages
estivate Aug 31, 2026
5a5b1ff
Scope registered runtime preparation to what the stage constructs
estivate Aug 31, 2026
85baaf3
Render generated string defaults as Python literals
estivate Aug 31, 2026
eaffbef
Refuse non-finite defaults and report closed-domain refusals
estivate Aug 31, 2026
cd2462f
Make the normalized snapshot immutable to its depth
estivate Aug 31, 2026
9b60b8c
Stop claiming a plan already records the schema fingerprint
estivate Aug 31, 2026
9c3d1d3
Prove the acceptance behaviour instead of describing it
estivate Aug 31, 2026
0ea74e0
Make the isolation and no-source oracles able to fail
estivate Aug 31, 2026
e451008
Resolve a class-valued entry point's model base from its own module
estivate Aug 31, 2026
01ae678
Admit a registered dotted source only from an installed distribution
estivate Aug 31, 2026
085b614
Bind installed source resolution to reviewed code
estivate Aug 31, 2026
1a41c4c
Validate the module registered resolution actually loaded
estivate Aug 31, 2026
6ac9385
Admit an installed source shipped as a namespace package
estivate Aug 31, 2026
1f1adef
ci: exclude managed worker-path test on Python 3.10
estivate Aug 31, 2026
d68b69a
fix: defer managed apply schema construction
estivate Aug 31, 2026
fe82966
fix(plugin-loader): validate entry-point module origins
estivate Aug 31, 2026
7c7ca99
fix(schema): reject malformed SDK snapshot values
estivate Aug 31, 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
13 changes: 11 additions & 2 deletions docs/docs/reference/durable-product-records.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,27 @@ An adapter's own configuration check keeps its own codes, which are outside this

#### Destination schema validation codes

These four codes are emitted **only on the explicit opt-in**: `validate` given a
These five codes are emitted **only on the explicit opt-in**: `validate` given a
destination-schema options object. The default `validate` path judges declared content
only, performs no schema read and no network I/O, and never emits them. All four carry an
only, performs no schema read and no network I/O, and never emits them. All five carry an
`error` severity.

| Code | What it means | Where it points |
| ---- | ------------- | --------------- |
| `destination-schema-mismatch` | A declared schema mapping disagrees with the destination's schema snapshot: an undeclared kind, a field that is neither an attribute nor a relationship, a relationship reference on an attribute, or a static value whose shape disagrees with the relationship's cardinality. | the mapping entry, field, reference, or static value |
| `destination-schema-read-failed` | The destination schema could not be read: a timeout, refused credentials, an unreachable server, a rejected or unusable response, an unresolvable declared token, or unusable declared client settings. The message names the failure class. | `/configuration/destination` |
| `destination-schema-unsupported-semantics` | The destination schema was read, but it declares semantics outside the supported schema domain — an unknown relationship cardinality, a member shape the domain does not define, or a default no JSON encoding can carry. A run of this configuration refuses the same schema. | `/configuration/destination` |
| `destination-schema-validation-unsupported` | Schema validation was explicitly requested against a destination adapter that does not declare it. A missing capability needed to determine safety is an error, not a warning. | `/configuration/destination` |
| `unsupported-destination-write` | The configuration requests destination write operations the destination adapter does not declare support for. | `/configuration/destination` |

A successful opt-in read also returns `destination_schema_fingerprint`: the full SHA-256
digest of the schema semantics this configuration consumes — each mapped kind, its
DiffSync identifiers, its ordered human-friendly ID and uniqueness-constraint component
paths, every mapped field's type and required/default/unique properties, and every
mandatory-without-default field on those kinds. Unmapped destination growth and
differences in schema delivery order leave it unchanged. It is `null` whenever no
snapshot was read — the default path, a non-declaring destination, or a failed read.

#### Warning-channel codes

The warning channel is closed: warnings are limited to intentional omissions and
Expand Down
5 changes: 5 additions & 0 deletions infrahub_sync/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from collections.abc import Iterable

from infrahub_sync.cache.cursors import CursorState
from infrahub_sync.runtime_schema import RuntimeModelPlan

import pydantic

Expand Down Expand Up @@ -149,6 +150,10 @@ class SyncInstance(SyncConfig):
directory: str
# Worker-only state, deliberately absent from serialized configuration data.
_configuration_binding: tuple[str, int, str] | None = pydantic.PrivateAttr(default=None)
# The registered run's runtime model plan, when one was built. Its presence is what
# tells engine assembly to use installed resolution and bind in-memory classes rather
# than the legacy generated-wrapper path.
_runtime_models: RuntimeModelPlan | None = pydantic.PrivateAttr(default=None)


def resolve_effective_diffsync_flags(
Expand Down
124 changes: 109 additions & 15 deletions infrahub_sync/configuration/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

from __future__ import annotations

import math
import re
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from enum import Enum
from types import MappingProxyType
from typing import Any, Literal
from urllib.parse import urlsplit
Expand All @@ -15,9 +17,11 @@
WriteOperation = Literal["create", "update", "delete"]
ConfigurationValidator = Callable[[ConfigurationPackage, AdapterRole], Sequence[ValidationFinding]]
# The destination-schema accessor contract: (package, branch) -> one JSON-native schema
# snapshot, mapping each kind name to its attributes (name -> attribute kind) and
# relationships (name -> {"peer", "cardinality"}). Raises DestinationSchemaReadError and
# nothing else for a read that fails; performs I/O only when called, never at import.
# snapshot, mapping each kind name to its ordered "human_friendly_id" and
# "uniqueness_constraints" component paths, its attributes
# (name -> {"kind", "optional", "default_value", "unique"}), and its relationships
# (name -> {"peer", "cardinality", "optional", "kind"}). Raises DestinationSchemaReadError
# and nothing else for a read that fails; performs I/O only when called, never at import.
DestinationSchemaAccessor = Callable[[ConfigurationPackage, str], Mapping[str, Any]]
_SCHEMA_READ_REASON = re.compile(r"^[a-z]{1,32}$")
_ADAPTER_NAME = re.compile(r"^[a-z][a-z0-9_-]*$")
Expand Down Expand Up @@ -292,41 +296,131 @@ def _normalized_schema_snapshot(schema: object) -> Mapping[str, Any]:


def _build_schema_snapshot(schema: object) -> dict[str, Any]:
"""Build the snapshot from a third-party response, inside the boundary above."""
"""Build the snapshot from a third-party response, inside the boundary above.

Each kind carries its ordered ``human_friendly_id`` and ``uniqueness_constraints``
component paths and, per member, every property that can change a constructed
runtime model or a planned write. Nothing else from the response crosses.
"""
if not isinstance(schema, Mapping):
raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected")
snapshot: dict[str, Any] = {}
for kind, node in schema.items():
if not isinstance(kind, str):
raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected")
snapshot[kind] = {
"attributes": {attribute.name: attribute.kind for attribute in getattr(node, "attributes", ()) or ()},
"human_friendly_id": _optional_string_path(getattr(node, "human_friendly_id", None)),
"uniqueness_constraints": _optional_string_paths(getattr(node, "uniqueness_constraints", None)),
"attributes": {
attribute.name: {
"kind": _member_text(attribute.kind),
"optional": _exact_bool(attribute.optional),
"default_value": _json_native_default(attribute.default_value),
"unique": _exact_bool(attribute.unique),
}
for attribute in getattr(node, "attributes", ()) or ()
},
"relationships": {
relationship.name: {"peer": relationship.peer, "cardinality": relationship.cardinality}
relationship.name: {
"peer": relationship.peer,
"cardinality": _member_text(relationship.cardinality),
"optional": _exact_bool(relationship.optional),
"kind": _member_text(relationship.kind),
}
for relationship in getattr(node, "relationships", ()) or ()
},
}
_require_usable_snapshot(snapshot)
return snapshot


def _optional_string_path(value: object) -> list[str]:
"""Copy an optional SDK component path without coercing malformed containers."""
if value is None:
return []
return _string_path(value)


def _optional_string_paths(value: object) -> list[list[str]]:
"""Copy optional SDK component paths without coercing malformed containers."""
if value is None:
return []
if isinstance(value, (str, bytes, bytearray)) or not isinstance(value, Sequence):
raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected")
return [_string_path(path) for path in value]


def _string_path(value: object) -> list[str]:
"""Copy one non-string SDK sequence containing only string components."""
if isinstance(value, (str, bytes, bytearray)) or not isinstance(value, Sequence):
raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected")
components: list[str] = []
for component in value:
if not isinstance(component, str):
raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected")
components.append(component)
return components


def _exact_bool(value: object) -> bool:
"""Return an SDK flag only when it is an exact boolean."""
if value is True:
return True
if value is False:
return False
raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected")


def _member_text(value: object) -> object:
"""Return the value of an SDK string enum, leaving anything else to the shape check."""
return value.value if isinstance(value, Enum) else value


def _json_native_default(value: object) -> Any:
"""Keep a JSON-native declared default; refuse anything a model cannot reproduce.

A non-finite float is refused here rather than carried: JSON has no encoding for it,
so it could not survive the canonical projection a plan is identified by.
"""
if isinstance(value, float) and not math.isfinite(value):
raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected")
if value is None or isinstance(value, (str, bool, int, float)):
return value
if isinstance(value, Enum):
return _json_native_default(value.value)
if isinstance(value, (list, tuple)):
return [_json_native_default(item) for item in value]
if isinstance(value, Mapping) and all(isinstance(key, str) for key in value):
return {key: _json_native_default(item) for key, item in value.items()}
raise DestinationSchemaReadError(_UNUSABLE_SCHEMA_RESPONSE, reason="rejected")


def _require_usable_snapshot(snapshot: Mapping[str, Any]) -> None:
"""Refuse a built snapshot that is not the string shape the schema checks consume.
"""Refuse a built snapshot that is not the string shape its consumers expect.

The last step inside the normalization boundary: the members were read without
raising and every kind is already a string, but the snapshot is usable only when
every attribute name and kind, relationship name, peer, and cardinality is a string
too — the shape the SDK contract promises and ``compute_schema_subhash`` and the
content checks rely on.
every member name and every declared text property is a string too — the shape the
SDK contract promises and the content checks and the normalized runtime domain rely
on.
"""
for entry in snapshot.values():
attributes: dict[str, Any] = entry["attributes"]
relationships: dict[str, Any] = entry["relationships"]
usable = all(isinstance(name, str) and isinstance(value, str) for name, value in attributes.items()) and all(
isinstance(name, str)
and isinstance(relationship["peer"], str)
and isinstance(relationship["cardinality"], str)
for name, relationship in relationships.items()
paths: list[Any] = [
*entry["human_friendly_id"],
*(component for constraint in entry["uniqueness_constraints"] for component in constraint),
]
usable = (
all(isinstance(name, str) and isinstance(attribute["kind"], str) for name, attribute in attributes.items())
and all(
isinstance(name, str)
and isinstance(relationship["peer"], str)
and isinstance(relationship["cardinality"], str)
and isinstance(relationship["kind"], str)
for name, relationship in relationships.items()
)
and all(isinstance(component, str) for component in paths)
)
if not usable:
msg = "destination returned an unusable schema member shape"
Expand Down
72 changes: 70 additions & 2 deletions infrahub_sync/configuration/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
ValidationError,
field_serializer,
field_validator,
model_serializer,
model_validator,
)
from pydantic_core import PydanticCustomError
Expand Down Expand Up @@ -61,6 +62,13 @@
_INVALID_UNICODE_SURROGATE_ERROR = "invalid_unicode_surrogate"
_INVALID_JSON_VALUE_ERROR = "invalid_json_value"
_INVALID_DIFFSYNC_FLAG_NAME_ERROR = "invalid_diffsync_flag_name"
_UNSUPPORTED_ADAPTER_SPEC_ERROR = "unsupported_adapter_spec"
# An installed source adapter is one Python import target: dot-separated identifiers,
# optionally naming a class after a colon. Every filesystem form a plugin loader would
# otherwise accept fails this by construction — a path separator, a leading "." or "~",
# an empty segment, a space — and a ".py" module tail is refused alongside it, because a
# loader reads that as a file rather than a module.
_INSTALLED_ADAPTER_SPEC = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*(\.[A-Za-z_][A-Za-z0-9_-]*)*(:[A-Za-z_][A-Za-z0-9_]*)?$")
_SAFE_PYDANTIC_FAILURE_REASONS = {
"missing": "required field is missing",
"literal_error": "unsupported value",
Expand Down Expand Up @@ -152,6 +160,7 @@ def _require_known_fields(value: Any, model: type[BaseModel], *, location: str)
_raise_unsupported_declared_fields(location=location, fields=unknown)


_SOURCE_LOCATION = "configuration.source"
_STRICT_CONFIGURATION_CHILDREN: dict[type[BaseModel], dict[str, tuple[type[BaseModel], bool]]] = {
SyncConfig: {
"store": (SyncStore, False),
Expand All @@ -168,6 +177,29 @@ def _require_known_fields(value: Any, model: type[BaseModel], *, location: str)
}


def _raise_unsupported_adapter_spec(*, location: str) -> None:
"""Raise one structured error for a source adapter outside installed resolution."""
pointer = "/" + location.replace(".", "/")
raise PydanticCustomError(
_UNSUPPORTED_ADAPTER_SPEC_ERROR,
"{location} contains an unsupported adapter specification", # noqa: RUF027
{"location": location, "pointer": pointer},
)


def _require_installed_adapter_spec(value: Any, *, location: str) -> None:
"""Refuse a declared source adapter a registered worker could not safely resolve.

Registered execution resolves through installed-only loading, so a declaration is
admitted when it names a dotted import target or an entry point and refused when it
names anything on a filesystem.
"""
if type(value) is not str or _INSTALLED_ADAPTER_SPEC.fullmatch(value) is None: # pylint: disable=unidiomatic-typecheck
_raise_unsupported_adapter_spec(location=location)
if value.partition(":")[0].endswith(".py"):
_raise_unsupported_adapter_spec(location=location)


def _require_strict_model(value: Any, model: type[BaseModel], *, location: str) -> None:
"""Apply extra-forbid semantics to one registered legacy model node."""
if not isinstance(value, Mapping):
Expand All @@ -176,7 +208,12 @@ def _require_strict_model(value: Any, model: type[BaseModel], *, location: str)
if model is SyncConfig and value.get("adapters_path") is not None:
_raise_unsupported_declared_fields(location=location, fields=("adapters_path",))
if model is SyncAdapter and value.get("adapter") is not None:
_raise_unsupported_declared_fields(location=location, fields=("adapter",))
# The source may name an installed adapter; the destination may not, because the
# destination owns the schema-discovery and saved-plan write seams this release
# qualifies only for the bundled Infrahub adapter.
if location != _SOURCE_LOCATION:
_raise_unsupported_declared_fields(location=location, fields=("adapter",))
_require_installed_adapter_spec(value["adapter"], location=f"{location}.adapter")
for field_name, (child_model, many) in _STRICT_CONFIGURATION_CHILDREN.get(model, {}).items():
child = value.get(field_name)
child_location = f"{location}.{field_name}"
Expand Down Expand Up @@ -345,6 +382,26 @@ def _serialize_settings(self, value: Mapping[str, Any] | None) -> dict[str, Any]
return cast("dict[str, Any] | None", _thaw_json(value))


class _ImmutableSyncSourceAdapter(_ImmutableSyncAdapter):
"""The source adapter, which may declare one installed resolution target.

``adapter`` is serialized — and so covered by the package checksum — whenever it is
declared, because two packages that resolve different source code are not the same
package. It is omitted when absent, so a package that declares no source adapter keeps
the exact declared content, and the exact checksum, it had before the field was
admitted.
"""

adapter: str | None = None

@model_serializer(mode="wrap")
def _omit_absent_adapter(self, handler: Callable[[Any], dict[str, Any]]) -> dict[str, Any]:
content = handler(self)
if content.get("adapter") is None:
content.pop("adapter", None)
return content


class _ImmutableSyncStore(SyncStore):
"""Package-local immutable form of legacy store settings."""

Expand Down Expand Up @@ -374,7 +431,7 @@ class _ImmutableSyncConfig(SyncConfig):
model_config = ConfigDict(frozen=True)

store: _ImmutableSyncStore | None = None
source: _ImmutableSyncAdapter
source: _ImmutableSyncSourceAdapter
destination: _ImmutableSyncAdapter
# Refused when non-null by _require_strict_model, so the value is always null. Excluded
# from the dump: carrying a constant into the checksum makes removing it a rehash later.
Expand Down Expand Up @@ -768,12 +825,23 @@ def _decode_diffsync_failure(record: dict[object, object], location: str) -> tup
return ((location, "invalid diffsync flag name"),)


def _decode_adapter_spec_failure(record: dict[object, object], location: str) -> tuple[tuple[str, str], ...]:
"""Decode one closed unsupported-adapter-specification custom error context."""
context = _closed_context(record)
if context is not None:
pointer = _safe_context_pointer(context.get("pointer"))
if pointer is not None:
return ((pointer, "unsupported adapter specification"),)
return ((location, "unsupported adapter specification"),)


_CustomFailureDecoder = Callable[[dict[object, object], str], tuple[tuple[str, str], ...]]
_CUSTOM_FAILURE_DECODERS: dict[str, _CustomFailureDecoder] = {
_INVALID_JSON_VALUE_ERROR: _decode_json_failure,
_INVALID_UNICODE_SURROGATE_ERROR: _decode_unicode_failure,
_UNSUPPORTED_DECLARED_FIELDS_ERROR: _decode_unsupported_field_failures,
_INVALID_DIFFSYNC_FLAG_NAME_ERROR: _decode_diffsync_failure,
_UNSUPPORTED_ADAPTER_SPEC_ERROR: _decode_adapter_spec_failure,
}


Expand Down
19 changes: 18 additions & 1 deletion infrahub_sync/configuration/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,33 @@

from __future__ import annotations

from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING, Any, cast

from infrahub_sync import SyncInstance

from .credentials import _REGISTERED_CONTEXT, resolve_reference

if TYPE_CHECKING:
from collections.abc import Mapping

from .models import ConfigurationPackage


def effective_destination_branch(settings: Mapping[str, Any] | None, run_branch: str | None) -> str:
"""Resolve the one destination branch a run works against.

Declared ``destination.settings.branch`` first, then the run request's branch, then
``"main"`` — the SDK's own default. Schema discovery, destination adapter
construction, and the plan's destination binding all resolve through this, so a run
cannot read one branch's schema and write another's. Explicit configuration
validation has no run request and passes ``None``.
"""
declared = (settings or {}).get("branch")
if isinstance(declared, str) and declared:
return declared
return run_branch or "main"


def resolve_runtime_instance(package: ConfigurationPackage, *, directory: str) -> SyncInstance:
"""Resolve declared credential references without adapter ambient lookup."""

Expand Down
Loading
Loading