Skip to content

docs(spec): GraphQL access to inherited-subtype properties on Image and File fields (#34540) - #37537

Open
fabrizzio-dotCMS wants to merge 4 commits into
mainfrom
34540-graphql-asset-subtype-fields
Open

fabrizzio-dotCMS wants to merge 4 commits into
mainfrom
34540-graphql-asset-subtype-fields

Conversation

@fabrizzio-dotCMS

Copy link
Copy Markdown
Member

PR 1 of 2 — spec only. Carries spec.md alone. Approval here gates /speckit-plan; no implementation until then.

Refs #34540.

The problem

Querying an Image or File field over GraphQL returns DotFileasset — a flat, six-property object built once in a static block — instead of the real type of the asset the field points at.

image { tags }       → Field 'tags' in type 'DotFileasset' is undefined
image { identifier } → Field 'identifier' in type 'DotFileasset' is undefined
image { ... on Images { tags } }
                     → objects of type DotFileasset can never be of type Images

So a customer who extends DOTASSET or FILEASSET to model their own assets can author those fields but cannot read them back. The AI tagging workflow is blocked the same way. There is no workaround.

Root cause is ContentAPIGraphQLTypesProvider lines 97-98, mapping both ImageField.class and FileField.class to the single CustomFieldType.FILEASSET. The per-content-type generation and the DotAssetBaseType / FileBaseType interfaces already exist and are already dynamic — verified by introspection against a running instance — so the gap is only in how these two field classes are typed.

Two decisions the reviewer is being asked to confirm

1. Breaking existing queries is accepted (FR-012).

The current view is a facade: it reports image-style content using property names borrowed from the file-style base type. Measured against a running instance, five of its six properties (fileName, fileAsset, metaData, showOnMenu, sortOrder) do not exist on the real type at all. Keeping them working means keeping the misdescription forever.

Those five break loudly — an outright request failure. description does not, and that is the one to look at: it stays a valid name and keeps returning a string, but a different one. Today it returns the asset title (the file name, populated for 57 of 57 images); afterwards it returns the asset's stored description (populated for 2 of those 57). No error, no log line. FR-009a therefore requires it to be renamed rather than silently repurposed.

File-style assets fare better: their properties are real and merely move to the specific type.

2. PR #35363 is superseded, not merged (FR-013).

That PR adds a DotBinaryLike interface flattening 12 binary properties onto DotFileasset, and states it closes #34540. It delivers only the second limb of the issue; customer-defined fields stay unreachable after it.

Its benefit does not survive this feature. It exists to spare one level of nesting — image { size } over image { fileAsset { size } } — but once the field returns the asset's real type, that type's asset property is already a DotBinary carrying all 12 plus focalPoint. The client still descends exactly one level, just to the correctly named property. Meanwhile its deliverable is attached to the view this feature removes, its checks have been failing since it was last updated on a check in its own area, and it carries no review.

The remaining scope is deferred, not dropped: #34540 stays open after this ships, carrying the second stage, whose design depends on the shape this one establishes.

Worth flagging separately

GraphqlAPITest.areFileassetFieldsPresent uses allMatch, so it asserts that the asset view exposes no property other than the six it has today — the opposite of what its name says. It is what #35363's CI trips on, and it will block this work too. FR-014 requires correcting it.

Not in this PR

No implementation. Planning starts once this is approved.

🤖 Generated with Claude Code

…nd File fields (#34540)

Image and File fields are typed in the GraphQL schema as DotFileasset, a flat
six-property object built once in a static block, instead of the real type of the
asset they point at. Customer fields on DOTASSET/FILEASSET-extended content types,
tags, and even the asset's own identifier are therefore unreachable, and a client
cannot tell an image-style asset from a file-style one.

Two decisions are recorded in the spec:

- Breaking existing queries is accepted (FR-012). Five of the six properties the
  current view exposes are synthesized for image-style assets and have no
  equivalent on the real type, so preserving them would mean preserving the
  misdescription permanently. FR-009a additionally requires every break to be
  visible: `description` survives as a valid name but changes meaning, so it must
  be renamed rather than repurposed.

- PR #35363 is superseded rather than merged (FR-013). Its benefit — sparing the
  client one level of nesting to reach an asset's binary properties — does not
  survive this feature, because the real asset type already exposes a fully
  described binary carrying those properties. Issue #34540 stays open afterwards,
  carrying the second stage, which must be designed on the shape this feature
  establishes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Claude finished @fabrizzio-dotCMS's task in 1m 40s —— View job


Spec Review — GraphQL asset-subtype fields

I read specs/34540-graphql-asset-subtype-fields/spec.md in full and traced it against the review thread. This is a spec-only PR, so the review is about internal consistency, coverage, and the two open reviewer concerns — not code.

Reviewer concerns — status

  • Generality (nollymar, CHANGES_REQUESTED "written to solve the example, not the issue in general"). Addressed. The Overview now leads with "Any content type built that way is in scope … there is no privileged set," the banner/PDF cases are explicitly demoted to illustrations (lines 22-24), and FR-001a + Scenario 5 (lines 70-74) state the general case and that passing 1-2 alone does not satisfy it. FR-001b (lines 157-162) additionally closes the real defect you surfaced — an Image field resolving file-style content — so a field is no longer typed by its own kind. This resolves the requested change.

Open — not yet reflected in the spec

  • 🟠 spec.md:219-228 (FR-016 / FR-016a) — the "non-fatal warning" has no home in GraphQL, and this is unresolved. nollymar's last comment (2026-09-14T21:36:46Z) flagged exactly this and the spec has not moved: "Standard GraphQL has no warnings slot; the realistic homes are the errors array with a path, or extensions." The requirement as written (MUST additionally carry a non-fatal warning naming the clause that matched nothing) has two concrete tensions the spec should acknowledge, even if it defers the mechanism to /speckit-plan:

    1. errors array conflicts with FR-016's own "never a failed request." Returning data alongside an errors entry is the standard GraphQL partial-success pattern, but many client libraries (Apollo default, graphql-request) treat any errors entry as a thrown error. So the one channel with a path is the channel most likely to make strict clients experience precisely the failed request FR-016 forbids.
    2. extensions avoids that but is non-normative — clients may drop it, which weakens "so a client can tell 'this asset wasn't that type' apart from 'I named the wrong type'." A warning nobody receives doesn't serve that goal.

    Recommend the spec either (a) name extensions as the intended home and accept clients must opt in to read it, or (b) restate FR-016/016a as "the response MUST make the non-match discoverable without failing the request" and let the plan pick the channel. Right now it mandates a "warning" as if the mechanism were free.

  • 🟡 spec.md:222,228 — "naming the clause" is under-specified against GraphQL's model. An inline fragment ... on Images is identified only by its type condition, not by any clause id. The warning can name the type, and possibly a response path, but "which clause" isn't a first-class thing a resolver can hand back. This is fine in the common case, but the requirement's wording ("identify which clause matched nothing") implies more addressability than GraphQL gives. Suggest rewording to "identify the type condition (and path) that matched nothing" so the plan isn't chasing a non-existent handle. Ties directly to the point above.

Minor / non-blocking

  • 🟡 spec.md:129-146 (Edge Cases) vs Requirements — coverage gaps. Several edge cases have no backing FR and so aren't testable/traceable:

    • Line 136-137, archived/deleted referenced asset ("degrade predictably") — no FR. FR-007 covers empty fields only.
    • Line 138-139, language fallback — asserted in Assumptions (line 365) but no FR; the acceptance scenarios never exercise it.
    • Line 143-144, a deleted property a live query still asks for ("clear error naming the missing property") — no FR. This is arguably a hard requirement, not just an edge note.

    Not blocking for a spec, but each of these is behavior the plan/tasks can silently skip because nothing normative pins it.

  • 🟡 spec.md:140-142 vs FR-009 — collision handling reads inconsistently. The edge case says a customer property colliding with a general asset property "MUST resolve to a single, documented meaning — a client must never silently receive one when it asked for the other." FR-009 (lines 180-182) only covers the asset-vs-binary name collision, not customer-property-vs-inherited-property. The edge case describes a broader collision class than any FR guarantees. Worth an FR so it's testable.

Everything else — FR-012/012a/012b expand→mark→retire framing, the ADR-0020/0022 alignment, the #35363 supersede decision (FR-013), and the FR-014 test correction — is coherent and well-evidenced. Those are product/process calls for the human reviewer, not review defects.

Net: the generality change is done. The one thing I'd want resolved before approval is FR-016's warning mechanism — it's the only open reviewer thread and it prescribes something GraphQL doesn't hand you for free.

· 34540-graphql-asset-subtype-fields

Comment thread specs/34540-graphql-asset-subtype-fields/spec.md Outdated

@nollymar nollymar left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It seems like the spec is written to try to solve the issue in the provided example, not the issue in general.

The GraphQL API should expose any dotAsset/fileAsset content type so that I can be able to "cast" any binary to that type. And in case it is not possible, I should return the results that could be casted and the warnings for those that couldn't

…4540)

Four requirements the first draft left implicit, all verified by introspection
against a running instance:

- Shared asset properties are selectable both directly on the field and inside a
  clause that narrows to a specific type (FR-015).
- Narrowing to a type the asset does not happen to be must not fail the request;
  it contributes nothing, and a non-fatal warning names the clause that matched
  nothing, so "wasn't that type" is distinguishable from "wrong type name"
  (FR-016). There is no cast here to fail — the clause is a condition.
- Narrowing to a type that does not exist must still fail loudly (FR-017).
- Clauses for the base kind and for the specific type merge into one result
  object (FR-018).

Also records that the hierarchy is exactly two levels deep and cannot grow: a
content type may only extend one of the fixed base types, never another content
type. A chain of narrowing clauses therefore does not arise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fabrizzio-dotCMS

Copy link
Copy Markdown
Member Author

Pushed a second commit pinning down how narrowing clauses behave on an asset field — four requirements the first draft left implicit, each checked by introspection against a running instance.

Shared properties are available at both levels (FR-015). A client can select them directly on the field and again inside a clause that narrows to a specific type:

image {
  identifier                       # shared, at the top
  ... on Images { tags }           # plus what's specific to this type
}

Narrowing to a type the asset is not must not fail the request (FR-016). Worth being precise: there is no cast here to fail. ... on Images is a condition, not a coercion — if the asset is not an Images, the clause contributes nothing and the rest of the response is delivered normally. That is already how it behaves. What is being added is a non-fatal warning naming the clause that matched nothing, so a client can distinguish "this asset wasn't that type" from "I typed the wrong type name".

Naming a type that does not exist must still fail (FR-017). No valid reading, so failing loudly is correct.

Clauses merge (FR-018). Asking for the base kind and the specific type in one request yields one object, not two partial ones:

... on DotAssetBaseType { asset { size } }
... on Images           { tags }
→ {"asset": {"size": 43692}, "tags": []}

On hierarchy depth — this came up as an open question and the answer is that it does not arise. Introspection shows the base-kind description sits under nothing, and a specific type names its shared descriptions side by side rather than nested:

DotAssetBaseType   kind=INTERFACE   interfaces=NONE
BannerImages       implements=['DotAssetBaseType', 'DotContentlet']

So the hierarchy is exactly two levels and cannot grow, because a content type may only extend one of the fixed base types and never another content type. A client never faces a chain of narrowing clauses — at most one for the base kind and one for the specific type, and per FR-018 those merge.

…types (#34540)

Addresses @nollymar's change request. Her inline note is about framing, but
checking the second half of it turned up a real gap in the proposed shape.

Framing (her inline comment on the Overview):
- The Overview now leads with "any content type extending either asset base type"
  and says outright that the banner/PDF cases are illustrations.
- Added two acceptance scenarios to User Story 1 stating the general case
  explicitly, and noting that passing the two example scenarios does not satisfy it.

Substance ("expose any dotAsset/fileAsset content type so I can cast any binary
to that type"):
- FR-001a: the reachable set is every content type extending either base type,
  including ones created later, and may not be satisfied by enumeration.
- FR-001b: an asset-pointing field must span BOTH base types regardless of the
  field's own kind. Verified on a running instance — an Image field accepts and
  resolves a reference to file-style content (a plain-text file came back through
  one). The earlier "shape of the solution" typed each field by its own kind,
  which would have silently dropped content those fields already hold today. That
  section is corrected.
- FR-016a: the warning behavior holds across a whole result set. Assets that match
  a clause come back populated, assets that do not still come back, and one
  non-matching asset must not suppress the matching ones.

Also fixes the requirement ordering (FR-014 was listed last).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fabrizzio-dotCMS

Copy link
Copy Markdown
Member Author

@nollymar thanks — you were right on both counts, and chasing the second one turned up a real gap in the proposed shape, not just the framing.

On the framing. Fair. The normative requirements were already general (FR-001 says "any property the customer defined on the content type of the asset that field points at"), but the Overview and every acceptance scenario were written around the banner/PDF cases, which is what makes it read as example-driven. Fixed: the Overview now leads with "any content type extending either asset base type" and states outright that those two are illustrations. User Story 1 gains two scenarios stating the general case, one of which says explicitly that passing the two example scenarios does not satisfy it.

On "cast any binary to that type" — this found a defect. I tested whether an Image field is actually constrained to image-style content. It is not:

"image": {
  "fileName": "storeProductList.vtl",
  "fileAsset": { "mime": "text/plain; charset=ISO-8859-1", "size": 3128 }
}

That is an Image field on a real content type, pointing at a FileAsset — a .vtl text file — resolving fine today. The field's own kind does not constrain the base type of what it references, and the current DotFileasset already quietly handles both (that is what its two base-type ternaries are for).

My "shape of the solution" section typed an Image field as the image-style interface and a File field as the file-style one. That would have silently dropped content those fields already hold. Corrected, and pinned as requirements:

  • FR-001a — the reachable set is every content type extending either base type, including ones a customer creates later; it may not be satisfied by enumerating known types.
  • FR-001b — an asset-pointing field must span both base types regardless of the field's own kind, with the evidence above recorded.

On "return the results that could be casted and the warnings for those that couldn't." That is a collection-level statement and FR-016 was written per-asset, so I added FR-016a: across one result set, assets matching a clause come back populated, assets that do not still come back, the request as a whole succeeds, and a single non-matching asset must not suppress the matching ones. Warnings identify which clause matched nothing rather than being one opaque flag.

One related thing already in the spec, since it is the same conversation: narrowing to a type that does not exist still fails the request (FR-017). The "must not fail" rule is for a type that exists but is not what this particular asset turned out to be. Say the word if you would rather both be warnings.

Pushed as 92f5317c87.

@fabrizzio-dotCMS

Copy link
Copy Markdown
Member Author

Correction to my last comment: the final paragraph offered to reconsider whether a non-existent type should warn instead of fail. That is not open — it is already decided. A clause naming a type that does not exist fails the request (FR-017). It is a client mistake with no valid reading, and a silent pass would hand back missing data with no signal.

The lenient rule applies only to a type that exists but is not what this particular asset turned out to be (FR-016 / FR-016a). No spec change; disregard that paragraph.

@nollymar

Copy link
Copy Markdown
Member

@fabrizzio-dotCMS just to keep in mind: FR-016 and FR-016a require a non-fatal warning per clause that matched nothing, identified by clause. Standard GraphQL has no warnings slot; the realistic homes are the errors array with a path, or extensions

nollymar
nollymar previously approved these changes Sep 14, 2026
…4540)

The ADR consultation that gates planning turned up a conflict with the
decision this spec had already recorded, so the decision is reversed.

Two accepted decisions in platform-adrs govern a published contract:

- ADR-0020 deprecated a core REST endpoint and kept it functional, annotated
  for removal, existing integrations explicitly unaffected.
- ADR-0022 states that "a URL, parameter, or response field is a promise to
  callers in exactly the same way a database column is a promise to queries",
  and names why it bites here: dotCMS is self-hosted and upgraded on the
  customer's schedule, so "nobody is calling the old endpoint right now" is
  not the same question as "it's safe to remove." It prescribes
  expand -> adopt -> bake -> retire, gates removal on a supported-version
  floor plus a confirmed zero-use window, and requires the deprecation to be
  marked in the schema itself rather than only in code.

So instead of replacing the asset view in place:

- FR-012 keeps every existing selection working and returning what it returns
  today; the new capability ships alongside.
- FR-012a marks the current view as superseded in the published API
  description, naming each property's replacement.
- FR-012b puts removal out of scope for this feature, behind the same gate,
  and requires the retirement ticket to be opened up front.
- FR-009a is reframed: rather than renaming the one property that would have
  changed meaning, the corrected value is reached through the new surface and
  the existing name is left alone.
- SC-008 and SC-009 make the guarantee measurable.

The decisive case is unchanged and now argues the other way: the asset
description would have kept returning a string while changing which string —
57 of 57 images populated today, 2 of 57 afterwards. A self-hosted customer
who had not upgraded would have seen no failure, just different data.

Adds an ADR Alignment section. No exception to either ADR is requested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fabrizzio-dotCMS

Copy link
Copy Markdown
Member Author

@nollymar re-review needed — an approved decision in this spec has been reversed.

Running the ADR consultation that gates /speckit-plan turned up a conflict with the backward-compatibility decision you approved, so I stopped before writing the plan and changed the spec instead.

What the ADRs say

  • ADR-0020 (accepted) deprecated a core REST endpoint and kept it functional, annotated for removal, with existing integrations explicitly unaffected.
  • ADR-0022 (accepted) states the principle directly — "a URL, parameter, or response field is a promise to callers in exactly the same way a database column is a promise to queries" — and names why it bites here: dotCMS is self-hosted and upgraded by customers on their own schedule, so "'nobody is calling the old endpoint right now' is not the same question as 'it's safe to remove.'" It prescribes expand → adopt → bake → retire, gates removal on a supported-version floor plus a confirmed zero-use window, and requires the deprecation to be marked in the schema itself, not only in code.

What changed in the spec

before after
FR-012 — existing queries may break, with announcement and migration guidance FR-012 — existing queries keep working unchanged; the new capability ships alongside
FR-012a — the current view is marked superseded in the published API description, naming each property's replacement
FR-012b — removal is out of scope here, gated on the supported-version floor plus a zero-use window, with the retirement ticket opened up front
FR-009a — rename the property that would change meaning, so the break is visible FR-009a — do not touch it; reach the corrected value through the new surface instead
SC-008 / SC-009 — make the guarantee measurable

Plus an ADR Alignment section. No exception to either ADR is requested.

Why this is the right way round. The decisive case is the same one and it now argues the other way. The asset description would have kept returning a string while changing which string — populated for 57 of 57 images today, 2 of 57 afterwards. A self-hosted customer who had not upgraded would have seen no failure, just different data. That is precisely the outcome ADR-0022's reasoning exists to prevent.

Nothing else changed — the generalization work from your earlier review (FR-001a, FR-001b, FR-016a) is untouched.

Pushed as 3f3465fb4c. Planning is on hold until this is re-approved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

GraphQL Support for dotAssets and FileAssets in Image/File Fields

2 participants