Skip to content

Extend type/value contradiction validation to complex declared types (REFERENCE/ASSET/LOCATION/LIST) #376

@leogdion

Description

@leogdion

Background

Follow-up to #375. While fixing the TIMESTAMP write/read defect, we made the response→domain conversion fail loud when an explicit CloudKit type contradicts the decoded value's shape, instead of silently coercing.

That strict validation currently covers scalar type tags only. This issue tracks extending it to complex/list declared types.

Scalar errors thrown today (the current behavior)

The value oneOf in the generated FieldValueResponse is undiscriminated — the decoder is first-match-wins (String → Int64 → Double → Bytes → Date). Response conversion (Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift) therefore honors an explicit type over the decoded case, and validates each scalar type against the value's category:

  • Numeric scalar tagsTIMESTAMP, DOUBLE, INT64 — require the value to be a number (numericValue(from:) non-nil, i.e. decoded as Int64Value/DoubleValue/DateValue). Validated by requireNumeric(_:fieldName:declaredType:).
  • String scalar tagsSTRING, BYTES — require the value to be a string (stringValue(from:) non-nil, i.e. StringValue/BytesValue). Validated by requireString(_:fieldName:declaredType:).

When the category doesn't match, conversion throws:

// Sources/MistKit/Models/ConversionError.swift
case typeValueMismatch(fieldName: String, declaredType: String, value: String)

Message format:

Field '<fieldName>' declared type <DECLARED_TYPE> but its value is incompatible (<value>)

declaredType is the literal CloudKit tag string ("TIMESTAMP", "DOUBLE", "INT64", "BYTES", "STRING"). It is thrown via reportAndThrow(), so in DEBUG it trips the ConversionFailureReporter assertion handler first (suppressible in tests via ConversionFailureReporter.$assertionHandler.withValue), and in release it logs to the .api subsystem then throws the typed ConversionError.

Truth table (scalar tags)

Response Result
TIMESTAMP + "hello" throws typeValueMismatch
BYTES + 42 throws
STRING + 42 throws
DOUBLE + "x" throws
INT64 + "x" throws
TIMESTAMP + {recordName:…} throws
INT64 + 3.5 .double(3.5) — numeric category OK, defers to inference (no truncation)
STRING + "hello" .string("hello")

The gap

The strict check is scoped to scalar tags. Complex/list declared types are not validated against the value — they fall through makeTypedScalar's default: to inference / makeComplexFieldValue, deferring entirely to the value's self-describing structure. So a contradiction like:

Response Result today
REFERENCE + 42 .int64(42) (tag ignored, no throw)
ASSET + "text" .string("text") (no throw)
LIST + 42 .int64(42) (no throw)

This is the documented, deliberate boundary from #375 (see the "Response type recovery" note in CLAUDE.md), chosen to limit read-path regression risk. But it's asymmetric: TIMESTAMP over a non-number throws, while REFERENCE over a non-object is silently coerced.

Proposed work

  1. Decide whether complex/list type tags should be validated against the decoded value the same way scalars are (numeric/string contradiction → typeValueMismatch).
  2. If yes, map each complex declared type to its acceptable decoded oneOf case(s):
    • REFERENCEReferenceValue
    • ASSET / ASSETIDAssetValue (decide how ASSETID maps; today there's no distinct domain case)
    • LOCATIONLocationValue
    • LISTListValue (and consider element-type tags, e.g. *_LIST)
    • throw typeValueMismatch (or a new dedicated case) when the value's structure can't satisfy the tag.
  3. Watch for read-path robustness regressions — complex value mapping is looser than scalars (e.g. asset-vs-location oneOf greediness, see the assetFieldValueConvertsToAsset regression test), so validate this doesn't reject well-formed responses.
  4. Add tests mirroring FieldValueConversionTests+ResponseTypes.swift (complexTypeContradictionStaysLenient currently asserts the lenient behavior and would flip).

Pointers

  • Conversion: Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift (makeTypedScalar, requireNumeric, requireString) and FieldValue+Components.swift (makeComplexFieldValue)
  • Error type: Sources/MistKit/Models/ConversionError.swift (typeValueMismatch)
  • Tests: Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ResponseTypes.swift
  • Policy doc: CLAUDE.md → "Response type recovery (issue FieldValueRequest can't tag scalar TIMESTAMP — Date/Time writes fail with BAD_REQUEST #375)"

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    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