Skip to content

Pre-1.0.0 correctness & safety hardening#357

Merged
leogdion merged 8 commits into
v1.0.0-beta.2from
pre-1.0-correctness-hardening
May 18, 2026
Merged

Pre-1.0.0 correctness & safety hardening#357
leogdion merged 8 commits into
v1.0.0-beta.2from
pre-1.0-correctness-hardening

Conversation

@leogdion
Copy link
Copy Markdown
Member

@leogdion leogdion commented May 18, 2026

Summary

Umbrella PR for the pre-1.0 correctness pass tracked in #356. Eight commits, each scoped to a single sub-issue or design follow-up:

Out of scope under #356: the rest of #38 (auto-chunking >200-record batches) and #350 (closed as not-planned).

Design evolution

The PR ended up landing two architectural shifts that emerged from review discussion:

1. Pure thin wrapper for validation. `ee1293e` originally added client-side pre-flight for record/asset size limits. On reflection that was the wrong default — CloudKit owns the canonical limits, our checks duplicate Apple's validation and add drift risk. `caa61d3` reverted size pre-flight; `a8fbcb0` extended the reversal to all other client-side throws (empty-collection guards, numeric range caps, scope checks). The faithful pass-through is now consistent across operations.

Removed the now-producerless `.invalidArgument` case. The only escape hatch for callers who want a pre-flight: `CloudKitService.maxRecordDataBytes` / `maxAssetUploadBytes` (public constants) + `RecordOperation.encodedRecordSize()` (helper).

2. Type-safe error split for the three differentiated codes. `a8fbcb0` introduces:

```swift
case quotaExceeded(reason: String?, hint: QuotaHint?) // 413 / QUOTA_EXCEEDED
case badRequest(reason: String?) // 400 / BAD_REQUEST
case atomicFailure(reason: String?) // 400 / ATOMIC_ERROR
```

Why just these three:

  • `QUOTA_EXCEEDED` is overloaded at the wire (storage-quota / per-record / per-asset all collapse to the same code) — `QuotaHint` disambiguates from local request state
  • `ATOMIC_ERROR` ≠ `BAD_REQUEST` semantically despite sharing HTTP 400 (batch rollback vs malformed input)

The remaining 11 `serverErrorCode` values stay in `.httpErrorWithDetails` because the action a consumer takes on them is uniform (re-auth / back off / retry / surface). Full enumeration with `.unknownServerError` fallback is tracked in #358 as post-1.0 work.

Catch blocks in `modifyRecords` / `uploadAssetData` enrich `.quotaExceeded` (or a bare 413 from the CDN) with a `QuotaHint.recordExceedsSizeLimit` / `.assetExceedsSizeLimit` when local state explains the server's response. When there's no local size violation (real storage exhaustion), the hint is `nil` and the error passes through unchanged.

