Skip to content

Restore the model state of rejected updates - #194

Merged
wol-soft merged 38 commits into
masterfrom
claude/restore-composition-state-on-failed-update
Oct 23, 2026
Merged

wol-soft merged 38 commits into
masterfrom
claude/restore-composition-state-on-failed-update

Conversation

@wol-soft

@wol-soft wol-soft commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

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 SetterRollbackHookInterface of 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.
  • Wrapped: 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).
  • The snapshot copies each value: 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 \Throwable in both error modes.
  • The rollbacks of single validators (composition state, additional/pattern property storage) are removed.

Updates validate like the constructor

  • The base validation is split: _executeObjectValidators() holds the object-level validators, _executeBaseValidators() keeps a single parameter and calls it and the compositions.
  • Setters validate the object-level validators (maxProperties, propertyNames, patternProperties, additionalProperties for 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 the SchemaHookResolver).
  • 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-level required) 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 by DependencyValidation; Schema::getDependencyValidators() provides the validators).
  • A setter providing the current value of a property which isn't stored yet (null, a default) added the key without validation; it is validated like any added key now.

Only what an update can change is validated

  • The validators of an object describe via ObjectUpdateDependentValidatorInterface how an update of a key may change their result (ObjectUpdateDependency: None, AddedKey, Value - 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. Validators which don't describe it (e.g. custom validators) depend on the value of every key. minProperties, maxProperties and denied properties got dedicated validator classes; DependencyValidatorInterface marks the dependency validators.
  • Setters render two paths: adding the key runs everything the key may affect, changing the value of a present key only what depends on the value. A path which validates nothing besides the property itself doesn't snapshot the model state - e.g. the setter of a declared property never validates additionalProperties.
  • A composition branch which depends on the values of keys it doesn't declare (e.g. patternProperties, a nested composition) is always evaluated again; a branch which depends on the keys only (e.g. maxProperties, propertyNames, denied additional properties - also via setDenyAdditionalProperties) keeps its cached result unless an update adds a key; other branches keep it as long as none of their properties is updated.
  • A true branch 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.
  • The validator which only stores additional properties without constraints is extracted into UpdateAdditionalPropertiesValidator so it doesn't count as a constraint.

Branch defaults only for compositions of the schema itself

  • The branch default map resolved branch properties on the outer schema by name. For a composition on a named property, a sibling property with the same name was overwritten with the default of the nested branch (getter then failed with a 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

  • _propertyValidationState was only declared if a setter validates a composition, e.g. a mutable anyOf of 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)

  • A property transferred from a composition branch kept the dependency validators of the branch: the model enforced the dependency of a oneOf/anyOf branch also for data matching another branch, and reported a violated dependency of an allOf branch twice. The transferred property drops them, the composition validates them.

Boolean false branches of compositions of the schema itself (pre-existing on master)

  • A false branch (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-level allOf: [false] and not: true accepted every object. The object is always present: such a branch now always fails, not: true always rejects.

then: false / else: false with a real if (pre-existing on master)

  • Was rejected at generation time as unsatisfiable (SchemaException), but only the values entering the branch are invalid, e.g. "if": {"required": ["kind"]}, "then": false denies kind. The false branch is kept and rejects the values entering it; then: false with else: false emits 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)

  • The production library rendered collected condition / branch errors of a ConditionalException glued 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)

  • The staging of the filtered values of active branches moved from the removed composition setter hook into the setter rendering: each setter path which validates a composition resets the staging and prepares the values before anything is assigned, the setter applies them once the update succeeded; 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 with maxProperties): 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)

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. $ref branch, implicitly denied additional properties, an additionalProperties schema, 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 a minProperties branch counts, evaluates the branch again; a branch with maxProperties keeps 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 schema false) is rejected like the constructor, changing a present one doesn't validate; a true branch keeps its cached result and doesn't make setters validate; a false / not: true / nested anyOf: [false] branch is evaluated with every update — both error modes.
  • BooleanCompositionSchemaTest: false / not: true / nested anyOf: [false] branches of compositions of the schema itself and root-level allOf: [false] / not: true reject like JSON Schema; then: false / else: false with a real if reject only the values entering the branch (property and object level); then: false + else: false warns and rejects every value — complete messages, both error modes.
  • ComposedIfTest: collected conditional errors are rendered as bullets (requires production#22).
  • SetterObjectLevelValidationTest: setter and populate violating maxProperties, propertyNames, patternProperties, the schema dependency of another property, or additionalProperties for a property only a branch declares are rejected like the constructor — both error modes; setters of declared properties don't validate additionalProperties; 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 active oneOf branch 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 a oneOf/anyOf branch doesn't apply to data matching another branch; a violated dependency of an allOf branch is reported once (complete messages).
  • ComposedAnyOfTest: mutable object-level anyOf without declared properties creates no dynamic property and no modified values helper.
  • SchemaHookTest: state changed by a custom SetterBeforeValidationHookInterface hook 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).
  • Each test added for the review fixes failed before its fix; the skip rules and update dependency classifications were verified by weakening each one (the corresponding tests fail). Moving the update rendering out of RenderHelper doesn'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 4 ReferencePropertyTest network-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

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
@coveralls

coveralls commented Oct 14, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 39961809750

Coverage increased (+0.03%) to 99.21%

Details

  • Coverage increased (+0.03%) from the base build.
  • Patch coverage: 375 of 375 lines across 32 files are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 8857
Covered Lines: 8787
Line Coverage: 99.21%
Coverage Strength: 605.67 hits per line

💛 - 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
@wol-soft wol-soft changed the title Restore the composition state when an update is rejected Restore the composition state and branch defaults when an update is rejected Oct 14, 2026
…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
@wol-soft wol-soft changed the title Restore the composition state and branch defaults when an update is rejected Restore the model state of rejected updates Oct 16, 2026
…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
claude added 17 commits October 21, 2026 16:32
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
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
wol-soft merged commit 34f546d into master Oct 23, 2026
7 checks passed
@wol-soft
wol-soft deleted the claude/restore-composition-state-on-failed-update branch October 23, 2026 13:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants