Skip to content

False "additional property" failure when allOf branches merge a $ref property with an inline property definition #582

Description

@argos83

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): FAILuser_locale rejected as additional property
  • /bar (data inline): PASSuser_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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    smartbear-supportedSmartBear engineering team will support this issue. See https://docs.pact.io/help/smartbear

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions