Skip to content

Split request/response model types; tolerant responses via all-Option fields - #316

Open
sdairs wants to merge 26 commits into
mainfrom
issue-314-request-response-split
Open

Split request/response model types; tolerant responses via all-Option fields#316
sdairs wants to merge 26 commits into
mainfrom
issue-314-request-response-split

Conversation

@sdairs

@sdairs sdairs commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Closes #314

Stacked on #311 — base is issue-308-clickstack-drift; retarget to main after #311 merges. Replaces #313 (closed), whose serde(default) sweep fabricated values indistinguishable from server-sent ones.

What and why

A server-dropped or null response field must never fail deserialization — each strict response field is a latent outage point. Tolerance now lives in the type system instead of serde attributes:

  • Request types: strict. Required fields are T, optional are Option<T> + skip_serializing_if. The compiler enforces "strict in what we send".
  • Response types: all-Option. Every field of every type reachable from a Client return type (339 types) is Option<T>. Missing key and explicit null both land as None — nothing is fabricated, so "server sent 0" and "server dropped the field" stay distinguishable.
  • Enums/unions unchanged: Unknown catch-alls absorb unrecognized values and (new in this PR) malformed payloads under a recognized discriminator.

How

  • Naming: a schema used in both directions splits into {Name} (request) + {Name}Response — 88 pairs, more than the 75+2 the issue measured (agents verified reachability from client.rs rather than trusting the list). Unions whose variant structs split are duplicated (discriminated_union! invocation per direction).
  • Serialization pinned: absent response fields are omitted from --json/print_human output, never emitted as null (skip_serializing_if on every response field, test-enforced).
  • Write-back is explicit: new convert.rs with TryFrom<{Name}Response>/From conversions (top-level: PostgresInstanceConfig, ClickStackSource); a fallible conversion returns MissingRequiredFields naming the missing wire fields. This closes Adopt tolerant-response deserialization: sweep serde(default) and codify the policy #313's GET→write-back caveat instead of documenting it.
  • serde(default) is banned in models.rs, enforced by test via the analyzer.
  • Analyzer is direction-aware: requiredness rules apply in request position only; response-side optionality findings are suppressed by design (presence and enum-value drift remain checked in both directions); response-position schemas resolve to {Name}Response when it exists. New response_tree() API backs the enforcement tests.
  • Union hardening (carried from Adopt tolerant-response deserialization: sweep serde(default) and codify the policy #313 + extended): none unless "connectionId" | "sqlTemplate" guards on the chart-config unions (mandatory now — all-Option variants are total, so a dropped discriminator would otherwise silently retype a RawSql payload); known-discriminator malformed payloads degrade to lossless Unknown(Value); every union Default round-trips to the same variant, with the covered list structurally required to equal the manual impl Default blocks.
  • CLI: no unwrap/expect on response fields; absence renders as - (or_absent); postgres config set builds the request variant; delete --json no longer fabricates status/requestId.

Residual (documented, intentional)

A key that is present with a changed type still fails a plain struct field — Option<T> absorbs absence and null, not shape changes. Enums/unions absorb those via catch-alls. Spec conformance stays the drift job's responsibility.

Breaking changes (library consumers, pre-1.0)

  • All response-tree fields are now Option<T>; response-position types returned by Client methods are the {Name}Response variants where split.
  • discriminated_union! grammar: bare none => arms no longer parse; absence-discriminated unions must name disqualifying keys.
  • CLI DeleteResponse fields are Option.

Verification

Every commit is independently green (per-commit verifier agents re-ran all gates): cargo test --workspace, clippy -D warnings workspace-wide, cargo fmt --all --check, cargo check --workspace --all-features, python3 -m unittest discover -s scripts/tests, and python3 scripts/check-openapi-drift.py --dry-run → 0 actionable findings against the live spec. A fresh-context adversarial review of the full stack found no high/medium defects; its three low-severity findings are fixed in the final commit. Enforcement tests were mutation-tested (strictening a response field / removing a covered union fails the corresponding test with the exact offender named).

🤖 Generated with Claude Code

sdairs and others added 14 commits July 27, 2026 20:14
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>
Carries the union-semantics fixes from #313's 6d1eb1b, which the all-Option
response split makes more necessary rather than less: an all-Option variant
struct matches any JSON object, so explicit discriminator dispatch is the only
thing left doing real work.

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 of the six chart-config unions became total once the cherry-pick
of d57839e defaulted every builder field, 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 both by a dedicated test and by a cross-union
invariant test asserting every discriminated_union! enum's Default is a fixed
point of its own Deserialize.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ue 314)

Classify every spec schema by usage position: request (reachable from a
request body or operation parameter) and/or response (reachable from an
operation response), via transitive closure over the schema $ref graph.
Requiredness/optionality findings now apply only in request position;
in response position every field is Option<T> by policy, so optionality
findings are suppressed by design while field-presence (missing/extra)
and enum-value checks keep working in both directions. Schemas reachable
from neither direction keep the historical strict request-side checks.

A schema used in both directions maps to two Rust types: {Name} for the
request variant and {Name}Response for the response variant when that
type exists, guarded against Rust types that model a spec schema
literally named {Name}Response; until the split lands, both directions
collapse onto {Name}, keeping the current unsplit models.rs green.
Deprecated-field expectations expand the same way, so a split variant
carrying a spec-deprecated field doubles the DEPRECATED_FIELDS
bookkeeping and is enforced per variant.

Expose response-tree membership as a public API for the later policy
enforcement test: response_tree(client_rs, models_rs) returns the model
types transitively reachable from Client method return types (through
struct fields, enum variant payloads, and type aliases) plus the
(type, wire field) pairs that are not yet Option<T>. Client method
return types were not previously inventoried; MethodInfo now records
every named type in the return position.

Config audit: no optionality_exemptions entry is removed — every entry
keys a request-position struct (ServicePostRequest, ClickPipe*, PgConfig
via the bidirectional pgConfig schema, etc.), so all remain live under
suppression. Both partial_required_schemas entries (Service,
ServiceScalingPatchResponse) sit in response position and are now inert;
they are retained with documentation that partial_required_schemas is
request-position-only semantics.

No report schema_version bump: no new FindingKind, no new report
fields, and no Python rendering changes — the direction rules only
narrow where existing finding kinds fire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every Postgres-family response type is now all-`Option<T>` with
`skip_serializing_if` on each field, so a field the API drops or sends as
`null` deserializes to `None` instead of failing the response, and absent
fields are omitted from `--json` output rather than emitted as `null`.

`postgresInstanceConfig`, `pgConfig` and `pgBouncerConfig` are used in both
directions, so each splits into a strict request variant keeping the schema's
name and a tolerant `{Name}Response` variant; `postgres_instance_config_get`
now returns `PostgresInstanceConfigResponse`. Writing a fetched configuration
back goes through the new `crate::convert` module, whose
`TryFrom<PostgresInstanceConfigResponse> for PostgresInstanceConfig` names the
missing required wire fields instead of fabricating empty objects. All
`#[serde(default)]` is gone from the Postgres slice, request side included:
`postgres config replace/patch --file` keeps accepting a document that omits a
section, but the handler now resolves the omission explicitly.

CLI reads of Postgres response fields render absence as `-` (list table,
detail view) and no longer match a filter on an absent field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e 314)

Every response type in the services, organizations, keys, members,
invitations, roles, backups, scaling, upgrade-window, query-endpoint, usage
and ClickHouse-settings families is now all-`Option<T>` with
`skip_serializing_if` on each field, so a field the API drops or sends as
`null` deserializes to `None` instead of failing the response, and absent
fields are omitted from `--json` output rather than emitted as `null`. All
`#[serde(default)]` is gone from these families, request side included.

`ipAccessListEntry` and `resourceTagsV1` are sent as well as returned, so each
splits into a strict request variant keeping the schema's name and a tolerant
`{Name}Response` variant; `Service`, `ServiceScalingPatchResponse`, `ApiKey`
and the Postgres responses (via the new `PgTagsResponse` alias) now reference
the response variants. Writing a fetched value back goes through
`crate::convert`, which gains `TryFrom` conversions for tags, scaling-schedule
entries and upgrade windows that name the missing required wire fields instead
of fabricating values.

`BackupBucket::default()` now names its variant's own `bucketProvider`, because
an all-`Option` variant would otherwise serialize to `{}` and dispatch back
through `discriminated_union!` as `Unknown`.

CLI reads of these response fields render absence as `-` through the shared
`ABSENT`/`or_absent` helpers (moved to `cloud::output` and reused by the
Postgres slice), no longer `unwrap()` a response field, and treat an absent
field as non-matching when filtering. Query-endpoint provisioning fails loudly
when the API omits a credential rather than persisting an empty key pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The last non-ClickPipes, non-ClickStack response types are now all-`Option<T>`
with `skip_serializing_if` on every field: `OrganizationQuota` and
`ReversePrivateEndpoint` no longer fail a response when the API drops a field or
sends it as `null`, and absent fields are omitted from `--json` output rather
than emitted as `null`. The `ApiResponse` envelope omits an absent `result` for
the same reason.

`customPrivateDnsMapping` and `RBACPolicyTags` are sent as well as returned, so
each splits: the strict request variant keeps the schema's name for
`CreateReversePrivateEndpoint`/`UpdateReversePrivateEndpoint` and
`RBACPolicyCreateRequest`, while `ReversePrivateEndpoint` and `RBACPolicy` point
at the new tolerant `CustomPrivateDnsMappingResponse` and
`RBACPolicyTagsResponse`. The `BackupBucket` unions needed no split: the
`AwsBackupBucket`/`GcpBackupBucket`/`AzureBackupBucket` variants are
response-only, and the request unions have their own `*PostRequestV1`/
`*PatchRequestV1` variant structs.

`#[serde(default)]` is gone from every remaining infrastructure and
miscellaneous model — reverse private endpoints, quotas, RBAC policy tags, the
`ApiResponse` envelope, the SCIM family, and the unreferenced `License`,
`AzureEventHub`, `MutualTLS`, `PLAIN`, `MskIamUser` and `ServiceAccount`
schemas. Those last ones and the SCIM types are request-position (orphan or
request-only), so they stay strict: a payload missing a required field is now
rejected instead of silently defaulted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SCIM family needs no request/response split: the spec defines 40
`Scim*` schemas but declares no SCIM path, so no `Client` method sends or
returns one and the family is reachable from neither a request root nor a
response root. Verified empirically that the analyzer resolves such
operation-unreferenced schemas in request position — making a SCIM
list/response envelope all-`Option` reports `FieldOptionalityMismatch`
drift while protecting no actual response — so the family stays strict.

Record that determination structurally instead of by assumption: a new
`scim_models_are_outside_the_response_tree` test asserts SCIM stays out of
the analyzer's response tree, so adding a SCIM operation to `client.rs`
forces the split first, and `scim_request_models_stay_strict` pins the
request-side behaviour. `OrganizationQuota` (the slice's only
response-tree type) is already all-`Option`; add the missing
explicit-`null` and omit-on-serialize coverage for it.

No CLI change: neither SCIM nor quotas are exposed in `clickhousectl`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(issue 314)

The ClickPipes response tree is now tolerant by type: every field of every
ClickPipe response struct is `Option<T>` with `skip_serializing_if`, so a key
the API drops — or sends as an explicit `null` — deserializes to `None`
instead of failing the response, and absence stays absent in `--json` output.

The spec's own top-level split (`ClickPipePost*`/`Patch*`/`Mutate*` vs the
response shapes) is preserved. The 15 nested schemas used in both directions
split into a strict request variant that keeps the schema's name and an
all-`Option` `{Name}Response`: the pipe settings and table mappings for
BigQuery/MongoDB/MySQL/Postgres, the destination column/table
definition/table engine shapes, the field mapping, the Kafka offset, plus
`ClickPipeSettings` and `ClickPipeScaling`. `click_pipe_settings_get`/`_update`
now return `ClickPipeSettingsResponse`.

`ClickPipeScalingResponse.concurrency` carries the spec's deprecation marker,
so `DEPRECATED_FIELDS` gains the response-variant entry; the regeneration
script derives names from the spec alone and both it and `meta.rs` now say so.

`#[serde(default)]` is gone from the whole family, including the request-only
structs — no request wire shape changes, because `default` only ever affected
deserialization. `serde_helpers::null_to_empty` goes with it: `Option<Vec<T>>`
absorbs `null` natively on the response side, and on a request struct the
helper only ever worked in combination with the `default` this policy bans.

CLI reads of pipe response fields go through the shared `or_absent`, and the
ClickPipes integration suites read ids and states through two new support
helpers rather than unwrapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e 314)

ClickStack sources are the last bidirectional top-level type: the same
schema is a create/update body and a GET response. Split it, and every
type in its tree, so response tolerance lives in the type system.

- `ClickStackSourceResponse` joins `ClickStackSource`, over all-`Option`
  `*Response` variant structs, with `kind` dispatch and the lossless
  `Unknown(Value)` catch-all preserved. It deliberately has no `Default`:
  an all-`Option` variant's default serializes to `{}`, which carries no
  discriminator and would not round-trip to the same variant.
- 20 nested structs split (sources, `SourceFrom`, `MetricTables`,
  materialized views, filter settings, query settings, `ClickStackFilter`,
  saved-search filters, `CASLPermission`); connections, roles and saved
  searches are response-only and become all-`Option` in place.
- `TryFrom<ClickStackSourceResponse> for ClickStackSource` in `convert.rs`,
  with the nested conversions it needs, so a get/edit/write-back names the
  fields the response omitted instead of fabricating them. An unknown
  `kind` passes through the request union's `Unknown` arm verbatim.
- `#[serde(default)]` is gone from every struct in this slice; the
  remaining occurrences are alerts, dashboards, charts and webhooks.

ClickStack is not exposed in the CLI, so there are no CLI changes.
Analyzer `response_tree()` non-`Option` fields: 235 -> 149.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…response variants (issue 314)

Split the 33 bidirectional dashboard-tree structs (chart configs, select
items, number formats, color conditions, on-click targets, dashboard
containers, alert channels) into strict request types and all-Option
{Name}Response types, and duplicate their 11 discriminated unions as
{Name}Response unions with identical dispatch semantics: configType
presence still selects raw-SQL vs builder, the `none unless
"connectionId" | "sqlTemplate"` guard still keeps a dropped
discriminator from silently retyping a raw-SQL payload as the now-total
builder variant, and a known discriminator over a payload that no
longer fits falls to the lossless Unknown(Value). The response unions
deliberately have no Default; ClickStackWebhook (response-only, so not
split) keeps its Default by naming its own `service` wire value now
that its variants are all-Option.

Response-only models (alerts, dashboards, tiles, validation results,
webhooks) become all-Option in place, and the last `#[serde(default)]`
attributes leave models.rs: request bodies are strict again and
response tolerance lives entirely in the type system. The analyzer's
response tree now reports zero non-Option fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add three policy tests to spec_coverage_test.rs, all backed by the shared
analyzer so no source parsing leaks into the library crate:

- every_response_tree_field_is_option: every field of every
  response-reachable type is Option<T>, with an empty exception list and a
  vacuity guard on the tree size.
- every_response_tree_option_field_omits_none_when_serialized: pins the
  serialization decision — absent response fields are omitted from JSON
  output, never emitted as null. The analyzer's ResponseTree now reports
  Option fields lacking skip_serializing_if to back this.
- models_carry_no_serde_default: zero #[serde(default)] anywhere in
  models.rs. The inventory records the attribute in bare, `default = path`
  and container-level forms (a container default marks every field, so the
  ban cannot be dodged by moving the attribute up).

Close the one serialization gap earlier slices left:
ClickStackValidateDashboardResponse.normalized now carries
skip_serializing_if, and its test asserts explicit null deserializes to
None and is omitted on the way out instead of pinning the old null echo.

Deprecated-field bookkeeping needed no change: regenerating from the spec
reproduces meta.rs exactly, ClickPipeScaling is the only split schema with
a deprecated field, and both its variants already carry the entry and the
cfg marker; cargo check --workspace --all-features is green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The policy record still argued for the superseded serde(default) sweep:
AGENTS.md told contributors to "retain #[serde(default)] on model fields"
and pointed at an enforcement test that no longer exists. Replace it with
the split-type policy the code now implements.

AGENTS.md gains a "Request and response models" section covering the
strict-request/all-Option-response split, the {Name}Response naming
convention that makes the analyzer's dual mapping mechanical, union and
enum catch-alls being unchanged by the split, the deliberate
skip_serializing_if omission decision, convert.rs write-back conversions
replacing the old GET-write-back caveat, the honest residual (a present
key whose type changed still fails a plain struct field), and the four
spec_coverage_test.rs tests that pin it. Drift remediation, deprecated
field hiding, and the cloud-command checklist now reflect the convention;
the direction-aware optionality text says why response-side optionality
drift is invisible by design.

Crate docs get the same policy for published-crate consumers, with a
compiled example of an explicit write-back through TryFrom, and README
documents that an absent field renders as "-" and is omitted from --json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CLI's DeleteResponse was the one remaining --json path that fabricated
values the server did not send ("status": 0.0, "requestId": ""); its fields
are now Option + skip_serializing_if like every library response type, and
the six CloudClient delete wrappers pass the library fields through.

first_endpoint dropped a partially-present endpoint (host without port
rendered as "-"); it now keeps whichever half the API sent.

The Default round-trip invariant's covered-union list was enforced only by a
doc-comment convention. The analyzer now exposes
model_types_with_manual_default_impl, and the test requires its covered list
to equal the set of hand-written `impl Default for` blocks in models.rs, so
a new union gaining a Default cannot silently escape the invariant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread crates/clickhousectl/src/cloud/postgres.rs Outdated
Comment thread crates/clickhousectl/src/cloud/commands.rs Outdated
Comment thread crates/clickhousectl/src/cloud/commands.rs Outdated
Comment thread crates/clickhousectl/src/cloud/postgres.rs
Comment thread crates/clickhousectl/src/cloud/service_query.rs
Comment thread crates/clickhouse-openapi-analyzer/src/openapi.rs
@sdairs

sdairs commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

bugbot review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR implements the “split request/response model types” policy across clickhouse-cloud-api and updates both the OpenAPI drift analyzer and clickhousectl to treat API response fields as tolerant (Option<T>) without fabricating defaults. This aligns the library with the goal of preventing deserialization outages when the Cloud API drops fields or returns null, while keeping request bodies strict.

Changes:

  • Introduces direction-aware drift checking and a computed Rust “response tree” to enforce that all response-reachable fields are Option<T> and omit None during serialization.
  • Updates clickhousectl cloud commands to avoid unwrap/fabrication on response fields and to render absent values consistently (-) in human/table outputs while omitting absent keys in --json.
  • Updates integration/unit tests and metadata tooling/docs to reflect the request/response split and deprecated-field bookkeeping for *Response variants.

Reviewed changes

Copilot reviewed 30 out of 32 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
scripts/regenerate-deprecated-fields.py Documents manual follow-up needed for {Name}Response deprecated-field entries after regeneration.
README.md Adds user-facing documentation explaining how absent API fields are rendered and serialized.
crates/clickhousectl/src/cloud/types.rs Makes delete payload fields optional and omits absent fields from JSON serialization.
crates/clickhousectl/src/cloud/types_test.rs Updates serialization/deserialization tests for optional delete fields and Option-based response enums.
crates/clickhousectl/src/cloud/service_query.rs Adds explicit “required field” extraction to avoid persisting unusable credentials when responses omit fields.
crates/clickhousectl/src/cloud/postgres.rs Adapts Postgres handlers/output/filtering to Option-based response models; adds strict request-body construction helper.
crates/clickhousectl/src/cloud/output.rs Adds ABSENT/or_absent helpers to standardize rendering of missing response fields.
crates/clickhousectl/src/cloud/commands.rs Updates multiple cloud commands to print/format Option response fields safely; adds endpoint/list helpers.
crates/clickhousectl/src/cloud/client.rs Stops fabricating delete-response fields; updates method return types to response variants where split.
crates/clickhouse-openapi-analyzer/src/rust_inventory.rs Enhances Rust inventory with type-name reachability, serde attribute tracking, and response-tree reachability walking.
crates/clickhouse-openapi-analyzer/src/openapi.rs Classifies schemas into request/response positions via transitive closure; tracks rust schema names for split mapping safety.
crates/clickhouse-openapi-analyzer/src/lib.rs Exposes response_tree() and policy helpers (serde default detection, manual Default impl listing).
crates/clickhouse-openapi-analyzer/src/config.rs Updates config semantics/docs for request-position-only optionality/requiredness logic.
crates/clickhouse-openapi-analyzer/src/compare.rs Implements direction-aware field checks; maps response position to {Name}Response where applicable; doubles deprecated-field expectations.
crates/clickhouse-cloud-api/tests/spec_coverage_test.rs Adds enforced tests for all-Option response tree, omit-None serialization, and banning #[serde(default)].
crates/clickhouse-cloud-api/tests/integration_test.rs Updates ClickHouse lifecycle integration to Option response fields with explicit required-field extraction.
crates/clickhouse-cloud-api/tests/integration_postgres_test.rs Updates Postgres lifecycle integration for Option response fields and response tag handling.
crates/clickhouse-cloud-api/tests/integration_org_test.rs Updates org lifecycle integration to avoid unwraps and to use Option-safe helpers.
crates/clickhouse-cloud-api/tests/common/support.rs Adds shared helpers (require_field, field_string, service_state, https_endpoint) for Option response handling.
crates/clickhouse-cloud-api/tests/client_test.rs Updates mocked-client tests to assert against Option response fields.
crates/clickhouse-cloud-api/tests/clickpipes/support.rs Adds ClickPipe-specific accessors for Option response fields used across clickpipes tests.
crates/clickhouse-cloud-api/tests/clickpipes/stages/mod.rs Updates ClickPipe stage logic to tolerate Option state/id and avoid direct unwraps.
crates/clickhouse-cloud-api/tests/clickpipes/smoke_test.rs Updates smoke test cleanup/logging to handle Option response id/state.
crates/clickhouse-cloud-api/tests/clickpipes/postgres_cdc_test.rs Updates CDC E2E test to avoid relying on fields that may be omitted and to use Option-safe helpers.
crates/clickhouse-cloud-api/src/serde_helpers.rs Replaces null-to-empty helper with deserialize_or_raw to preserve payloads when variant parsing fails.
crates/clickhouse-cloud-api/src/meta.rs Documents and enforces deprecated-field duplication for split {Name}Response types; adds an example entry.
crates/clickhouse-cloud-api/src/lib.rs Adds crate-level docs describing strict requests, tolerant responses, split naming, and explicit write-back conversions.
crates/clickhouse-cloud-api/src/client.rs Updates client method return types to use *Response variants for response-position types.
AGENTS.md Updates contributor guidance to reflect the new request/response split, response Option policy, and enforcement tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/clickhousectl/src/cloud/commands.rs

@cursor cursor 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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 0a6cddf. Configure here.

sdairs and others added 4 commits July 28, 2026 14:10
…view)

