Adopt tolerant-response deserialization: sweep serde(default) and codify the policy - #313
Adopt tolerant-response deserialization: sweep serde(default) and codify the policy#313sdairs wants to merge 6 commits into
Conversation
Convert the union from derived untagged Deserialize to explicit dispatch on
the `type` field ("email"/"webhook"), so unrecognized types land in Unknown
instead of being shape-matched. Serialization is unchanged (still untagged).
With hard dispatch in place, the variant structs' discriminating fields can
be tolerant: add serde(default) to ClickStackAlertChannelEmail's
emailRecipients/type and ClickStackAlertChannelWebhook's type/webhookId so a
server-dropped field degrades to a default rather than failing the response.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Give discriminated_union! an optional trailing `none` arm that routes an absent (or non-string) discriminator to a named variant instead of Unknown, and use it for the six ClickStack chart-config sub-unions: `configType: "sql"` selects the raw-SQL variant, key absence selects the builder variant. Without a `none` arm the expansion is unchanged. Serialization is untouched: the enums keep Serialize plus serde(untagged) and only drop the derived Deserialize. With hard dispatch in place the 12 variant structs' fields can be tolerant: add serde(default) to every strict field so a server-dropped field degrades to a default. That makes each builder variant total, so its arm always succeeds and the sub-union Unknown is now reachable only via an unrecognized string configType — pinned by new tests alongside the non-string and key-absent dispatch cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Record the field-level `#[serde(default)]` attribute on every public model field, recognizing both the bare and `default = "path"` forms ahead of the generic `key = value` arm, and expose a narrow public `model_fields_missing_serde_default` helper that the tolerant-deserialization policy test in clickhouse-cloud-api can call without pulling `syn` into that crate's dependency graph. Comparison, report, and configuration semantics are untouched, so the drift report stays byte-identical for identical inputs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sweeps #[serde(default)] across the 367 remaining public model fields in models.rs so a response field the server stops sending degrades to that field's default instead of failing the whole payload. Requests stay strict through the type system, and serialization is untouched: serde(default) is deserialize-only. Adds every_model_field_carries_serde_default to spec_coverage_test.rs, which fails with the offending StructName.field list if a new model field lands without a default. There is no exception list. Adds representative sparse-payload tests for ScimUser, ClickPipePostPubSubSource and OrganizationQuota. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the AGENTS.md optionality bullet with the full policy: requiredness lives only in the type, every model field carries #[serde(default)], unknown fields are ignored, and enums/unions carry Unknown catch-alls, with the untagged-discriminator exception and the rationale that defaults never change serialization. Add a "Tolerant deserialization" section to the crate docs covering the read-modify-write caveat and the drift job that bounds exposure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The discriminated_union! arms propagated the selected variant's error, so a recognized discriminator whose payload no longer fits (an array field that became a string) hard-failed the whole response where the untagged derive had degraded to Unknown. Arms now route through serde_helpers::deserialize_or_raw and fall back to the lossless Unknown(Value) catch-all. The `none` arm was total once every builder field was defaulted, so a raw-SQL chart config whose spec-required configType the server drops was silently retyped as an empty builder config, discarding connectionId/sqlTemplate. The arm is now `none unless "connectionId" | "sqlTemplate"`, which sends such a payload to Unknown intact. ClickStackAlertChannelEmailType defaulted to Webhook (the spec gives both alert-channel variants the same enum), so ClickStackAlertChannel::default() — reachable from a response that drops `channel` — deserialized back as the webhook variant and could be PUT back as a webhook channel with no webhookId. #[default] moves to Email, pinned by a per-union defaults round-trip test. Narrows the crate docs and the AGENTS.md policy: a changed field *type* is not covered by #[serde(default)] and still fails a plain struct field, and the Patch request types are not all-optional. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Reviewed with focus on the idea, the policy, the standard, and the structure. Overall: approve — the policy is coherent, honestly documented (including what it does not cover), and enforced structurally via the empty-exception-list
(5) needs no action; (3) and (4) are take-or-leave; (1) and (2) are worth follow-up issues. |
|
I read the adversarial review above and checked its three findings against the code. It's technically accurate on all three — but I disagree with the severity ratings and the proposed architecture. My take: [P2, lib.rs:46–48] — correct, easy fix, should block. "Bounded by the daily OpenAPI drift job" overstates the safety net: the analyzer compares spec ↔ Rust source, so a deployed server omitting a still-spec-required field is invisible to it — precisely the failure mode this PR masks. Weaken the sentence to scope the bound to spec drift and admit server-behavior drift is only covered by integration tests. Honest docs, no architecture change. [P1, [P1, models_test.rs:4102] — the test is mislabeled, not wrong. Where I part ways with the review:
Suggested path — approve with two blocking items, not request-changes:
The review's north star (responses usable without inventing facts; incomplete requests unsendable; missing data distinguishable from real |
Closes #312
Stacked on #311 — base is
issue-308-clickstack-drift; retarget tomainafter #311 merges.Adopts "strict in what we send, liberal in what we accept" across
clickhouse-cloud-api: every model field now carries#[serde(default)], so a server-dropped response field degrades to a default instead of failing the whole response. Requests stay strict via the type system (TvsOption<T>is unchanged, so analyzer drift detection is unaffected).What's in here (one commit per step)
ClickStackAlertChannel→discriminated_union!dispatch ontype(email/webhook), with its variants' strict fields defaulted in the same commit (defaulting a distinguishing field while the union is still untagged would misroute payloads — that ordering constraint holds throughout this PR).discriminated_union!via a new key-absence arm: RawSql payloads carryconfigType: "sql"(note: the issue body said"rawSql"; the spec and existing tests say"sql"), Builder payloads lack the key entirely. Absent and present-but-non-string keys both dispatch to Builder — deliberate and tested. The absence arm takes a mandatory disqualifying-key guard (none unless "connectionId" | "sqlTemplate") so a RawSql body whoseconfigTypethe server stops sending lands losslessly inUnknowninstead of silently becoming an empty builder tile.rust_inventory.rsnow tracksserde(default)(bare anddefault = "path"forms), exposed via a narrowmodel_fields_missing_serde_default()— no newFindingKind, no reportschema_versionchange, no Python changes, andsynstays out of the published crate's dependency graph.#[serde(default)]on every remaining strict field (~365 fields across ClickStack, SCIM, ClickPipes, Postgres, quotas, RBAC) plus 12 bareOptionfields, enforced by a newevery_model_field_carries_serde_defaulttest inspec_coverage_test.rswith an empty exception list.#[serde(default)]" line replaced with the full policy and rationale (so future review-bot "serde(default) masks required field" findings can be answered by citation); crate docs gain a "Tolerant deserialization" section including the GET→write-back caveat.Semantics decided during adversarial review
An adversarial review pass confirmed three real gaps, fixed in the final commit:
Unknown(value)instead of hard-failing. Previously one malformed element (e.g. a field whose type changed server-side) failed an entirelistresponse — exactly the outage mode this PR exists to close.Unknownis now reachable via unrecognized discriminator values or undeserializable payloads, both round-tripping losslessly; the macro docs and tests pin both routes.configTypemisroute — fixed by thenone unlessguard described above.ClickStackAlertChannel::default()round-tripped to the wrong variant (ClickStackAlertChannelEmailType's#[default]sat onWebhookbecause the spec gives both variants the same two-value enum).#[default]moved toEmail; a new cross-union invariant test asserts everydiscriminated_union!enum'sDefaultround-trips to the same variant.Residual (documented, intentional): a plain struct field whose type changes server-side still fails the response —
#[serde(default)]only covers missing keys. Explicit JSONnullonVecfields also remains an error (out of scope per the issue).Verification
Every commit is independently green; on the final tree:
cargo test --workspace(0 failures),cargo clippy --workspace --all-targets -- -D warnings,cargo fmt --all --check,cargo check --workspace --all-features,python3 -m unittest discover -s scripts/tests, andpython3 scripts/check-openapi-drift.py --dry-runconfirms the sweep is analyzer-neutral.🤖 Generated with Claude Code