Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/memory/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native
## Index

- [CloudKit archived endpoints not in local docs](reference_cloudkit_archived_endpoints.md) — Verify CloudKit endpoints (e.g. assets/rereference) against Apple's archived reference, not just .claude/docs/webservices.md
- [CloudKit Zone Dictionary has exactly 3 keys](reference_cloudkit_zone_dictionary.md) — zoneID/syncToken/atomic only; isEager, modify-request `atomic`, and zone create options do NOT exist
- [wasm CI failure signatures](reference_wasm_ci_signatures.md) — Two distinct wasm failures: silent exit-1 (OOM on big test target) vs curl exit-7 (SDK download flake, just re-run)
- [Swift Testing availability guard](feedback_swift_testing_availability.md) — Never annotate @Suite types with @available; use guard #available inside @Test functions instead
- [GitHub Action pinning preference](feedback_action_pinning.md) — Use @v<major> for brightdigit-owned actions; pin third-party actions explicitly
Expand Down
32 changes: 32 additions & 0 deletions .claude/memory/reference_cloudkit_zone_dictionary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
name: reference_cloudkit_zone_dictionary
description: "CloudKit's Zone Dictionary has exactly three keys (zoneID, syncToken, atomic) — isEager and zone create options do not exist"
metadata:
node_type: memory
type: reference
---

Verified against Apple's archived CloudKit Web Services Reference during issue #386 / PR #427.

**Zone Dictionary documents exactly three keys** ([Types.html](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/Types.html)):

| Key | Apple's wording |
|-----|-----------------|
| `zoneID` | "The dictionary that identifies a record zone in the database" |
| `syncToken` | "The current point in the zone's change history." |
| `atomic` | "A Boolean value indicating whether this zone supports atomic operations." |

All four zone endpoints (`zones/list`, `zones/lookup`, `zones/modify`, `zones/changes`) route their **success** payload through this dictionary, and their **failure** payload through the "Zone Fetch Error Dictionary" (`zoneID`, `reason`, `serverErrorCode`, `retryAfter`, `redirectURL`).

**Things that do NOT exist — do not add them speculatively:**

- **`isEager`** — appears in no primary Apple source, nor in `.claude/docs/webservices.md` or `.claude/docs/cloudkitjs.md`. It was proposed in issue #386 but is unsourced.
- **`atomic` on the `zones/modify` request** — the request body is `operations` only. `records/modify` *does* document `atomic`; the asymmetry is deliberate.
- **Zone create options on `ZoneOperation`** — the operation's `zone` is documented as having "a single `zoneID` key".