Test plan

  • `swift build` — clean on MistKit
  • `swift test` — 449 tests pass (down from 462 — net change: -13 from deleted validation suites, +4 new hint enrichment tests, +others removed in cleanup)
  • `./Scripts/lint.sh` — 0 violations, no unused code, headers consistent
  • `swift-format -i -r Sources/ Tests/` — clean
  • Examples build: MistDemo, BushelCloud, CelestraCloud (CelestraCloud's `CelestraError` retriability switch updated for the new cases in `a8fbcb0`)

Notes for reviewer

🤖 Generated with Claude Code

leogdion and others added 6 commits May 18, 2026 12:21
Replace the `default: assertionFailure(...); throw .invalidResponse` fallback
in every `CloudKitResponseProcessor` function with explicit case lists. The
generator's per-operation `Operations.*.Output` enums are frozen, so listing
the non-OK cases by name turns "new response variant added to openapi.yaml"
into a compile-time break at the processor — exactly when the team needs to
decide how to map it — instead of a silent `.invalidResponse` at runtime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Synthetic `httpErrorWithRawResponse(statusCode: 400, ...)` was reporting
caller-side argument failures as if they came from the server, so retry
middleware, metrics, and callers inspecting `httpStatusCode` couldn't tell
"we didn't even send the request" from "the server rejected it." Add a
dedicated `invalidArgument(parameter:reason:)` case (no HTTP status code)
and migrate `queryRecords`'s empty-recordType + out-of-range-limit guards.

`CloudKitService+ZoneOperations.swift`'s synthetic-400 sites migrate in the
same-file ZoneOps commit that handles #349.

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

`listZones` and `lookupZones` rendered zones with a nil `zoneName` as
`ZoneInfo(zoneName: "Unknown", ...)` — indistinguishable from a real zone
named "Unknown". Extend the existing `compactMap` guard pattern that
already drops entries with a nil `zoneID` to also drop entries with a
missing name, so a malformed payload fails closed instead of normalizing
to a plausible-but-wrong value. Regression test fixture mocks a response
with one valid + one nameless entry.

Same-file: migrate the lookupZones empty-zoneIDs / empty-zoneName guards
from synthetic `httpErrorWithRawResponse(400, ...)` to the dedicated
`.invalidArgument` case introduced in the prior commit (#352).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
At `.debug` level the middleware was eagerly collecting up to 1 MB of
every response body — including asset payloads where the bytes aren't
loggable text anyway. Lower the cap to 64 KB (enough to surface the JSON
envelope + any error reason) and skip body collection entirely for
non-`application/json` content types. Production traffic at `.info` and
above is unaffected.

Tests assert the body round-trips correctly for both JSON and
octet-stream content types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CloudKit caps record field data at 1 MB and asset uploads at 15 MB.
MistKit was relying on the server to reject oversize payloads, which
wastes a network round-trip and surfaces as an opaque server error.
Pre-flight both limits as `.invalidArgument`:

- `modifyRecords` JSON-encodes each `RecordRequest` and throws if any
  exceeds `maxRecordDataBytes` (1 MB). The encoded representation is
  what actually travels over the wire, so it's the closest fidelity
  measure available without a separate sizing pass.
- `uploadAssetData` (the low-level CDN path) and `uploadAssets` (the
  two-step convenience) both reject `data.count > maxAssetUploadBytes`
  (15 MB) before invoking the uploader closure.

Out of scope: auto-chunking when a caller passes more than 200 records
to `modifyRecords`. That has atomicity/partial-failure semantics worth
discussing on its own.

Also migrates `uploadAssets`'s existing empty-data + oversized-asset
guards from synthetic `httpErrorWithRawResponse(400|413, ...)` to
`.invalidArgument`, completing the #352 audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The package floor sat one OS major below where
P256.Signing.PrivateKey(pemRepresentation:) became available, so
ServerToServerCredentials could be constructed in the gap window
but never used to sign — Credentials+TokenManager either silently
fell back to web-auth or threw .preferenceRequired despite valid
credentials being configured. Raising the floor closes the
window; S2S signing is unconditional when credentials are
present.

Drop the now-redundant @available gates from auth, the README
platform table, and the Examples (whose own floors are well
above macOS 11). Also fixes a pre-existing non-exhaustive switch
in CelestraError.swift after CloudKitError.invalidArgument was
added in 0dca94b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 82f7fe85-b299-4093-9ee0-44f85ba41e5d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pre-1.0-correctness-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude
Copy link
Copy Markdown

claude Bot commented May 18, 2026

Code Review — Pre-1.0.0 correctness & safety hardening

Overall this is a well-scoped, well-motivated PR. The six commits are clearly isolated and the rationale is strong. A few observations below, ordered roughly by severity.


Leftover available guards are now dead code (cleanup)

After the platform-minimum bump in commit 1f79d67, guards like

guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { ... }

can never be false. They appear in several existing test files that were touched by this PR, and a brand-new guard of this form was introduced in the new test lookupZonesDropsZonesWithoutName (added in LookupZones+SuccessCases.swift). Since these are dead-from-day-one, they create confusion rather than documenting a real constraint.

Additionally, the new SizeLimits test files guard with macOS 12.0 / iOS 15.0, which is above the new package floor and looks inconsistent. If uploadAssetData and createRecord do not actually require macOS 12 (they don't appear to), these guards are also overly conservative.


Double JSON encoding in validateRecordDataSize (performance consideration)

CloudKitService+WriteOperations.swift encodes every RecordRequest in validateRecordDataSize via a fresh JSONEncoder, then the HTTP transport encodes the entire request body again. For a batch of ~200 records near the 1 MB boundary that is significant extra work in the hot path.

The commit message explains why the encoded size is the right proxy, but it is worth noting the tradeoff here. An easy mitigation: calculate sizes during the existing operations.map { try Components.Schemas.RecordOperation(from: $0) } pass, so the encoding is reused rather than duplicated. Not a correctness issue, but worth flagging before 1.0.


Misleading comment in createRecordAcceptsSmallRecord test

CloudKitServiceTests.SizeLimits+Records.swift contains:

Issue.record("Expected network error — no real transport configured")

But the service is configured with a MockTransport. What actually happens is that the mock returns a success stub that does not parse as ModifyResponse, so a decoding error is thrown — the test passes correctly. The comment will mislead the next person debugging a failure here. It should say something like "expected a non-validation error (decode or auth)" to match reality.


Fallback ?? .invalidResponse in exhaustive switches (minor)

The exhaustive switch pattern is a clear improvement over the old default: assertionFailure. The ?? .invalidResponse safety net is fine for .undocumented cases where no typed mapping exists, but it silently swallows detail for named cases (e.g., .badRequest) if CloudKitError(response) ever returns nil for one of them. A one-line assertionFailure or logger.warning before the throw for the non-.undocumented arms would preserve the compiler-exhaustiveness benefit while keeping observability. Minor, but consistent with the spirit of the exhaustive-switch change.


LoggingMiddleware emoji in log line (style nit)

logger.debug("Response Body: <skipped, content-type=...>")

CLAUDE.md says to avoid emojis unless explicitly requested. The existing log lines in LoggingMiddleware already use emoji so this is consistent with the existing file style — just noting the inconsistency with the stated convention if that matters.


What works well

  • The CloudKitError.invalidArgument case is clearly motivated and cleanly distinct from HTTP-originated errors. CelestraError.isRetryable is updated correctly.
  • The zoneName == "Unknown" fix is the right call (fail closed > silent normalization), and the regression test fixture (one valid + one nameless zone) covers the exact scenario.
  • The LoggingMiddleware cap reduction (1 MB to 64 KB) and the content-type gate are conservative in the right direction — no memory surprise on asset responses.
  • Removing the #available wrapping from Credentials+TokenManager.swift closes a real silent fall-through bug (S2S credentials ignored on the old floor).
  • Test coverage for size limits and middleware body handling is solid.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 18, 2026

Codecov Report

❌ Patch coverage is 56.57895% with 66 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.82%. Comparing base (d11c6c5) to head (a8fbcb0).
⚠️ Report is 1 commits behind head on v1.0.0-beta.2.

Files with missing lines Patch % Lines
...ources/MistKit/CloudKitService/CloudKitError.swift 23.80% 16 Missing ⚠️
...it/CloudKitService/CloudKitResponseProcessor.swift 40.00% 15 Missing ⚠️
Sources/MistKit/OpenAPI/LoggingMiddleware.swift 7.14% 13 Missing ⚠️
Sources/MistKit/CloudKitService/QuotaHint.swift 0.00% 10 Missing ⚠️
...istKit/CloudKitService/CloudKitError+OpenAPI.swift 88.46% 3 Missing ⚠️
...KitService/CloudKitResponseProcessor+Changes.swift 78.57% 3 Missing ⚠️
...udKitService/CloudKitService+WriteOperations.swift 86.95% 3 Missing ⚠️
...oudKitService/CloudKitService+ZoneOperations.swift 50.00% 2 Missing ⚠️
...ervice/CloudKitResponseProcessor+ModifyZones.swift 50.00% 1 Missing ⚠️
Additional details and impacted files
@@                Coverage Diff                @@
##           v1.0.0-beta.2     #357      +/-   ##
=================================================
- Coverage          68.95%   68.82%   -0.13%     
=================================================
  Files                111      113       +2     
  Lines               2641     2630      -11     
=================================================
- Hits                1821     1810      -11     
  Misses               820      820              
Flag Coverage Δ
mistdemo-spm-macos 11.93% <1.31%> (+0.12%) ⬆️
mistdemo-swift-6.2-jammy 11.93% <1.31%> (+0.08%) ⬆️
mistdemo-swift-6.2-noble 11.93% <1.31%> (+0.12%) ⬆️
mistdemo-swift-6.3-jammy 11.97% <1.31%> (+0.16%) ⬆️
mistdemo-swift-6.3-noble 11.93% <1.31%> (+0.12%) ⬆️
spm 67.52% <55.92%> (+0.35%) ⬆️
swift-6.1-jammy 67.60% <56.57%> (+0.43%) ⬆️
swift-6.1-noble 67.45% <55.92%> (+0.20%) ⬆️
swift-6.2-jammy 67.56% <56.57%> (+0.28%) ⬆️
swift-6.2-noble 67.49% <55.92%> (+0.01%) ⬆️
swift-6.3-jammy 67.52% <55.92%> (+0.05%) ⬆️
swift-6.3-noble 67.49% <55.92%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Reverting the policy added in ee1293e. CloudKit owns the canonical
per-record (1 MB) and per-asset (15 MB) limits; pre-flighting them
in MistKit duplicated Apple's check and risked drift if the limits
change. A developer who wants to bail out before sending can just
as easily check sizes themselves.

What stays as a faithful thin wrapper:
- `modifyRecords` no longer JSON-encodes operations for size; lets
  CloudKit reject oversized records.
- `uploadAssets` / `uploadAssetData` no longer guard against >15 MB;
  let the CDN reject. The empty-data guard on `uploadAssets` stays
  (as .invalidArgument) — that's a programmer error, not a
  CloudKit-imposed size policy.

What replaces it for callers who do want a pre-flight:
- `CloudKitService.maxRecordDataBytes` / `maxAssetUploadBytes` are
  now public — the documented constants are available without
  hardcoding.
- New `RecordOperation.encodedRecordSize() throws -> Int` reports
  the JSON-encoded wire size of the record envelope, so callers
  can write `try op.encodedRecordSize() <= CloudKitService.maxRecordDataBytes`
  if they want to check ahead of `modifyRecords`.

Tests: deletes the SizeLimits suites; drops the oversized-asset
test from Upload+Validation (keeps empty + valid-size coverage);
adds RecordOperationEncodedSizeTests for the new helper.

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

claude Bot commented May 18, 2026

Code Review: Pre-1.0.0 correctness & safety hardening

Overall this is a well-structured set of changes. The motivation behind each commit is clear, the design note on the size-validation reversal is convincing, and 460 tests passing is a good signal. A few things worth addressing before merge:


Bug (medium) — LoggingMiddleware may return a partially-consumed body

File: Sources/MistKit/OpenAPI/LoggingMiddleware.swift (lines 127–137)

do {
    let bodyData = try await Data(
        collecting: responseBody,
        upTo: Self.responseBodyLogCap   // 64 KB
    )
    logBodyData(bodyData)
    return HTTPBody(bodyData)
} catch {
    logger.error("📄 Response Body: <failed to read: \(error)>")
    return responseBody   // ⚠️ partially-consumed stream
}

When Data(collecting:upTo:) throws because the body exceeds 64 KB, responseBody's underlying AsyncSequence has already had up to 64 KB of chunks drained from it. Returning the same responseBody instance hands a partially-exhausted, single-use stream to the response processor, which will then fail with a JSON decoding error.

The comment "Bodies bigger than this still stream through to the caller untouched" is not accurate — the stream has been touched.

This existed before this PR at the 1 MB cap, but was rarely triggered in practice. At 64 KB it becomes realistic: a queryRecords response returning 200 records with even modest field data can easily exceed that.

Suggested fixes (pick one):

  • Collect the full body (no cap), but truncate only the printed string to 64 KB: logBodyData(bodyData) can call String(data: bodyData.prefix(responseBodyLogCap), ...) while return HTTPBody(bodyData) still returns the complete data.
  • Use the HTTPBody.length hint before deciding whether to collect: skip logging (and leave body untouched) when the hint indicates the body is larger than the cap.
  • Replace the cap-based collection with a streaming read that buffers up to the cap and emits the full body from a new HTTPBody.

Minor — encodedRecordSize() docstring contradicts implementation

File: Sources/MistKit/Models/RecordOperation+EncodedSize.swift (lines 40–49)

Delete operations carry a small envelope (record name, record type, empty fields) so they report a tiny but non-zero size.

But the implementation:

guard let record = apiOperation.record else { return 0 }

returns 0 when record is nil. If delete operations produce a nil record in the API representation (which seems plausible since a delete only needs the record name, not the field values), callers get 0, not a small non-zero value. The docstring should be corrected — either update it to say deletes return 0, or verify that RecordOperation(from:) for a delete actually populates .record before asserting the "non-zero" claim in the doc.


Minor — Redundant #available guard in the new test

File: Tests/MistKitTests/CloudKitService/LookupZones/CloudKitServiceTests.LookupZones+SuccessCases.swift

guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else {
    Issue.record("CloudKitService is not available on this operating system.")
    return
}

Package.swift now declares .macOS(.v11) as the minimum, so this guard is always true — the Issue.record branch is unreachable dead code. Not a correctness problem, but the guard adds noise and could confuse future readers into thinking the test might be skipped. A follow-up pass to remove these throughout Tests/MistKitTests/ would clean things up.


Nit — Content-type check could be a prefix match

File: Sources/MistKit/OpenAPI/LoggingMiddleware.swift (line 121)

contentType.lowercased().contains("application/json")

contains would technically match "x-not-application/json". A hasPrefix or checking that the type segment starts at a word boundary would be more precise. Practically irrelevant on CloudKit's API surface, but hasPrefix("application/json") is the more defensive idiom.


What looks good

  • Exhaustive switching is the right call. Removing default: turns "new OpenAPI variant added" from a silent .invalidResponse at runtime into a compiler error at the exact function that needs updating. Clean design.
  • CloudKitError.invalidArgument is a clean separation. The parameter + reason associated values give callers enough context to surface actionable messages without a status code that implies a network round-trip happened.
  • The zoneName ?? "Unknown" fix is strictly correct — failing closed (dropping the zone) is far better than producing a record with a plausible-but-wrong name that's indistinguishable from a real zone named "Unknown".
  • The platform floor bump's rationale is solid and the removal of now-redundant @available gates in auth code is a nice cleanup.
  • The maxRecordDataBytes / maxAssetUploadBytes public constants + encodedRecordSize() helper is the right ergonomics for a thin wrapper that wants to stay out of Apple's limit enforcement path.

…MIC_ERROR; enrich quota errors with QuotaHint

This is the larger-than-planned follow-through on the "thin wrapper"
philosophy. Two coordinated changes:

1) Drop all client-side validation throws. The previous iteration
   (ee1293e, caa61d3) removed size pre-flighting; this commit removes
   the rest:

   - queryRecords: empty recordType + limit out-of-range
   - lookupZones: empty zoneIDs + empty zoneName
   - modifyZones: empty operations + empty zoneName + .public scope
   - fetchRecordChanges: resultsLimit out-of-range
   - uploadAssets: empty data

   CloudKit owns these constraints. The server returns BAD_REQUEST
   with a specific reason — duplicating that check client-side adds
   drift risk without ergonomic upside. The only pre-flight that
   remains is the OpenAPI converting init (RecordOperation type
   mapping), which catches a programmer error before serialization.

   Removes the now-producerless .invalidArgument case from
   CloudKitError. Deletes the +Validation test suites (Query,
   LookupZones, ModifyZones) and stale ValidationErrorType /
   validationError helpers. FetchChanges+Validation keeps its
   non-validation tests; Upload+Validation keeps its happy-path
   coverage.

2) Split three serverErrorCodes from httpErrorWithDetails into
   dedicated cases so consumers can pattern-match by intent rather
   than parsing strings:

   - .quotaExceeded(reason:, hint: QuotaHint?)  // 413 / QUOTA_EXCEEDED
   - .badRequest(reason:)                        // 400 / BAD_REQUEST
   - .atomicFailure(reason:)                     // 400 / ATOMIC_ERROR

   Why these three: QUOTA_EXCEEDED is overloaded across storage-quota,
   per-record size, and per-asset size; ATOMIC_ERROR ≠ BAD_REQUEST
   semantically (batch rollback vs malformed input). The other 11
   serverErrorCodes stay in httpErrorWithDetails — the action is the
   same regardless of which one fired (re-auth, back off, retry).
   A follow-up issue will track full enumeration with .unknownServerError.

   New QuotaHint enum carries the disambiguation CloudKit's response
   doesn't. modifyRecords / uploadAssetData enrich .quotaExceeded
   (or a bare 413 from the CDN) with .recordExceedsSizeLimit /
   .assetExceedsSizeLimit when local request state explains the
   server error. addingQuotaHint helper is a no-op when the hint
   doesn't apply, so non-size QUOTA_EXCEEDED (real storage exhaustion)
   passes through unchanged.

Migrates 4 pattern-match sites from .httpErrorWithDetails(_,
"BAD_REQUEST", _) to .badRequest. Sites matching 404/409/401 are
unaffected (those codes still land in httpErrorWithDetails). Adds
DemoErrorsRunner+Output coverage for the new cases. Updates
CelestraCloud's CelestraError retriability switch for the new cases.

Adds CloudKitServiceTests.SizeLimits + Records + Assets suites
covering the post-flight enrichment path.

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

claude Bot commented May 18, 2026

Code Review — Pre-1.0.0 Correctness & Safety Hardening

Overall this is a solid pass: the exhaustive-switch refactor alone is a meaningful correctness win, and the QuotaHint design is clean and well-reasoned. A few things worth looking at before merge.


Correctness

Stale #available guards survive the platform-minimum bump.
Several tests still contain guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { … } after Package.swift raises the minimum to exactly those versions. The guards are now dead code — they can never be false — and their presence is misleading.

The most obvious case is the new test added in this PR:

// Tests/MistKitTests/CloudKitService/LookupZones/CloudKitServiceTests.LookupZones+SuccessCases.swift
@Test("lookupZones() drops zones with missing zoneName instead of substituting placeholder")
internal func lookupZonesDropsZonesWithoutName() async throws {
    guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else {
        Issue.record("CloudKitService is not available on this operating system.")
        return
    }

Same issue remains in FetchChanges+Validation.swift (the surviving fetchAllRecordChangesThrowsForNilSyncTokenWithMoreComing test) and in FetchZoneChanges+ErrorHandling.swift. The platform cleanup in the production code is complete; the tests didn't fully follow.


Performance

recordSizeQuotaHint runs unconditionally on every error from modifyRecords.

// CloudKitService+WriteOperations.swift
} catch let cloudKitError as CloudKitError {
    throw cloudKitError.addingQuotaHint(
        Self.recordSizeQuotaHint(for: apiOperations)
    )
}

addingQuotaHint short-circuits on non-quota errors, but recordSizeQuotaHint doesn't — it scans every operation and re-encodes each record (a full JSONEncoder().encode(record) per op) before addingQuotaHint discards the result. For a missingCredentials or networkError thrown on a large batch this is wasted work. Gating on the error case first would be cleaner:

} catch let cloudKitError as CloudKitError {
    if case .quotaExceeded = cloudKitError {
        throw cloudKitError.addingQuotaHint(Self.recordSizeQuotaHint(for: apiOperations))
    }
    throw cloudKitError
}

