You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Follow-up from the work that added SchemaProcessor::checkObjectRepresentability() and GeneratorConfiguration::setImplicitObjectComposition() (rejecting class-defining compositions —
file root, cross-file $ref targets — that cannot resolve to a definite object, unless the value
is guaranteed by an explicit type: object on the schema itself). Several related gaps were
identified during and after that work that are deliberately out of scope for it. Full analysis
below so the reasoning survives independently of the session that found it.
This ships in the same release as #166, so the behaviour changes land as a single migration
for users rather than several consecutive breaks.
Code references below were re-checked against master after the $ref-sibling rework (#185)
landed and #166 merged it.
Part 0 — documentation and API text to extend when Part 1 lands
#166 narrowed all user-facing text to the scope it actually implements, so nothing here is
currently inaccurate. What follows is the list of places describing the current narrow scope
that Part 1 must widen — a to-do for the implementer, not a correction:
GeneratorConfiguration::setImplicitObjectComposition()'s docblock, which scopes the flag to
"a schema file's root, or a $ref target parsed as a top-level schema in its own right", and
additionally states that a root carrying only object keywords with no composition keyword is
skipped before the check runs and is not affected by the flag at all.
docs/source/gettingStarted.rst, "Implicit object composition", same narrow wording.
docs/source/combinedSchemas/impliedObjects.rst, "Class-defining compositions must resolve to a
definite object". Two .. note:: blocks close that section: one states that a named property,
array item or schema dependencies value never triggers the rejection, the other that a bare
object-keyword root is skipped entirely. Both become false when Part 1 lands and are the
markers to search for.
Part 1 is a further default-behaviour change, so it also needs its own entry in the 1.0 release
notes alongside #166's.
Part 1 — Extend setImplicitObjectComposition() to every object-describing site, not just class-defining compositions
Today the check (and its opt-in flag) only affects the file root and cross-file $ref targets —
because every other site that reaches PropertyFactory::createObjectProperty() always does so with type: objectalready forced onto the JSON by its own caller, before checkObjectRepresentability() would ever get a chance to look at it. Two call sites currently
bypass any flag-aware check entirely.
1. Named object properties, array items, and composition branches
PropertyFactory::rerouteBareObjectValidator() (extracted from create() during the #166 review —
it is no longer inline in create(), so search by method name) handles the "bare object-validator
schema" reroute: no type, no filter, no composition keyword and no $ref, classifying as ObjectShape::ObjectDescribing.
For a property, array item, or composition branch (each branch of a oneOf/allOf/anyOf is
itself processed through this same create() entry point) written as {"properties": {"name": {"type": "string"}}} with no type, this always proceeds: it logs a
warning and generates a guarded class — object input is instantiated and validated, non-object
input silently passes through unchanged (the getter/setter stay mixed). This happens
unconditionally, regardless of setImplicitObjectComposition() — the flag is never consulted here.
2. Schema dependencies targets — no check, no warning at all
PropertyDependencyTrait::addDependencyValidator() (this method was called wireSchemaDependency() before the #185 rework; the mechanism below is unchanged):
The least-guarded of the two: it force-sets type: object on an untyped dependencies schema
unconditionally, with noObjectShapeResolver classification and no warning of any kind —
not even the log message the property/array-item path at least emits.
Required change
Route both sites through the same classification checkObjectRepresentability() already uses.
When the resolved shape is ObjectShape::ObjectDescribing and setImplicitObjectComposition() is false (default), generation should throw a SchemaException instead of the current
silent/warning-only guarded behavior. When the flag is true, today's guarded/pass-through
behavior is preserved unchanged, including the warning. ObjectShape::ObjectAsserting is
unaffected either way.
Note the exemption shape changed in #166. There is no longer a filter early return to
replicate: ObjectShapeResolver classifies a filter-bearing schema as ObjectShape::Undecidable, and checkObjectRepresentability() returns without throwing for that
verdict, which also covers a filter nested inside a composition branch (the old early return only
ever saw a top-level filter key). Whatever check Part 1 adds at the sites above must likewise let Undecidable through, or it will mask both filter and unresolved-reference diagnostics the same
way the root check did before it was fixed.
A schema whose dependency/property/branch value is itself a $ref likely stays unaffected the same
way root-level $refs are exempted today — worth confirming during implementation. Note that
sibling handling next to a $ref is now draft-dependent (Draft 07 suppresses siblings via ExclusiveProducer, Draft 2019-09+ applies them); any new check must derive that from the draft
rather than restate it, exactly as ObjectShapeResolver::classifyReference() does.
This is a default-behavior change for any existing schema that currently relies on the
guarded-object warning without ever setting setImplicitObjectComposition(true) — such schemas
would start throwing at generation time.
Tests expected to change
Found by searching every fixture for a bare (no type, no $ref) properties/required schema
sitting at a named-property, composition-branch, or schema-dependencies site. Each will need
either setImplicitObjectComposition(true) added to its GeneratorConfiguration to keep testing
today's guarded behavior, or be rewritten to assert the new SchemaException:
the bare-validator oneOf instantiate/reject coverage
the bare-validator anyOf coverage, including the non-object-accepted-per-spec case
testStandaloneObjectDescribingPropertyWarnsAtGenerationAndRejectsInvalidObjects — special
case: half its purpose is asserting the warning-only behavior Part 1 replaces by default, so it
needs to be redesigned (e.g. split into "throws by default" + "still warns when the flag is
enabled"), not just given the flag.
Checked and confirmed unaffected (already type: object explicit, or $ref-based): NestedObjectSchemaDependency.json, CompositionSchemaDependency.json, ReferenceSchemaDependency.json, DependenciesImpliedObjectSimple.json, DependenciesImpliedObject.json, PropertyDependencyTest/*.json. No existing fixture exercises a
bare object-describing array item, so array items get this behavior for the first time with no
prior test to update.
Base-level $ref parity — resolved in #166, keep it that way
An earlier revision listed a concern that a bare base-level $ref root could not be rescued by setImplicitObjectComposition(true). Fixed in #166; recorded so Part 1 does not regress it.
Note the code moved during the #185 rework — it now lives in Draft\Producer\RefResolver, not PropertyFactory:
RefResolver::resolveBaseReference() no longer requires a single nested schema. A reference to a
root-level anyOf/oneOf transfers its composition via transferComposedPropertiesToSchema(),
exactly as the inline form does, and the referenced schema's base validators are transferred so
object-level keywords are not silently dropped.
A failure raised while eagerly generating a referenced schema's own class is no longer rewrapped
as "Unresolved Reference"; it propagates naming the referenced file
(SchemaException::isReferencedSchemaFailure()).
ComposedObjectShapeValidationTest asserts inline/$ref parity through a shared assertion helper.
Part 1 must keep those passing.
Part 2 — Reconsider inheritPropertyType()'s forced type injection onto composition branches
The current mechanism has two logically distinct parts
PropertyFactory::createBaseProperty() does one line of work with two very different
justifications bundled into it:
Part A — making the class's own direct keywords get processed at all.Draft::getCoveredTypes()
requires the literal string 'object' to be present in $type for the objectType entry's
validators to run. properties, required, patternProperties, additionalProperties, propertyNames, minProperties, maxProperties and ObjectModifier are all registered
exclusively under the objectType. Without type: 'object' literally present in the JSON handed
to applyModifiers(), none of these run and the class's own properties would silently never be
parsed. Required, load-bearing infrastructure — do not touch.
Part B — propagating that forced type onto composition branches, via inheritPropertyType()/inheritIfPropertyType(). This is the part in question.
Question A: can the root's class-or-not decision be driven by ObjectShapeResolver instead of the crude keyword gate?
SchemaProcessor::processSchema()'s gate is a crude syntactic pre-filter that returns null (skip
silently) for anything without type: object or a composition keyword or $ref. checkObjectRepresentability() runs after it, as a separate semantic pass over the same JSON.
The natural simplification is to compute the classification once and derive both "should we even
attempt a class" and "is it valid" from it.
This is not a lossless merge. The silent-skip branch covers two behaviorally identical but
semantically different cases a plain shape classification cannot distinguish:
Genuinely nothing here — a definitions-only library file, an annotation-only fragment.
Explicitly, deliberately a different type — a root that is just {"type": "string"}: a
reusable scalar type-definition file meant only to be $ref-ed elsewhere.
Both must stay a silent skip — RecursiveDirectoryProvider iterates every .json file in a schema
directory, and directories routinely contain non-object reusable type-definition files. But a
schema that has a composition keyword and also lands in ObjectShape::NotObject must not be
a silent skip — that is exactly the new rejection case.
Conclusion: the two mechanisms can be unified into one classification pass, but only by keeping
an explicit pre-check ahead of the ObjectShapeResolver call. This removes the redundant second
JSON walk without changing behavior, but does not eliminate the gate's keyword check, only
consolidate it with the classification that follows.
A third case belongs in this audit. A root carrying object keywords but no composition keyword,
no $ref and no type — e.g. {"properties": {"id": {"type": "string"}}, "required": ["id"]} —
produces no class and no diagnostic at all, under both settings of the flag. Verified identical
on master, so pre-existing rather than a #166 regression. It never reaches checkObjectRepresentability() because it has no composition keyword.
This is the OpenAPI-inheritance case's other half: #166 fixes Derived.json, but the Base.json
component it inherits from still yields nothing. Question A must state deliberately whether the
flag should cover it, or whether such a root should warn rather than skip. #166 documents the
boundary in the meantime.
Note also that not appears in neither the gate's keyword list nor ObjectShapeResolver::COMPOSITION_KEYWORDS, so a root {"not": ..., "properties": ...} is silently
skipped today. Pre-existing and arguably correct, but it belongs in the same audit.
Question B: can branch-level inheritPropertyType()/inheritIfPropertyType() be dropped for the object case?
Tracing what happens to a branch without the forced injection, for a schema that has already
passed checkObjectRepresentability():
Aggregate Asserting via the type: object early-return: every branch is evaluated against
an instance JSON Schema has already filtered to objects via the sibling type: object constraint.
Forcing type: object onto a branch changes nothing.
Aggregate Asserting via oneOf/anyOf's disjunctive combination: combineDisjunctive()
requires every branch to already be independently Asserting — no branch "needed" the injection.
Aggregate Asserting via allOf's conjunctive combination: other branches can be Describing/Neutral, but since allOf requires every branch to hold simultaneously and the Asserting branch alone already restricts accepted values to objects, an untyped sibling is
evaluated only against values already known to be objects.
In every case the forced branch-level injection is provably redundant for the accepted value set
once representability has been established upfront.
Bug 1: the forced injection manufactures an unsatisfiable branch
Branch 1 → Asserting; branch 2 → Neutral. combineConjunctive → Asserting, which passes checkObjectRepresentability() correctly. But that classification is computed on the JSON before inheritPropertyType() mutates branch 2: it force-injects type: object, turning it into {"type": "object", "enum": [1, 2, 3]} — a branch no value can satisfy. The class becomes silently
uninstantiable via this branch combination.
Confirmed empirically: generates without complaint on master, and every input then throws AllOfException ... Value for 'Root' must be one of [1,2,3], got {} — the got {} proving branch 2
was force-typed to object and the value instantiated.
#166 closes the silence, not the corruption: it emits a generation-time warning when the
injection forces object onto a branch whose enum/const values contain no object. Deliberately
a warning rather than a SchemaException, because the unsatisfiability is manufactured by the
generator's own mutation step — once the injection is removed the same schema becomes merely odd
(an object can never equal an integer), which is visible in the schema as written. Part 2 should
turn that warning into either correct generation or a SchemaException once the injection is gone.
Bug 2: the forced injection suppresses the vacuous-branch warning
Verified: {"type": "string", "allOf": [{"minLength": 2}, {}]} emits no vacuous-branch warning;
dropping the outer type emits it. The {} branch inherits the outer type, which counts as a
constraint and suppresses the check. Since createBaseProperty() always forces type: object at a
root, a {} branch in a root composition never warns — only a literal true branch does, because
boolean branches skip the injection entirely.
#166 documents the discrepancy in allOf.rst rather than hiding it. Removing the injection fixes it.
Bug 3: inheritIfPropertyType() also force-types the if condition itself
if is a pure boolean test — forcing type: object onto it changes which values satisfy the
condition, which can change whether a value is routed through then or else. A distinct
correctness concern from redundancy: only then/else need their own type for merging purposes.
Needs its own investigation.
Why this can't simply be deleted wholesale
inheritPropertyType() is type-agnostic machinery used by every composition, for every declared
outer type, at every site a composition can appear. A scalar example that still needs it: {"type": "string", "oneOf": [{"enum": ["a", "b"]}]} — the branch has no type of its own;
injecting string is what lets scalar validators apply, and there is no ObjectShapeResolver
equivalent for scalar types. Any change must be scoped specifically to the type: object case.
What needs verification before implementing Question B
Empirically audit every existing test exercising an untyped composition branch at a class
boundary, confirming none rely on the forced injection to reach the object path through a branch
that would not otherwise independently classify as Asserting/Describing.
Confirm the same reasoning holds for Describing aggregates accepted under setImplicitObjectComposition(true) — the argument above was made for Asserting.
Re-verify the if/then/else case specifically.
Decide whether Question A's unification lands alongside Question B or separately — related but
not dependent (A changes whether a class is attempted; B changes what happens to branches
after that decision).
Check the interaction with the base-validator transfer and composition transfer now living in RefResolver::resolveBaseReference(), since PropertyDependencyTrait builds its dependency
class through the same processSchema() path — so Part 1's dependencies work and this
mechanism meet in the same code.
Part 3 — $ref to a boolean schema cannot be represented
Separate, small, independent of Parts 1 and 2.
JsonSchema::$json is typed array, so a boolean-valued definition ("definitions": {"yes": true})
cannot round-trip through it. Two consequences:
ObjectShapeResolver::forDictionary()'s peek fails and the reference is classified as decidably Blocking, so {"allOf": [<object branch>, {"$ref": "#/definitions/yes"}]} at a root is rejected
as non-representable even though true imposes nothing and the allOf genuinely is a definite
object. The equivalent with an inlinetrue branch is accepted.
The same schema at a named property fails with an uncaught TypeError (Cannot assign true to property JsonSchema::$json of type array), because AllOfValidatorFactory resolves every branch
into a real Property regardless of classification.
Fixing only the classification was attempted during the #166 review and deliberately reverted:
classifying a $ref to true faithfully as neutral lets generation proceed past the
representability check and then hit that uncaught TypeError, trading a clean SchemaException for
a PHP type error.
#166 hardened this further. When the Undecidable verdict was introduced, the boolean-leaf case was
deliberately kept out of it: forDictionary() catches the TypeError separately from genuine
resolution failures and maps it to Blocking, precisely so it does not get handed back to the
pipeline and resurface as the TypeError again. That distinction is load-bearing — do not collapse
the two catches when implementing this part.
The real fix is to let JsonSchema carry a boolean (or model boolean schemas explicitly), which
touches JsonSchema, SchemaDefinition and SchemaDefinitionDictionary signatures. Worth doing on
its own. This is the last remaining inline-vs-$ref divergence after #166 closed the composition
ones.
Part 4 — Branch enumeration skips mutable base validators
Composition error messages in direct-exception mode enumerate every branch with its own reason —
except when the composition is a mutable base validator. Both ComposedItem.phptpl and ComposedItemBody.phptpl gate the enumeration on not isMutableBaseValidator(...).
Verified: the same root oneOf, generated with setImmutable(true), produces the full per-branch
breakdown; with setImmutable(false) it produces only the bare two-line summary. A named-property
composition is unaffected under either setting — the gap is specific to base validators.
Closing it means giving the mutable base-validator template path its own per-branch error registry. #166 pins the current divergence with a test
(testBranchEnumerationInDirectExceptionModeSkipsMutableBaseValidators) so the eventual fix shows
up as a named failure rather than a silent change, and qualifies its release-notes entry accordingly.
Background
Follow-up from the work that added
SchemaProcessor::checkObjectRepresentability()andGeneratorConfiguration::setImplicitObjectComposition()(rejecting class-defining compositions —file root, cross-file
$reftargets — that cannot resolve to a definite object, unless the valueis guaranteed by an explicit
type: objecton the schema itself). Several related gaps wereidentified during and after that work that are deliberately out of scope for it. Full analysis
below so the reasoning survives independently of the session that found it.
This ships in the same release as #166, so the behaviour changes land as a single migration
for users rather than several consecutive breaks.
Part 0 — documentation and API text to extend when Part 1 lands
#166 narrowed all user-facing text to the scope it actually implements, so nothing here is
currently inaccurate. What follows is the list of places describing the current narrow scope
that Part 1 must widen — a to-do for the implementer, not a correction:
GeneratorConfiguration::setImplicitObjectComposition()'s docblock, which scopes the flag to"a schema file's root, or a $ref target parsed as a top-level schema in its own right", and
additionally states that a root carrying only object keywords with no composition keyword is
skipped before the check runs and is not affected by the flag at all.
docs/source/gettingStarted.rst, "Implicit object composition", same narrow wording.docs/source/combinedSchemas/impliedObjects.rst, "Class-defining compositions must resolve to adefinite object". Two
.. note::blocks close that section: one states that a named property,array item or schema
dependenciesvalue never triggers the rejection, the other that a bareobject-keyword root is skipped entirely. Both become false when Part 1 lands and are the
markers to search for.
Part 1 is a further default-behaviour change, so it also needs its own entry in the 1.0 release
notes alongside #166's.
Part 1 — Extend
setImplicitObjectComposition()to every object-describing site, not just class-defining compositionsToday the check (and its opt-in flag) only affects the file root and cross-file
$reftargets —because every other site that reaches
PropertyFactory::createObjectProperty()always does so withtype: objectalready forced onto the JSON by its own caller, beforecheckObjectRepresentability()would ever get a chance to look at it. Two call sites currentlybypass any flag-aware check entirely.
1. Named object properties, array items, and composition branches
PropertyFactory::rerouteBareObjectValidator()(extracted fromcreate()during the #166 review —it is no longer inline in
create(), so search by method name) handles the "bare object-validatorschema" reroute: no
type, nofilter, no composition keyword and no$ref, classifying asObjectShape::ObjectDescribing.For a property, array item, or composition branch (each branch of a
oneOf/allOf/anyOfisitself processed through this same
create()entry point) written as{"properties": {"name": {"type": "string"}}}with notype, this always proceeds: it logs awarning and generates a guarded class — object input is instantiated and validated, non-object
input silently passes through unchanged (the getter/setter stay
mixed). This happensunconditionally, regardless of
setImplicitObjectComposition()— the flag is never consulted here.2. Schema
dependenciestargets — no check, no warning at allPropertyDependencyTrait::addDependencyValidator()(this method was calledwireSchemaDependency()before the #185 rework; the mechanism below is unchanged):The least-guarded of the two: it force-sets
type: objecton an untypeddependenciesschemaunconditionally, with no
ObjectShapeResolverclassification and no warning of any kind —not even the log message the property/array-item path at least emits.
Required change
Route both sites through the same classification
checkObjectRepresentability()already uses.When the resolved shape is
ObjectShape::ObjectDescribingandsetImplicitObjectComposition()isfalse(default), generation should throw aSchemaExceptioninstead of the currentsilent/warning-only guarded behavior. When the flag is
true, today's guarded/pass-throughbehavior is preserved unchanged, including the warning.
ObjectShape::ObjectAssertingisunaffected either way.
Note the exemption shape changed in #166. There is no longer a
filterearly return toreplicate:
ObjectShapeResolverclassifies a filter-bearing schema asObjectShape::Undecidable, andcheckObjectRepresentability()returns without throwing for thatverdict, which also covers a filter nested inside a composition branch (the old early return only
ever saw a top-level
filterkey). Whatever check Part 1 adds at the sites above must likewise letUndecidablethrough, or it will mask both filter and unresolved-reference diagnostics the sameway the root check did before it was fixed.
A schema whose dependency/property/branch value is itself a
$reflikely stays unaffected the sameway root-level
$refs are exempted today — worth confirming during implementation. Note thatsibling handling next to a
$refis now draft-dependent (Draft 07 suppresses siblings viaExclusiveProducer, Draft 2019-09+ applies them); any new check must derive that from the draftrather than restate it, exactly as
ObjectShapeResolver::classifyReference()does.This is a default-behavior change for any existing schema that currently relies on the
guarded-object warning without ever setting
setImplicitObjectComposition(true)— such schemaswould start throwing at generation time.
Tests expected to change
Found by searching every fixture for a bare (no
type, no$ref)properties/requiredschemasitting at a named-property, composition-branch, or schema-
dependenciessite. Each will needeither
setImplicitObjectComposition(true)added to itsGeneratorConfigurationto keep testingtoday's guarded behavior, or be rewritten to assert the new
SchemaException:tests/Issues/Issue/Issue72Test.php(NestedOneOfBareObjectValidators.json,NestedAnyOfBareObjectValidators.json,StandaloneObjectDescribingProperty.json). This file wasconsolidated in Fix crashes and silent misvalidation in object-implied compositions, reject non-representable ones #166, so locate cases by fixture rather than by old method names.
oneOfinstantiate/reject coverageanyOfcoverage, including the non-object-accepted-per-spec casetestStandaloneObjectDescribingPropertyWarnsAtGenerationAndRejectsInvalidObjects— specialcase: half its purpose is asserting the warning-only behavior Part 1 replaces by default, so it
needs to be redesigned (e.g. split into "throws by default" + "still warns when the flag is
enabled"), not just given the flag.
tests/Basic/SchemaDependencyTest.php(SchemaDependency.json):testValidSchemaDependency,testInvalidSchemaDependency(both data-provider driven)tests/Issues/Issue/Issue86Test.php(schemaDependency.json):testDifferentSchemaDependenciesForReferencedObject,testDifferentSchemaDependenciesForReferencedObjectWithInvalidInputChecked and confirmed unaffected (already
type: objectexplicit, or$ref-based):NestedObjectSchemaDependency.json,CompositionSchemaDependency.json,ReferenceSchemaDependency.json,DependenciesImpliedObjectSimple.json,DependenciesImpliedObject.json,PropertyDependencyTest/*.json. No existing fixture exercises abare object-describing array item, so array items get this behavior for the first time with no
prior test to update.
Base-level
$refparity — resolved in #166, keep it that wayAn earlier revision listed a concern that a bare base-level
$refroot could not be rescued bysetImplicitObjectComposition(true). Fixed in #166; recorded so Part 1 does not regress it.Note the code moved during the #185 rework — it now lives in
Draft\Producer\RefResolver, notPropertyFactory:RefResolver::resolveBaseReference()no longer requires a single nested schema. A reference to aroot-level
anyOf/oneOftransfers its composition viatransferComposedPropertiesToSchema(),exactly as the inline form does, and the referenced schema's base validators are transferred so
object-level keywords are not silently dropped.
as "Unresolved Reference"; it propagates naming the referenced file
(
SchemaException::isReferencedSchemaFailure()).ComposedObjectShapeValidationTestasserts inline/$refparity through a shared assertion helper.Part 1 must keep those passing.
Part 2 — Reconsider
inheritPropertyType()'s forced type injection onto composition branchesThe current mechanism has two logically distinct parts
PropertyFactory::createBaseProperty()does one line of work with two very differentjustifications bundled into it:
Part A — making the class's own direct keywords get processed at all.
Draft::getCoveredTypes()requires the literal string
'object'to be present in$typefor theobjectTypeentry'svalidators to run.
properties,required,patternProperties,additionalProperties,propertyNames,minProperties,maxPropertiesandObjectModifierare all registeredexclusively under the
objectType. Withouttype: 'object'literally present in the JSON handedto
applyModifiers(), none of these run and the class's ownpropertieswould silently never beparsed. Required, load-bearing infrastructure — do not touch.
Part B — propagating that forced type onto composition branches, via
inheritPropertyType()/inheritIfPropertyType(). This is the part in question.Question A: can the root's class-or-not decision be driven by ObjectShapeResolver instead of the crude keyword gate?
SchemaProcessor::processSchema()'s gate is a crude syntactic pre-filter that returns null (skipsilently) for anything without
type: objector a composition keyword or$ref.checkObjectRepresentability()runs after it, as a separate semantic pass over the same JSON.The natural simplification is to compute the classification once and derive both "should we even
attempt a class" and "is it valid" from it.
This is not a lossless merge. The silent-skip branch covers two behaviorally identical but
semantically different cases a plain shape classification cannot distinguish:
{"type": "string"}: areusable scalar type-definition file meant only to be
$ref-ed elsewhere.Both must stay a silent skip —
RecursiveDirectoryProvideriterates every.jsonfile in a schemadirectory, and directories routinely contain non-object reusable type-definition files. But a
schema that has a composition keyword and also lands in
ObjectShape::NotObjectmust not bea silent skip — that is exactly the new rejection case.
Conclusion: the two mechanisms can be unified into one classification pass, but only by keeping
an explicit pre-check ahead of the
ObjectShapeResolvercall. This removes the redundant secondJSON walk without changing behavior, but does not eliminate the gate's keyword check, only
consolidate it with the classification that follows.
A third case belongs in this audit. A root carrying object keywords but no composition keyword,
no
$refand notype— e.g.{"properties": {"id": {"type": "string"}}, "required": ["id"]}—produces no class and no diagnostic at all, under both settings of the flag. Verified identical
on
master, so pre-existing rather than a #166 regression. It never reachescheckObjectRepresentability()because it has no composition keyword.This is the OpenAPI-inheritance case's other half: #166 fixes
Derived.json, but theBase.jsoncomponent it inherits from still yields nothing. Question A must state deliberately whether the
flag should cover it, or whether such a root should warn rather than skip. #166 documents the
boundary in the meantime.
Note also that
notappears in neither the gate's keyword list norObjectShapeResolver::COMPOSITION_KEYWORDS, so a root{"not": ..., "properties": ...}is silentlyskipped today. Pre-existing and arguably correct, but it belongs in the same audit.
Question B: can branch-level
inheritPropertyType()/inheritIfPropertyType()be dropped for the object case?Tracing what happens to a branch without the forced injection, for a schema that has already
passed
checkObjectRepresentability():Assertingvia thetype: objectearly-return: every branch is evaluated againstan instance JSON Schema has already filtered to objects via the sibling
type: objectconstraint.Forcing
type: objectonto a branch changes nothing.AssertingviaoneOf/anyOf's disjunctive combination:combineDisjunctive()requires every branch to already be independently
Asserting— no branch "needed" the injection.AssertingviaallOf's conjunctive combination: other branches can beDescribing/Neutral, but sinceallOfrequires every branch to hold simultaneously and theAssertingbranch alone already restricts accepted values to objects, an untyped sibling isevaluated only against values already known to be objects.
In every case the forced branch-level injection is provably redundant for the accepted value set
once representability has been established upfront.
Bug 1: the forced injection manufactures an unsatisfiable branch
{ "allOf": [ { "type": "object", "properties": { "name": { "type": "string" } } }, { "enum": [1, 2, 3] } ] }Branch 1 →
Asserting; branch 2 →Neutral.combineConjunctive→Asserting, which passescheckObjectRepresentability()correctly. But that classification is computed on the JSON beforeinheritPropertyType()mutates branch 2: it force-injectstype: object, turning it into{"type": "object", "enum": [1, 2, 3]}— a branch no value can satisfy. The class becomes silentlyuninstantiable via this branch combination.
Confirmed empirically: generates without complaint on
master, and every input then throwsAllOfException ... Value for 'Root' must be one of [1,2,3], got {}— thegot {}proving branch 2was force-typed to object and the value instantiated.
#166 closes the silence, not the corruption: it emits a generation-time warning when the
injection forces
objectonto a branch whoseenum/constvalues contain no object. Deliberatelya warning rather than a
SchemaException, because the unsatisfiability is manufactured by thegenerator's own mutation step — once the injection is removed the same schema becomes merely odd
(an object can never equal an integer), which is visible in the schema as written. Part 2 should
turn that warning into either correct generation or a
SchemaExceptiononce the injection is gone.Bug 2: the forced injection suppresses the vacuous-branch warning
Verified:
{"type": "string", "allOf": [{"minLength": 2}, {}]}emits no vacuous-branch warning;dropping the outer
typeemits it. The{}branch inherits the outer type, which counts as aconstraint and suppresses the check. Since
createBaseProperty()always forcestype: objectat aroot, a
{}branch in a root composition never warns — only a literaltruebranch does, becauseboolean branches skip the injection entirely.
#166 documents the discrepancy in
allOf.rstrather than hiding it. Removing the injection fixes it.Bug 3:
inheritIfPropertyType()also force-types theifcondition itselfifis a pure boolean test — forcingtype: objectonto it changes which values satisfy thecondition, which can change whether a value is routed through
thenorelse. A distinctcorrectness concern from redundancy: only
then/elseneed their own type for merging purposes.Needs its own investigation.
Why this can't simply be deleted wholesale
inheritPropertyType()is type-agnostic machinery used by every composition, for every declaredouter type, at every site a composition can appear. A scalar example that still needs it:
{"type": "string", "oneOf": [{"enum": ["a", "b"]}]}— the branch has notypeof its own;injecting
stringis what lets scalar validators apply, and there is noObjectShapeResolverequivalent for scalar types. Any change must be scoped specifically to the
type: objectcase.What needs verification before implementing Question B
boundary, confirming none rely on the forced injection to reach the object path through a branch
that would not otherwise independently classify as
Asserting/Describing.Describingaggregates accepted undersetImplicitObjectComposition(true)— the argument above was made forAsserting.if/then/elsecase specifically.not dependent (A changes whether a class is attempted; B changes what happens to branches
after that decision).
RefResolver::resolveBaseReference(), sincePropertyDependencyTraitbuilds its dependencyclass through the same
processSchema()path — so Part 1'sdependencieswork and thismechanism meet in the same code.
Part 3 —
$refto a boolean schema cannot be representedSeparate, small, independent of Parts 1 and 2.
JsonSchema::$jsonis typedarray, so a boolean-valued definition ("definitions": {"yes": true})cannot round-trip through it. Two consequences:
ObjectShapeResolver::forDictionary()'s peek fails and the reference is classified as decidablyBlocking, so{"allOf": [<object branch>, {"$ref": "#/definitions/yes"}]}at a root is rejectedas non-representable even though
trueimposes nothing and theallOfgenuinely is a definiteobject. The equivalent with an inline
truebranch is accepted.TypeError(Cannot assign true to property JsonSchema::$json of type array), becauseAllOfValidatorFactoryresolves every branchinto a real
Propertyregardless of classification.Fixing only the classification was attempted during the #166 review and deliberately reverted:
classifying a
$reftotruefaithfully as neutral lets generation proceed past therepresentability check and then hit that uncaught
TypeError, trading a cleanSchemaExceptionfora PHP type error.
#166 hardened this further. When the
Undecidableverdict was introduced, the boolean-leaf case wasdeliberately kept out of it:
forDictionary()catches theTypeErrorseparately from genuineresolution failures and maps it to
Blocking, precisely so it does not get handed back to thepipeline and resurface as the
TypeErroragain. That distinction is load-bearing — do not collapsethe two catches when implementing this part.
The real fix is to let
JsonSchemacarry a boolean (or model boolean schemas explicitly), whichtouches
JsonSchema,SchemaDefinitionandSchemaDefinitionDictionarysignatures. Worth doing onits own. This is the last remaining inline-vs-
$refdivergence after #166 closed the compositionones.
Part 4 — Branch enumeration skips mutable base validators
Composition error messages in direct-exception mode enumerate every branch with its own reason —
except when the composition is a mutable base validator. Both
ComposedItem.phptplandComposedItemBody.phptplgate the enumeration onnot isMutableBaseValidator(...).Verified: the same root
oneOf, generated withsetImmutable(true), produces the full per-branchbreakdown; with
setImmutable(false)it produces only the bare two-line summary. A named-propertycomposition is unaffected under either setting — the gap is specific to base validators.
Closing it means giving the mutable base-validator template path its own per-branch error registry.
#166 pins the current divergence with a test
(
testBranchEnumerationInDirectExceptionModeSkipsMutableBaseValidators) so the eventual fix showsup as a named failure rather than a silent change, and qualifies its release-notes entry accordingly.