Skip to content

Commit e1a7339

Browse files
committed
fix: make format warnings reach execution boundary and fix schema handling defects
This commit addresses multiple critical schema and validation issues: 1. Adds `warn_format_violations` and ensures it runs at input/output validation boundaries, so format warnings now fire for actual module invocations instead of only direct schema validation calls 2. Fixes format warning walk to reach into combinators (anyOf/oneOf/allOf/additionalProperties) and avoid duplicate reports 3. Reworks type array handling to properly create real unions instead of collapsing to first member 4. Makes `type` and combinator siblings (enum/const/anyOf/etc) both enforced instead of discarding one 5. Honours object form of `additionalProperties` instead of ignoring it 6. Enforces `not` keyword instead of aborting schema generation 7. Fixes option keyword leakage across types and restores missing description/title fields on generated models 8. Adds comprehensive test coverage for all these changes Signed-off-by: tercel <tercel.yi@gmail.com>
1 parent a1ecfd4 commit e1a7339

7 files changed

Lines changed: 658 additions & 104 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88

9+
## [Unreleased]
10+
11+
> **Release note:** this section contains BREAKING changes. It must ship as a
12+
> **minor** (or major) version bump, never a patch.
13+
14+
### Changed
15+
16+
- **BREAKING: a `type` array converts to a real union instead of collapsing to its first member.** `_schema_to_field_info` kept only the first non-`null` member of `{"type": [...]}` and dropped the rest, which failed in both directions. When the leading member was a scalar the annotation was **over-tightened**: `{"type": ["string", "boolean"]}` — what apexe emits for a value-optional flag — became `str`, so `color=true` was rejected while apcore-rust accepted it. When the leading member was `object` or `array` the annotation **widened to `Any`**, because `_TYPE_MAP` has no entry for either, so `{"type": ["object", "null"]}` accepted `42` and `"str"` alike. Each member now becomes its own union branch, and the type-specific option keywords are kept per branch, so `{"type": ["string", "integer"], "minLength": 3, "minimum": 10}` no longer applies the numeric bound to the string branch. **Impact:** a value that was accepted only because the union collapsed to `Any` now fails with `SCHEMA_VALIDATION_ERROR`; a value that was rejected only because a branch was discarded now succeeds. **Migration:** none for a well-formed call — this is the behaviour apcore-rust has always had.
17+
18+
- **BREAKING: a combinator sibling of a `type` keyword is enforced, and `type` is no longer discarded by one.** The dispatch chain returned on the first keyword it matched, so `type` and its siblings could never both hold. `const`/`enum`/`oneOf`/`anyOf`/`allOf` were checked *before* the type dispatch and won outright: `{"type": "string", "anyOf": [{"minLength": 3}]}` annotated the field `Any` and accepted `12345`, `{"a": 1}`, and `True`. `type` and a combinator keyword are independent assertions that must both hold (JSON Schema 2020-12 §10.2), so the type-derived annotation is now intersected with a jsonschema-backed check for every sibling it does not already cover — the same engine `hardening.validate_schema_dict` uses, which keeps the two validation paths in agreement. **Impact:** a value that passed only because one of the two assertions was dropped now fails with `SCHEMA_VALIDATION_ERROR`. **Migration:** none for a well-formed call.
19+
20+
- **BREAKING: the object form of `additionalProperties` is honoured.** `generate_model` only inspected `additionalProperties is False`; a sub-schema form (`{"type": "integer"}`) fell through to Pydantic's default `extra="ignore"`, so an undeclared key of any type was silently accepted and dropped. It now maps to `extra="allow"` plus a typed `__pydantic_extra__`, so undeclared keys are kept and their values validated. `additionalProperties: false` is unchanged. **Impact:** a call passing an undeclared key whose value does not match the declared sub-schema now fails with `SCHEMA_VALIDATION_ERROR`; undeclared keys that do match are now **retained** rather than dropped. **Migration:** none for a well-formed call — apcore-rust (jsonschema crate) has always rejected these.
21+
22+
- **BREAKING: `not` is enforced instead of aborting schema generation.** `'not' keyword not yet supported` was raised at *model-build* time, so a module whose contract carried `not` anywhere could not be registered at all. It is now applied as a sibling assertion like every other combinator. **Impact:** a contract that previously failed to load now loads and validates; a value the `not` excludes is now rejected at the validation boundary. `if`/`then`/`else` still raise — unchanged and out of scope here.
23+
24+
### Fixed
25+
26+
- **Option keywords no longer leak across types, and no longer vanish on a required field.** Two related defects in `_build_field`. First, every constraint was applied field-wide regardless of the declared type, so `{"type": ["string", "integer"], "minimum": 10}` attached `ge=10` to the string branch. Constraints for a `type` array now live on their own branch. Second, the `enum`/`const`/combinator branches returned a bare `Field(default=...)`, discarding every constraint — but only for **required** fields, because the optional path rebuilt the field through `_clone_field_with_default` and picked them back up. `{"type": "string", "minLength": 5, "enum": ["ab", "abcdef"]}` therefore accepted `"ab"` when required and rejected it when optional. Both paths now build the field the same way.
27+
28+
- **`description` and `title` reach the generated model.** `_build_field` never copied either keyword, so both were dropped for every property — a scalar `type` as much as a `type` array. Pydantic then re-derived a title from the field name, which made the loss easy to miss. LLM-facing exports (MCP / OpenAI / Anthropic tool definitions) and `content_hash` read the raw JSON Schema rather than the generated model, so they were unaffected; only direct consumers of `generate_model()` saw the omission.
29+
30+
- **The SHOULD-level format warning reaches the execution boundary.** `_check_formats_and_warn` was only ever called from `hardening.validate_schema_dict`, which `SchemaValidator.validate` invokes solely when the schema carries a **top-level** `oneOf`/`anyOf`. Module invocation validates through `input_schema.model_validate` (`builtin_steps.py`), which never reached it, so a format violation on a real call emitted nothing at all — the conformance fixture `schema_hardening_formats.json`'s `warn_logged: true` half was satisfied only by tests calling `validate_schema_dict` directly. The new `hardening.warn_format_violations(data, model)` is now called from both the input and output validation steps, reading the source JSON Schema that `SchemaLoader` attaches to every generated model. Whether a schema declares any `format` at all is computed once and cached on the model, so a schema without one costs a single attribute lookup. A natively declared Pydantic model (no source schema) is skipped.
31+
32+
- **The format warning walk reaches into combinators.** The walk descended only through `properties` and `items`, so a `format` inside an `anyOf` / `oneOf` / `allOf` branch or an `additionalProperties` sub-schema never warned. Each node is now checked against its own `format` before the walk descends, and the walk covers those four node kinds. A union branch is only descended into when the data actually satisfies it, so a sibling branch cannot report a format the value never carried, and an annotation reached through more than one branch is reported once. An unrecognised format still never warns and never fails, per JSON Schema 2020-12 §7.2.1.
33+
934
## [0.26.0] - 2026-07-13
1035