LoggingMiddleware body cap — verify the "stream through untouched" claim

The new constant and comment say:

/// Bodies bigger than this still stream through to the caller untouched.
private static let responseBodyLogCap: Int = 64 * 1_024

In swift-openapi-runtime, HTTPBody is a single-pass async sequence. Once Data(collecting:upTo:) starts reading and then throws TooManyBytesError (body > 64 KB), the bytes already read are consumed. The catch block must return a body that contains the full original content; if it returns only what was collected so far, a large-but-valid JSON response (e.g., a query result with 200 records and non-trivial field values) would be silently truncated, causing a downstream decoding error that looks like a MistKit bug to the caller.

Please confirm the catch path in logResponseBody returns the body intact when it exceeds the cap — or that the CloudKit JSON shapes that reach this path will always fit in 64 KB in practice. If the TooManyBytesError path drops or truncates the body, the cap should be raised to cover the realistic maximum JSON response size, or the middleware should buffer speculatively and log a truncation notice while passing the full buffered data downstream.


Minor nits

encodedRecordSize() allocates a fresh JSONEncoder per call. Not a problem in the error-path hint, but callers using it as a pre-flight check across a whole batch will create N encoders. A static encoder or a static batch helper would avoid the churn. Not a blocker.

QuotaHint.description emits integer-division MB. max / 1_024 / 1_024 truncates. For the current constants (1 MB, 15 MB) it's exact, but if the constants ever change to a non-round number the display will silently lose precision. Using String(format: "%.1f MB", Double(max) / 1_024 / 1_024) is more resilient.


What's working well

  • Exhaustive switches in CloudKitResponseProcessor — swapping default: for explicit case lists means the compiler will catch new response variants from the OpenAPI generator. This is the most important correctness improvement in the PR.
  • zoneName ?? "Unknown" removal — silent substitution of a placeholder was a correctness hazard (a zone named "Unknown" would be indistinguishable from a truly unknown zone). Filtering via compactMap is the right call.
  • QuotaHint design — attaching local-context disambiguation to an overloaded server code, rather than asking callers to decode the free-form reason string, is a clean API.
  • Client-side validation removal — consistent with the thin-wrapper principle; CloudKit's own validation surfaces cleaner errors and eliminates drift risk.
  • Platform minimum bump + #available cleanup in production code — comprehensive and the right direction.

@leogdion leogdion merged commit b0c65d8 into v1.0.0-beta.2 May 18, 2026
70 of 72 checks passed
@leogdion leogdion deleted the pre-1.0-correctness-hardening branch May 18, 2026 14:42
@claude claude Bot mentioned this pull request May 27, 2026
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