An omitted `tags` in a Postgres GET response is indistinguishable from a
field the API dropped, and the PATCH replaces the tag set wholesale, so
coercing absence to an empty set silently deleted every existing tag not
named by --add-tag. Resolve absence explicitly instead: the new
`merge_response_tags` helper refuses the merge with an error saying the
response omitted the field. A genuine empty list still merges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`service delete --force` polled the stop with `or_absent(state)`, so an
absent state matched neither the success nor the failure set and the
timeout-less loop spun forever; `classify_stop_poll_state` now fails on
an absent state instead.

`key create` decided "no generated key material returned" from the
response alone, so a generated-key request whose response omitted
`keyId`/`keySecret` printed success plus a false explanation while the
one-time secret was lost; `resolve_key_create_material` reads the mode
from the request and fails, naming the key that may still exist.

`service create` rendered its query hint with `--id -` when the response
omitted the service id; the hint is now dropped, leaving the rest of the
output (including the one-time password) intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A scalar, array or null root made every `doc.get(key)` lookup miss, so both
sections resolved to `{}` and the CLI sent an empty body that reset the
service configuration instead of rejecting the malformed file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…316 review)

`ensure_service_query_setup` only cleaned up the API key it created when
the query-endpoint binding failed. An absent `keyId`/`keySecret` on the
create response, or an absent `id` on the endpoint upsert response,
returned straight out and left an orphaned key behind on every retry.

Resolve the key's resource UUID first (the only absence with no cleanup
available, since it names the key to delete), then route each subsequent
failure through a best-effort delete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Base automatically changed from issue-308-clickstack-drift to main July 28, 2026 13:28
Position classification only followed `#/components/schemas/...` refs, so an
operation naming a reusable `#/components/responses/...`,
`#/components/requestBodies/...` or `#/components/parameters/...` component
left every schema behind it unclassified — defaulting it to request position
and inviting spurious FieldOptionalityMismatch findings. The reference walk
now resolves non-schema component refs against the spec document and continues
through them, with a visited set so a component reference cycle terminates.

The vendored snapshot uses no such components, so the drift report is
unchanged; this is robustness against future spec drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 31 out of 33 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (6)

crates/clickhousectl/src/cloud/types_test.rs:74

  • These calls pass Option<Enum> by value into enum_wire_value, moving fields out of act. After changing enum_wire_value to accept Option<&T>, pass .as_ref() here to avoid moves.
    crates/clickhousectl/src/cloud/types_test.rs:96
  • Same move issue as above: pass .as_ref() into enum_wire_value so act can still be borrowed afterward.
    crates/clickhousectl/src/cloud/types_test.rs:108
  • Pass .as_ref() into enum_wire_value to avoid moving act.r#type.
    crates/clickhousectl/src/cloud/types_test.rs:174
  • In this loop, act.r#type is moved into enum_wire_value; pass .as_ref() to match the updated helper signature and avoid moves.
    crates/clickhousectl/src/cloud/types_test.rs:185
  • Pass .as_ref() into enum_wire_value to avoid moving act.actor_type.
    crates/clickhousectl/src/cloud/types_test.rs:212
  • Pass .as_ref() into enum_wire_value to avoid moving act.key_update_type.

Comment thread crates/clickhousectl/src/cloud/types_test.rs
@sdairs