1136
### Added

‎src/apcore/builtin_steps.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
StepResult,
4242
)
4343
from apcore.policy import ExecutionPolicy, PolicyDecision
44+
from apcore.schema.hardening import warn_format_violations
4445
from apcore.config import Config
4546
from apcore.utils.call_chain import guard_call_chain
4647

@@ -757,6 +758,8 @@ async def execute(self, ctx: PipelineContext) -> StepResult:
757758
errors=errors,
758759
) from exc
759760

761+
warn_format_violations(ctx.inputs, input_schema)
762+
760763
ctx.validated_inputs = ctx.inputs
761764

762765
# Redact sensitive fields after successful validation
@@ -907,6 +910,8 @@ async def execute(self, ctx: PipelineContext) -> StepResult:
907910
errors=errors,
908911
) from exc
909912

913+
warn_format_violations(ctx.output, output_schema)
914+
910915
ctx.validated_output = ctx.output
911916

912917
# Store redacted output as first-class Context field (symmetric with

‎src/apcore/schema/hardening.py‎

Lines changed: 103 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
from apcore.schema.types import SchemaValidationErrorDetail, SchemaValidationResult
2727

28-
__all__ = ["content_hash", "validate_schema_dict"]
28+
__all__ = ["content_hash", "validate_schema_dict", "warn_format_violations"]
2929

