Skip to content

fix(types): inherit the generated constraints on the canonical creatives field - #1548

Open
KonstantinMirin wants to merge 1 commit into
adcontextprotocol:mainfrom
KonstantinMirin:fix/canonical-sync-creatives-inherit-constraints
Open

KonstantinMirin wants to merge 1 commit into
adcontextprotocol:mainfrom
KonstantinMirin:fix/canonical-sync-creatives-inherit-constraints

Conversation

@KonstantinMirin

Copy link
Copy Markdown
Collaborator

What this changes

The canonical SyncCreativesRequest redeclares creatives for one reason — to narrow the element
type from the generated Creative to the canonical CreativeAsset — and it did that by assigning a
fresh Field(...). A fresh FieldInfo replaces the inherited one wholesale, so the generated
field's max_length=100 and the bundle's description went with it.

This swaps the assignment for _inherit, the helper already in that module for exactly this case.
Its docstring states the rule:

A canonical model that narrows an inherited field's annotation still wants the generated field's
constraints, description and default. Redeclaring with this as the assigned value keeps every one
of them, where a bare Field() would silently drop min_length and friends.

What was wrong, measured

On main at 5ce9ee4dc (CPython 3.13.11), the pinned 3.2 bundle declares
creatives: {type: array, minItems: 1, maxItems: 100, description: "Array of creative assets to sync (create or update)"}. The canonical model published:

adcp.types.SyncCreativesRequest.model_json_schema()["properties"]["creatives"]
# {'anyOf': [{'items': {'$ref': '#/$defs/CreativeAsset'}, 'minItems': 1, 'type': 'array'},
#            {'type': 'null'}],
#  'default': None, 'title': 'Creatives'}

No maxItems, no description. And the ceiling was not merely unadvertised — a sync of 101
creatives was accepted
by the model, while validate_request(...) reported
('/creatives', 'maxItems'). The two arms disagreed.

This drop predates #1546: before that merge (15c85cf6e) the line read
creatives: list[CreativeAsset] = Field(min_length=1), and 101 creatives were accepted there too
with the same two pieces already missing from the published node.

What this does not fix

It does not refuse an explicit JSON null for creatives. The generated parent renders the
optional property as list[Creative] | None, so a null is admitted before any canonical
redeclaration is consulted — deleting the redeclaration outright leaves the null accepted. That
half is a generator-level rule affecting thousands of properties and is filed separately as
#1547, which also records why neither deleting the redeclaration nor giving it a non-nullable
annotation is the right fix. Not marking this PR as closing it.

Context: #1545, #1546.

Tests

Two added to tests/test_sync_creatives_assignment_only.py:

  • test_public_schema_keeps_the_bundled_constraints_on_creatives — grades the published node
    against the bundled node on type, description, minItems and maxItems. The file's existing
    required test does not catch this: a replaced FieldInfo drops the constraints while leaving
    required exactly as that test wants it.
  • test_sync_refuses_more_creatives_than_the_bundle_permits — asserts the ceiling is enforced,
    not advertised, reading maxItems out of the bundle rather than hardcoding 100, and checking both
    the model (too_long at ('creatives',)) and the JSON-Schema arm.

tests/test_sync_creatives_assignment_only.py is 20 passed on this branch (CPython 3.13.11),
including #1546's own assignment-only and canonical-parse tests.

Suggested generator-level treatment for the remaining half

Render an optional-non-nullable property as absent-or-valid rather than T | None: accept the
property being missing, refuse an explicit null. A property whose schema node does list "null"
keeps a nullable annotation. Detail and measurements in #1547.

…ves field

The canonical SyncCreativesRequest redeclares `creatives` to narrow the element
type to CreativeAsset, and assigned a fresh Field() to do it. A fresh FieldInfo
replaces the inherited one wholesale, so the generated field's max_length=100 and
the bundle's description were lost: measured against the pinned 3.2 schema, the
model published a node with neither maxItems nor description, and accepted 101
creatives where the schema declares maxItems: 100. That drop predates adcontextprotocol#1546 --
the line read `Field(min_length=1)` before it, with the same two pieces missing.

`_inherit` in the same module exists for this case; its docstring warns that a
bare Field() "would silently drop min_length and friends". The redeclaration now
spells only the annotation it narrows and inherits everything else.

What this does NOT do: it does not refuse an explicit JSON null for `creatives`.
The generated parent renders the optional property as `list[Creative] | None`, so
a null is admitted before any canonical redeclaration is consulted -- deleting
the redeclaration outright leaves it accepted. That half is a generator-level
rule, filed separately as adcontextprotocol#1547.

Two tests: one grades the published node against the bundled node on type,
description, minItems and maxItems; the other asserts the ceiling is enforced
rather than advertised, on both the model and the JSON-Schema arm.

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — clean fix with no blocking or medium findings.

Checked: the diff swaps a hand-written Field(default=None, min_length=1) on canonical SyncCreativesRequest.creatives for _inherit(...), restoring the max_length=100 ceiling and description from the generated parent FieldInfo that the hand-written Field had silently dropped. This closes a real divergence where the model accepted 101 creatives while validate_request rejected them. default=None (preserving #1546's optional behavior) and the CreativeAsset element narrowing are preserved. Two added tests grade the published node against the bundle and assert the ceiling is enforced on both arms. fix: is the correct semver signal — no public-surface break. No forward-compat, import-layering, or generated-code concerns.

Decision table: no critical/high (row 1 n/a); gated_paths false (row 2 n/a); high_risk false (rows 3/5 n/a); no medium findings (rows 4/8 n/a); no prior escalation (row 6 n/a); no no-auto-approve team match (row 7 n/a). Falls through to row 9 → approve.

This branch has not been deployed

No deployments
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.

1 participant