sdairs commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Deep review of the full stack (19 commits, reviewed against 97ac80d as the true PR boundary — the client.rs endpoint additions and union groundwork in the GitHub diff belong to #311). I ran the full gate suite locally rather than trusting the PR description: cargo test for both crates (480+ and 506 tests), clippy -D warnings, cargo fmt --check, cargo check --workspace --all-features, the Python unittests, and check-openapi-drift.py --dry-run → 0 actionable drift against the live spec. I also independently mutation-probed the union dispatch guard semantics with adversarial payloads.

Verdict: approve. The core design — tolerance in the type system rather than serde attributes — is strictly better than the #313 serde(default) sweep it replaces, because it preserves the absence/presence distinction instead of fabricating values. Enforcement is structural (derived from client.rs return types with a vacuity guard), not conventional.

What impressed me most:

  1. The none unless guard is correct and load-bearing. With all-Option variant structs the absence arm is total, so a raw-SQL chart config with a server-dropped configType would silently retype to an empty builder config, discarding connectionId/sqlTemplate. I probed all five adversarial cases (dropped discriminator, empty object, malformed payload under a known discriminator, unknown discriminator, non-string discriminator) — every one routes exactly as documented, and the macro rustdoc honestly owns the deliberate "absent vs non-string" conflation.

  2. The union Default round-trip invariant is enforced structurally — the test's covered list must equal the analyzer-inventoried set of manual impl Default blocks, so a new union can't silently escape it. The two fixes this produced (BackupBucket/ClickStackWebhook naming their own discriminator wire value; ClickStackAlertChannelEmailType defaulting to Email) are exactly the class of bug that otherwise ships silently.

  3. The review-round commits are real fixes, not cosmetics: merge_response_tags refusing to merge on an omitted tags (prevents wholesale tag deletion via PATCH-replaces-set semantics); classify_stop_poll_state failing on absent state (the old code spun the timeout-less poll forever); resolve_key_create_material reading the mode from the request rather than inferring from the response (prevents printing success while the one-time secret is lost); ensure_service_query_setup resolving key.id first and routing every later failure through key deletion (kills the orphaned-key-per-retry leak, with wiremock coverage end-to-end).

Minor findings, none blocking:

  • ClickStackAlertChannelEmailType's #[default] Email is only protected at one remove. The union-level invariant test covers it, but the field enum is shared by both alert-channel variants (both declare ["webhook","email"]), so a future spec change adding a variant could re-open the misroute. A one-line comment on the enum itself — "default must match the email variant's discriminator" — would make it self-defending.
  • DeleteResponse (CLI) keeps rename_all = "camelCase" while the library now mandates explicit renames for greppability. Deliberate and harmless (the analyzer only parses models.rs); noting only for completeness.
  • Coverage gap worth restating for the record: the all-Option migration touched every integration suite's assertions, but only Postgres CDC runs in CI. A real response-shape regression on a third-party pipe or ClickStack endpoint won't surface until someone runs the manual suite.

Things I specifically checked that are NOT problems: no fabrication anywhere on the response path (DeleteResponse no longer invents 0.0; key create/service create/service_query resolve absence explicitly; postgres_reset_password falls back to the password we sent, not a placeholder); --json emits exactly the API's key set (pinned by test); serde(default) is genuinely gone from models.rs including the container-level dodge; the response_position_type guard against a spec schema literally named {Name}Response; convert.rs is uniform (collects all missing fields, wire names, nested at the right level — including the literal-space "exponential histogram" wire name, which matches the spec).

The main risk — 88 hand-maintained split pairs — is inherent to the design and mitigated as well as it can be by the direction-aware analyzer. Ship it (after retargeting to main once #311's base merges, per the description).

@sdairs

sdairs commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Deep review of the current head (0719f04). The request/response split and analyzer design are strong, but I found five omission-handling paths that should be fixed before merge.

Findings

  1. [P1] Preserve existing query-endpoint keys when openApiKeys is absent
    crates/clickhousectl/src/cloud/service_query.rs:151-158

    A successful endpoint GET with openApiKeys omitted or null is coerced to an empty list. The following upsert is a full replacement, so provisioning a new key can silently revoke every existing query-endpoint key. Treat None as an incomplete response and abort/clean up; only a genuine 404 should initialize an empty list.

  2. [P2] Do not delete a usable key over an unused endpoint ID
    crates/clickhousectl/src/cloud/service_query.rs:117-125

    The endpoint upsert has already succeeded, but an omitted response id causes the newly bound key to be deleted, leaving a dangling UUID in openApiKeys. ServiceQueryKey.endpoint_id is never consumed outside persistence/tests. Persist without depending on the echoed ID, refetch it, or explicitly roll back the binding before deleting the key.

  3. [P2] Distinguish generated-password responses
    crates/clickhousectl/src/cloud/commands.rs:2221-2230

    When neither hash flag is supplied, the empty request asks the API to generate a new password. If the response omits that one-time password, the command incorrectly reports “Password hash updated” and exits successfully, leaving the user without the generated credential. Resolve behavior from the request mode and report an incomplete response when generation was requested.

  4. [P2] Do not render missing one-time credentials as -
    crates/clickhousectl/src/cloud/commands.rs:897-910
    crates/clickhousectl/src/cloud/postgres.rs:676-678

    A created ClickHouse or Postgres service whose response omits password is reported as successful with Password: -, even though that password is shown only once. Emit a prominent incomplete-response warning/error with recovery instructions instead of presenting the placeholder as a credential.

  5. [P2] Validate generated key material before branching on output mode
    crates/clickhousectl/src/cloud/commands.rs:2961-2974

    resolve_key_create_material runs only for human output. Explicit --json and automatically detected coding-agent invocations bypass it, print an incomplete generated-key response, and exit successfully despite losing the one-time secret. Move validation before the output-mode branch while preserving raw JSON when the response is complete.

Verification

I ran:

  • cargo test --workspace
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo fmt --all --check
  • cargo check --workspace --all-features
  • python3 -m unittest discover -s scripts/tests -p 'test_*.py'
  • python3 scripts/check-openapi-drift.py --dry-run against the live spec: 0 actionable findings

The worktree remained clean.

The current Copilot comment about enum_wire_value causing a compile failure is a false positive: Rust permits these partial moves because subsequent accesses use different fields, and the full workspace compiles successfully.

Verdict: request changes. The remaining issues can revoke existing credentials or lose one-time secrets under the exact partial-response conditions this PR is intended to make safe.

sdairs and others added 7 commits July 28, 2026 16:34
… 316 review)

A 200 `instance_query_endpoint_get` whose `result` or `openApiKeys` was absent
was coerced to an empty list, and the upsert replaces the binding wholesale, so
provisioning silently revoked every key already bound to the endpoint. Only a
404 now starts from an empty list; an incomplete 200 fails naming the omitted
field, and the caller's existing cleanup discards the provisioned key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…316 review)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…16 review)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…view)

`generation_requested` treated a body carrying only `newDoubleSha1Hash` as a
hash update, but the spec says that key "will be ignored and the generated
password will be used" when `newPasswordHash` is absent, and that the response
carries a password "only if there was no 'newPasswordHash' in the request". So
`--new-double-sha1-hash` on its own rotates the password to a freshly generated
one, and reporting "password hash updated; no plaintext password returned"
discarded the only copy of it. Key the mode on `newPasswordHash` alone.

Also stop telling a Postgres create to reset a password that is not lost: the
spec says `connectionString` embeds the service password, so when the response
omitted `password` but carried a connection string, the recovery advice would
rotate a working credential. Point at the connection string instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eview)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sdairs
sdairs deployed to cloud-integration July 28, 2026 16:21 — with GitHub Actions Active
@sdairs