3030
logger = logging.getLogger(__name__)
3131

@@ -130,6 +130,46 @@ def validate_schema_dict(data: Any, schema: dict[str, Any]) -> SchemaValidationR
130130
return SchemaValidationResult(valid=True, errors=[])
131131

132132

133+
def warn_format_violations(data: Any, model: Any) -> None:
134+
"""Emit the SHOULD-level format warnings for *data* against *model*'s source schema.
135+
136+
Module invocation validates through Pydantic, which has no format-annotation
137+
concept, so without this call the warnings would only ever fire on the
138+
`validate_schema_dict` path — which the executor does not reach. Models carry
139+
their source JSON Schema as `__apcore_source_schema__` (set by `SchemaLoader`);
140+
a model without one (a natively declared Pydantic model) is skipped.
141+
142+
Whether the schema declares any `format` at all is computed once and cached on
143+
the model, so the common no-format schema costs a single attribute lookup.
144+
"""
145+
source_schema = getattr(model, "__apcore_source_schema__", None)
146+
if not isinstance(source_schema, dict):
147+
return
148+
149+
declares_format = getattr(model, "__apcore_declares_format__", None)
150+
if declares_format is None:
151+
declares_format = _declares_format(source_schema)
152+
try:
153+
model.__apcore_declares_format__ = declares_format
154+
except (AttributeError, TypeError): # pragma: no cover - defensive
155+
pass
156+
if not declares_format:
157+
return
158+
159+
_check_formats_and_warn(data, source_schema)
160+
161+
162+
def _declares_format(node: Any) -> bool:
163+
"""Return True when *node* carries a `format` keyword anywhere in its tree."""
164+
if isinstance(node, dict):
165+
if "format" in node:
166+
return True
167+
return any(_declares_format(value) for value in node.values())
168+
if isinstance(node, list):
169+
return any(_declares_format(item) for item in node)
170+
return False
171+
172+
133173
def _map_error_code(errors: list[JsonschemaError]) -> str:
134174
"""Map jsonschema validation errors to apcore error codes."""
135175
for error in errors:
@@ -156,7 +196,7 @@ def _error_to_detail(error: JsonschemaError) -> SchemaValidationErrorDetail:
156196
)
157197

158198

159-
def _check_formats_and_warn(data: Any, schema: Any, _path: str = "") -> None:
199+
def _check_formats_and_warn(data: Any, schema: Any, _path: str = "", _seen: set[Any] | None = None) -> None:
160200
"""Walk the data/schema tree and log warnings for format violations.
161201
162202
Sync finding A-D-032: previously this function only iterated
@@ -165,11 +205,21 @@ def _check_formats_and_warn(data: Any, schema: Any, _path: str = "") -> None:
165205
in Python while apcore-typescript and apcore-rust did warn. The
166206
walker now recurses into nested ``properties`` AND ``items`` so
167207
cross-language conformance fixtures see the same warning set.
208+
209+
Each node is checked against its own ``format`` before the walk descends,
210+
which also covers combinator nodes: ``anyOf`` / ``oneOf`` branches (only the
211+
ones the data actually satisfies, so a sibling branch cannot report a format
212+
the value never carried) and every ``allOf`` member. An annotation reached
213+
through more than one branch is reported once.
168214
"""
169215
if not isinstance(schema, dict):
170216
return
217+
if _seen is None:
218+
_seen = set()
219+
220+
_warn_if_format_violated(data, schema, _path, _seen)
171221