**Open discrepancies (unresolved, see issue #386 comment):**

- `zones/changes` documents its token key as **`metaSyncToken`** in both request and response; MistKit sends/reads `syncToken`. Apple's page contradicts itself (the `moreComing` description refers back to "the included `syncToken` key"), so this needs a live-response check before changing.
- `zones/changes` is documented as **deprecated** in favor of `changes/database`.

Note `ZoneID`'s owner key: Apple documents `ownerRecordName`, while MistKit's `ZoneID` domain type calls it `ownerName` and the wire schema uses `ownerName`. Related: [[reference_cloudkit_archived_endpoints]].
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,26 @@ In MistDemo, integration runs targeting these endpoints use `PhaseContext.userCo
- `QueryResult` — `records: [RecordInfo]`, `continuationMarker: String?`
- `RecordChangesResult` — `records: [RecordInfo]`, `syncToken: String?`, `moreComing: Bool`
- `ZoneChangesResult` — `zones: [ZoneInfo]`, `syncToken: String?`, `moreComing: Bool`
- `ZoneInfo` — `zoneName: String`, `ownerRecordName: String?`, `capabilities: [String]`, `syncToken: String?`, `atomic: Bool?`
- `UserIdentity` — `userRecordName: String?`, `nameComponents: NameComponents?`, `lookupInfo: UserIdentityLookupInfo?`
- `UserIdentityLookupInfo` — `emailAddress: String?`, `phoneNumber: String?`, `userRecordName: String?`
- `NameComponents` — full personal name parts (givenName, familyName, nickname, etc.)

**Zone metadata (issue #386):** all four zone responses (`zones/list`, `zones/lookup`,
`zones/modify`, `zones/changes`) share one `Zone` schema in `openapi.yaml` carrying the
three keys Apple's archived ["Zone Dictionary"](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/Types.html)
documents: `zoneID`, `syncToken`, and `atomic`. These surface on `ZoneInfo` as
`syncToken`/`atomic`, both optional — `atomic` is **not** defaulted to `false`, so an
absent key stays distinguishable from an explicit `false`. Note the zone-level
`syncToken` is distinct from the response-level `syncToken` on `ZoneChangesResult`.

`isEager` is **deliberately not modeled**: it appears in no primary Apple source
(neither the archived Web Services reference nor `.claude/docs/cloudkitjs.md`).
Likewise `zones/modify` takes **no** `atomic` request flag and `ZoneOperation` has
**no** create options — Apple documents the request body as `operations` only, and each
operation's `zone` as having "a single `zoneID` key". Do not add these speculatively;
confirm against a live response first.

**Protocols:**
- `RecordTypeIterating` (`Sources/MistKit/RecordManagement/RecordTypeIterating.swift`) — `forEach(_ action:)` to iterate over CloudKit record types; used by `fetchAllRecordChanges`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ public struct CreateZoneCommand: MistDemoCommand, OutputFormatting {
if !zone.capabilities.isEmpty {
print(" Capabilities: \(zone.capabilities.joined(separator: ", "))")
}
if let syncToken = zone.syncToken {
print(" Sync Token: \(syncToken)")
}
if let atomic = zone.atomic {
print(" Atomic: \(atomic)")
}

print("\n" + String(repeating: "=", count: 60))
print("✅ Zone creation completed!")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ public struct LookupZonesCommand: MistDemoCommand, OutputFormatting {
if !zone.capabilities.isEmpty {
print(" Capabilities: \(zone.capabilities.joined(separator: ", "))")
}
if let syncToken = zone.syncToken {
print(" Sync Token: \(syncToken)")
}
if let atomic = zone.atomic {
print(" Atomic: \(atomic)")
}
}

print("\n" + String(repeating: "=", count: 60))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ internal struct ListZonesPhase: IntegrationPhase {
if context.verbose {
for zone in zones {
print(" - \(zone.zoneName)")
if let syncToken = zone.syncToken {
print(" Sync Token: \(syncToken)")
}
if let atomic = zone.atomic {
print(" Atomic: \(atomic)")
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ internal struct LookupZonePhase: IntegrationPhase {
if !zone.capabilities.isEmpty {
print(" Capabilities: \(zone.capabilities.joined(separator: ", "))")
}
if let syncToken = zone.syncToken {
print(" Sync Token: \(syncToken)")
}
if let atomic = zone.atomic {
print(" Atomic: \(atomic)")
}
}

return NoState()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,20 @@ internal struct ModifyZonesPhase: IntegrationPhase {
zoneIDs: [zoneID],
database: context.database
)
guard lookedUp.contains(where: { $0.zoneName == zoneName }) else {
guard let verifiedZone = lookedUp.first(where: { $0.zoneName == zoneName }) else {
try await cleanup(zoneID: zoneID, context: context)
throw IntegrationTestError.verificationFailed(
"created zone '\(zoneName)' not returned by lookupZones"
)
}
if context.verbose {
print(" ✅ Verified zone via lookupZones")
if let syncToken = verifiedZone.syncToken {
print(" Sync Token: \(syncToken)")
}
if let atomic = verifiedZone.atomic {
print(" Atomic: \(atomic)")
}
}

try await cleanup(zoneID: zoneID, context: context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ extension CloudKitService {
let zonesData: Components.Schemas.ZonesModifyResponse =
try await responseProcessor.processModifyZonesResponse(response)

return try (zonesData.zones ?? []).map { try ZoneInfo(fromZoneID: $0.zoneID) }
return try (zonesData.zones ?? []).map { try ZoneInfo(from: $0) }
} catch {
throw mapToCloudKitError(error, context: "modifyZones")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ extension CloudKitService {

let zonesData: Components.Schemas.ZonesListResponse =
try await responseProcessor.processListZonesResponse(response)
return try (zonesData.zones ?? []).map { try ZoneInfo(fromZoneID: $0.zoneID) }
return try (zonesData.zones ?? []).map { try ZoneInfo(from: $0) }
} catch {
throw mapToCloudKitError(error, context: "listZones")
}
Expand Down Expand Up @@ -113,7 +113,7 @@ extension CloudKitService {
let zonesData: Components.Schemas.ZonesLookupResponse =
try await responseProcessor.processLookupZonesResponse(response)

return try (zonesData.zones ?? []).map { try ZoneInfo(fromZoneID: $0.zoneID) }
return try (zonesData.zones ?? []).map { try ZoneInfo(from: $0) }
} catch {
throw mapToCloudKitError(error, context: "lookupZones")
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/MistKit/Models/Zones/ZoneChangesResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public struct ZoneChangesResult: Codable, Sendable {
internal init(from response: Components.Schemas.ZoneChangesResponse) throws(ConversionError) {
var zones: [ZoneInfo] = []
for zone in response.zones ?? [] {
zones.append(try ZoneInfo(fromZoneID: zone.zoneID))
zones.append(try ZoneInfo(from: zone))
}
self.zones = zones
self.syncToken = response.syncToken
Expand Down
40 changes: 37 additions & 3 deletions Sources/MistKit/Models/Zones/ZoneInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,30 @@ public struct ZoneInfo: Codable, Sendable {
/// Note: always empty — CloudKit Web Services zone responses do not include
/// capabilities in the current OpenAPI schema.
public let capabilities: [String]
/// The current point in the zone's change history.
///
/// Present on zone responses that carry Apple's "Zone Dictionary" payload;
/// `nil` when the server omits it.
public let syncToken: String?
/// Whether this zone supports atomic operations.
///
/// `nil` when the server omits the key — deliberately *not* defaulted to
/// `false`, so "absent" stays distinguishable from "explicitly not atomic".
public let atomic: Bool?

/// Initialize zone information
public init(zoneName: String, ownerRecordName: String?, capabilities: [String]) {
public init(
zoneName: String,
ownerRecordName: String?,
capabilities: [String],
syncToken: String? = nil,
atomic: Bool? = nil
) {
self.zoneName = zoneName
self.ownerRecordName = ownerRecordName
self.capabilities = capabilities
self.syncToken = syncToken
self.atomic = atomic
}

/// Convert a CloudKit zone payload's `zoneID` into a `ZoneInfo`.
Expand All @@ -60,7 +78,11 @@ public struct ZoneInfo: Codable, Sendable {
/// and make the generated decoder reject otherwise-valid payloads, so the
/// response-side "must be present" rule is enforced here at the domain
/// boundary instead.
internal init(fromZoneID zoneID: Components.Schemas.ZoneID?) throws(ConversionError) {
internal init(
fromZoneID zoneID: Components.Schemas.ZoneID?,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

remove fromZoneID and just do zoneID

syncToken: String? = nil,
atomic: Bool? = nil
) throws(ConversionError) {
guard let zoneID else {
try ConversionError.zoneMissingID.reportAndThrow()
}
Expand All @@ -70,7 +92,19 @@ public struct ZoneInfo: Codable, Sendable {
self.init(
zoneName: zoneName,
ownerRecordName: zoneID.ownerName,
capabilities: []
capabilities: [],
syncToken: syncToken,
atomic: atomic
)
}

/// Convert a CloudKit `Zone` payload into a `ZoneInfo`, carrying the
/// zone-level metadata (`syncToken`, `atomic`) alongside the identity.
internal init(from zone: Components.Schemas.Zone) throws(ConversionError) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

instead of (from zone:) make this (zone: )

try self.init(
fromZoneID: zone.zoneID,
syncToken: zone.syncToken,
atomic: zone.atomic
)
}
}
Loading
Loading