sdairs commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

All five findings were valid and are now fixed, one commit each, with tests confirmed to fail against the pre-fix code:

  1. [P1] openApiKeys merge — fixed in 07ba6f6. bind_query_endpoint now refuses a 200 GET whose result or openApiKeys is absent (the error names the field and the revocation consequence); only a genuine 404 initializes an empty list, and an explicit "openApiKeys": [] still merges. The existing cleanup arm discards the just-provisioned key on this path — pinned end-to-end by a wiremock test asserting zero endpoint POSTs, exactly one DELETE /keys/{uuid}, nothing persisted, non-zero exit.

  2. endpoint_id deletion — fixed in 45941b7. ServiceQueryKey.endpoint_id is now Option<String> (CLI-internal storage, old credential files still deserialize) and an absent echoed id persists the credentials with endpoint_id: None instead of deleting the freshly bound key. Wiremock test: upsert 200 without id → exit 0, no DELETE /keys/, key pair persisted.

  3. Generated-password mode — fixed in a927957 + corrected in 049aeca. service_reset_password now resolves the mode from the request via a resolve_reset_password_outcome helper that runs before the --json/human branch, so both modes fail when a requested generated password is omitted (explicit "" counts as sent, mirroring resolve_key_create_material). The follow-up commit matters: per the spec, newDoubleSha1Hash alone is ignored and a password is still generated, so mode is keyed on newPasswordHash alone — the initial fix (and the finding as scoped) would have silently discarded the generated password for a --new-double-sha1-hash-only reset. Unit tests cover all mode×presence cells plus that flag combination; wiremock tests cover both output modes.

  4. Missing one-time passwords rendered as - — fixed in 475f250 (+ 049aeca). Both create flows route the credentials block through a pure helper: an absent password now prints a prominent warning with the exact recovery command (clickhousectl cloud service reset-password <uuid> / clickhousectl cloud postgres reset-password <uuid> --generate — positional id, the --id form suggested in the finding would not parse), generic wording when the id is also absent. Exit stays 0 since the create succeeded and the password is recoverable. The follow-up fixes a contradiction: when the Postgres response omits password but carries connectionString (which the spec says embeds the password), the block points at the connection string instead of telling the user to rotate a working credential.

  5. --json bypassing key-material validation — fixed in c5159bf. The resolve_key_create_material call is hoisted above the output-mode branch, so explicit --json and auto-detected agent invocations now fail before any success output; a complete response still prints the raw serialized body byte-identically (the pinned --json = API's actual key set policy). Wiremock tests cover incomplete-generated (both modes, non-zero exit, no success JSON), complete --json passthrough, and pre-hashed success.

Also in this push: 29beb7d narrows the bind_query_endpoint doc comment — it only ever merges the key list; the upsert still replaces roles/allowedOrigins wholesale, which is pre-existing behavior worth a separate product decision (merge vs. document).

Gates on the final head: cargo test --workspace (1020 passed), cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --all --check, cargo check --workspace --all-features — all clean. A sweep for analogous spots (other or_absent credential renders, unwrap_or_default before full-replacement writes, human-branch-only validation) found none; postgres_reset_password's unwrap_or(pw) falls back to the client-generated password it just sent, which is a real credential, not a fabrication.

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.

Split request/response model types; tolerant responses via all-Option fields (supersedes #312)

2 participants