172-
# Object: walk each property + recurse on its sub-schema
222+
# Object: recurse on each declared property's sub-schema.
173223
if isinstance(data, dict):
174224
properties = schema.get("properties", {})
175225
if isinstance(properties, dict):
@@ -180,34 +230,59 @@ def _check_formats_and_warn(data: Any, schema: Any, _path: str = "") -> None:
180230
if value is None:
181231
continue
182232
child_path = f"{_path}/{prop_name}" if _path else f"/{prop_name}"
183-
fmt = prop_schema.get("format")
184-
if fmt and isinstance(value, str):
185-
checker = _FORMAT_CHECKERS.get(fmt)
186-
if checker is not None and not checker(value):
187-
logger.warning(
188-
"Format violation (non-fatal): field %r declared format=%r but value %r is not conformant",
189-
child_path,
190-
fmt,
191-
value,
192-
)
193-
# Recurse into nested objects and arrays.
194-
_check_formats_and_warn(value, prop_schema, child_path)
233+
_check_formats_and_warn(value, prop_schema, child_path, _seen)
234+
235+
additional = schema.get("additionalProperties")
236+
if isinstance(additional, dict):
237+
declared = properties if isinstance(properties, dict) else {}
238+
for key, value in data.items():
239+
if key in declared or value is None:
240+
continue
241+
child_path = f"{_path}/{key}" if _path else f"/{key}"
242+
_check_formats_and_warn(value, additional, child_path, _seen)
195243

196244
# Array: walk each element against the schema's `items` declaration.
197245
if isinstance(data, list):
198246
items_schema = schema.get("items")
199247
if isinstance(items_schema, dict):
200248
for idx, value in enumerate(data):
201-
child_path = f"{_path}[{idx}]"
202-
if isinstance(value, str):
203-
fmt = items_schema.get("format")
204-
if fmt:
205-
checker = _FORMAT_CHECKERS.get(fmt)
206-
if checker is not None and not checker(value):
207-
logger.warning(
208-
"Format violation (non-fatal): field %r declared format=%r but value %r is not conformant",
209-
child_path,
210-
fmt,
211-
value,
212-
)
213-
_check_formats_and_warn(value, items_schema, child_path)
249+
_check_formats_and_warn(value, items_schema, f"{_path}[{idx}]", _seen)
250+
251+
# Combinators: a union branch annotates the data only when the data satisfies it.
252+
for keyword in ("anyOf", "oneOf"):
253+
branches = schema.get(keyword)
254+
if isinstance(branches, list):
255+
for branch in branches:
256+
if isinstance(branch, dict) and Draft202012Validator(branch).is_valid(data):
257+
_check_formats_and_warn(data, branch, _path, _seen)
258+
259+
members = schema.get("allOf")
260+
if isinstance(members, list):
261+
for member in members:
262+
if isinstance(member, dict):
263+
_check_formats_and_warn(data, member, _path, _seen)
264+
265+
266+
def _warn_if_format_violated(data: Any, schema: dict[str, Any], path: str, seen: set[Any]) -> None:
267+
"""Log the SHOULD-level warning when *data* does not satisfy this node's ``format``.
268+
269+
An unrecognised format is skipped: JSON Schema 2020-12 §7.2.1 puts ``format`` in
270+
the format-annotation vocabulary, where a format the implementation does not
271+
recognise is collected as an annotation, never treated as a failure.
272+
"""
273+
fmt = schema.get("format")
274+
if not fmt or not isinstance(data, str):
275+
return
276+
checker = _FORMAT_CHECKERS.get(fmt)
277+
if checker is None or checker(data):
278+
return
279+
key = (path, fmt, data)
280+
if key in seen:
281+
return
282+
seen.add(key)
283+
logger.warning(
284+
"Format violation (non-fatal): field %r declared format=%r but value %r is not conformant",
285+
path,
286+
fmt,
287+
data,
288+
)

0 commit comments

Comments
 (0)