Repository navigation
Restore the model state of rejected updates - #194
Merged
wol-soft merged 38 commits intoOct 23, 2026
Merged
Conversation
The composition validation records the result of each component in the internal validation state while a setter validates the update. A setter which failed after the composition passed (e.g. the own validation of the property rejected the value) left the state of the rejected data behind, so later valid updates were evaluated against it and wrongly rejected. Add a SetterRollbackHookInterface (backup code at the start of the setter, restore code before the exception of a rejected update is passed on, also for collected errors) and let the composition post processor use it. populate() restores the state through its existing rollback values. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
Coverage Report for CI Build 39961809750Coverage increased (+0.03%) to 99.21%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
… hook for populate The composition validation applies the defaults of the matching branches before the update is accepted, a rejected update left the defaults of a branch behind which doesn't match the stored data. The rollback of the composition hook now also covers the branch default attributes. A batch update (populate) renders the backup and restore code once for the whole update, so populate no longer needs a special rollback entry for the validation state. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GXn5VCTemp5rnjGrXMMvJs
…cted updates The SetterRollbackHookInterface is replaced by a rollback of the model state in the update operations themselves (setter, populate, additional property accessors), which also covers state changed by custom hooks. This commit removes the hook; the regression tests for the composition state stay and fail until the generic rollback is in place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhwL6viwijk8NfnTTfufRS
The branch default map resolved the branch properties on the containing schema by name. For a composition on a named property this matched an unrelated sibling property with the same name, which was overwritten with the default of the nested branch. Only base validators transfer their branch properties onto the schema, so only they reset branch defaults now. The is_array guard of the reset loop is dead with that and is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhwL6viwijk8NfnTTfufRS
A composition of the schema caches the result of each branch and evaluates a branch again only if an update changes one of the properties the branch declares. A branch with an object-level keyword (maxProperties, additionalProperties - also implicitly denied via the configuration -, patternProperties, propertyNames, dependencies, a nested composition, ...) depends on keys it doesn't declare, so setters of other properties, populate and additionalProperties()->set() accepted data the constructor rejects. Such a branch is now evaluated again on every update and its composition is validated by the setter of every property of the schema. The validator which only stores additional properties is extracted into a named class so it doesn't count as a constraint. The composition setter hook no longer runs for batch updates: populate already executes all compositions, the second evaluation reported each failing composition twice with collected errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhwL6viwijk8NfnTTfufRS
Validation code may change the model state before an update is accepted: compositions record their results and apply branch defaults, base validators store additional and pattern properties, custom setter hooks may change anything. A rejected update restores the complete model state from a snapshot taken before the update, in every update operation which runs such code: setters with before validation hook code, populate, and the additional property accessors. Setters without hook code throw before the model state changes and stay unchanged. The snapshot copies each value, as get_object_vars() keeps the references linking declared properties into the pattern property storage. populate uses the same rollback instead of its own list of rolled back attributes, which missed the composition state and branch defaults of properties not part of the update. Removing an additional property validates the compositions whose branches depend on keys they don't declare against the remaining data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhwL6viwijk8NfnTTfufRS
The composition validator restored its validation state and the additional and pattern property validators restored their storage when they rejected a value. Every update operation executing these validators now restores the complete model state of a rejected update (the constructor discards the object), so the validator-level rollbacks are redundant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhwL6viwijk8NfnTTfufRS
Setters, populate and the additional property accessors leave the model unchanged if an update is rejected, including state changed by custom setter hooks. Removing an additional property validates the compositions depending on keys they don't declare, and compositions with object level keywords are validated again by every setter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhwL6viwijk8NfnTTfufRS
…e schema Since only compositions of the schema itself reset branch defaults, the guard for branch properties without an attribute on the schema is only reached by a `not` composition, whose branch properties are never transferred. Add a test for that case and correct the comment, which named the internal properties already skipped earlier. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhwL6viwijk8NfnTTfufRS
A deprecation (e.g. a dynamic property created by generated code) was only reported, the test run still succeeded. With failOnDeprecation, failOnWarning and failOnNotice the run fails, so the explicit get_object_vars() assertion of the `not` branch default test is dropped. The suite is free of such issues. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhwL6viwijk8NfnTTfufRS
A dynamic property created by generated code fails the test run via failOnDeprecation, so the get_object_vars() check of the digit-prefixed branch default test is redundant. The first column of the JsonSchemaDraft table in the test infrastructure docs was narrower than its content. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhwL6viwijk8NfnTTfufRS
…ater The setters validating a composition which depends on all properties were mapped when the composition validation was set up. A property added by a later post processor wasn't part of the schema at that time, so its setter accepted data the constructor rejects. The hook now resolves these compositions for every property of the schema when the setter is rendered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhwL6viwijk8NfnTTfufRS
…cope setScope() compared the validator loosely with the base validators of the schema, while the branch default handling compares strictly. A loose comparison of validators compares whole object graphs; an equal but distinct validator currently doesn't match only because setScope() changes the compared state first. Compare by identity in both places. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhwL6viwijk8NfnTTfufRS
…il() The rejected-update tests called $this->fail() inside a try/catch(Throwable), so a missing exception was caught as the exception under test, and they only asserted the exception class. They now use catchException() (moved to the base test case) and assert the complete message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhwL6viwijk8NfnTTfufRS
A setter of a property without validators throws if its hook code (e.g. the validation of a composition depending on all properties) rejects the update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhwL6viwijk8NfnTTfufRS
…thod populate and the additional property accessors rendered the part wrapped into the rollback each with an own inline renderer and a different set of template values. RenderedMethod::renderPart() renders it with the defaults of a method. The additional property setter resolved the hook twice for a condition which is already covered by the base validators. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhwL6viwijk8NfnTTfufRS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhwL6viwijk8NfnTTfufRS
…perties With denyAdditionalProperties every composition branch without an explicit additionalProperties rejects undefined properties and therefore depends on all properties of the model, so its composition is validated by every setter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhwL6viwijk8NfnTTfufRS
This was referenced Oct 17, 2026
The composition of a mutable model records the result of each branch, also in the constructor and also if no branch declares a property. The state was only declared if a setter validates the composition, so e.g. an object-level anyOf of untyped object branches created it as (deprecated) dynamic property, which fails the suite with failOnDeprecation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
The setter of a property matching a pattern property executes the base validators to store the pattern property, the compositions included. The composition setter hook validates the compositions the property may change as well, so with collected errors each failing composition was reported twice. The pattern property hook now skips the compositions, the composition hook stays the single place validating them in setters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
Removing an additional property only validated the compositions with a branch depending on keys it doesn't declare. A property declared only inside a branch (e.g. the stub of a branch-level required) is stored as additional property and can be removed, so a branch which only declares properties depends on it as well: the removal was accepted although the constructor rejects the remaining data, and the cached result of the branch kept accepting later invalid states. Every composition is validated now, after its cached branch results were discarded - a removed key can't be passed as updated model data. The dependencies of the declared properties weren't validated at all: removing the property a dependency requires was accepted. They are validated against the remaining data now, in the order of the constructor and within the model state rollback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
A property transferred from a composition branch to the model kept the dependency validators of the branch, so the model enforced a dependency of a oneOf/anyOf branch also for data matching another branch, and reported a violated dependency of an allOf branch twice with collected errors. The composition validates the dependencies of its branches, the transferred property drops them. This also restores a test for the dependency check of the cached composition result: adding the property a dependency of a branch requires must evaluate the branch again. It wasn't reachable before, the model rejected the initial data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
…s helper Every mutable schema with compositions declares the internal composition validation state now. A branch class without declared properties then made the outer composition generate and call a _getModifiedValues_* helper which never collects anything. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
The removal validation, the composition cache decision and the transfer filter for branch properties each checked for both dependency validator classes. A cross-key validator added later implements DependencyValidatorInterface instead of being added to three checks which must agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
Three sibling conditions had to stay mutually exclusive by hand. The generated code is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
Each dependency validator checks itself whether its property is present; the removal only provides the value passed to the exception. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
Also moves the docblock of getErrorRegistryException() back to its method. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
Setters didn't validate the object-level validators of the schema (e.g. maxProperties, propertyNames, additionalProperties for a property declared only by a composition branch) or the schema dependencies of other properties, so a setter accepted data the constructor rejects. Only setters of properties matching a pattern property validated the object-level validators, as a side effect of storing the pattern property - via a flag on _executeBaseValidators which skipped the compositions. The base validation is split: _executeObjectValidators() holds the object-level validators, _executeBaseValidators() keeps a single parameter and calls it and the compositions. Each setter executes the object-level validators and the schema dependencies of the other properties in the order of the constructor and within the model state rollback, but only if an update of its key may change their result: the validators describe that via ObjectUpdateDependentValidatorInterface, so e.g. the setter of a declared property doesn't validate additionalProperties and simple models keep their plain setters. populate() validates the schema dependencies of the properties it doesn't update as well; the pattern property setter hook is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
A composition branch with an object-level keyword was evaluated again on every update, and every setter of a model with such a composition snapshotted the model state - also when it only changed the value of a present key, which can't change e.g. maxProperties, propertyNames or denied additional properties. The validators of an object describe how an update of a key may change their result (in either direction: adding a key may also satisfy minProperties or a property dependency of a branch), also for keys the object doesn't declare. A branch depending on the keys only keeps its cached result unless an update adds a key. The setters validate the compositions themselves instead of via a setter hook and render two paths: adding the key runs everything the key may affect, changing a present key only what depends on its value. A path which runs nothing doesn't snapshot the model state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
Resolution: the setters validate the compositions themselves instead of via the composition setter hook, so the staging of the filtered values of the active branches (#193) moves from that hook into the core setter rendering: each setter path which validates a composition resets the staging and prepares the filtered values, the setter applies them once the update succeeded. populate() prepares them after the dependency validation of the properties it doesn't update. The per-composition restore of the validation state stays removed in favour of the model state snapshot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
The early return of a setter providing the current value of a property stores the provided value as raw input. For a property which isn't stored yet (null or a default value) this added the key without any validation, e.g. exceeding maxProperties. The early return only applies to stored keys now, adding a key is validated like any other added key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
additionalProperties()->set() and ->remove() validate the compositions, which stage the filtered values of their active branches, but neither prepared nor applied them: an added or removed key which switched the active branch (e.g. a branch with maxProperties) kept the filtered value of the previous branch, and a value of the newly active branch which the property rejects was accepted. Both reset the staging, prepare the values after the validation (no property of the model is processed by them) and apply them once the update succeeded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
Tests for the update dependencies the coverage report flagged: - a branch denying a key (property or pattern property with the schema false) is evaluated again when the key is added, changing the value of a present key neither validates the composition nor snapshots - a branch with an additionalProperties schema is evaluated with every update A true branch of a composition of the schema has no validators and was treated like a branch with unknown validators: every setter of the model validated the composition and snapshotted the model state. No update can change its result, it reports no dependency now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
A false branch of a composition of the schema itself (also a branch with
not: true or a composition of false branches) checked the presence of a
key named like the class: it matched any object. not: true on the schema
itself accepted any object for the same reason. The object is always
present, the branch now always fails (not: true always rejects).
then: false / else: false with a real if condition was rejected as
unsatisfiable at generation time, but only the values entering the
branch are invalid (e.g. if: {required: [kind]}, then: false denies
kind). The false branch is kept, both branches false emit the
always-unsatisfiable warning.
Covers the update dependency of a branch without a nested schema: every
update evaluates the composition again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
The production library renders collected condition and branch errors of a ConditionalException as bullets now (production#22), like a single exception: update the expectation which contained the glued message and assert the complete messages of false conditional branches in both error modes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
The rendering of the model updates was added to RenderHelper, next to the generic template helpers. It moves into classes by responsibility in SchemaProcessor\ModelUpdate: - ModelStateRollback wraps an update into the snapshot / restore of the model state and adds the backup / restore methods to the schema - DependencyValidation adds _executeDependencyValidators() to the schema, also for populate() - SetterValidationRenderer renders the validation of the setters; it is passed to the model template like the SchemaHookResolver The dependency validators of the properties are provided by the Schema, next to its object validators. The rollback wrapper and the two setter paths are rendered from heredocs. The generated code is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG
wol-soft
deleted the
claude/restore-composition-state-on-failed-update
branch
October 23, 2026 13:41
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A rejected update (setter,
populate(),additionalProperties()->set()/remove()) left parts of the model state changed: compositions had already recorded their results and applied branch defaults, base validators had already stored additional and pattern properties. The rejected data then influenced later updates (a cached composition result was reused) and the model contained defaults of a branch which doesn't match.Instead of a rollback per validator (and the
SetterRollbackHookInterfaceof the first version of this PR), every update operation which runs such code now restores the complete model state of a rejected update from a snapshot.While working on this, several related bugs in the same area were found and fixed here: every update now rejects exactly the data the constructor rejects, and validates only what the update can change.
Changes
Generic rollback of rejected updates
ModelStateRollback::wrap()(SchemaProcessor\ModelUpdate) wraps an update into snapshot /try/ restore-and-rethrow. The helper methods_backupModelState()/_restoreModelState()are added to a class on first use only.populate(),additionalProperties()->set()/->remove(), and each setter path which validates the object (object-level validators, compositions, dependencies of other properties) or runs before-validation hook code. A setter path which only validates the property itself throws before the model state changes and isn't wrapped (no runtime overhead for simple models).get_object_vars()keeps the references linking declared properties into the pattern property storage, so the snapshot would follow later changes.populate()uses the same rollback instead of its own list of rolled back attributes (which missed the composition state and branch defaults of properties not part of the update) and catches\Throwablein both error modes.Updates validate like the constructor
_executeObjectValidators()holds the object-level validators,_executeBaseValidators()keeps a single parameter and calls it and the compositions.maxProperties,propertyNames,patternProperties,additionalPropertiesfor a property declared only by a composition branch, ...), the compositions and the schema dependencies of other properties, in the order of the constructor. Before, only setters of properties matching a pattern property validated the object-level validators, as a side effect of a hook which executed all base validators. That pattern property setter hook and the composition setter hook are removed: the setters render the validation themselves (SetterValidationRenderer, passed to the model template like theSchemaHookResolver).populate()additionally validates the schema dependencies of the properties it doesn't update.remove()validates every composition (after discarding the cached branch results, a removed key can't be passed as updated model data) - a property declared only inside a branch (e.g. the stub of a branch-levelrequired) is stored as additional property and can be removed - and the dependencies of the declared properties against the remaining data. All dependency validation uses the generated_executeDependencyValidators()(added byDependencyValidation;Schema::getDependencyValidators()provides the validators).null, a default) added the key without validation; it is validated like any added key now.Only what an update can change is validated
ObjectUpdateDependentValidatorInterfacehow an update of a key may change their result (ObjectUpdateDependency:None,AddedKey,Value- in either direction, adding a key may also satisfyminPropertiesor a property dependency of a branch), also for keys the object doesn't declare. Validators which don't describe it (e.g. custom validators) depend on the value of every key.minProperties,maxPropertiesand denied properties got dedicated validator classes;DependencyValidatorInterfacemarks the dependency validators.additionalProperties.patternProperties, a nested composition) is always evaluated again; a branch which depends on the keys only (e.g.maxProperties,propertyNames, denied additional properties - also viasetDenyAdditionalProperties) keeps its cached result unless an update adds a key; other branches keep it as long as none of their properties is updated.truebranch has no validators: no update can change its result, it keeps its cached result and doesn't make setters validate the composition. Other branches without a nested schema (e.g.false) depend on the value of every key.UpdateAdditionalPropertiesValidatorso it doesn't count as a constraint.Branch defaults only for compositions of the schema itself
TypeError). Only compositions of the schema itself apply branch defaults now.A failing composition was reported twice with collected errors
populate()validated the compositions again per updated property, setters of a property matching a pattern property validated them via both setter hooks. Each update validates each composition once now.Validation state declared on every mutable model with compositions
_propertyValidationStatewas only declared if a setter validates a composition, e.g. a mutableanyOfof untyped object branches created it as (deprecated) dynamic property. Internal properties don't count as branch properties (no empty_getModifiedValues_*helper).Dependencies of a composition branch only apply with the branch (pre-existing on master)
oneOf/anyOfbranch also for data matching another branch, and reported a violated dependency of anallOfbranch twice. The transferred property drops them, the composition validates them.Boolean
falsebranches of compositions of the schema itself (pre-existing on master)falsebranch (also{"not": true}or{"anyOf": [false]}as branch) of a composition of the schema itself checked the presence of a key named like the class, so it matched any object: e.g.oneOf: [{"required": ["note"]}, false]accepted{}and rejected{"note": "n"}. A root-levelallOf: [false]andnot: trueaccepted every object. The object is always present: such a branch now always fails,not: truealways rejects.then: false/else: falsewith a realif(pre-existing on master)SchemaException), but only the values entering the branch are invalid, e.g."if": {"required": ["kind"]}, "then": falsedenieskind. The false branch is kept and rejects the values entering it;then: falsewithelse: falseemits the always-unsatisfiable warning. Docs (if.rst,allOf/anyOf/oneOf/not.rst) updated.Bulleted conditional messages with collected errors (follow-up to wol-soft/php-json-schema-model-generator-production#22)
ConditionalExceptionglued to the header line (- Condition: FailedMissing required value for 'kind'); fixed in production#22. The generator test expecting the glued message (ComposedIfTest, collect errors) now expects the bullets.Merge of master (#193 branch-scoped filters)
populate()prepares them after the dependency validation.additionalProperties()->set()/->remove()validate the compositions (see above) and may switch the active branch (e.g. a branch withmaxProperties): they reset, prepare and apply the staged filtered values as well. Before, the filtered value of the previous branch was kept, and a value of the newly active branch which the property rejects was accepted.Related issues found (not fixed here)
propertiesorrequiredare ignoredpatternon a property withouttypeis ignored (probably covered by 97ba6db on theunevaluated-propertiesbranch)maxProperties/minPropertiescount expression violates the generated-code phpcs indentation rule (unchanged by this PR)Test plan
ComposedFailedUpdateStateTest: rejected setter/populate after a successful composition, rejection by a second composition after the first one applied a default, branch default on a pattern property (reference kept intact) — both error modes.ComposedObjectLevelKeywordUpdateTest: setter, populate,additionalProperties()->set()and->remove()are rejected with exactly the constructor's exception message for each object-level keyword (incl.$refbranch, implicitly denied additional properties, anadditionalPropertiesschema, collected errors) and leave the model unchanged; branches with declared properties only keep the cache; removal of a property only a branch requires (incl. no stale cached result for a later removal); setter of a property matching a pattern property reports each failing composition once; adding the property a branch dependency requires, or a key aminPropertiesbranch counts, evaluates the branch again; a branch withmaxPropertieskeeps its cache for present keys and the setter of a present key neither validates the composition nor snapshots; adding a key a branch denies (property or pattern property with schemafalse) is rejected like the constructor, changing a present one doesn't validate; atruebranch keeps its cached result and doesn't make setters validate; afalse/not: true/ nestedanyOf: [false]branch is evaluated with every update — both error modes.BooleanCompositionSchemaTest:false/not: true/ nestedanyOf: [false]branches of compositions of the schema itself and root-levelallOf: [false]/not: truereject like JSON Schema;then: false/else: falsewith a realifreject only the values entering the branch (property and object level);then: false+else: falsewarns and rejects every value — complete messages, both error modes.ComposedIfTest: collected conditional errors are rendered as bullets (requires production#22).SetterObjectLevelValidationTest: setter and populate violatingmaxProperties,propertyNames,patternProperties, the schema dependency of another property, oradditionalPropertiesfor a property only a branch declares are rejected like the constructor — both error modes; setters of declared properties don't validateadditionalProperties; the setter of a present key skips key-only validators; a setter adding a key with the current value is validated.AdditionalPropertiesAccessorPostProcessorTest: removal breaking a property / schema dependency is rejected like the constructor and leaves the model unchanged — both error modes.FilterCompositionBranchScopeTest: adding / removing an additional property which switches the activeoneOfbranch adopts the filtered value of the new branch; a switch to a branch whose filtered value the property rejects is rejected like the constructor and keeps the previous branch — both error modes.PropertyDependencyTest: a property / schema dependency of aoneOf/anyOfbranch doesn't apply to data matching another branch; a violated dependency of anallOfbranch is reported once (complete messages).ComposedAnyOfTest: mutable object-levelanyOfwithout declared properties creates no dynamic property and no modified values helper.SchemaHookTest: state changed by a customSetterBeforeValidationHookInterfacehook is restored; setters without hook code contain no rollback code.ComposedOneOfBranchDefaultTest: named-property composition doesn't apply a branch default to a sibling with the same name (constructor and setter).RenderHelperdoesn't change the generated code (540 generated classes compared before / after, mutable with populate and accessors, both error modes). Full suite on PHP 8.4.27 with production master (incl. Post Processor pre/post execution hooks #22): 3416 tests, 9633 assertions, green except 4ReferencePropertyTestnetwork-reference cases which couldn't reach raw.githubusercontent.com from the sandbox (same before the changes); all changed lines of the update dependency classes covered (pcov); phpcs clean on changed files.🤖 Generated with Claude Code
https://claude.ai/code/session_01KhwL6viwijk8NfnTTfufRS
https://claude.ai/code/session_016TbSpXPq3pUmw9Hrh9CfRG