False "additional property" failure when allOf branches merge a $ref property with an inline property definition
Summary
When a response schema is composed via allOf and two branches define the same property key — one as a plain object schema (inline) and one as a $ref — the resulting flattened schema silently loses all properties defined by the inline branch. This causes valid pact response bodies to be rejected with "Response body is incompatible: ... must NOT have additional properties".
Steps to reproduce
complete pact and openapi spec files attached
OpenAPI spec
components:
schemas:
LocalizationData:
type: object
properties:
data:
type: object
properties:
user_locale:
type: string
# ❌ data is a $ref here — triggers the bug
MyResponseSchema:
allOf:
- $ref: '#/components/schemas/LocalizationData'
- type: object
properties:
data:
$ref: '#/components/schemas/MyResponseData'
MyResponseData:
type: object
properties:
my_response_result:
type: string
# ✅ data is inline here — works fine
MyOtherResponseSchema:
allOf:
- $ref: '#/components/schemas/LocalizationData'
- type: object
properties:
data:
type: object
properties:
my_other_response_result:
type: string
Pact
{
"interactions": [
{
"description": "POST /foo — user_locale incorrectly rejected",
"request": { "method": "POST", "path": "/foo" },
"response": {
"status": 200,
"body": {
"data": {
"my_response_result": "some_result",
"user_locale": "en_US"
}
}
}
},
{
"description": "POST /bar — user_locale correctly accepted",
"request": { "method": "POST", "path": "/bar" },
"response": {
"status": 200,
"body": {
"data": {
"my_other_response_result": "some_result",
"user_locale": "en_US"
}
}
}
}
]
}
Result
/foo (data via $ref): FAIL — user_locale rejected as additional property
/bar (data inline): PASS — user_locale accepted
Both interactions should pass. user_locale is legitimately declared inside LocalizationData.properties.data.properties, which is transitively included in both schemas via GenericResponseFields.
Root cause
The bug lives in src/transform/flattenAllOf.ts. When _flat encounters an allOf, it uses lodash.merge to combine all sub-schemas. Consider what happens to MyResponseSchema:
After recursion, the two allOf items resolve to:
// Item 1 (from GenericResponseFields → LocalizationData)
{ type: "object", properties: { data: { type: "object", properties: { user_locale: { type: "string" } } } } }
// Item 2 (specific response fields)
{ type: "object", properties: { data: { $ref: "#/components/schemas/MyResponseData" } } }
lodash.merge deep-merges these, producing:
{
type: "object",
properties: {
data: {
type: "object",
properties: { user_locale: { type: "string" } },
$ref: "#/components/schemas/MyResponseData" // ← hybrid!
}
}
}
This hybrid { $ref, ...siblings } schema is invalid under JSON Schema draft-07, where a $ref ignores all sibling keywords. AJV therefore validates data exclusively against MyResponseData, which only declares my_response_result. When transformResponseSchema later adds additionalProperties: false to MyResponseData, user_locale becomes an illegal additional property — even though it was declared in a sibling that $ref silently swallowed.
The inline variant (MyOtherResponseSchema) is not affected because lodash.merge on two plain objects produces a correctly merged properties map with no $ref involved.
Affected pattern
Any response schema matching this shape is affected:
SomeResponse:
allOf:
- $ref: SchemaWithDataProperty # defines data.fieldA
- type: object
properties:
data:
$ref: SomeSpecificData # defines data.fieldB
The data property appears in both allOf branches: once as an inline object (transitively, via the first $ref) and once as a bare $ref. After lodash.merge, data becomes { ...inlineProperties, $ref: SomeSpecificData }, and the inlined properties are invisible to AJV.
Expected behaviour
All fields that are legitimately declared anywhere in the transitively resolved allOf chain should be accepted in the response body.
Notes
- The
MyOtherResponseSchema variant (inline data definition) is a reliable workaround until a fix lands.
- For historical context, the original decision in
swagger-mock-validator not to inject additionalProperties: false is documented in Bitbucket issue #84. I'm attaching a copy of that discussion as Bitbucket is retiring its Issues feature on 2026-08-20 and the thread would otherwise be lost.
- There is no targeted escape hatch: the
no-transform-non-nullable-response-schema quirk only skips additionalProperties: false injection for nullable: true schemas, which doesn't apply here.
- A sample fix is attached (
flattenAllOf.patch). It resolves pure $ref property schemas (i.e. { $ref: X } with no other keys) into their target schemas before lodash.merge runs, so the merge never produces a hybrid { $ref, properties } node. The inlining is intentionally scoped to the allOf merge path to avoid infinite recursion on circular $ref chains (e.g. AnyJSON → JSONArray → AnyJSON). I wanted to open a discussion here before investing further in the fix and its test coverage.
Attachments
Appendix: limitations of the flattenAllOf approach
While not directly related to the reported bug, I want to flag a more general concern with the flattenAllOf approach.
Semantic incorrectness of the existing comment
The comment above flattenAllOf reads:
OpenAPI defines allOf to mean the union of all sub-schemas...
This is backwards. allOf is an intersection — a value must satisfy every sub-schema simultaneously. anyOf is the union. The distinction matters because it shapes what "flattening" should mean.
lodash.merge is not schema intersection
The current implementation uses lodash.merge to combine allOf sub-schemas. This is a shallow approximation: it works well for disjoint properties maps, but produces incorrect results whenever two sub-schemas constrain the same keyword.
Two examples:
# enum — correct intersection is ["b", "c"]
allOf:
- enum: ["a", "b", "c"]
- enum: ["b", "c", "d"]
lodash.merge merges arrays by index, so the second array overwrites the first element-by-element, yielding ["b", "c", "d"] — a different set entirely, not the intersection.
# numeric bounds — correct intersection is {minLength: 10, maxLength: 20}
allOf:
- minLength: 5
maxLength: 20
- minLength: 10
maxLength: 25
The intersection requires the most restrictive bound for each keyword: the higher floor (minLength: 10) and the lower ceiling (maxLength: 20). lodash.merge takes the last value for each scalar key, producing {minLength: 10, maxLength: 25} — a wider range than correct, silently dropping the tighter maxLength.
The same problem applies to minimum/maximum, pattern, required arrays, not schemas, and any nested combination thereof.
A fully correct implementation would require set-theoretic schema intersection — effectively re-implementing a JSON Schema diff engine. Projects like openapi-diff (which uses json-schema-diff internally, both of which I co-authored) have tackled this and it is a substantial undertaking with inherently limited keyword coverage.
The current approach as a trade-off
The flattenAllOf + additionalProperties: false approach sits between two extremes:
- SMV behaviour (no flattening, no
additionalProperties: false): pact bodies with entirely undeclared fields pass silently — typos go undetected.
- Full schema intersection: semantically correct but extremely complex to implement correctly and maintain.
The current approach catches the most common case (undeclared top-level properties) at the cost of producing subtly wrong schemas for complex compositions. The bug reported in this issue is one symptom of that; the examples above illustrate other categories of potential false positives or false negatives.
Suggestion
A configuration flag to opt out of additionalProperties: false injection on response schemas entirely (beyond the existing no-transform-non-nullable-response-schema partial escape hatch) would let users who hit correctness problems with complex allOf compositions fall back to SMV-style permissive validation, while keeping the stricter default for simpler schemas.
False "additional property" failure when
allOfbranches merge a$refproperty with an inline property definitionSummary
When a response schema is composed via
allOfand two branches define the same property key — one as a plain object schema (inline) and one as a$ref— the resulting flattened schema silently loses all properties defined by the inline branch. This causes valid pact response bodies to be rejected with"Response body is incompatible: ... must NOT have additional properties".Steps to reproduce
complete pact and openapi spec files attached
OpenAPI spec
Pact
{ "interactions": [ { "description": "POST /foo — user_locale incorrectly rejected", "request": { "method": "POST", "path": "/foo" }, "response": { "status": 200, "body": { "data": { "my_response_result": "some_result", "user_locale": "en_US" } } } }, { "description": "POST /bar — user_locale correctly accepted", "request": { "method": "POST", "path": "/bar" }, "response": { "status": 200, "body": { "data": { "my_other_response_result": "some_result", "user_locale": "en_US" } } } } ] }Result
/foo(data via$ref): FAIL —user_localerejected as additional property/bar(data inline): PASS —user_localeacceptedBoth interactions should pass.
user_localeis legitimately declared insideLocalizationData.properties.data.properties, which is transitively included in both schemas viaGenericResponseFields.Root cause
The bug lives in
src/transform/flattenAllOf.ts. When_flatencounters anallOf, it useslodash.mergeto combine all sub-schemas. Consider what happens toMyResponseSchema:After recursion, the two
allOfitems resolve to:lodash.mergedeep-merges these, producing:This hybrid
{ $ref, ...siblings }schema is invalid under JSON Schema draft-07, where a$refignores all sibling keywords. AJV therefore validatesdataexclusively againstMyResponseData, which only declaresmy_response_result. WhentransformResponseSchemalater addsadditionalProperties: falsetoMyResponseData,user_localebecomes an illegal additional property — even though it was declared in a sibling that$refsilently swallowed.The inline variant (
MyOtherResponseSchema) is not affected becauselodash.mergeon two plain objects produces a correctly mergedpropertiesmap with no$refinvolved.Affected pattern
Any response schema matching this shape is affected:
The
dataproperty appears in both allOf branches: once as an inline object (transitively, via the first$ref) and once as a bare$ref. Afterlodash.merge,databecomes{ ...inlineProperties, $ref: SomeSpecificData }, and the inlined properties are invisible to AJV.Expected behaviour
All fields that are legitimately declared anywhere in the transitively resolved
allOfchain should be accepted in the response body.Notes
MyOtherResponseSchemavariant (inline data definition) is a reliable workaround until a fix lands.swagger-mock-validatornot to injectadditionalProperties: falseis documented in Bitbucket issue #84. I'm attaching a copy of that discussion as Bitbucket is retiring its Issues feature on 2026-08-20 and the thread would otherwise be lost.no-transform-non-nullable-response-schemaquirk only skipsadditionalProperties: falseinjection fornullable: trueschemas, which doesn't apply here.flattenAllOf.patch). It resolves pure$refproperty schemas (i.e.{ $ref: X }with no other keys) into their target schemas beforelodash.mergeruns, so the merge never produces a hybrid{ $ref, properties }node. The inlining is intentionally scoped to theallOfmerge path to avoid infinite recursion on circular$refchains (e.g.AnyJSON → JSONArray → AnyJSON). I wanted to open a discussion here before investing further in the fix and its test coverage.Attachments
Appendix: limitations of the
flattenAllOfapproachWhile not directly related to the reported bug, I want to flag a more general concern with the
flattenAllOfapproach.Semantic incorrectness of the existing comment
The comment above
flattenAllOfreads:This is backwards.
allOfis an intersection — a value must satisfy every sub-schema simultaneously.anyOfis the union. The distinction matters because it shapes what "flattening" should mean.lodash.mergeis not schema intersectionThe current implementation uses
lodash.mergeto combineallOfsub-schemas. This is a shallow approximation: it works well for disjointpropertiesmaps, but produces incorrect results whenever two sub-schemas constrain the same keyword.Two examples:
lodash.mergemerges arrays by index, so the second array overwrites the first element-by-element, yielding["b", "c", "d"]— a different set entirely, not the intersection.The intersection requires the most restrictive bound for each keyword: the higher floor (
minLength: 10) and the lower ceiling (maxLength: 20).lodash.mergetakes the last value for each scalar key, producing{minLength: 10, maxLength: 25}— a wider range than correct, silently dropping the tightermaxLength.The same problem applies to
minimum/maximum,pattern,requiredarrays,notschemas, and any nested combination thereof.A fully correct implementation would require set-theoretic schema intersection — effectively re-implementing a JSON Schema diff engine. Projects like
openapi-diff(which usesjson-schema-diffinternally, both of which I co-authored) have tackled this and it is a substantial undertaking with inherently limited keyword coverage.The current approach as a trade-off
The
flattenAllOf+additionalProperties: falseapproach sits between two extremes:additionalProperties: false): pact bodies with entirely undeclared fields pass silently — typos go undetected.The current approach catches the most common case (undeclared top-level properties) at the cost of producing subtly wrong schemas for complex compositions. The bug reported in this issue is one symptom of that; the examples above illustrate other categories of potential false positives or false negatives.
Suggestion
A configuration flag to opt out of
additionalProperties: falseinjection on response schemas entirely (beyond the existingno-transform-non-nullable-response-schemapartial escape hatch) would let users who hit correctness problems with complexallOfcompositions fall back to SMV-style permissive validation, while keeping the stricter default for simpler schemas.