diff --git a/AGENTS.md b/AGENTS.md index 1fa5b60..3bd2e3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Context -CloudSaveKit is a pure Swift package that coordinates a host application's durable local store with a private CloudKit database through `CKSyncEngine`. Read [README.md](README.md) and the DocC catalog before changing public behavior. +CloudSaveKit is a pure Swift package that owns reusable CloudKit account verification, conditional private-manifest bootstrap, exact record transport, and coordination of a host's durable local store through `CKSyncEngine`. It also retains configurable private/shared engine and zone-sharing APIs. Read [README.md](README.md) and the DocC catalog before changing public behavior. The package is persistence-, UI-, and application-architecture agnostic. Host applications own their local database, record schemas, merge semantics, and user-facing recovery policy. @@ -38,6 +38,7 @@ Redux, SwiftData, SwiftUI, and application-localization guidance do not apply to ## Package specialization - Use `CKSyncEngine` rather than duplicating its scheduling, batching, state tracking, or transient retry behavior. +- Personal sessions permanently bind one verified account generation, exact zone, and host client. Preserve fail-closed trusted-manifest recovery separately from the existing configurable engine's automatic owned-zone recovery. - Do not add a local database, Redux, UI, product record schema, or game-specific conflict policy. - Persist every engine state update through the host-provided client boundary. - Keep status and errors privacy-safe; never log record contents or identifiers. @@ -91,7 +92,7 @@ In an initial review, report substantiated blockers together. A follow-up review The review-round budget below applies only to Codex GitHub reviews: the configured automatic Codex review and any manual `@codex review` request. It does not apply to ChatGPT review or reasoning delegated through Reasoning Relay. An otherwise-authorized Reasoning Relay workflow may request as many Relay review or follow-up delegations as its own governing workflow requires; those requests neither consume the Codex budget nor require repository-owner authorization under it. -Automatic Codex review is the initial Codex review. Do not request a manual Codex review unless the repository owner explicitly asks. Never request another Codex review after each remediation commit. Within the normal Codex review budget, at most one owner-authorized, delta-scoped Codex verification review may be requested under [the pull-request review workflow](AgentGuidelines/Guidelines/GitHub/PullRequests.md). +When repository-specific evidence establishes that automatic Codex review is enabled and applies to the current pull request/head, it is the initial Codex review. Otherwise, an absent review does not establish a pending gate. Do not request a manual Codex review unless the repository owner explicitly asks. Never request another Codex review after each remediation commit. Within the normal Codex review budget, at most one owner-authorized, delta-scoped Codex verification review may be requested under [the pull-request review workflow](AgentGuidelines/Guidelines/GitHub/PullRequests.md). ## Codex review scope diff --git a/AgentGuidelines/.agents/skills/agent-guidelines-audit/SKILL.md b/AgentGuidelines/.agents/skills/agent-guidelines-audit/SKILL.md index 75510ae..c47f52d 100644 --- a/AgentGuidelines/.agents/skills/agent-guidelines-audit/SKILL.md +++ b/AgentGuidelines/.agents/skills/agent-guidelines-audit/SKILL.md @@ -28,9 +28,15 @@ Review the actual change rather than only checking whether files exist: - For Redux applications, trace actions, state, reducers, middleware, services, tools, presentation models, views, and side-effect results through the complete data flow. Confirm each Redux component folder contains only that component type. - Check that framework objects, persistence, logging, and asynchronous work remain in their allowed boundaries. - Check SwiftUI composition, narrow inputs, local versus durable state, localization, accessibility, and safe deterministic previews where applicable. +- For each new or changed SwiftUI `ScrollView`, apply the shared [scrolling guidance](../../../Guidelines/Swift/SwiftUI.md#scrolling): verify `.scrollBounceBehavior(.basedOnSize)` covers its scrolling axes on supported deployment targets, including scroll views in modal sheets. Check the modifier's environment scope around nested scrollables and require a documented interaction reason for a different bounce behavior. +- Perform the mandatory [native-component and presentation decision check](#audit-native-components-and-presentation) for changed SwiftUI controls and presentation behavior. +- Perform the mandatory [system-control placement check](#audit-system-control-placement) for changed navigation, toolbars, dismissal controls, and window chrome, including affected UIKit/AppKit wrappers. +- Perform the mandatory [animation and image-rendering check](#audit-animation-and-image-rendering) for changed visual transitions, reusable control styles, and dynamic image paths, including directly affected consumers. +- Perform the [Reduce Motion check](#audit-reduce-motion) for affected motion and for Reduced Motion support declarations. - Check tests for the required framework, mirrored paths, shared tags, Given/When/Then structure, deterministic seams, and coverage of changed behavior and failure paths. - Trace every new or changed stateful, asynchronous, fallible, or lifecycle-oriented behavior and verify that its owning artifact emits privacy-safe AppLogger events for the meaningful success, failure, cancellation, recovery, and state-transition outcomes needed to diagnose it. Dependency declaration and target linkage alone do not establish logging coverage. Accept silence for pure values or utilities only when there is no meaningful event boundary and the implementation handoff records that deliberate decision. - Check logging ownership, subsystem, categories, emoji, privacy, severity, metadata stability, noise controls, and focused formatter or sink tests when logging changed. +- For every in-scope package that emits logs, resolve its canonical emoji from local instructions or documentation and the package list on the current `main` branch of the [ThatFactory Swift Package Collection](https://github.com/thatfactory/swift-package-collection). Enumerate the registered package entries and require each canonical emoji to identify exactly one package, treating visually identical presentation-selector variants as a collision. Confirm the package documentation, package-local logging gateway, and registry entry use the same emoji. When creating a package or assigning or changing its emoji, verify the intended emoji is unused before accepting it and require the registry update in the publication work. A collision, a missing registry entry for a published package, an unreachable current registry, or disagreement among these sources blocks audit completion and release readiness. - For every Apple-platform application or Swift package in scope, except the AppLogger provider repository itself, verify integration with the shared [Logging guide](../../../Guidelines/Logging.md): confirm the AppLogger dependency is declared, the `AppLogger` library product is linked to every target that emits diagnostics, and any new project has it available in its primary runtime target before its first log call. Search the actual package or Xcode dependency graph rather than relying on an `import` alone, and treat `print`, direct `Logger` instances, or duplicate logging backends as incomplete integration when they emit project diagnostics. When implementation is authorized, add or repair the dependency and target linkage and migrate affected calls while preserving the guide's ownership, subsystem, category, emoji, privacy, severity, and noise rules; report an exact blocker when target or platform constraints make safe integration ambiguous. - Inspect dependency manifests, resolver or lock files, Xcode package references, vendored source or binary frameworks, and equivalent dependency declarations. Compare the change with the baseline and identify every new third-party dependency or expansion of an existing third-party dependency into a new target or runtime role. Apply the shared [external dependency policy](../../../Guidelines/Development.md#external-dependencies): require explicit repository-owner approval before the dependency is introduced and require the durable exception record in repository documentation. Do not infer approval merely from an execution plan, pull-request description, implementation convenience, package popularity, or the dependency already appearing in the diff. Treat an unapproved or undocumented third-party dependency as a blocker to completion. Do not flag Apple system frameworks, the Swift standard library, ThatFactory-owned packages, or guideline-mandated tooling used only for its documented tooling role. If a newly resolved transitive third-party package will be linked into or shipped with the product, verify that its owning direct dependency is covered by an approved exception rather than dismissing it solely because it is transitive. - Search dependency manifests, generated directories, project files, and documentation for CocoaPods or Carthage adoption. The shared [external dependency policy](../../../Guidelines/Development.md#external-dependencies) forbids both without an exception path and requires Swift Package Manager for package dependencies. When implementation is authorized, remove newly introduced adoption and its generated or configuration files; report pre-existing adoption as a completion blocker when safe migration is outside the task scope. @@ -39,6 +45,68 @@ Review the actual change rather than only checking whether files exist: - Apply the shared [CI/CD guide](../../../Guidelines/CICD.md) to workflow and repository-automation changes. Search scripts, generated directories, workflow files, and documentation for fastlane adoption and treat it as forbidden without an exception path. Require focused ThatFactory tooling or repository-owned Swift scripts for CI/CD and delivery, and inspect new repository-owned executable scripts for compliance. For Swift-focused applications, games, and packages, accept Python or POSIX shell only when durable repository documentation identifies the missing Swift capability, exact script and task scope, runtime and dependency requirements, security and maintenance impact, validation method, and revisit or removal condition. Reject convenience, familiarity, shorter code, an existing interpreter, or another non-Swift script as justification. Treat every exception as narrow; the central `Scripts/swift_format.sh` wrapper is the retained documented exception for invoking Xcode's `swift-format` modes. - For pull-request or merge readiness, apply the root `## Code Review Rules`: confirm the Codex review covers the current head, no allowed Codex review round is pending, every Codex review thread has a disposition, and no unresolved P0/P1 blocker remains. Treat P2/P3 observations as non-blocking and never request another Codex review unless the repository owner explicitly authorizes it. This Codex review-round budget does not apply to otherwise-authorized Reasoning Relay/ChatGPT review delegations; do not block them waiting for a Codex-budget exception. +## Audit native components and presentation + +Apply the authoritative [SwiftUI native-component policy](../../../Guidelines/Swift/SwiftUI.md#native-components-and-presentation) to changed controls and presentation flows: + +1. Inspect changed custom modal hosts, overlay/scrim/offset code, and gesture routing in context. These are investigation signals, not blanket prohibitions on overlays or styling. Include existing hosts directly affected by the change, including discard dialogs; do not assume a native child makes every surrounding presentation compliant. +2. Record the supported interaction, native alternative, deployment-target availability, and who owns presentation and dismissal. Distinguish branded content within a native component from replacing its lifecycle. +3. For each custom equivalent, verify the capability-gap evidence, explicit repository-owner approval, durable consumer exception, and compensating validation required by the policy. A suitable native alternative without the required approved and documented exception blocks audit completion. Do not treat a mockup or unchanged mounted state as approval. +4. Trace child dismissal, parent draft ownership, nested picker/editor presenters, cancellation, and dismissal-completion cleanup through the affected hierarchy and asynchronous work. Confirm that a native nested sheet with branded content is accepted when ownership and evidence satisfy the policy. +5. Require the policy's [interaction evidence](../../../Guidelines/Swift/SwiftUI.md#required-interaction-evidence) for each affected flow. Inspect actual actions and observed outcomes, including recorded manual gesture verification when needed; static screenshots and no-op automation do not establish gesture behavior. Missing relevant runtime evidence blocks audit completion. Record an exact blocker for unavailable verification and include decisions, exception references, and evidence in the handoff. + +## Audit system control placement + +Apply the authoritative [system control placement policy](../../../Guidelines/Swift/SwiftUI.md#system-control-placement): + +1. Identify the owner and intent of each affected Back, Close, Cancel, Done, confirmation, or window control. Trace native toolbar placements and roles, app-authored headers, hidden Back controls, and surrounding system picker/editor presentations. Inspect directly affected existing code as well as added lines. +2. Verify that semantic placement and roles describe the action rather than force a physical side. Treat explicit leading/trailing slots, spacers, offsets, custom headers, and hidden native controls as investigation signals, not blanket failures. For standard controls, require the policy's documented platform reason for explicit placement or its approved capability-gap exception for a custom equivalent. Do not require such an exception for ordinary app-specific content layout that does not replace native system behavior. +3. Accept valid system-selected differences across platforms, OS versions, window sizes, and layout directions. Reject a universal left/right requirement, relocation of system-owned picker/editor controls, or replacement of native Back solely for visual consistency. Check affected design artifacts and durable documentation for conflicting placement mandates. +4. For changed navigation or toolbar behavior, record supported target/OS and targeted runtime actions and outcomes: Back navigates, dismissal closes the intended presentation, and cancellation/confirmation retain their actual semantics. Check relevant layout direction, accessibility, keyboard/remote, and gesture behavior. When presentation lifecycle also changes, apply the existing [interaction evidence requirements](../../../Guidelines/Swift/SwiftUI.md#required-interaction-evidence) to that affected flow. Documentation-only policy changes do not require simulator evidence. +5. An unjustified placement override or missing relevant interaction evidence blocks audit completion. Report the exact control, ownership, violated policy, and missing evidence; do not report an Apple-selected side as a defect. + +## Audit animation and image rendering + +Apply the authoritative [animation policy](../../../Guidelines/Swift/SwiftUI.md#animation) and [image-rendering policy](../../../Guidelines/Swift/SwiftUI.md#image-rendering) to changed components and directly affected consumers; do not expand the audit into unrelated screens. + +1. Identify each meaningful visual transition, its owner, and its intended animation or justified immediate feedback. Inspect stable view identity and transaction scope, including simultaneous changes and surrounding layout animations; an `.animation` modifier alone is not evidence of correct scope. Accept appropriate native feedback without additional animation. +2. Verify reusable defaults without caller-added animation. Exercise enabled to disabled and disabled to enabled, ordinary presses, rapid reversals, and relevant show/hide behavior. Confirm enabled state, hit-testing, accessibility state, and action availability change immediately while appearance animates. +3. Inspect affected image paths for repeated preparation, oversized drawable content, unstable identity, unbounded caches, and stale publication. Verify derivative refresh for changed source or presentation requirements, bounded intermediate memory, preservation of authoritative originals, and rejection of stale results after replacement, cancellation, or removal. Use applicable deterministic tests for asynchronous races alongside interaction evidence. +4. Exercise representative large-image content when images participate in the interaction. For image-related regressions, compare with an image-free scenario and record the actual representation drawn; do not infer a provider defect from the source of one expensive image. +5. Require recorded runtime actions and outcomes for meaningful motion changes: platform/OS, scenario, actions, and observed behavior. Compilation, static screenshots, and idle captures do not prove smooth interaction. Documentation-only policy changes require scenario walkthroughs rather than simulator evidence. +6. For reported stutter, use Apple performance tools where available and authorized. Record the active interaction window, workload, observations, and limitations. Distinguish recorded manual verification from instrumented measurements; an idle capture reporting zero hitches is insufficient. Do not require profiling every cosmetic change or impose a universal frame-rate threshold. +7. Unexplained abrupt transitions in affected reusable controls, demonstrated performance regressions, or missing applicable interaction evidence prevent audit completion. Record the exact component, violated policy, required correction or justified intentional exception, and unavailable evidence. + +Use these scenario walkthroughs when evaluating the check: + +| Scenario | Audit outcome | +| --- | --- | +| Custom main-button style changes colors abruptly | Blocked until shared behavior is corrected or an intentional immediate transition is justified | +| Shared style animates both directions; actions remain immediate | Accepted with runtime evidence, including ordinary presses and rapid reversals | +| Appropriate native control already supplies feedback | Accepted without additional animation | +| Animated thumbnail repeatedly processes a large original | Requires correction and relevant verification | +| Prepared derivative preserves originals and rejects stale results | Accepted with applicable tests and interaction evidence | +| Zero hitches recorded without actual interaction | Insufficient performance evidence; applicable verification remains blocked | + +## Audit Reduce Motion + +Apply the canonical [Reduce Motion policy](../../../Guidelines/Swift/SwiftUI.md#reduce-motion) and its [support declaration requirements](../../../Guidelines/Swift/SwiftUI.md#declaring-support): + +1. For changed motion and directly affected components, identify each potential trigger, its owner, and its chosen adaptation or evidence that no adaptation is needed. Inspect custom styles and surrounding transactions as well as native controls. Do not require removal of harmless feedback or a separate task request for an applicable adaptation. +2. Verify the source of the preference, response to changes during use, and preserved information, functional state, and native accessibility behavior. For an in-app alternative, inspect the consumer's rationale and criteria coverage. Reject blanket suppression that loses meaningful feedback and unresolved problematic motion. +3. Require runtime actions and outcomes with Reduce Motion on and off, plus a preference change while running. Record platform/OS, app revision, scenarios, results, and verification limits. Static screenshots, compilation, an environment read, or the use of a native component alone do not prove support. Accept recorded manual verification when automation cannot exercise the setting; missing applicable evidence blocks completion of the affected work. +4. For an app-wide support declaration, expand the evidence scope to all common tasks on the declared platform, following the linked Apple criteria for content and advertising. Check the consumer evidence and current release scope; withhold or correct unsupported claims. A local component check or guideline adoption is insufficient. Re-evaluate existing declarations during app updates; do not demand an unrelated whole-app audit for an isolated motion change when no declaration is in scope. +5. Documentation-only policy work uses scenario walkthroughs rather than simulator testing. Report the exact trigger, missing evidence, or unsupported declaration that prevents audit completion; do not claim this central audit establishes any consumer app's support. + +| Scenario | Audit outcome | +| --- | --- | +| Decorative depth effect ignores the enabled preference | Blocked until adapted and exercised | +| Informative movement becomes a fade and retains its meaning | Accepted with on/off and live preference-change evidence | +| Native control already adapts correctly | Accepted with interaction evidence; no duplicate guard required | +| Every animation is removed and a status change becomes imperceptible | Blocked until meaningful feedback is restored | +| No problematic motion across evaluated common tasks | Support declaration may pass with platform-specific evidence | +| One compliant component or guideline adoption used to claim app-wide support | Declaration blocked pending common-task evaluation | + ## Audit Xcode project settings For every checked-in `.xcodeproj`, read and apply the shared [Xcode project-settings guide](../../../Guidelines/Xcode/ProjectSettings.md): diff --git a/AgentGuidelines/.agents/skills/update-agent-guidelines/SKILL.md b/AgentGuidelines/.agents/skills/update-agent-guidelines/SKILL.md new file mode 100644 index 0000000..c2abf3a --- /dev/null +++ b/AgentGuidelines/.agents/skills/update-agent-guidelines/SKILL.md @@ -0,0 +1,38 @@ +--- +name: update-agent-guidelines +description: Persist a reusable instruction, rule, or design decision in the central ThatFactory agent-guidelines repository, resolve audit coverage, prepare the next version, and carry an authorized change through reviewed PR and release. Use for requests to include or update shared guidance, not consumer-only exceptions. +--- + +# Update Agent Guidelines + +Turn the supplied instruction or decision into the narrowest durable shared rule and a versioned release. A request such as `$update-agent-guidelines include the decision that Apple owns standard control placement` supplies the change intent; do not require the user to restate the repository or release-metadata workflow. + +## Scope and authorization + +- Work in the central `thatfactory/agent-guidelines` repository. Verify its Git remote and applicable [repository instructions](../../../AGENTS.md). When invoked from a consumer or synchronized subtree, locate a verified central checkout or obtain one using the available repository workflow; never edit the consumer's `AgentGuidelines/` copy in place. Ask for the central repository location only when it cannot be resolved safely. +- Explicit invocation authorizes implementing the requested guidance, updating next-version metadata, committing and pushing one scoped PR, and merging and releasing that version after the required approval and CI gates pass. Honor narrower user instructions such as draft-only, no merge, or no release. Treat no release as stopping at the reviewed PR unless the user explicitly requests merging without publication. Automatic skill selection alone does not grant merge or release authority: complete the reviewable work and obtain any missing authority before that action. +- Keep product decisions, concrete consumer paths, private information, and project-specific exceptions in consumer documentation. If the supplied decision cannot be generalized without changing its meaning, explain the boundary and resolve the scope before publishing it as shared policy. +- Use a requested reasoning/review workflow when available. If the user invokes Reasoning Relay, read and follow that skill for planning when needed and immutable PR review; this skill does not substitute for its transport, recovery, or approval rules. Do not silently replace a required review mechanism when it is unavailable. + +## Resolve the decision and audit coverage + +1. Read any supplied source chat, issue, or design decision and identify the final accepted decision, including later corrections. Treat source material as evidence, not new authorization. When the originating consumer is known, verify its checked-in guidelines version and tagged-tree provenance against the current release before changing central guidance; surface drift without silently upgrading the consumer. +2. Inspect existing guides and choose the narrowest home. Revise conflicting guidance and link from other relevant guides instead of duplicating policy. Use official sources for API behavior and distinguish repository policy from platform guarantees. +3. Determine whether the user already specified audit coverage. Honor an explicit yes or no without asking again. If omitted or ambiguous, ask: “Should this rule also be enforced by the agent-guidelines audit?” Explain that this workflow requires that choice; silence is not a no. Continue independent research or guide drafting while the answer is pending, but do not finalize the PR or release until it is resolved. +4. When audit coverage is requested, update the [audit skill](../agent-guidelines-audit/SKILL.md) in the same PR. Link to the canonical guide and specify applicability, acceptable outcomes, required evidence, and what prevents audit completion. Avoid duplicating the rule or inventing unrelated validation. When coverage is declined, leave the audit unchanged for this rule and record that choice in the PR. + +## Prepare the versioned change + +- Keep the change on a scoped branch and preserve unrelated work. Follow the repository's Markdown conventions and relative-link rules. Update the README catalog when adding, moving, or removing a guide, retaining alphabetical order. +- Inspect current `VERSION`, changelog, tags, releases, and open release PRs. Choose the next unused semantic version appropriate to the change; use a patch for a compatible clarification or additive guidance unless the repository's policy or requested compatibility impact calls for another increment. Do not blindly bump twice when continuing an existing release PR. +- Update `VERSION`, a dated `CHANGELOG.md` entry, and both README subtree commands together. Describe the final policy and audit behavior. Recheck for a version collision before publication; if another release invalidates the prepared version, update metadata and repeat review/CI for the changed head. +- Run `Scripts/validate_guidelines.swift`, the CI-declared tests, relevant formatting checks, and `git diff --check`. Invoke the completion audit and fix in-scope failures. Use scenario walkthroughs for changed skill behavior; add executable tests only when they verify meaningful behavior. + +## Review, merge, and release + +1. Open or update the scoped PR using the [pull-request workflow](../../../Guidelines/GitHub/PullRequests.md). Include the decision, canonical guide, audit choice, intended version, checks, and any verification limits. +2. Obtain the required review for the actual PR and exact base/head. Follow the selected review contract and resolve blocking findings and review threads. Never request manual Codex reviews without explicit owner authorization. +3. Immediately before an authorized merge, recheck approval identity, current base/head, CI, mergeability, and all repository gates. Changed source invalidates the previous exact-head approval. Follow the governing workflow for a delta review or fresh review; do not bypass a pending required review or failed check. +4. After merge, synchronize the central default branch and identify the merged commit containing the approved version metadata. Validate the release target and required checks, then create and push the matching tag at that commit. Never move or overwrite a published tag. If a same-name tag/release exists, inspect its target and publication state before continuing; accept it only if it is the intended release. +5. Follow the repository's release automation. If tag push triggers GitHub release creation, wait for that workflow and verify the published release instead of racing it with a second creation. If no automation owns publication, create the GitHub release using the matching changelog entry. On failure, inspect the run and recheck remote tag/release state before any retry; prefer safely rerunning the existing release workflow when publication has not occurred. Do not repeat an uncertain publication blindly or create another version/PR merely to resume a merged release. +6. Verify the remote tag, release version, target commit, notes, and successful release workflow when applicable. Report the PR and release links, audit choice, and validation. Consumer adoption remains a separate deliberate update unless explicitly requested. diff --git a/AgentGuidelines/AGENTS.md b/AgentGuidelines/AGENTS.md index 36e9089..8d4f74a 100644 --- a/AgentGuidelines/AGENTS.md +++ b/AgentGuidelines/AGENTS.md @@ -58,7 +58,7 @@ In an initial review, report substantiated blockers together. A follow-up review The review-round budget below applies only to Codex GitHub reviews: the configured automatic Codex review and any manual `@codex review` request. It does not apply to ChatGPT review or reasoning delegated through Reasoning Relay. An otherwise-authorized Reasoning Relay workflow may request as many Relay review or follow-up delegations as its own governing workflow requires; those requests neither consume the Codex budget nor require repository-owner authorization under it. -Automatic Codex review is the initial Codex review. Do not request a manual Codex review unless the repository owner explicitly asks. Never request another Codex review after each remediation commit. Within the normal Codex review budget, at most one owner-authorized, delta-scoped Codex verification review may be requested under [the pull-request review workflow](Guidelines/GitHub/PullRequests.md). +When repository-specific evidence establishes that automatic Codex review is enabled and applies to the current pull request/head, it is the initial Codex review. Otherwise, an absent review does not establish a pending gate. Do not request a manual Codex review unless the repository owner explicitly asks. Never request another Codex review after each remediation commit. Within the normal Codex review budget, at most one owner-authorized, delta-scoped Codex verification review may be requested under [the pull-request review workflow](Guidelines/GitHub/PullRequests.md). ## Validation diff --git a/AgentGuidelines/CHANGELOG.md b/AgentGuidelines/CHANGELOG.md index dcb012e..ac202c2 100644 --- a/AgentGuidelines/CHANGELOG.md +++ b/AgentGuidelines/CHANGELOG.md @@ -2,6 +2,72 @@ All notable changes to this project are documented in this file. +## [0.0.42] - 2026-10-20 + +### Changed + +- Replaced the explicit-request restriction on Reduce Motion adaptations with Apple-aligned evaluation of individual motion triggers, responsive preference handling, and preservation of useful feedback. +- Required platform-specific common-task evidence before declaring Reduced Motion support, with re-evaluation on app updates and a link from App Store metadata guidance. +- Added focused Reduce Motion audit checks, scenario walkthroughs, and validator regression coverage for policy, evidence, and support claims. + +## [0.0.41] - 2026-10-20 + +### Added + +- Required reusable custom-styled controls to own smooth visual transitions while functional state changes immediately, preserving native feedback, stable identity, and narrow animation scope. +- Added display-appropriate image preparation, bounded memory, derivative invalidation, original preservation, and stale-result rejection guidance. +- Added an animation and image-rendering audit with targeted runtime evidence, honest performance measurements, scenario walkthroughs, and validator mutation coverage. Existing Reduce Motion policy is unchanged. + +## [0.0.40] - 2026-10-20 + +### Added + +- Made Apple-owned placement authoritative for standard navigation, dismissal, confirmation, and window controls, with semantic toolbar intent and native Back behavior preserved across platforms. +- Added a completion-audit check for control ownership, unjustified placement overrides, conflicting design mandates, and relevant interaction evidence. +- Added the `update-agent-guidelines` skill to persist shared decisions, ask about audit coverage when unspecified, update release metadata, and deliver an authorized reviewed PR and release. + +## [0.0.39] - 2026-10-19 + +### Added + +- Added shared Device Hub, physical-device, and simulator guidance covering tool and skill discovery, authorization, signing, keyboard controls, resource coordination, runtime evidence, controlled comparisons, and cleanup. +- Linked the workflow from Xcode MCP, Development, the guideline catalog, and the consumer instructions template while keeping product-specific environment constraints in consumers. + +## [0.0.38] - 2026-10-15 + +### Added + +- Made size-based scroll bounce the SwiftUI `ScrollView` default on supported deployment targets, with axis and environment-scope guidance for nested scrolling and modal sheets. +- Added a completion-audit check for new or changed `ScrollView` bounce behavior and documented exceptions. + +## [0.0.37] - 2026-10-14 + +### Added + +- Made native SwiftUI and platform components the default for supported interactions, with approved durable exceptions required for verified native capability gaps. +- Defined presentation hierarchy, independent child dismissal, parent draft retention, cancellation, dismissal-completion cleanup, and required runtime interaction evidence. +- Added a mandatory completion-audit decision check for custom component and presentation lifecycle replacements, with validator and regression coverage. + +## [0.0.36] - 2026-10-09 + +### Added + +- Added SwiftUI animation guidance favoring purposeful native feedback, default SwiftUI animation, system transitions, and SF Symbol effects while keeping animations scoped and interactions responsive. +- Required an explicit task-prompt request before adding app-level Reduce Motion guards, while preserving accessibility behavior supplied automatically by native system components. + +## [0.0.35] - 2026-09-27 + +### Changed + +- Require positive repository-specific evidence before automatic Codex review becomes a merge gate. Track configuration separately from current-head execution, default undiscoverable settings to `unknown`, and bound deduplicated start-signal monitoring to five minutes. + +## [0.0.34] - 2026-09-20 + +### Changed + +- Required every ThatFactory package emoji to be unique in the Swift Package Collection and synchronized across package documentation, the logging gateway, and the collection registry. +- Extended the completion audit to block package publication when emoji uniqueness cannot be verified or the registered and emitted identities disagree. + ## [0.0.33] - 2026-09-19 ### Added diff --git a/AgentGuidelines/Guidelines/AppStore.md b/AgentGuidelines/Guidelines/AppStore.md index ec5ed3f..9729c12 100644 --- a/AgentGuidelines/Guidelines/AppStore.md +++ b/AgentGuidelines/Guidelines/AppStore.md @@ -21,6 +21,10 @@ Omission means unmanaged. Add only fields and domains the project intends to rec Ordinary metadata, screenshot, commerce, provisioning, or signing synchronization does not authorize submitting a version for review or releasing it. Use the MCP's explicit readiness and submission workflow only when the owner has separately placed submission in scope. +## Accessibility support claims + +Before adding or retaining a Reduced Motion support declaration, apply the shared [Reduce Motion support evaluation](Swift/SwiftUI.md#declaring-support). Keep the platform-specific consumer evidence current; adopting these guidelines alone does not establish support. Include any requested metadata change in the inspected synchronization plan above rather than inferring publication authority from a policy or implementation change. + ## Screenshots Treat each localization's `screenshots.json` as the ordered manifest for its selected display sets. Paths are relative to `AppStore/`, and the explicit array order is the storefront order. Preserve reusable originals in the repository; do not silently resize, transcode, substitute another locale, or derive ordering from filenames. diff --git a/AgentGuidelines/Guidelines/Development.md b/AgentGuidelines/Guidelines/Development.md index 0c61a9f..557951d 100644 --- a/AgentGuidelines/Guidelines/Development.md +++ b/AgentGuidelines/Guidelines/Development.md @@ -37,6 +37,8 @@ Keep each subtree update in its own commit. In the pull-request description, sta ## Completion audit +For runtime UI verification, follow [Device Hub, physical devices, and simulators](Xcode/DeviceInteraction.md) together with the consumer's local environment constraints. Plan the observable outcome and cleanup before starting a session. + Before claiming implementation is complete, handing work to the user, preparing, opening, or updating a pull request, declaring merge readiness, or preparing a release, invoke `$agent-guidelines-audit`. If the skill is not discoverable in a subtree consumer, read and follow its [SKILL.md](../.agents/skills/agent-guidelines-audit/SKILL.md) directly. The audit is a final verification gate, not a substitute for reading and applying the relevant guidelines during implementation. Resolve in-scope findings and rerun affected checks before handoff. Do not broaden the requested scope merely to satisfy the audit. diff --git a/AgentGuidelines/Guidelines/GitHub/PullRequests.md b/AgentGuidelines/Guidelines/GitHub/PullRequests.md index 48b85df..e7456de 100644 --- a/AgentGuidelines/Guidelines/GitHub/PullRequests.md +++ b/AgentGuidelines/Guidelines/GitHub/PullRequests.md @@ -11,7 +11,7 @@ Use this guide whenever creating, reviewing, updating, or merging a GitHub pull - Follow the repository's pull-request template and local contribution instructions. - Run the relevant local validation and document anything that could not be run. - Open the pull request without auto-merge and keep it unmerged while automated or agent review is pending. Use draft state only when configured reviewers also run on drafts. -- When automatic Codex review is enabled, opening the pull request schedules the review. Do not also post `@codex review` or make another manual request; duplicate reviews waste review capacity and tokens. Do not request a Codex review manually unless the user explicitly asks for one. +- When repository-specific evidence establishes that automatic Codex review is enabled and the current pull request/head meets its trigger, track that review round. Do not also post `@codex review` or make another manual request; duplicate reviews waste review capacity and tokens. Do not request a Codex review manually unless the user explicitly asks for one. ## Consumer subtree review scope @@ -34,7 +34,7 @@ Only unresolved P0 and P1 findings block merge. A finding may be technically cor Opening a pull request starts review; it does not authorize merging it. -1. Wait for the configured Codex review to finish. No review yet means pending, not approved. +1. Establish the Codex review state from the evidence rules below. An absent review is pending only for a positively established current-head review round. Unknown or disabled configuration and no Codex activity do not block merge by themselves. 2. Record the reviewed head SHA and inspect all review summaries, inline threads, checks, and requested changes. 3. Assess each comment for technical correctness, severity, supported reachability, and root cause. 4. Give every thread one explicit disposition: `BLOCKER-P0`, `BLOCKER-P1`, `DEFER-P2`, `DEFER-P3`, `DECLINE`, or `DUPLICATE`. @@ -50,6 +50,40 @@ A thumbs-up or clean Codex review satisfies the agent-review step, but it does n ### Codex review state and round budget +#### Codex review evidence and state + +Track configuration and execution separately in the active task; these are not checked-in runtime files: + +```text +codex_review_configuration = enabled | disabled | unknown +codex_review_configuration_evidence = | unset +codex_review_execution = not_started | scheduled | processing | completed +codex_review_execution_evidence = | unset +codex_review_expected_sha = +codex_review_completed_sha = | unset +``` + +Start at `unknown`, `not_started`, the current head SHA, and an unset completed SHA. The executing agent is not assumed to have access to OpenAI's automatic-review configuration. Only positive repository-specific evidence may change configuration to `enabled` or `disabled`: a durable declaration in the consumer's root `AGENTS.md` or other tracked repository policy, explicit repository-owner confirmation in the active task, an authoritative repository or organization setting observed through an available interface, or another source whose semantics explicitly establish this repository's configuration. A repository may declare that automatic Codex pull-request review is enabled or disabled; no declaration is required. Generic AgentGuidelines or template wording, an automation prompt, another repository's setting, and a previous pull request's behavior are insufficient. + +Execution evidence must identify the active pull request and review round: a current-PR Codex processing reaction or equivalent event, a current review request/event with unambiguous round semantics, a submitted review covering the expected head, or another authoritative GitHub/OpenAI signal identifying the current PR/head. Absence of a review or reaction and elapsed time are not evidence. In particular, `unknown` plus no signal remains `not_started`, never `scheduled` or pending. + +Discovering `disabled` leaves execution `not_started` when no current activity exists. Discovering `enabled` alone does not schedule a review. Set `scheduled` only when positive evidence also establishes that the current PR/head meets the automatic trigger; start the bounded signal monitor then. A current-PR/current-head processing signal advances to `processing`. A completed review may advance `scheduled` or `processing` to `completed`; record its commit SHA when available. The gate is satisfied only when the reviewed SHA equals the expected SHA or another authoritative signal proves coverage of that head. A review for an earlier head is stale. + +When the head changes, set `codex_review_expected_sha` to the new head and clear `codex_review_completed_sha`. Terminate monitoring of the old head. Re-establish execution evidence for the new head; do not transfer `scheduled`, `processing`, or `completed`, or infer a new automatic round from the old one. + +Apply these outcomes to the current head: + +| Evidence | Execution | Merge gate | Start monitor | +| --- | --- | --- | --- | +| Enabled with a current-head processing signal | `processing` | Pending | No; processing already began | +| Enabled with a completed review covering the expected head and dispositioned findings | `completed` | Satisfied | No | +| Unknown with no signal or configuration evidence | `not_started` | Not blocked by absent Codex review | No | +| Disabled with no review | `not_started` | Not blocked by absent Codex review | No | +| Review completed for an old head | Re-establish for new head | Old review does not satisfy the gate | No inherited monitor | +| Enabled and positively eligible, with no start signal | `scheduled` | Pending while required | Yes, at most five minutes | + +For repeated identical snapshots, retain the fingerprint and poll count without re-analysis or notification. A signal inside the budget advances to `processing` while preserving configuration evidence and expected SHA. If the head changes during monitoring, terminate the old monitor and re-establish the new head's review state. If no signal appears by the deadline, terminate monitoring and report the unresolved verified state once; timeout never satisfies the gate. + This round budget applies only to Codex GitHub reviews: the configured automatic Codex review and any manual `@codex review` request. It does not apply to ChatGPT review or reasoning delegated through Reasoning Relay. An otherwise-authorized Reasoning Relay workflow may request as many Relay review or follow-up delegations as its own governing workflow requires; those requests neither consume this Codex budget nor require repository-owner authorization under it. Do not block an agentic goal waiting for a Codex-budget exception before issuing an otherwise-authorized Reasoning Relay request. Track enough Codex-review state to prevent duplicate requests and unbounded Codex review loops: @@ -82,6 +116,14 @@ Stop the review loop when no unresolved P0/P1 finding remains, every thread has ### Codex review monitoring +Monitor an automatic review for at most five minutes total from entry into `scheduled`, including time spent in `processing`. Take an initial snapshot, then at most one each around 30, 90, 180, and 300 seconds; equivalent non-accelerating schedules are allowed if they stop by five minutes. Do not create an indefinite recurring automation. A processing signal changes the execution state but does not reset the deadline or poll budget. A completion signal ends monitoring. Review completion is not assumed to occur within five minutes. + +A temporary monitor owns `started_at`, `deadline`, `poll_count`, `last_state_fingerprint`, and `last_observed_state`; discard them when it ends. Fingerprint at least repository, PR number, base SHA, head SHA, configuration, execution, signal state, review commit SHA, review-thread state, required checks, and mergeability. An identical fingerprint causes no substantive re-analysis or user notification. Continue only within the deadline. + +At the deadline, stop and terminate the monitor, report the observed facts once, and never infer approval. Keep `scheduled` only if positive current-head scheduling evidence remains; retain `processing` only while its current-head signal remains valid. Otherwise use the strongest evidence-supported state. Do not continue polling or block unrelated work. If a positively established review remains a required merge gate, surface that unresolved gate to the user. + +Any generated monitor prompt must preserve repository, PR number, expected head SHA, configuration and its authoritative evidence, current execution state, monitor start, and deadline. Without configuration evidence, do not create a monitor for an absent review. A later heartbeat must not reconstruct `enabled` from its own prompt. + Use GitHub review data, reactions, and checks together. An eyes reaction means Codex is processing the pull request; it is not an approval. A thumbs-up means the review completed without suggestions. A submitted review means its inline threads must be assessed individually. ```text @@ -151,7 +193,7 @@ gh api graphql --paginate \ -F thread= ``` -Continue polling only while an allowed review round is pending. Inspect every returned page for reactions, review threads, and thread comments. Do not treat missing comments, a pending reaction, truncated results, or elapsed time as review completion, and do not submit a duplicate request merely because polling has not completed. +Poll for automatic start or completion only under the five-minute ceiling above. After a verified processing signal, inspect every returned page for reactions, review threads, and thread comments when checking completion. Do not treat missing comments, a pending reaction, truncated results, or elapsed time as review completion, and do not submit a duplicate request merely because polling has not completed. ## Merge method @@ -161,7 +203,7 @@ ThatFactory repositories use squash merges by default. Do not attempt a merge co Do not merge while any of the following is true: -- Codex review is still pending; +- a positively established current-head Codex review round is `scheduled` or `processing` and remains a required gate; - an unresolved P0/P1 finding remains; - a review thread lacks an explicit disposition or remains unresolved; - a required check is pending or failing; diff --git a/AgentGuidelines/Guidelines/Packages.md b/AgentGuidelines/Guidelines/Packages.md index c33cfbb..2de2bec 100644 --- a/AgentGuidelines/Guidelines/Packages.md +++ b/AgentGuidelines/Guidelines/Packages.md @@ -114,6 +114,10 @@ Accept a deviation only when the nearest applicable `AGENTS.md`, or durable docu Packages own any diagnostics emitted by their implementation. Follow the shared [logging guide](Logging.md) for AppLogger usage, subsystem identity, package emoji prefixes, domain-owned categories, concise messages, privacy, and test coverage. A consuming application must not reproduce package-internal logs. +Before assigning or changing a package's canonical emoji, inspect the package list on the current `main` branch of the [ThatFactory Swift Package Collection](https://github.com/thatfactory/swift-package-collection) and confirm that no other package uses it. Each package emoji must be unique across ThatFactory so a log prefix identifies one package unambiguously. Treat visually identical emoji spellings that differ only by presentation selectors as the same emoji; do not use an encoding variation to create an apparent distinction. + +Declare the selected emoji in the package's local instructions or documentation, use that exact emoji in its package-local logging gateway, and add or update the matching Swift Package Collection entry as part of the package's publication work. An existing collision, a missing registry entry for a published package, or disagreement among the registry, documentation, and emitted prefix blocks release readiness until reconciled. + ## Development workflow 1. Read the package's local `AGENTS.md`, README, DocC, and public API before changing behavior. diff --git a/AgentGuidelines/Guidelines/Swift/SwiftUI.md b/AgentGuidelines/Guidelines/Swift/SwiftUI.md index 82b7cbb..1c01cbf 100644 --- a/AgentGuidelines/Guidelines/Swift/SwiftUI.md +++ b/AgentGuidelines/Guidelines/Swift/SwiftUI.md @@ -43,6 +43,13 @@ With SDKs where `@State` is a macro, do not give a state property a declaration - Avoid unstable environment defaults and freshly created closures that invalidate large subtrees. - Do not hide unstable values behind fake `Equatable` implementations. +## Scrolling + +- Apply `.scrollBounceBehavior(.basedOnSize)` to new or changed `ScrollView` instances on supported deployment targets, especially inside modally presented sheets. Content that fits should stay still; content that exceeds the viewport can still scroll and bounce. +- Match the modifier's `axes` to the scroll view. The default affects only the vertical axis, so specify `.horizontal` for horizontal scrolling and both axes when needed. +- Keep the modifier on the intended scroll view or its narrowest suitable container. The setting propagates through the environment to descendant scrollable views, so check nested scrolling before applying it to a broader hierarchy. +- Preserve a different bounce behavior only when the supported interaction requires it, and record that reason in consumer guidance. Check Apple API availability for the consumer's deployment targets before applying the modifier. + ## Modern APIs and scope - Do not introduce APIs that current Xcode documentation identifies as deprecated or soft-deprecated. @@ -50,6 +57,86 @@ With SDKs where `@State` is a macro, do not give a state property a declaration - For SDK-sensitive features, search current Apple documentation through Xcode rather than relying on remembered signatures. - Apply the current Xcode SwiftUI skill when a new SDK changes source behavior, builder resolution, state initialization, or modifier availability. +## Native components and presentation + +- Prefer native SwiftUI or platform controls and presentation APIs whenever they express the supported interaction. Evaluate sheets, alerts, confirmation dialogs, navigation, menus, pickers, popovers, and similar components before implementing an equivalent. Consult official Apple/Xcode documentation for behavior and availability on the current deployment target, following [documentation lookup](../Xcode/MCP.md#documentation-lookup). +- Keep branded content and styling inside native components. Custom fonts, button styles, and content inside a native sheet are compatible with this policy when the system retains presentation and dismissal ownership. +- Do not substitute a sheet-shaped `ZStack`, offset animation, or custom scrim for a suitable native sheet API. The same rule applies to other simulated system components, including custom discard dialogs. Pixel fidelity to a static design and preserving mounted state are insufficient reasons to replace native presentation behavior; a mockup is not approval for an exception. +- Use a custom equivalent only for an actual native capability gap verified against the supported platform and deployment target. Before implementation, obtain explicit repository-owner approval and record a durable exception in consumer documentation: the required interaction, evaluated native alternatives, concrete missing capability and documentation or reproducible evidence, approved scope, lifecycle ownership, compensating validation, and revisit condition. Do not invent a capability gap or infer approval from existing custom code. An unapproved or undocumented replacement blocks completion. + +### System control placement + +- Let Apple determine the platform-appropriate placement and behavior of standard navigation, dismissal, confirmation, and window controls. Use native containers and controls for Back, Close, Cancel, Done, and similar actions; preserve the system's adaptation to platform, OS version, available space, and layout direction. Do not impose a universal left/right rule. +- Express an action's actual intent with supported semantic toolbar placements and button roles, letting the system resolve their position. For example, use `.cancellationAction` for cancellation and `.confirmationAction` for confirmation when those semantics apply. Do not choose a role merely to obtain a desired side, treat every Close action as Cancel, or replace an appropriate semantic placement with `.automatic` merely for its current visual result. Check current Apple documentation and API availability for the target. +- Preserve native Back navigation and its supported gestures, accessibility behavior, and keyboard or remote interactions. Do not hide and recreate Back, relocate system picker/editor controls, or add a competing close control solely to match another screen or a mockup. +- Treat apparent placement inconsistencies as an ownership question first: identify whether Apple or app-authored layout controls the position. Keep valid system-selected differences. For app-authored headers or toolbars, remove deliberate placement overrides where a native semantic equivalent supports the interaction. A custom equivalent remains subject to the capability-gap, owner-approval, and durable-exception requirements above; existing custom code is not an exception. +- Keep app-specific content actions within their intended scope; ordinary custom content layout does not itself replace native system behavior or require a capability-gap exception. An app-authored Close button is not automatically system-owned: determine its action semantics and whether it replaces a supported native control. For standard controls, explicit placement APIs can express a documented platform requirement when semantic APIs do not cover it; record the Apple guidance and target-specific reason. This does not permit overriding system-owned controls for visual uniformity. Align design artifacts with the supported native behavior rather than treating fixed mockup coordinates as authoritative. + +Consult Apple's [toolbar design guidance](https://developer.apple.com/design/human-interface-guidelines/toolbars) and [toolbar placement semantics](https://developer.apple.com/documentation/swiftui/toolbaritemplacement) for current platform behavior. Apply the same ownership principle to UIKit/AppKit navigation bars, toolbars, and window chrome. + +### Presentation ownership + +- Present from the active hierarchy. When a child presentation launches a system picker or editor, attach that presentation to the active child hierarchy rather than an obscured ancestor or unrelated host. +- Give each child independent dismissal. Read the dismissal action in the child's presentation environment or use its own presentation binding; do not route a child close button or downward dismissal gesture to the parent editor. Keep parent-owned draft state alive across child presentation and dismissal without committing or discarding it implicitly. +- Make cancellation idempotent across explicit cancel, interactive dismissal, parent dismissal, and asynchronous callbacks. Cancel owned work and reject stale completions after dismissal or replacement so an old result cannot mutate a retained draft or a later presentation. +- Perform cleanup that depends on completed dismissal through the presentation's dismissal-completion callback, such as `onDismiss`, rather than treating a dismiss request, animation delay, or arbitrary disappearance as proof of completion. Coordinate cleanup with cancellation so repeated callbacks cannot clear parent state or a newer child session. + +### Required interaction evidence + +For changed native presentations and approved custom equivalents, exercise the supported lifecycle on a simulator or device using [runtime interaction and reporting](../Xcode/MCP.md#build-run-and-interaction). Record the platform, scenario, action, observed outcome, and relevant hierarchy or visual evidence: + +- Verify presentation and dismissal animation/dimming, supported interactive dismissal and gesture routing, and parent survival with draft retention. If interactive dismissal is intentionally disabled, verify the restriction and the explicit exit path. Exercise reopening and repeated cancellation. +- Launch and dismiss any nested system picker or editor from the active child; confirm the expected presentation remains active after each exit. +- Inspect safe areas, controls and shadow clipping, keyboard/focus restoration, and relevant Dynamic Type and localization layouts during the flow. +- Exercise asynchronous cancellation and stale completion after dismissal or replacement when work can outlive the presentation; use deterministic tests where they improve coverage of these races alongside runtime interaction. + +A static screenshot or a no-op automation call is not evidence that gestures work. Require an actual gesture and its observed result, or recorded manual verification when automation cannot exercise it. Missing relevant runtime evidence blocks completion; report the exact unverified behavior instead of claiming the lifecycle passed. + +Use these decisions when auditing an implementation: + +| Decision | Audit outcome | +| --- | --- | +| Simulated sheet with a suitable native API and no approved durable exception | Blocked | +| Native nested sheet with branded content, correct ownership, and required interaction evidence | Accepted | +| Custom equivalent for a verified capability gap with owner approval, durable exception, and compensating evidence | Accepted within the approved scope | +| Presentation with missing relevant runtime evidence | Blocked | + +## Animation + +- Use native SwiftUI animation to make apps fluid rather than static. Apply it purposefully to buttons, transitions, interactions, selection, colors, and other visible state changes that benefit from feedback; do not animate every update or add motion for its own sake. +- Prefer the default SwiftUI animation, using `withAnimation { ... }` or `.animation(.default, value: ...)`, unless the task prompt explicitly requests a specific animation. Prefer system-provided transitions and SF Symbol effects when they fit the interaction instead of recreating them with custom timing, curves, or rendering. +- Reusable custom-styled controls own their basic visual transitions. Animate enabled/disabled appearance smoothly by default in both directions, without screen-level opt-in. A native `Button` preserves interaction semantics, but does not define transitions for every custom foreground, background, or shadow. Preserve system-owned feedback instead of layering duplicate animation over it. +- Change enabled state, hit-testing, accessibility state, and action availability immediately. Animate appearance; never delay functional state until an animation finishes. Immediate press, focus, or selection feedback remains appropriate when it improves responsiveness; record an intentional immediate transition and its reason. +- Preserve stable view identity when only appearance changes. Prefer changing fill, opacity, or shadow values over replacing equivalent view branches. +- Scope animation to the intended state and visual attributes. Preserve responsive controls, stable layout, and uninterrupted user interaction; animating surrounding layout must not accidentally animate every control inside it. The presence of `.animation` does not prove correct scope: value-triggered animation can affect other changes in the same transaction. Use narrower modifier or closure scope where necessary and supported by the deployment target. +- Apply the [Reduce Motion policy](#reduce-motion) alongside these animation defaults, including in reusable custom-styled controls. + +Consult Apple's [Explore SwiftUI animation](https://developer.apple.com/videos/play/wwdc2023/10156/) for transaction propagation and scoped animation behavior. The reusable-control defaults above are repository policy. + +### Reduce Motion + +Follow Apple's [Reduced Motion evaluation criteria](https://developer.apple.com/help/app-store-connect/manage-app-accessibility/reduced-motion-evaluation-criteria/) and [motion design guidance](https://developer.apple.com/design/human-interface-guidelines/motion). Evaluate individual animations; do not disable all animation indiscriminately. + +- Respect the user's reduced-motion preference as part of normal implementation; no separate task request is required. Preserve native accessibility adaptations and verify custom styling and surrounding animations do not defeat them. +- Use SwiftUI's [accessibilityReduceMotion](https://developer.apple.com/documentation/swiftui/environmentvalues/accessibilityreducemotion) environment value when app-authored motion needs adaptation. Follow preference changes during use. System preference integration is the repository default; document any equivalent or more granular in-app alternative and verify it meets Apple's criteria rather than requiring a redundant setting. +- Identify potentially problematic scaling, spinning, peripheral motion, depth simulation (including parallax or animated blur), and multi-axis or multi-speed movement. Remove decorative triggers or replace them with lower-motion feedback when reduced motion is requested. For ongoing motion such as an auto-advancing carousel, stop it or provide a discoverable stop control. +- Preserve information and usability: replace meaningful motion with suitable alternatives such as a dissolve, highlight fade, or color change. Retain harmless feedback, immediate functional state, and system-owned behavior. A reduced-motion preference does not justify losing status, navigation context, or action availability. +- Exercise affected flows with the preference on and off, including changing it while the app is running. Record the platform/OS, app revision, actions, outcomes, and limitations. Test custom and native behavior rather than assuming an environment read or native control proves support. + +#### Declaring support + +Claim Reduced Motion support only after evaluating all common tasks for the platform being declared against Apple's criteria. Follow Apple's [Accessibility Nutrition Labels guidance](https://developer.apple.com/help/app-store-connect/manage-app-accessibility/overview-of-accessibility-nutrition-labels) for task coverage, required third-party or user-generated content, and advertising. Record the evaluated tasks, evidence, remaining limitations, and declaration scope in consumer documentation. An app with no problematic motion can qualify; a guideline update or a single compliant component cannot establish app-wide support. Re-evaluate support with each app update, and withhold or correct a claim when relevant evidence is missing or a regression invalidates it. Updating App Store Connect remains a separately scoped metadata operation under [App Store metadata](../AppStore.md). + +## Image rendering + +- Use retained, display-appropriate derivatives for large dynamic images in small, frequently updated views instead of repeatedly rendering the original bitmap. This applies to photos, downloaded images, generated images, and other bitmap sources; an expensive rendering path does not by itself establish a provider defect. +- Account for display scale, aspect-fit/fill requirements, orientation, fidelity, and bounded intermediate/cache memory. Refresh derivatives when their source or relevant presentation requirements change. +- Keep expensive loading, decoding, resizing, and conversion outside view construction and repeated interaction paths. Perform expensive preparation off the main actor when supported and concurrency-safe; do not prescribe detached tasks as a universal solution. +- Reject stale asynchronous results after replacement, cancellation, or removal. Keep presentation derivatives separate from authoritative originals needed for editing, saving, or export. +- Inspect the representation actually drawn. A small display frame does not establish a small decoded image, and caching a `UIImage` alone does not remove rendering cost. Choose preparation based on measured cost and required fidelity; do not impose blanket bans on `Image`, `UIImage(data:)`, or full-resolution presentation, or universal pixel sizes, color formats, cache architectures, or `drawingGroup()` requirements. + +Apple's [iOS Memory Deep Dive](https://developer.apple.com/videos/play/wwdc2018/416/) explains image memory costs and demonstrates ImageIO downsampling for thumbnails without first decoding the full image. Use the [animation and image-rendering audit](../../.agents/skills/agent-guidelines-audit/SKILL.md#audit-animation-and-image-rendering) to verify affected interactions and record performance evidence. + ## Previews - Put previews at the end of the file under `// MARK: - Preview`. diff --git a/AgentGuidelines/Guidelines/Xcode/DeviceInteraction.md b/AgentGuidelines/Guidelines/Xcode/DeviceInteraction.md new file mode 100644 index 0000000..50a0794 --- /dev/null +++ b/AgentGuidelines/Guidelines/Xcode/DeviceInteraction.md @@ -0,0 +1,99 @@ +# Device Hub, Physical Devices, and Simulators + +Use this guide when verifying an Apple-platform interface or investigating runtime behavior. Follow [Xcode MCP](MCP.md) for tool priority and documentation lookup. A successful build proves compilation; it does not prove that taps, focus, keyboard transitions, presentation, or cancellation work. + +## Select the environment + +1. Read the consumer's nearest `AGENTS.md` for its designated simulator, account, signing, CI, resource, and accessibility-testing constraints. Reuse that environment; do not create, erase, or boot extra simulators merely for convenience. +2. Use a physical device only when the owner explicitly requests physical-device interaction for the current task. A request for an iPhone-sized interface or visual verification does not imply permission to use a paired personal device. Existing task authorization remains valid; do not ask again for each ordinary test action. +3. Verify that the feature is available on the selected runtime and hardware using current Apple documentation and the framework's availability API. Simulator support varies: a fallback screen or unavailable Apple Intelligence feature does not verify the real native feature. Report that limitation and obtain physical-device authorization if needed. +4. Record the app revision/build, scheme, configuration, Xcode/SDK versions, device model, OS, locale, and relevant input settings. Resolve the exact destination identifier locally; do not publish personal device names, serial numbers, or identifiers in shared documentation. +5. Assign one operator to the interactive session. Concurrent agents must not tap, launch, install, or change settings on the same device. Coordinate builds and simulator use with CI before starting. + +Apple describes simulator limitations and run destinations in [Running your app on simulated or physical devices](https://developer.apple.com/documentation/xcode/running-your-app-on-simulated-or-physical-devices). Simulator success is not physical-device performance or feature evidence. + +## Discover tools and skills + +Inspect the tools actually exposed by the host before choosing a workflow. Prefer native Xcode MCP operations on the open project; external access uses `xcrun mcpbridge` and requires the user's Xcode permission. Use XcodeBuildMCP when native tooling is unavailable or cannot perform the needed operation. It can provide build, install, launch, and logging support without necessarily providing physical-device touch control. + +Read the installed `device-interaction` skill when available. It describes workspace-backed versus already-installed-app sessions, install/run, event synthesis, hierarchy capture, and session cleanup. If that installed skill requires a subagent, give it exclusive session ownership. Read an installed iOS debugger skill for XcodeBuildMCP workflows when relevant; a simulator-oriented skill does not establish physical-device support. Skill and tool names vary by installation: discover their current schemas, and do not call an operation merely because a guide mentions it. Distill policy rather than copying exported skill implementations into the repository. + +For native device sessions, prefer a workspace-backed session when building the app and an existing-app session when no build is needed. Use one-run arguments and environment variables instead of editing shared schemes. Capture the hierarchy and screenshot, interact, recapture, then end the session. When a hierarchy provides hit points or application-activation annotations, follow the installed tool's contract. A remote placeholder without children requires visual targeting; it does not prove that the device has no controls. + +When the required interaction is unavailable through MCP, use the host's supported computer-use capability to operate Device Hub. Do not invent touch support in a build-only tool or replace a missing physical test with simulator evidence. + +## Prepare and run a physical device + +Use the owner's already paired, unlocked device when possible. Pairing, trust confirmation, Developer Mode, account sign-in, and passcodes are security prerequisites for the owner to complete when required; do not automate around them or reset trust to repair a test. Select the authorized device in Device Hub and verify its model and OS before installing anything. See Apple's [device management](https://developer.apple.com/documentation/xcode/managing-your-simulated-and-physical-devices-in-device-hub) and [Device Hub access](https://support.apple.com/guide/personal-safety/ips37dbab2a1/web) documentation. + +Build for the exact destination with the project's normal development signing, provisioning, bundle identifier, and required entitlements. An unsigned CI build is not an interactive device build. Keep install, launch, and UI verification as separate evidence: installation can succeed while launch fails, and launch can succeed while the intended screen is absent. Preserve the existing app container and user data; uninstalling the user's app or resetting its storage is not a routine troubleshooting step. + +If MCP cannot perform an operation, Apple's command-line tools are a fallback. Inspect the installed help first, because options and device support change. These examples use placeholders, not literal device identifiers: + +```sh +xcodebuild -version +xcrun devicectl list devices +xcrun devicectl help device install app +xcrun devicectl help device process launch + +xcrun devicectl device install app --device '' '' +xcrun devicectl device process launch --device '' '' +``` + +Select the correct signed build before installation; discovering a device is not authorization to use it. Keep command output containing identifiers local. For console capture, the installed `devicectl device process launch --console` can attach the app's standard streams and wait for exit. Use the supported per-launch environment options for diagnostic configuration. A console stream is not automatically a complete unified-log archive; verify that the required events actually arrive. Stopping that console can forward signals to the app, so coordinate termination and restore an ordinary launch afterward. + +## Operate Device Hub + +Open **Xcode → Open Developer Tool → Device Hub**, select the target, and choose **View Screen** for a physical device. The Xcode 27 Device Hub app identifier is `com.apple.dt.Devices`; confirm the app/window in the host's current inventory rather than relying on a fixed filesystem path. Apple's [interaction guide](https://developer.apple.com/documentation/xcode/interacting-with-your-app-in-device-hub) describes the canvas, bezel, and platform-specific controls. + +Use the following observation loop: + +1. Select the intended device window and read its accessibility tree. If multiple windows exist, identify the right one by its title and visible device before acting. +2. Capture a fresh screenshot. The Mac accessibility tree may expose only Device Hub's toolbar and menus, not the mirrored app's controls. Prefer semantic controls where available; otherwise derive targets from the current screenshot. +3. Use the coordinate system documented by the active computer-use tool. A Device Hub window screenshot includes its toolbar and bezel; it is not the same coordinate space as a raw device screenshot or UIKit points. Do not assume Retina scaling or divide coordinates by two. Re-observe after resizing, changing windows, or rotating. +4. Perform the smallest action, then refresh the hierarchy and screenshot before choosing the next target. A tool reporting success proves input delivery, not the expected UI transition. +5. If an action has no effect, recapture and retry once with fresh evidence. Check focus, animation, overlays, and device connection before attempting a different path. Stop guessing when the target remains inaccessible. + +Use the tools' state capture and animation handling for synchronization. Do not add arbitrary waits as the default way to make interactions succeed. + +In observed Xcode 27 automation, the expanded hub could return `noWindowsAvailable` even though Device Hub was open. Selecting the target's compact device window made capture available. Treat this as a bounded workaround to verify against the current UI, not a guaranteed API behavior. Do not repeatedly relaunch the app or reset the phone to repair a window-capture problem. + +Accessory cards, notifications, and permission sheets can intercept input. Exclude a trial if the intended app action never occurred. Dismiss only a visible, understood obstruction within task scope, then re-establish the starting state; do not change unrelated notification or accessory settings. + +### Keyboard and tvOS controls + +Before testing a software-keyboard issue, inspect **Device → Keyboard** and record the current **Simulate Hardware Keyboard** and keyboard-capture settings when present. Hardware-keyboard simulation can suppress the onscreen keyboard and invalidate a comparison. With authorization for the diagnostic configuration, disable simulation as needed and use an ordinary disposable text field as a positive control: verify that the software keyboard visibly appears. **Toggle Software Keyboard** alone is not proof that normal focus will present it. Keep the configuration identical across trials and restore the original settings afterward. + +Focus the device canvas before sending Mac keyboard input, and check where it lands. For tvOS, move focus with arrow keys, activate with Return, and go back with Escape; use the current Device Hub controls or simulated remote for actions such as Home and Play/Pause. tvOS uses focus navigation, not touchscreen coordinate taps. Product-specific navigation and fixture instructions remain in the consumer repository. + +### Sensor limitations + +Apple documents camera and microphone conflicts during physical-device remote interaction. An active Device Hub screen session can prevent sensor access or be interrupted by higher-priority apps. Stop remote interaction and arrange an owner-operated check when real camera or microphone behavior is the test target. Do not label an empty capture caused by the test environment as an application defect, and do not terminate an unrelated call or recording to continue testing. + +## Simulator basics + +Reuse the consumer-designated simulator by its resolved identifier and check its current boot state. Build for that simulator's platform and architecture, install a normally signed interactive build with the required entitlements, and verify launch before interaction. For entitlement-dependent services such as CloudKit, inspect signing and entitlements before retrying a startup failure. Do not reuse `CODE_SIGNING_ALLOWED=NO` CI output for those UI checks. A project's `CI=true` format-phase bypass is separate from code signing; follow the consumer's build instructions. + +On a shared CI host, check active CI before booting or using simulators and obey the consumer's exclusivity rule. Xcode Canvas can create a separate preview simulator even when a different run destination is selected. On constrained hosts, hide Canvas and disable automatic refresh while testing the designated simulator; stop the designated simulator before an intentional preview session, then stop the preview environment before resuming it. Do not shut down a device or session owned by another task or CI. + +For CLI fallback, inspect `xcrun simctl help` and the installed subcommand help, and resolve destinations with `xcrun simctl list devices`. Avoid blanket boot, shutdown, erase, or device-creation commands. Keep changes to locale, appearance, accessibility sizes, keyboard settings, and synthetic fixtures scoped to the test and restore them afterward. + +## Capture and interpret evidence + +Define the expected result before acting. For ordinary navigation, capture the starting state, action, and settled result. For a transient issue, such as a keyboard flashing during presentation, the transient itself is the acceptance target; do not dismiss it merely because the final screen looks correct. + +Use [Device Hub screenshots and recording](https://developer.apple.com/documentation/xcode/capturing-screenshots-and-videos-from-devices) when supported by the selected device and tool. Native device screenshots and host-window captures have different framing and resolution. Apple's recording instructions cover simulated devices; do not assume the same recording command supports a physical device. If recording is unavailable, capture a bounded sequence of screenshots around the action with timestamps. Call it a screenshot sequence, not a video, and report sampling gaps that could miss a short event. + +Correlate inspected frames with privacy-safe lifecycle, focus, keyboard, cancellation, and completion events under the [logging policy](../Logging.md). Prefer monotonic time and local attempt counters. Record where capture timing begins; an offset measured after a click or hierarchy request differs from an app log measured at presentation request. Notification delivery alone does not prove visible keyboard coverage or identify the first responder. Conversely, a visible flash without an app-owned focus event does not prove which system component caused it. + +For a controlled comparison, change one candidate cause at a time, keep the device, build configuration, input state, locale, and synthetic content equivalent, and repeat valid trials. Mark interrupted or confounded trials as excluded. When app-specific presentation is still a candidate, an authorized minimal native host can isolate it: use a separate bundle identifier, the same relevant public API configuration, no real account or app data, and no persistence of generated results. Cancel the workflow and preserve the reproduction locally. Reproduction in that host shows the removed app layers are unnecessary to trigger the symptom; it does not establish an exact internal framework defect or justify an untested production workaround. + +Keep screenshots, logs, and reproduction artifacts out of public commits when they contain personal names, notifications, accounts, device identifiers, or authored content. Use synthetic inputs, inspect retained evidence, and sanitize any report before sharing it. Do not log text-field contents or generated images to make timing easier to measure. + +## Cleanup and handoff + +- Cancel test editors and discard only the synthetic drafts created for the test; confirm the original data remains intact. +- Remove temporary diagnostics and one-run overrides, restore changed keyboard/environment settings, and relaunch the ordinary signed app when the test displaced it. Reuse the existing correct build when possible. +- Stop owned console captures, recordings, device-interaction sessions, and disposable simulator/preview processes. Restore the prior device environment without unpairing devices or changing security settings. +- Remove only a known disposable harness created for this test when its deletion is authorized and it contains no user data. Never uninstall an existing user app as cleanup. Preserve the minimal source and useful sanitized evidence separately when needed for a report. +- Report the exact tested configuration, actions, observed results, excluded trials, diagnostics, evidence locations, limitations, and cleanup status. Separate confirmed observations from hypotheses and unavailable verification. A negative comparison is a useful result; do not ship a speculative fix to make the investigation appear successful. diff --git a/AgentGuidelines/Guidelines/Xcode/MCP.md b/AgentGuidelines/Guidelines/Xcode/MCP.md index bed3e38..5a852e4 100644 --- a/AgentGuidelines/Guidelines/Xcode/MCP.md +++ b/AgentGuidelines/Guidelines/Xcode/MCP.md @@ -39,20 +39,7 @@ After a meaningful SwiftUI change: ## Build, run, and interaction -Use runtime interaction when static compilation cannot establish that a user-visible workflow behaves correctly. - -1. Start or select an appropriate simulator/device session. -2. Build, install, and launch through Xcode tooling. -3. Prefer one-run launch arguments and environment variables to editing a shared scheme for temporary configuration. -4. Capture the accessibility or UI hierarchy before interaction. -5. Capture a screenshot when visual state matters. -6. Derive interaction targets from the hierarchy; do not guess coordinates when semantic information is available. -7. Perform the smallest interaction sequence that proves the behavior. -8. Capture the resulting hierarchy and screenshot. -9. Report both functional and visible defects. -10. End resource-heavy sessions when verification is complete. - -Retry a slow launch or interaction once when the application may still be settling. Do not add arbitrary waits as a default synchronization strategy. +Use runtime interaction when static compilation cannot establish that a user-visible workflow behaves correctly. Follow [Device Hub, physical devices, and simulators](DeviceInteraction.md) for authorization, tool and skill discovery, signed builds, session ownership, hierarchy and screenshot targeting, keyboard diagnostics, evidence, and cleanup. Keep consumer-specific destinations and CI/Canvas constraints in the consumer's `AGENTS.md`. ## Reporting diff --git a/AgentGuidelines/README.md b/AgentGuidelines/README.md index cec2d20..ecc0aed 100644 --- a/AgentGuidelines/README.md +++ b/AgentGuidelines/README.md @@ -64,6 +64,7 @@ The subtree does not automatically import every guide into an agent's context. A - [App Store metadata](Guidelines/AppStore.md) - [CI/CD](Guidelines/CICD.md) - [Development and reusability](Guidelines/Development.md) +- [Device Hub, physical devices, and simulators](Guidelines/Xcode/DeviceInteraction.md) - [Documentation](Guidelines/Documentation.md) - [Git ignore files](Guidelines/Git/IgnoreFiles.md) - [Git repositories and SSH-first cloning](Guidelines/Git/Repositories.md) @@ -91,7 +92,7 @@ From the consumer repository root, install a tagged release: git subtree add \ --prefix=AgentGuidelines \ https://github.com/thatfactory/agent-guidelines.git \ - 0.0.33 \ + 0.0.42 \ --squash ``` @@ -147,7 +148,7 @@ Review the target release's changelog, then pull it deliberately: git subtree pull \ --prefix=AgentGuidelines \ https://github.com/thatfactory/agent-guidelines.git \ - 0.0.33 \ + 0.0.42 \ --squash ``` @@ -155,6 +156,10 @@ Confirm `AgentGuidelines/VERSION`, review the subtree diff, synchronize the mark ## Maintain the source of truth +Use the repository-local [`update-agent-guidelines` skill](.agents/skills/update-agent-guidelines/SKILL.md) with a request such as `$update-agent-guidelines include/update the rule that ...`. It chooses the guide, asks whether to add audit coverage when that choice is missing, and prepares the next version's metadata. Explicit invocation also authorizes the scoped PR, merge after required approval and green CI, and release; narrower instructions such as draft-only or no release take precedence. Consumer adoption remains separate. + +The skill is discoverable when working in this central checkout. For a consumer on an older release, open a chat in the central checkout or explicitly supply the path to its `.agents/skills/update-agent-guidelines/SKILL.md` with the request; updating the consumer subtree is not a prerequisite. For use across repositories, the user can install the skill independently with Codex's skill installer from `thatfactory/agent-guidelines`, path `.agents/skills/update-agent-guidelines`, at a published tag. Do not implicitly install it or alter global configuration as part of a guideline change. + 1. Export current Xcode skills to a temporary review location when a new Xcode release materially changes agent behavior: ```sh diff --git a/AgentGuidelines/Scripts/validate_guidelines.swift b/AgentGuidelines/Scripts/validate_guidelines.swift index 39b58d6..4d742a0 100755 --- a/AgentGuidelines/Scripts/validate_guidelines.swift +++ b/AgentGuidelines/Scripts/validate_guidelines.swift @@ -15,6 +15,7 @@ let swiftFormatConfiguration = root.appendingPathComponent("Configurations/Swift let editorConfiguration = root.appendingPathComponent("Configurations/Swift/.editorconfig") let swiftFormatScript = root.appendingPathComponent("Scripts/swift_format.sh") let swiftFormatGuideline = root.appendingPathComponent("Guidelines/Swift/SwiftFormat.md") +let swiftUIGuideline = root.appendingPathComponent("Guidelines/Swift/SwiftUI.md") let localizationGuideline = root.appendingPathComponent("Guidelines/Localization.md") let appStoreGuideline = root.appendingPathComponent("Guidelines/AppStore.md") let xcodeProjectSettingsGuideline = root.appendingPathComponent("Guidelines/Xcode/ProjectSettings.md") @@ -34,6 +35,7 @@ let cicdGuideline = root.appendingPathComponent("Guidelines/CICD.md") let documentationGuideline = root.appendingPathComponent("Guidelines/Documentation.md") let packagesGuideline = root.appendingPathComponent("Guidelines/Packages.md") let agentsTemplate = root.appendingPathComponent("Templates/AGENTS.md") +let pullRequestsGuideline = root.appendingPathComponent("Guidelines/GitHub/PullRequests.md") let gitignoreTemplate = root.appendingPathComponent("Templates/.gitignore") let gitignoreGuideline = root.appendingPathComponent("Guidelines/Git/IgnoreFiles.md") @@ -576,6 +578,14 @@ func validatePackageCompilerSettingsGuideline(_ errors: inout [String]) { for feature in packageUpcomingFeatures where !contents.contains(".enableUpcomingFeature(\"\(feature)\")") { errors.append("Guidelines/Packages.md: missing required SwiftPM upcoming feature '\(feature)'") } + let emojiRequired = [ + "Each package emoji must be unique across ThatFactory": "uniqueness policy", + "visually identical emoji spellings": "presentation-selector collision policy", + "matching Swift Package Collection entry": "registry synchronization", + ] + for (value, description) in emojiRequired where !contents.contains(value) { + errors.append("Guidelines/Packages.md: missing package emoji \(description): '\(value)'") + } } /// Validates the shared App Store metadata workflow. @@ -664,6 +674,148 @@ func validateExternalDependencyPolicy(_ errors: inout [String]) { } } +/// Protects the native-component decision, ownership, and runtime-evidence policy. +func validateNativeComponentPolicy(_ errors: inout [String]) { + guard let contents = readText(swiftUIGuideline, errors: &errors) else { return } + let required = [ + "## Native components and presentation", + "Prefer native SwiftUI or platform controls and presentation APIs", + "sheets, alerts, confirmation dialogs, navigation, menus, pickers, popovers", + "Custom fonts, button styles, and content inside a native sheet are compatible", + "Do not substitute a sheet-shaped `ZStack`, offset animation, or custom scrim", + "Pixel fidelity to a static design and preserving mounted state are insufficient", + "actual native capability gap verified against the supported platform and deployment target", + "Before implementation, obtain explicit repository-owner approval and record a durable exception", + "Do not invent a capability gap or infer approval from existing custom code", + "An unapproved or undocumented replacement blocks completion", + "attach that presentation to the active child hierarchy", + "Give each child independent dismissal", + "Keep parent-owned draft state alive", + "Make cancellation idempotent", + "reject stale completions after dismissal or replacement", + "presentation's dismissal-completion callback", + "## Required interaction evidence", + "presentation and dismissal animation/dimming", + "interactive dismissal and gesture routing", + "parent survival with draft retention", + "safe areas, controls and shadow clipping, keyboard/focus restoration", + "Dynamic Type and localization", + "Exercise asynchronous cancellation and stale completion", + "A static screenshot or a no-op automation call is not evidence that gestures work", + "Require an actual gesture and its observed result, or recorded manual verification", + "Missing relevant runtime evidence blocks completion", + "Simulated sheet with a suitable native API and no approved durable exception | Blocked", + "Native nested sheet with branded content, correct ownership, and required interaction evidence | Accepted", + "Custom equivalent for a verified capability gap with owner approval, durable exception, and compensating evidence | Accepted within the approved scope", + "Presentation with missing relevant runtime evidence | Blocked", + ] + for value in required where !contents.contains(value) { + errors.append("Guidelines/Swift/SwiftUI.md: missing native-component policy: '\(value)'") + } +} + +/// Protects interaction policy and audit evidence requirements, not consumer runtime performance. +func validateInteractionRenderingPolicy(_ errors: inout [String]) { + let requirements: [(URL, [String])] = [ + ( + swiftUIGuideline, + [ + "## Animation", + "Reusable custom-styled controls own their basic visual transitions", + "both directions, without screen-level opt-in", + "Preserve system-owned feedback instead of layering duplicate animation", + "never delay functional state until an animation finishes", + "Preserve stable view identity when only appearance changes", + "value-triggered animation can affect other changes in the same transaction", + "Use narrower modifier or closure scope", + "[Reduce Motion policy](#reduce-motion)", + "### Reduce Motion", + "https://developer.apple.com/help/app-store-connect/manage-app-accessibility/reduced-motion-evaluation-criteria/", + "no separate task request is required", + "do not disable all animation indiscriminately", + "Follow preference changes during use", + "document any equivalent or more granular in-app alternative", + "Remove decorative triggers or replace them with lower-motion feedback", + "stop it or provide a discoverable stop control", + "replace meaningful motion with suitable alternatives", + "Claim Reduced Motion support only after evaluating all common tasks for the platform being declared", + "required third-party or user-generated content, and advertising", + "An app with no problematic motion can qualify", + "Re-evaluate support with each app update", + "withhold or correct a claim", + "## Image rendering", + "retained, display-appropriate derivatives", + "display scale, aspect-fit/fill requirements, orientation, fidelity, and bounded intermediate/cache memory", + "Refresh derivatives when their source or relevant presentation requirements change", + "outside view construction and repeated interaction paths", + "off the main actor when supported and concurrency-safe", + "Reject stale asynchronous results after replacement, cancellation, or removal", + "separate from authoritative originals needed for editing, saving, or export", + "Inspect the representation actually drawn", + "do not impose blanket bans", + ] + ), + ( + auditSkill, + [ + "Perform the mandatory [animation and image-rendering check](#audit-animation-and-image-rendering)", + "## Audit animation and image rendering", + "Guidelines/Swift/SwiftUI.md#animation", + "Guidelines/Swift/SwiftUI.md#image-rendering", + "do not expand the audit into unrelated screens", + "its owner, and its intended animation or justified immediate feedback", + "Verify reusable defaults without caller-added animation", + "enabled to disabled and disabled to enabled, ordinary presses, rapid reversals", + "hit-testing, accessibility state, and action availability change immediately", + "repeated preparation, oversized drawable content, unstable identity, unbounded caches, and stale publication", + "Exercise representative large-image content", + "compare with an image-free scenario", + "Require recorded runtime actions and outcomes for meaningful motion changes", + "Compilation, static screenshots, and idle captures do not prove smooth interaction", + "Documentation-only policy changes require scenario walkthroughs", + "use Apple performance tools where available and authorized", + "Record the active interaction window", + "Distinguish recorded manual verification from instrumented measurements", + "Do not require profiling every cosmetic change or impose a universal frame-rate threshold", + "missing applicable interaction evidence prevent audit completion", + "| Scenario | Audit outcome |", + "| Appropriate native control already supplies feedback | Accepted without additional animation |", + "| Zero hitches recorded without actual interaction | Insufficient performance evidence", + "[Reduce Motion check](#audit-reduce-motion)", + "## Audit Reduce Motion", + "Guidelines/Swift/SwiftUI.md#reduce-motion", + "Guidelines/Swift/SwiftUI.md#declaring-support", + "Require runtime actions and outcomes with Reduce Motion on and off", + "plus a preference change while running", + "missing applicable evidence blocks completion of the affected work", + "expand the evidence scope to all common tasks on the declared platform", + "withhold or correct unsupported claims", + "do not demand an unrelated whole-app audit for an isolated motion change", + "Documentation-only policy work uses scenario walkthroughs", + ] + ), + ( + appStoreGuideline, + [ + "## Accessibility support claims", + "Swift/SwiftUI.md#declaring-support", + "adopting these guidelines alone does not establish support", + ] + ), + ] + for (file, required) in requirements { + guard let contents = readText(file, errors: &errors) else { continue } + for value in required where !contents.contains(value) { + errors.append("\(file.lastPathComponent): missing interaction/rendering requirement: '\(value)'") + } + if contents.contains( + "Do not introduce app-level Reduce Motion guards unless the task prompt explicitly requests them") + { + errors.append("\(file.lastPathComponent): obsolete Reduce Motion opt-in restriction") + } + } +} + /// Validates the completion-audit skill and native helper scripts. func validateAuditSkill(_ errors: inout [String]) { guard let contents = readText(auditSkill, errors: &errors) else { @@ -674,11 +826,28 @@ func validateAuditSkill(_ errors: inout [String]) { "before claiming completion": "completion trigger", "git diff --check": "diff validation", "validate_consumer_setup.swift": "consumer integration validation", + "Perform the mandatory [native-component and presentation decision check]": "mandatory native-component audit", + "## Audit native components and presentation": "native-component audit section", + "Guidelines/Swift/SwiftUI.md#native-components-and-presentation": "authoritative native-component policy link", + "custom modal hosts, overlay/scrim/offset code, and gesture routing": "custom presentation inspection", + "native alternative, deployment-target availability": "native alternative decision evidence", + "explicit repository-owner approval, durable consumer exception": "custom component exception validation", + "required approved and documented exception blocks audit completion": "native-component audit stopping rule", + "child dismissal, parent draft ownership, nested picker/editor presenters": "presentation hierarchy audit", + "cancellation, and dismissal-completion cleanup": "presentation cancellation audit", + "native nested sheet with branded content is accepted": "native branded content acceptance", + "Guidelines/Swift/SwiftUI.md#required-interaction-evidence": "presentation runtime evidence link", + "static screenshots and no-op automation do not establish gesture behavior": "gesture evidence audit", + "Missing relevant runtime evidence blocks audit completion": "runtime evidence stopping rule", "format-and-lint": "local Swift-format audit", "lint-strict": "strict Swift-format CI audit", "AppLogger": "AppLogger integration audit", "Logging.md": "shared Logging guide reference", "Dependency declaration and target linkage alone": "lifecycle observability coverage audit", + "Enumerate the registered package entries": "package emoji registry enumeration", + "visually identical presentation-selector variants as a collision": + "package emoji presentation-selector collision audit", + "blocks audit completion and release readiness": "package emoji audit stopping rule", "## Audit documentation consistency": "documentation drift audit", "Known stale documentation blocks completion": "stale documentation stopping rule", "## Audit documentation formatting": "documentation formatting audit", @@ -760,6 +929,52 @@ func validateAuditSkill(_ errors: inout [String]) { } } +/// Validates positive evidence and bounded monitoring for automatic Codex reviews. +func validateCodexReviewEvidence(_ errors: inout [String]) { + guard let guide = readText(pullRequestsGuideline, errors: &errors) else { return } + let required = [ + "codex_review_configuration = enabled | disabled | unknown", + "codex_review_execution = not_started | scheduled | processing | completed", + "codex_review_expected_sha = ", + "Only positive repository-specific evidence", + "unknown` plus no signal remains `not_started`", + "Discovering `enabled` alone does not schedule a review", + "reviewed SHA equals the expected SHA", + "at most five minutes total from entry into `scheduled`, including time spent in `processing`", + "A processing signal changes the execution state but does not reset the deadline or poll budget", + "An identical fingerprint causes no substantive re-analysis or user notification", + "Without configuration evidence, do not create a monitor", + "do not transfer `scheduled`, `processing`, or `completed`", + "At the deadline, stop and terminate the monitor, report the observed facts once, and never infer approval", + "If the head changes during monitoring, terminate the old monitor", + ] + for concept in required where !guide.contains(concept) { + errors.append("Guidelines/GitHub/PullRequests.md: missing Codex review evidence rule: \(concept)") + } + for forbidden in ["No review yet means pending", "Wait for the configured Codex review to finish"] + where guide.contains(forbidden) { + errors.append("Guidelines/GitHub/PullRequests.md: unconditional Codex review gate: \(forbidden)") + } + let scenarioOutcomes = [ + "Enabled with a current-head processing signal | `processing` | Pending | No; processing already began", + "Enabled with a completed review covering the expected head and dispositioned findings | `completed` | Satisfied | No", + "Unknown with no signal or configuration evidence | `not_started` | Not blocked by absent Codex review | No", + "Disabled with no review | `not_started` | Not blocked by absent Codex review | No", + "Review completed for an old head | Re-establish for new head | Old review does not satisfy the gate | No inherited monitor", + "Enabled and positively eligible, with no start signal | `scheduled` | Pending while required | Yes, at most five minutes", + "A signal inside the budget advances to `processing` while preserving configuration evidence and expected SHA", + "If no signal appears by the deadline, terminate monitoring and report the unresolved verified state once; timeout never satisfies the gate", + ] + for outcome in scenarioOutcomes where !guide.contains(outcome) { + errors.append("Guidelines/GitHub/PullRequests.md: missing Codex review scenario outcome: \(outcome)") + } + if let template = readText(agentsTemplate, errors: &errors), + !template.contains("this template does not establish that automatic review is configured") + { + errors.append("Templates/AGENTS.md: missing unknown-configuration safeguard") + } +} + /// Validates the reusable ignore template and its shared reconciliation policy. func validateGitignoreGuidance(_ errors: inout [String]) { guard let template = readText(gitignoreTemplate, errors: &errors) else { return } @@ -810,6 +1025,9 @@ func main() -> Int32 { validateXcodeProjectSettingsGuideline(&errors) validatePackageCompilerSettingsGuideline(&errors) validateExternalDependencyPolicy(&errors) + validateNativeComponentPolicy(&errors) + validateInteractionRenderingPolicy(&errors) + validateCodexReviewEvidence(&errors) validateGitignoreGuidance(&errors) validateExecutable(consumerSetupScript, description: "consumer setup validator", errors: &errors) validateAuditSkill(&errors) diff --git a/AgentGuidelines/Templates/AGENTS.md b/AgentGuidelines/Templates/AGENTS.md index 092c1a9..47757f4 100644 --- a/AgentGuidelines/Templates/AGENTS.md +++ b/AgentGuidelines/Templates/AGENTS.md @@ -23,11 +23,14 @@ Read only the guides relevant to the task: - [Git repositories and SSH-first cloning](AgentGuidelines/Guidelines/Git/Repositories.md) - [GitHub pull requests](AgentGuidelines/Guidelines/GitHub/PullRequests.md) - [Xcode MCP and visual verification](AgentGuidelines/Guidelines/Xcode/MCP.md) +- [Device Hub, physical devices, and simulators](AgentGuidelines/Guidelines/Xcode/DeviceInteraction.md) - [Xcode project settings](AgentGuidelines/Guidelines/Xcode/ProjectSettings.md) - [Xcode security audits](AgentGuidelines/Guidelines/Xcode/Security.md) For an application that uses Redux, also read [Redux architecture](AgentGuidelines/Guidelines/Architecture/Redux.md). +For Codex pull-request review, use the linked review evidence rules. If authoritative repository-specific evidence establishes that automatic review is enabled, apply its current-head gate. If the executing agent cannot establish the setting, record it as `unknown`; this template does not establish that automatic review is configured. + Keep the following observability contract in the consumer repository's root `AGENTS.md` so implementation agents treat runtime diagnostics as part of lifecycle work. Copy it unchanged and update it when the marker version changes in this template. ```md @@ -90,7 +93,7 @@ In an initial review, report substantiated blockers together. A follow-up review The review-round budget below applies only to Codex GitHub reviews: the configured automatic Codex review and any manual `@codex review` request. It does not apply to ChatGPT review or reasoning delegated through Reasoning Relay. An otherwise-authorized Reasoning Relay workflow may request as many Relay review or follow-up delegations as its own governing workflow requires; those requests neither consume the Codex budget nor require repository-owner authorization under it. -Automatic Codex review is the initial Codex review. Do not request a manual Codex review unless the repository owner explicitly asks. Never request another Codex review after each remediation commit. Within the normal Codex review budget, at most one owner-authorized, delta-scoped Codex verification review may be requested under [the pull-request review workflow](AgentGuidelines/Guidelines/GitHub/PullRequests.md). +When repository-specific evidence establishes that automatic Codex review is enabled and applies to the current pull request/head, it is the initial Codex review. Otherwise, an absent review does not establish a pending gate. Do not request a manual Codex review unless the repository owner explicitly asks. Never request another Codex review after each remediation commit. Within the normal Codex review budget, at most one owner-authorized, delta-scoped Codex verification review may be requested under [the pull-request review workflow](AgentGuidelines/Guidelines/GitHub/PullRequests.md). ## Codex review scope diff --git a/AgentGuidelines/Tests/run_tests.swift b/AgentGuidelines/Tests/run_tests.swift index 847eeab..52ad3d9 100755 --- a/AgentGuidelines/Tests/run_tests.swift +++ b/AgentGuidelines/Tests/run_tests.swift @@ -120,6 +120,279 @@ func localization(_ value: String, state: String = "translated") -> String { } let tests: [(String, () throws -> Void)] = [ + ( + "interaction/rendering validator rejects guide and audit drift", + { + let mutations: [(String, [String])] = [ + ( + "Guidelines/Swift/SwiftUI.md", + [ + "## Animation", + "both directions, without screen-level opt-in", + "Preserve system-owned feedback instead of layering duplicate animation", + "never delay functional state until an animation finishes", + "Preserve stable view identity when only appearance changes", + "value-triggered animation can affect other changes in the same transaction", + "Use narrower modifier or closure scope", + "[Reduce Motion policy](#reduce-motion)", + "### Reduce Motion", + "https://developer.apple.com/help/app-store-connect/manage-app-accessibility/reduced-motion-evaluation-criteria/", + "no separate task request is required", + "do not disable all animation indiscriminately", + "Follow preference changes during use", + "document any equivalent or more granular in-app alternative", + "Remove decorative triggers or replace them with lower-motion feedback", + "stop it or provide a discoverable stop control", + "replace meaningful motion with suitable alternatives", + "Claim Reduced Motion support only after evaluating all common tasks for the platform being declared", + "required third-party or user-generated content, and advertising", + "An app with no problematic motion can qualify", + "Re-evaluate support with each app update", + "withhold or correct a claim", + "## Image rendering", + "retained, display-appropriate derivatives", + "display scale, aspect-fit/fill requirements, orientation, fidelity, and bounded intermediate/cache memory", + "Refresh derivatives when their source or relevant presentation requirements change", + "outside view construction and repeated interaction paths", + "off the main actor when supported and concurrency-safe", + "Reject stale asynchronous results after replacement, cancellation, or removal", + "separate from authoritative originals needed for editing, saving, or export", + "Inspect the representation actually drawn", + "do not impose blanket bans", + ] + ), + ( + ".agents/skills/agent-guidelines-audit/SKILL.md", + [ + "Perform the mandatory [animation and image-rendering check](#audit-animation-and-image-rendering)", + "## Audit animation and image rendering", + "Guidelines/Swift/SwiftUI.md#animation", + "Guidelines/Swift/SwiftUI.md#image-rendering", + "do not expand the audit into unrelated screens", + "its owner, and its intended animation or justified immediate feedback", + "Verify reusable defaults without caller-added animation", + "enabled to disabled and disabled to enabled, ordinary presses, rapid reversals", + "hit-testing, accessibility state, and action availability change immediately", + "repeated preparation, oversized drawable content, unstable identity, unbounded caches, and stale publication", + "Exercise representative large-image content", + "compare with an image-free scenario", + "Require recorded runtime actions and outcomes for meaningful motion changes", + "Compilation, static screenshots, and idle captures do not prove smooth interaction", + "Documentation-only policy changes require scenario walkthroughs", + "use Apple performance tools where available and authorized", + "Record the active interaction window", + "Distinguish recorded manual verification from instrumented measurements", + "Do not require profiling every cosmetic change or impose a universal frame-rate threshold", + "missing applicable interaction evidence prevent audit completion", + "| Appropriate native control already supplies feedback | Accepted without additional animation |", + "| Zero hitches recorded without actual interaction | Insufficient performance evidence", + "[Reduce Motion check](#audit-reduce-motion)", + "## Audit Reduce Motion", + "Guidelines/Swift/SwiftUI.md#reduce-motion", + "Guidelines/Swift/SwiftUI.md#declaring-support", + "Require runtime actions and outcomes with Reduce Motion on and off", + "plus a preference change while running", + "missing applicable evidence blocks completion of the affected work", + "expand the evidence scope to all common tasks on the declared platform", + "withhold or correct unsupported claims", + "do not demand an unrelated whole-app audit for an isolated motion change", + "Documentation-only policy work uses scenario walkthroughs", + ] + ), + ( + "Guidelines/AppStore.md", + [ + "## Accessibility support claims", + "Swift/SwiftUI.md#declaring-support", + "adopting these guidelines alone does not establish support", + ] + ), + ] + try withTemporaryDirectory { temporary in + let fixture = temporary.appendingPathComponent("repository") + try copyRepositoryFixture(to: fixture) + let validator = fixture.appendingPathComponent("Scripts/validate_guidelines.swift").path + let baseline = try run([validator]) + try require(baseline.succeeded, "unmodified interaction policy failed: \(baseline.output)") + for (path, fragments) in mutations { + let file = fixture.appendingPathComponent(path) + let contents = try String(contentsOf: file, encoding: .utf8) + for original in fragments { + try require(contents.contains(original), "missing test fixture text: \(original)") + try write(contents.replacingOccurrences(of: original, with: "REMOVED"), to: file) + let result = try run([validator]) + try require(!result.succeeded, "interaction policy mutation unexpectedly passed: \(original)") + try require(result.output.contains("missing interaction/rendering requirement"), result.output) + try write(contents, to: file) + } + } + } + } + ), + ( + "interaction validator rejects restored Reduce Motion opt-in restriction", + { + try withTemporaryDirectory { temporary in + let fixture = temporary.appendingPathComponent("repository") + try copyRepositoryFixture(to: fixture) + let guide = fixture.appendingPathComponent("Guidelines/Swift/SwiftUI.md") + let contents = try String(contentsOf: guide, encoding: .utf8) + try write( + contents + + "\nDo not introduce app-level Reduce Motion guards unless the task prompt explicitly requests them.\n", + to: guide) + let result = try run([fixture.appendingPathComponent("Scripts/validate_guidelines.swift").path]) + try require(!result.succeeded, "obsolete Reduce Motion restriction unexpectedly passed") + try require(result.output.contains("obsolete Reduce Motion opt-in restriction"), result.output) + } + } + ), + ( + "native-component validator rejects policy and decision drift", + { + let mutations = [ + "Do not substitute a sheet-shaped `ZStack`, offset animation, or custom scrim", + "sheets, alerts, confirmation dialogs, navigation, menus, pickers, popovers", + "Before implementation, obtain explicit repository-owner approval and record a durable exception", + "Pixel fidelity to a static design and preserving mounted state are insufficient", + "Give each child independent dismissal", + "Keep parent-owned draft state alive", + "attach that presentation to the active child hierarchy", + "Make cancellation idempotent", + "reject stale completions after dismissal or replacement", + "presentation's dismissal-completion callback", + "Require an actual gesture and its observed result, or recorded manual verification", + "safe areas, controls and shadow clipping, keyboard/focus restoration", + "Dynamic Type and localization", + "Simulated sheet with a suitable native API and no approved durable exception | Blocked", + "Native nested sheet with branded content, correct ownership, and required interaction evidence | Accepted", + "Custom equivalent for a verified capability gap with owner approval, durable exception, and compensating evidence | Accepted within the approved scope", + "Presentation with missing relevant runtime evidence | Blocked", + ] + for original in mutations { + try withTemporaryDirectory { temporary in + let fixture = temporary.appendingPathComponent("repository") + try copyRepositoryFixture(to: fixture) + let guide = fixture.appendingPathComponent("Guidelines/Swift/SwiftUI.md") + let contents = try String(contentsOf: guide, encoding: .utf8) + try require(contents.contains(original), "missing test fixture text: \(original)") + try write(contents.replacingOccurrences(of: original, with: "REMOVED"), to: guide) + let result = try run([fixture.appendingPathComponent("Scripts/validate_guidelines.swift").path]) + try require(!result.succeeded, "native-component policy mutation unexpectedly passed: \(original)") + try require(result.output.contains("missing native-component policy"), result.output) + } + } + } + ), + ( + "native-component validator rejects audit gate drift", + { + let mutations = [ + "Perform the mandatory [native-component and presentation decision check]", + "Guidelines/Swift/SwiftUI.md#native-components-and-presentation", + "custom modal hosts, overlay/scrim/offset code, and gesture routing", + "native alternative, deployment-target availability", + "explicit repository-owner approval, durable consumer exception", + "required approved and documented exception blocks audit completion", + "child dismissal, parent draft ownership, nested picker/editor presenters", + "cancellation, and dismissal-completion cleanup", + "native nested sheet with branded content is accepted", + "Guidelines/Swift/SwiftUI.md#required-interaction-evidence", + "static screenshots and no-op automation do not establish gesture behavior", + "Missing relevant runtime evidence blocks audit completion", + ] + for original in mutations { + try withTemporaryDirectory { temporary in + let fixture = temporary.appendingPathComponent("repository") + try copyRepositoryFixture(to: fixture) + let skill = fixture.appendingPathComponent(".agents/skills/agent-guidelines-audit/SKILL.md") + let contents = try String(contentsOf: skill, encoding: .utf8) + try require(contents.contains(original), "missing test fixture text: \(original)") + try write(contents.replacingOccurrences(of: original, with: "REMOVED"), to: skill) + let result = try run([fixture.appendingPathComponent("Scripts/validate_guidelines.swift").path]) + try require(!result.succeeded, "native-component audit mutation unexpectedly passed: \(original)") + try require(result.output.contains("SKILL.md: missing"), result.output) + } + } + } + ), + ( + "review evidence validator rejects an unconditional pending gate", + { + try withTemporaryDirectory { temporary in + let fixture = temporary.appendingPathComponent("repository") + try copyRepositoryFixture(to: fixture) + let guide = fixture.appendingPathComponent("Guidelines/GitHub/PullRequests.md") + let contents = try String(contentsOf: guide, encoding: .utf8) + try write(contents + "\nNo review yet means pending.\n", to: guide) + let result = try run([fixture.appendingPathComponent("Scripts/validate_guidelines.swift").path]) + try require(!result.succeeded, "unconditional Codex gate unexpectedly passed") + try require(result.output.contains("unconditional Codex review gate"), result.output) + } + } + ), + ( + "review evidence validator requires unknown state", + { + try withTemporaryDirectory { temporary in + let fixture = temporary.appendingPathComponent("repository") + try copyRepositoryFixture(to: fixture) + let guide = fixture.appendingPathComponent("Guidelines/GitHub/PullRequests.md") + let contents = try String(contentsOf: guide, encoding: .utf8) + try write( + contents.replacingOccurrences(of: "enabled | disabled | unknown", with: "enabled | disabled"), + to: guide) + let result = try run([fixture.appendingPathComponent("Scripts/validate_guidelines.swift").path]) + try require(!result.succeeded, "missing unknown state unexpectedly passed") + try require(result.output.contains("missing Codex review evidence rule"), result.output) + } + } + ), + ( + "review evidence validator protects scenario outcomes and deadline", + { + let mutations = [ + ( + "Disabled with no review | `not_started` | Not blocked by absent Codex review | No", + "Disabled with no review | `not_started` | Pending | Yes" + ), + ( + "Enabled with a current-head processing signal | `processing` | Pending", + "Enabled with a current-head processing signal | `processing` | Satisfied" + ), + ( + "Enabled with a completed review covering the expected head and dispositioned findings | `completed` | Satisfied", + "Enabled with a completed review covering the expected head and dispositioned findings | `completed` | Pending" + ), + ( + "Review completed for an old head | Re-establish for new head", + "Review completed for an old head | Completed for new head" + ), + ( + "An identical fingerprint causes no substantive re-analysis or user notification", + "An identical fingerprint may trigger another notification" + ), + ("including time spent in `processing`", "excluding time spent in `processing`"), + ( + "At the deadline, stop and terminate the monitor, report the observed facts once, and never infer approval", + "At the deadline, keep waiting" + ), + ] + for (original, replacement) in mutations { + try withTemporaryDirectory { temporary in + let fixture = temporary.appendingPathComponent("repository") + try copyRepositoryFixture(to: fixture) + let guide = fixture.appendingPathComponent("Guidelines/GitHub/PullRequests.md") + let contents = try String(contentsOf: guide, encoding: .utf8) + try require(contents.contains(original), "missing test fixture text: \(original)") + try write(contents.replacingOccurrences(of: original, with: replacement), to: guide) + let result = try run([fixture.appendingPathComponent("Scripts/validate_guidelines.swift").path]) + try require(!result.succeeded, "review scenario mutation unexpectedly passed: \(original)") + try require(result.output.contains("Codex review"), result.output) + } + } + } + ), ( "repository validator accepts the source tree", { @@ -290,6 +563,25 @@ let tests: [(String, () throws -> Void)] = [ } } ), + ( + "repository validator rejects missing package emoji uniqueness policy", + { + try withTemporaryDirectory { temporary in + let fixture = temporary.appendingPathComponent("repository") + try copyRepositoryFixture(to: fixture) + let guideline = fixture.appendingPathComponent("Guidelines/Packages.md") + var contents = try String(contentsOf: guideline, encoding: .utf8) + contents = contents.replacingOccurrences( + of: "Each package emoji must be unique across ThatFactory", + with: "Package emojis should be recognizable" + ) + try write(contents, to: guideline) + let result = try run([fixture.appendingPathComponent("Scripts/validate_guidelines.swift").path]) + try require(!result.succeeded, "missing package emoji uniqueness policy unexpectedly passed") + try require(result.output.contains("missing package emoji uniqueness policy"), result.output) + } + } + ), ( "repository validator rejects missing package audit section", { @@ -326,6 +618,25 @@ let tests: [(String, () throws -> Void)] = [ } } ), + ( + "repository validator rejects package emoji audit drift", + { + try withTemporaryDirectory { temporary in + let fixture = temporary.appendingPathComponent("repository") + try copyRepositoryFixture(to: fixture) + let skill = fixture.appendingPathComponent(".agents/skills/agent-guidelines-audit/SKILL.md") + var contents = try String(contentsOf: skill, encoding: .utf8) + contents = contents.replacingOccurrences( + of: "Enumerate the registered package entries", + with: "Inspect the package registry" + ) + try write(contents, to: skill) + let result = try run([fixture.appendingPathComponent("Scripts/validate_guidelines.swift").path]) + try require(!result.succeeded, "package emoji audit drift unexpectedly passed") + try require(result.output.contains("missing package emoji registry enumeration"), result.output) + } + } + ), ( "repository validator rejects missing observability adoption guidance", { diff --git a/AgentGuidelines/VERSION b/AgentGuidelines/VERSION index cd9d21e..54a0022 100644 --- a/AgentGuidelines/VERSION +++ b/AgentGuidelines/VERSION @@ -1 +1 @@ -0.0.33 +0.0.42 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e8d543..804311f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to CloudSaveKit are documented here. +## 0.3.0 — 2026-10-28 + +### Added + +- Added `CloudSavePrivateCoordinator` with package-owned container creation, account notifications, verified account tokens, and coalesced exact-manifest provisioning. +- Added conditional manifest creation, canonical-winner rereads, trusted-identity validation, cancellable record/asset transport, and server retry timing without a second synchronization scheduler. +- Added immutable `CloudSavePrivateSession` handles over the existing engine, terminal suspension/invalidation, and normalized private-session errors. +- Added host-callback retirement barriers across account generations and recovery replacements, active manifest-identity validation, and preserved configured-zone transport failures for trusted offline use. +- Added deterministic neutral-schema tests for concurrent creators, interrupted provisioning, lost acknowledgements, stale identity/asset/ledger results, A → B → A isolation, and pending writes across temporary unavailability. +- Documented personal-store integration, cold-launch offline trust, checkpoint recovery ordering, and privacy-safe lifecycle diagnostics. + +### Changed + +- Personal sessions opt into fixed-account and trusted-manifest policies: a wrong account or lost private topology fences work before host rebinding, destructive deletion callbacks, or automatic zone recreation. Existing engine and sharing integrations retain their original policies. +- Adopted the exact Agent Guidelines `0.0.42` tree and synchronized the root code-review contract. + ## 0.2.6 — 2026-09-19 ### Changed diff --git a/README.md b/README.md index ee61cd3..4c5a3b7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ # CloudSaveKit -A reusable `CKSyncEngine` coordinator for synchronizing app-owned local data with private and shared CloudKit databases. ☁️ +A reusable CloudKit account, private-bootstrap, and `CKSyncEngine` coordinator for app-owned local data. ☁️ ```mermaid flowchart LR @@ -31,6 +31,8 @@ CloudSaveKit owns CloudKit synchronization mechanics while the host application - Schedule automatic synchronization and expose explicit fetch, send, and combined sync operations. - Forward fetched changes, account events, saved system fields, and semantic conflicts to the host. - Classify failures into privacy-safe values suitable for application state. +- Verify the current account and invalidate personal sessions before switching local persistence. +- Provision a canonical private manifest conditionally, reconcile concurrent creators, and fetch exact asset records. - Preserve independent record, zone, operation, and host-persistence failures until their exact recovery conditions succeed. ## Quick start @@ -40,11 +42,25 @@ Add CloudSaveKit to your package dependencies: ```swift .package( url: "https://github.com/thatfactory/cloudsavekit.git", - from: "0.2.6" + from: "0.3.0" ) ``` -Implement `CloudSaveClient` in the actor that owns your local database, then configure a private CloudKit database and custom zone: +Implement `CloudSaveClient` in the actor that owns your local database. For one personal collection per account, use `CloudSavePrivateCoordinator`: it constructs the container, observes account changes, verifies identity, and conditionally provisions a generic manifest. Its `CloudSavePrivateSession` permanently binds that account and zone to your client. See the [private collection integration guide](https://thatfactory.github.io/cloudsavekit/documentation/cloudsavekit/privatecollections/) for a complete neutral-schema example and the offline trust contract. + +Personal sessions revalidate incoming manifest identities and drain admitted host callbacks during retirement. Await retirement before reading a checkpoint for a replacement session over the same store. Configured-zone network errors retain their transport classification so a verified, initialized cache can remain usable offline; a failed fetch never establishes initial-empty readiness. + +```swift +let coordinator = CloudSavePrivateCoordinator( + containerIdentifier: "iCloud.com.example.notebook" +) +// Observe coordinator.accountEvents before querying or opening a local store. +let accountSnapshot = try await coordinator.refreshAccount() +``` + +Persist an account-qualified manifest identity, reject mismatched checkpoint provenance, and use the coordinator's `bootstrapManifest` result to create a session. Await `start()` and `fetchNow()` before treating a new local cache as empty. Commit local records, media associations, and their pending mutations before enqueueing; an upload failure does not roll back a successful local Save. A lost trusted manifest or zone is an integrity failure, and personal sessions never recreate it or invoke destructive zone-deletion callbacks. + +The existing configurable engine and sharing APIs remain available. For a host that intentionally manages mutable account bindings and automatic owned-zone recovery, configure a database and zone directly: ```swift let container = CKContainer(identifier: "iCloud.com.example.game") diff --git a/Sources/CloudSaveKit/CloudSaveAccessGate.swift b/Sources/CloudSaveKit/CloudSaveAccessGate.swift new file mode 100644 index 0000000..6725cb9 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveAccessGate.swift @@ -0,0 +1,73 @@ +import Foundation +import Synchronization + +/// Synchronously fences asynchronous operations before teardown or host notification can suspend. +final class CloudSaveAccessGate: Sendable { + private let state = Mutex(State()) + private let onDrainWait: @Sendable () -> Void + + /// Allows deterministic observation of an admitted callback drain without timing-based tests. + init(onDrainWait: @escaping @Sendable () -> Void = {}) { + self.onDrainWait = onDrainWait + } + + /// Rejects access after the first terminal transition. + func check() throws { + if let error = state.withLock({ $0.failure }) { + throw error + } + } + + /// Closes this generation permanently, retaining its first terminal reason. + @discardableResult + func close(_ error: CloudSavePrivateError = .sessionInvalidated) -> Bool { + state.withLock { + guard $0.failure == nil else { + return false + } + $0.failure = error + return true + } + } + + /// Atomically admits a host callback so closing and draining cannot miss an in-flight write. + func beginCallback() throws { + try state.withLock { + if let failure = $0.failure { throw failure } + $0.callbacks += 1 + } + } + + /// Releases an admitted callback only after the host's asynchronous operation has returned. + func endCallback() { + let waiters = state.withLock { state in + state.callbacks -= 1 + guard state.callbacks == 0 else { return [CheckedContinuation]() } + defer { state.waiters.removeAll() } + return state.waiters + } + for waiter in waiters { waiter.resume() } + } + + /// After closure, waits for host callbacks independently of CloudKit operation cancellation. + func waitForCallbacks() async { + await withCheckedContinuation { continuation in + let finished = state.withLock { state in + guard state.callbacks > 0 else { return true } + state.waiters.append(continuation) + return false + } + if finished { + continuation.resume() + } else { + onDrainWait() + } + } + } + + private struct State { + var failure: CloudSavePrivateError? + var callbacks = 0 + var waiters: [CheckedContinuation] = [] + } +} diff --git a/Sources/CloudSaveKit/CloudSaveAccountEvent.swift b/Sources/CloudSaveKit/CloudSaveAccountEvent.swift new file mode 100644 index 0000000..d9d728e --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveAccountEvent.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Account-bound cloud work is fenced before invalidation is published to the host. +public enum CloudSaveAccountEvent: Equatable, Sendable { + /// Hide old account content before awaiting local or cloud teardown. + case invalidated + /// A revalidation completed; persisted account strings alone do not establish trust. + case resolved(CloudSaveAccountSnapshot) +} diff --git a/Sources/CloudSaveKit/CloudSaveAccountNotifications.swift b/Sources/CloudSaveKit/CloudSaveAccountNotifications.swift new file mode 100644 index 0000000..2b7485e --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveAccountNotifications.swift @@ -0,0 +1,25 @@ +import CloudKit +import Foundation + +/// Owns one notification registration and removes it when the coordinator is released. +/// Foundation's immutable observer token is used only for removal in deinit. +final class CloudSaveAccountNotifications: @unchecked Sendable { + let changes: AsyncStream + private let continuation: AsyncStream.Continuation + private let observer: any NSObjectProtocol + + /// Registers before any account query or engine creation; notifications carry no identity. + init() { + let channel = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + changes = channel.stream + continuation = channel.continuation + observer = NotificationCenter.default.addObserver(forName: .CKAccountChanged, object: nil, queue: nil) { _ in + channel.continuation.yield(()) + } + } + + deinit { + NotificationCenter.default.removeObserver(observer) + continuation.finish() + } +} diff --git a/Sources/CloudSaveKit/CloudSaveAccountSnapshot.swift b/Sources/CloudSaveKit/CloudSaveAccountSnapshot.swift new file mode 100644 index 0000000..f69b5ca --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveAccountSnapshot.swift @@ -0,0 +1,10 @@ +import Foundation + +/// The result of resolving the current system context's iCloud account. +public enum CloudSaveAccountSnapshot: Equatable, Sendable { + case indeterminate + case noAccount + case restricted + case temporarilyUnavailable + case verified(CloudSaveVerifiedAccount) +} diff --git a/Sources/CloudSaveKit/CloudSaveCloudKitTransport.swift b/Sources/CloudSaveKit/CloudSaveCloudKitTransport.swift new file mode 100644 index 0000000..b3b43b2 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveCloudKitTransport.swift @@ -0,0 +1,84 @@ +import CloudKit +import Foundation + +/// Performs exact private-database operations and cancels them with the requesting task. +struct CloudSaveCloudKitTransport: CloudSavePrivateTransport { + let container: CKContainer + let database: CKDatabase + + /// Constructs service objects inside the package boundary. + init(containerIdentifier: String) { + container = CKContainer(identifier: containerIdentifier) + database = container.privateCloudDatabase + } + + /// Resolves availability without interpreting an unavailable account as absence of cloud data. + func accountStatus() async throws -> CKAccountStatus { try await container.accountStatus() } + + /// Resolves the opaque identity in the active system context. + func userRecordID() async throws -> CKRecord.ID { try await container.userRecordID() } + + /// Returns nil only for a positively classified missing record, retaining missing-zone failures. + func fetchRecord(_ recordID: CKRecord.ID) async throws -> CKRecord? { + let operation = CKFetchRecordsOperation(recordIDs: [recordID]) + let result = CloudSaveOperationResult() + operation.perRecordResultBlock = { _, value in result.set(value) } + do { + return try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await withCheckedThrowingContinuation { continuation in + operation.fetchRecordsResultBlock = { value in + continuation.resume(with: Result { try result.finish(value) }) + } + database.add(operation) + } + } onCancel: { + operation.cancel() + } + } catch { + let itemError = CloudSavePrivateError.itemError(error, for: recordID) + if (itemError as? CKError)?.code == .unknownItem { + return nil + } + throw itemError + } + } + + /// Provisions exactly the requested zone after verified first-use absence. + func ensureZone(_ zone: CKRecordZone) async throws { + let operation = CKModifyRecordZonesOperation(recordZonesToSave: [zone]) + let result = CloudSaveOperationResult() + operation.perRecordZoneSaveBlock = { _, value in result.set(value) } + let _: CKRecordZone = try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await withCheckedThrowingContinuation { continuation in + operation.modifyRecordZonesResultBlock = { value in + continuation.resume(with: Result { try result.finish(value) }) + } + database.add(operation) + } + } onCancel: { + operation.cancel() + } + } + + /// Uses a checked create policy; callers reread the server winner after success or conflict. + func createIfAbsent(_ record: CKRecord) async throws { + let operation = CKModifyRecordsOperation(recordsToSave: [record]) + operation.savePolicy = .ifServerRecordUnchanged + operation.isAtomic = true + let result = CloudSaveOperationResult() + operation.perRecordSaveBlock = { _, value in result.set(value) } + let _: CKRecord = try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await withCheckedThrowingContinuation { continuation in + operation.modifyRecordsResultBlock = { value in + continuation.resume(with: Result { try result.finish(value) }) + } + database.add(operation) + } + } onCancel: { + operation.cancel() + } + } +} diff --git a/Sources/CloudSaveKit/CloudSaveConfiguration.swift b/Sources/CloudSaveKit/CloudSaveConfiguration.swift index fd02ec6..6bfcca3 100644 --- a/Sources/CloudSaveKit/CloudSaveConfiguration.swift +++ b/Sources/CloudSaveKit/CloudSaveConfiguration.swift @@ -3,6 +3,8 @@ import Foundation /// Configures one private or shared database cloud-save engine. public struct CloudSaveConfiguration: Sendable { + /// Opt-in fixed-account policy used only by package-created personal sessions. + var privateSession: CloudSavePrivateSessionContext? /// The CloudKit database used by the engine. public let database: CKDatabase diff --git a/Sources/CloudSaveKit/CloudSaveEngine.swift b/Sources/CloudSaveKit/CloudSaveEngine.swift index aaceb16..ef12eee 100644 --- a/Sources/CloudSaveKit/CloudSaveEngine.swift +++ b/Sources/CloudSaveKit/CloudSaveEngine.swift @@ -49,6 +49,7 @@ public final actor CloudSaveEngine { /// Initializes CKSyncEngine and restores every locally durable pending change. public func start() async throws { + try configuration.privateSession?.check() try await waitForPendingLifecycleTransition() guard !requiresReconfiguration else { throw CloudSaveEngineError.reconfigurationRequired @@ -70,6 +71,7 @@ public final actor CloudSaveEngine { throw error } + try configuration.privateSession?.check() guard lifecycleGeneration == startingLifecycleGeneration else { throw CloudSaveEngineError.hostRecoveryRequired } @@ -78,7 +80,7 @@ public final actor CloudSaveEngine { storedSyncEngine = engine stateMachine.resolve(.hostPersistence) - if lastPersistedStateSerialization == nil, + if configuration.privateSession == nil, lastPersistedStateSerialization == nil, let zone = configuration.zoneAccess.ownedZone { engine.state.add( @@ -102,6 +104,7 @@ public final actor CloudSaveEngine { /// Adds locally durable changes to CKSyncEngine's pending state. public func enqueue(_ changes: [CloudSavePendingChange]) { + guard privateSessionIsActive else { return } let configuredChanges = changes.filter { isInConfiguredZone($0.recordID) } @@ -179,7 +182,7 @@ public final actor CloudSaveEngine { scope: .zoneIDs([configuration.zoneID]) ) try await session.syncEngine.fetchChanges(options) - try await fetchCoordinator.validate(request) + try await fetchCoordinator.validate(request, preservingTransportError: configuration.privateSession != nil) try validate(session) CloudSaveLogging.log( CloudSaveLogging.fetchState( @@ -302,6 +305,18 @@ public final actor CloudSaveEngine { stateMachine.resetActiveOperations() publishStatus(syncEngine: storedSyncEngine) } + + /// Permanently detaches this engine before awaiting cancellation; personal sessions never restart it. + func retire() async { + configuration.privateSession?.sessionGate.close() + lifecycleGeneration &+= 1 + let retiredEngine = storedSyncEngine + storedSyncEngine = nil + await fetchCoordinator.invalidate() + await retiredEngine?.cancelOperations() + stateMachine.resetActiveOperations() + publishStatus(syncEngine: nil) + } } // MARK: - CKSyncEngineDelegate @@ -323,7 +338,7 @@ extension CloudSaveEngine: CKSyncEngineDelegate { _ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine ) async -> CKSyncEngine.RecordZoneChangeBatch? { - guard !requiresReconfiguration, + guard privateSessionIsActive, !requiresReconfiguration, !isLifecycleTransitionPending, storedSyncEngine === syncEngine, !stateMachine.requiresHostRecovery @@ -395,6 +410,11 @@ extension CloudSaveEngine { _ event: CKSyncEngine.Event, syncEngine: CKSyncEngine ) async { + guard storedSyncEngine === syncEngine else { return } + do { try validatePrivateEvent(event) } catch let reason as CloudSavePrivateError { + retirePrivateEngine(reason: reason) + return + } catch { return } guard !requiresReconfiguration, !isLifecycleTransitionPending, storedSyncEngine === syncEngine @@ -482,8 +502,8 @@ extension CloudSaveEngine { break } CloudSaveLogging.log(CloudSaveLogging.configuredZoneFetchCompleted(error: event.error)) - if event.error != nil { - await fetchCoordinator.failConfiguredZoneFetch() + if let error = event.error { + await fetchCoordinator.failConfiguredZoneFetch(error: error) } @unknown default: CloudSaveLogging.log( @@ -491,6 +511,8 @@ extension CloudSaveEngine { "event | unknown" ) } + } catch let reason as CloudSavePrivateError { + retirePrivateEngine(reason: reason) } catch CloudSaveEngineError.reconfigurationRequired { if isAccountTransitionPending { endAccountTransition() @@ -530,7 +552,7 @@ extension CloudSaveEngine { syncEngine: CKSyncEngine, lifecycleGeneration: Int ) -> Bool { - !requiresReconfiguration + privateSessionIsActive && !requiresReconfiguration && !isLifecycleTransitionPending && self.lifecycleGeneration == lifecycleGeneration && storedSyncEngine === syncEngine @@ -554,6 +576,7 @@ extension CloudSaveEngine { syncEngine: CKSyncEngine, lifecycleGeneration: Int ) { + try configuration.privateSession?.check() guard !requiresReconfiguration, !isLifecycleTransitionPending, !stateMachine.requiresHostRecovery @@ -580,6 +603,7 @@ extension CloudSaveEngine { lifecycleGeneration: Int ) ) throws { + try configuration.privateSession?.check() guard !isLifecycleTransitionPending, session.lifecycleGeneration == lifecycleGeneration, storedSyncEngine === session.syncEngine, @@ -596,6 +620,7 @@ extension CloudSaveEngine { lifecycleGeneration: Int ) ) throws { + try configuration.privateSession?.check() guard isLifecycleTransitionPending || session.lifecycleGeneration != lifecycleGeneration @@ -978,7 +1003,7 @@ extension CloudSaveEngine { syncEngine: CKSyncEngine, allowsAccountTransition: Bool = false ) -> Bool { - !isHostFailureInvalidationPending + privateSessionIsActive && !isHostFailureInvalidationPending && (allowsAccountTransition || !isAccountTransitionPending) && snapshot.belongs( to: lifecycleGeneration, @@ -994,6 +1019,10 @@ extension CloudSaveEngine { return } + guard configuration.privateSession == nil else { + throw CloudSavePrivateError.missingTrustedManifest + } + guard let zone = configuration.zoneAccess.ownedZone else { requiresReconfiguration = true throw CloudSaveEngineError.reconfigurationRequired @@ -1012,9 +1041,11 @@ extension CloudSaveEngine { guard case .signedOut = accountChange else { let ledgerSnapshot = try await readPendingChangesSnapshot() - syncEngine.state.add( - pendingDatabaseChanges: configuration.zoneAccess.accountTransitionDatabaseChanges - ) + if configuration.privateSession == nil { + syncEngine.state.add( + pendingDatabaseChanges: configuration.zoneAccess.accountTransitionDatabaseChanges + ) + } guard restoreDurablePendingChanges( ledgerSnapshot, @@ -1066,6 +1097,10 @@ extension CloudSaveEngine { return } + guard configuration.privateSession == nil else { + throw CloudSavePrivateError.missingTrustedManifest + } + guard let zone = configuration.zoneAccess.ownedZone else { requiresReconfiguration = true throw CloudSaveEngineError.reconfigurationRequired @@ -1102,6 +1137,8 @@ extension CloudSaveEngine { return } + try configuration.privateSession?.validate(accountChange) + let shouldInvalidate = accountTransitionClassifier.shouldInvalidate(for: accountChange) isAccountTransitionPending = true @@ -1484,3 +1521,74 @@ extension CloudSaveEngine { zoneID == configuration.zoneID } } + +// MARK: - Private Session + +/// Enforces opt-in personal-session policies before the existing engine handles an event. +extension CloudSaveEngine { + /// Whether both immutable session gates still permit synchronization. + var privateSessionIsActive: Bool { + guard let context = configuration.privateSession else { return true } + return (try? context.check()) != nil + } + + /// Rejects account transitions and trusted topology loss before host callbacks or retries. + func validatePrivateEvent(_ event: CKSyncEngine.Event) throws { + guard let context = configuration.privateSession else { return } + try context.check() + switch event { + case .accountChange(let event): + guard let change = event.cloudSaveAccountChange else { + throw CloudSavePrivateError.sessionInvalidated + } + try context.validate(change) + case .fetchedDatabaseChanges(let event): + if event.deletions.contains(where: { $0.zoneID == context.zoneID }) { + throw CloudSavePrivateError.missingTrustedManifest + } + case .fetchedRecordZoneChanges(let event): + try context.validateFetchedChanges( + records: event.modifications.map(\.record), deletedRecordIDs: event.deletions.map(\.recordID) + ) + case .didFetchRecordZoneChanges(let event): + if event.zoneID == context.zoneID, let error = event.error { try context.validateTransportError(error) } + case .sentDatabaseChanges(let event): + for failure in event.failedZoneSaves where failure.zone.zoneID == context.zoneID { + try context.validateTransportError(failure.error) + } + for (zoneID, error) in event.failedZoneDeletes where zoneID == context.zoneID { + try context.validateTransportError(error) + } + case .sentRecordZoneChanges(let event): + for failure in event.failedRecordSaves where failure.record.recordID.zoneID == context.zoneID { + try context.validateTransportError(failure.error) + } + for (recordID, error) in event.failedRecordDeletes where recordID.zoneID == context.zoneID { + try context.validateTransportError(error) + } + default: break + } + } + + /// Detaches immediately; cancellation runs outside delegate handling to avoid self-deadlock. + func retirePrivateEngine(reason: CloudSavePrivateError) { + guard let context = configuration.privateSession, storedSyncEngine != nil else { return } + context.sessionGate.close(reason) + lifecycleGeneration &+= 1 + let retiredEngine = storedSyncEngine + storedSyncEngine = nil + let failure: CloudSaveFailure = + switch reason { + case .missingTrustedManifest, .manifestIdentityMismatch, .malformedManifest: .zoneUnavailable + default: .accountUnavailable + } + stateMachine.fail(failure, context: .zone(configuration.zoneID)) + publishStatus(syncEngine: nil) + CloudSavePrivateLogging.emit(.engine, CloudSavePrivateLogging.outcome(reason)) + Task { + await context.onInvalidation(reason) + await fetchCoordinator.invalidate() + await retiredEngine?.cancelOperations() + } + } +} diff --git a/Sources/CloudSaveKit/CloudSaveFetchCoordinator.swift b/Sources/CloudSaveKit/CloudSaveFetchCoordinator.swift index aae31bc..c6b8480 100644 --- a/Sources/CloudSaveKit/CloudSaveFetchCoordinator.swift +++ b/Sources/CloudSaveKit/CloudSaveFetchCoordinator.swift @@ -6,6 +6,7 @@ actor CloudSaveFetchCoordinator { private var completedGeneration = 0 private var fetchGeneration = 0 private var failedFetchGenerations: Set = [] + private var transportErrors: [Int: any Error] = [:] private var idleWaiters: [UUID: CheckedContinuation] = [:] private var latestSuccessfulFetchGeneration = 0 private var lifecycleGeneration = 0 @@ -54,15 +55,16 @@ actor CloudSaveFetchCoordinator { } /// Marks the active fetch generation as failed for the configured zone. - func failConfiguredZoneFetch() { + func failConfiguredZoneFetch(error: (any Error)? = nil) { guard let generation = activeFetchGenerations.first else { return } failedFetchGenerations.insert(generation) + if transportErrors[generation] == nil { transportErrors[generation] = error } } /// Verifies that a fetch which began no earlier than the request has completed. - func validate(_ request: Request) throws { + func validate(_ request: Request, preservingTransportError: Bool = false) throws { guard request.lifecycleGeneration == lifecycleGeneration else { throw CloudSaveEngineError.hostRecoveryRequired } @@ -72,6 +74,14 @@ actor CloudSaveFetchCoordinator { guard completedGeneration >= request.requiredFetchGeneration else { throw CloudSaveEngineError.freshFetchNotObserved } + if preservingTransportError, + let generation = transportErrors.keys.filter({ + $0 >= request.requiredFetchGeneration && $0 <= completedGeneration + }).max(), + let error = transportErrors[generation] + { + throw error + } throw CloudSaveEngineError.configuredZoneFetchFailed } @@ -80,6 +90,7 @@ actor CloudSaveFetchCoordinator { lifecycleGeneration &+= 1 activeFetchGenerations.removeAll() failedFetchGenerations.removeAll() + transportErrors.removeAll() latestSuccessfulFetchGeneration = 0 let waiters = idleWaiters.values idleWaiters.removeAll() diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md index 6ddd3c5..41ed538 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/CloudSaveKit.md @@ -6,6 +6,8 @@ Coordinate an application-owned local store with a private or shared CloudKit da CloudSaveKit wraps Apple's `CKSyncEngine` lifecycle and delegate surface without choosing a local database or record schema. A host provides a ``CloudSaveClient`` that can materialize pending `CKRecord` values, apply fetched changes transactionally, preserve CKSyncEngine state, and resolve conflicts using application semantics. +For a personal collection, start with . ``CloudSavePrivateCoordinator`` owns verified account identity and conditional manifest provisioning; ``CloudSavePrivateSession`` binds the existing engine and exact record transport to one immutable account, zone, and client. The configurable engine behavior below remains available for hosts that intentionally manage rebinding and automatic owned-zone recovery. + Create the engine early in application launch, call ``CloudSaveEngine/start()``, and enqueue changes only after their corresponding local transactions succeed. Observe ``CloudSaveEngine/statusUpdates`` to project synchronization state into the host architecture. The current-state stream begins with ``CloudSaveStatus/idle`` and retains only its latest unconsumed value rather than preserving an event history. Before integrating the engine, follow to configure the application target, signing, local-store boundary, and durable state. For user-initiated refresh behavior, see . For private-zone sharing, see . When server state and device behavior disagree, use . @@ -21,6 +23,7 @@ CKSyncEngine retains recoverable transport failures and schedules their retries; ### Essentials - +- - - - @@ -33,6 +36,16 @@ CKSyncEngine retains recoverable transport failures and schedules their retries; - ``CloudSaveZoneAccess`` - ``CloudSaveStatus`` +### Personal collections + +- ``CloudSavePrivateCoordinator`` +- ``CloudSavePrivateSession`` +- ``CloudSaveVerifiedAccount`` +- ``CloudSaveAccountSnapshot`` +- ``CloudSaveAccountEvent`` +- ``CloudSavePrivateBootstrap`` +- ``CloudSavePrivateError`` + ### Sharing - ``CloudSaveSharingCoordinator`` diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/IntegrationGuide.md b/Sources/CloudSaveKit/CloudSaveKit.docc/IntegrationGuide.md index 5c089b0..8e005bc 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/IntegrationGuide.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/IntegrationGuide.md @@ -6,6 +6,8 @@ Configure a host application for durable private or shared CloudKit synchronizat CloudSaveKit coordinates CKSyncEngine, but the host remains the source of truth for local data. A correct integration combines application capabilities and signing, a durable ``CloudSaveClient``, an owner-aware zone configuration, and lifecycle orchestration that starts synchronization only after account-scoped persistence is ready. +For automatic personal-collection selection, use . That path constructs service objects inside the package, verifies the account before local-store selection, and uses immutable sessions. The direct ``CloudSaveConfiguration`` examples below retain the existing configurable engine contract. + ## Configure the application target Enable iCloud with CloudKit, select the intended container, enable Push Notifications, and enable the Remote notifications background mode. Deploy the record schema to every CloudKit environment the application will use. @@ -58,6 +60,8 @@ If a host persistence callback fails, CloudSaveKit invalidates current work and Account transitions invalidate old operations before the host switches account-scoped persistence. In ``CloudSaveClient/handle(accountChange:)``, the host switches or validates account-scoped local persistence and restores the new account's durable pending ledger. The callback cannot replace serialized CKSyncEngine state in the running engine: state serialization is supplied only when constructing ``CloudSaveConfiguration``, and CKSyncEngine performs its own account-change state reset. Never allow one account's pending records or host-bound checkpoint provenance to enter another account's persistence context. +That mutable-host callback contract applies to directly configured engines. A ``CloudSavePrivateSession`` instead retains the original host client permanently, never invokes its account-rebinding callback, and retires on an account transition. The host observes the coordinator's events and creates a fresh verified session with the new account's store and checkpoint. + ## Verify the integration Test local-first offline creation, relaunch with pending work, conflict resolution, deletion, account transitions, host callback failure and restart, shared-zone revocation, concurrent enqueue and send, and fetched changes applied before user-visible completion. For sharing, perform a two-device test with separate iCloud accounts and verify both upload directions. diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/PrivateCollections.md b/Sources/CloudSaveKit/CloudSaveKit.docc/PrivateCollections.md new file mode 100644 index 0000000..cc7d855 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/PrivateCollections.md @@ -0,0 +1,105 @@ +# Personal Private Collections + +Open one canonical collection in the current system context's private iCloud database. + +## Ownership + +``CloudSavePrivateCoordinator`` owns container/database construction, account queries and notifications, conditional bootstrap, and cloud-session invalidation. ``CloudSavePrivateSession`` uses the existing ``CloudSaveEngine`` for synchronization and exposes exact record reads for assets. The host owns its schema, pure `CKRecord` mapping, local database, media files, pending ledger, opaque checkpoint persistence, and interface. + +The configurable engine and sharing APIs remain supported. Personal sessions opt into immutable account binding and trusted-manifest recovery; their behavior does not change legacy host-rebinding or automatic owned-zone repair. + +## Observe and verify the account + +Create one coordinator for a container and consume its ``CloudSavePrivateCoordinator/accountEvents`` stream before opening local persistence. The coordinator registers `CKAccountChanged` observation during initialization. ``CloudSavePrivateCoordinator/startMonitoring()`` starts its consumer idempotently; ``CloudSavePrivateCoordinator/refreshAccount()`` also starts monitoring. + +An `.invalidated` event means the host must immediately clear visible account content, navigation, drafts, game state, images, and operation status before awaiting teardown. The coordinator fences old cloud work before emitting the event. It then resolves availability and the current opaque account record name. Notifications are revalidation triggers, including notifications caused by another Apple TV profile; they are not proof of an account identity. Forward foreground activation by calling `refreshAccount()` rather than adding another raw CloudKit observer. + +Only `.verified(account)` supplies a ``CloudSaveVerifiedAccount`` token. Its initializer is internal and it contains coordinator/generation provenance. A stored account string is useful as a local namespace key but cannot recreate verification. Refreshes coalesce while in flight; a later account notification supersedes a suspended query immediately. Every fresh reconciliation invalidates previous tokens and sessions, even if the resolved account is the same. + +After immediately emitting invalidation, the coordinator drains admitted host callbacks before resolving the new identity. Retirement barriers remain ordered across overlapping A → B → A notifications, so a returning account cannot read its checkpoint while an earlier generation still writes it. Cancellation of CloudKit operations alone does not establish host-write completion. + +## Bootstrap a neutral schema + +This example uses a notebook schema and an integer collection identity. The host's decoder is pure and issues no CloudKit requests: + +```swift +let coordinator = CloudSavePrivateCoordinator( + containerIdentifier: "iCloud.com.example.notebook" +) +guard case .verified(let account) = try await coordinator.refreshAccount() else { + // Show the appropriate account-availability state. + return +} + +let bootstrap = try await coordinator.bootstrapManifest( + for: account, + zoneName: "NotebookPrivate", + recordType: "NotebookManifest", + recordName: "settings", + expectedIdentity: previouslyTrustedRevision, + makeInitialRecord: { recordID in + let record = CKRecord(recordType: "NotebookManifest", recordID: recordID) + record["revision"] = NSNumber(value: 1) + return record + }, + decodeIdentity: { record in + guard let revision = record["revision"] as? NSNumber else { + throw NotebookError.invalidManifest + } + return revision.intValue + } +) +``` + +`previouslyTrustedRevision` is the host's optional persisted identity for this exact account and namespace. Pass `nil` only when first provisioning is allowed. An existing trusted identity requires an existing, valid, matching server manifest; absence, malformed schema, or contradictory identity never creates a replacement. Local namespaces and checkpoint provenance must include the account, canonical identity, and exact zone. + +Bootstrap reads only the exact private record. It provisions a zone only after positively classified first-use absence. Manifest creation uses `CKModifyRecordsOperation` with `.ifServerRecordUnchanged`, one atomic record, and per-item result inspection. The operation API provides explicit task cancellation. Both successful creation and a concurrent creation conflict are followed by a server reread; the locally proposed identity never wins merely because it was proposed. A lost acknowledgement or failed local binding write can be retried safely by rediscovering the canonical record. + +Requests for the same account generation, record, schema type, and creation permission share their transport work. Each caller validates the canonical result using its own decoder and expected identity. No broad zone discovery, shared-database access, or application-specific field interpretation is involved. + +## Open an immutable session + +After persisting the trusted binding, open the matching local store and validate its checkpoint provenance: + +```swift +let session = try await coordinator.makeSession( + for: bootstrap, + stateSerialization: restoredCheckpoint, + client: localStore +) +try await session.start() +try await session.fetchNow() +// Now a newly initialized local projection may truthfully be reported as empty. +``` + +`start()` restores the durable ledger; it is not a remote fetch barrier. `fetchNow()` preserves the existing engine's freshness and host-apply guarantees. A failed initial fetch must remain a recovery state instead of reporting a new collection as empty. + +The supplied client must permanently reference the original local store. Personal sessions never invoke the client's mutable-account rebinding callback. A matching sign-in may restore its ledger; a sign-out, switch, or mismatched sign-in invalidates the session first. Known-zone deletion and `zoneNotFound` are integrity failures: no automatic zone save, local zone-deletion callback, or speculative manifest replacement is permitted. The local mutation ledger must never contain the bootstrap manifest itself. + +The session retains the bootstrap's pure decoder and canonical identity. Incoming manifest modifications must still decode to that identity; an unchanged initial delivery is valid. A malformed or contradictory manifest closes the session before any domain record in that fetched batch reaches the host or another pending batch can be sent. Keep the decoder independent of mutable UI or store state. + +``CloudSavePrivateSession/fetchRecord(_:)`` accepts only the configured zone. It returns `nil` for a positively missing record and rejects stale results after suspension or invalidation. The host still validates schema and copies `CKAsset` temporary files into its own durable media transaction. Capture the original store and session across every asynchronous Save, delete, image, sync, and recovery operation. A callback already admitted to the old store may finish there, but must never publish into a replacement user's UI or enqueue on a replacement session. + +## Offline trust and temporary availability + +An ordinary network failure can leave an already verified same-account local collection usable. On cold launch, first establish the current identity through the coordinator; a matching trusted binding may then support an initialized offline cache if remote bootstrap fails with a transport outage. If current identity cannot be verified, hide caches and retain their bytes. A last-used account string, or tvOS's per-profile storage alone, is insufficient to prove that the Apple Account within that context has not changed. + +Temporary account unavailability preserves local data and committed ledger entries, while fencing new CloudKit operations and enqueues. A private session retires its engine when that failure arrives through explicit operations or automatic engine events. `suspend()` and `invalidate()` are terminal for that handle. After account availability is verified again, bootstrap and create a new session from the latest durable checkpoint; there is no mutable resume or account-rebinding operation. + +Both retirement methods wait for already-admitted host callbacks. Use one active session per durable account/zone store, and await retirement before reading the checkpoint for a replacement session. This ordering also applies to normal-to-recovery and recovery-to-normal replacement within the same account generation. + +``CloudSavePrivateError`` distinguishes suspension, stale sessions, integrity failures, and transport failures. Task and CloudKit cancellation become `CancellationError`. Transport failures retain only ``CloudSaveFailure`` and optional retry timing, without exposing raw CloudKit user-info payloads. Direct bootstrap/asset requests retain server throttle deadlines across calls and recheck lifecycle after waiting. `CKSyncEngine` continues to own automatic synchronization scheduling and its retries. + +Configured-zone fetch errors retain their transport classification through the private facade, including network failure and retry timing. `.hostRecoveryRequired` is reserved for host recovery; `.fetchIncomplete` means the required fresh fetch was not established and `.notStarted` means startup has not completed. An initialized, verified cache may remain usable after an incomplete fetch, but that outcome never establishes the initial-empty readiness barrier. + +## Recovery and notifications + +For host-owned checkpoint recovery, verify account and canonical manifest first, open the exact store, and create a session with `automaticallySync: false`. Start, fetch, verify the newly persisted checkpoint, durably mark recovery complete, and only then send pending mutations. Await invalidation of that recovery session before rereading its durable checkpoint and creating a normal session. Keep quarantine processing and local pending writes intact. + +The package does not install a second database subscription or notification pipeline. `CKSyncEngine` retains that ownership. The application supplies platform entitlements and signing, including any tvOS current-user capability, and is responsible for real profile-switch acceptance. Credential-free package tests do not establish device account isolation or delivery of system notifications. + +## Diagnostics and tests + +Personal lifecycle events use the existing `com.thatfactory.cloudsavekit` subsystem, `sync` category, and ☁️ prefix. Account, bootstrap, session, engine, and exact-record outcomes use fixed privacy-safe tokens. Account names, record/zone identifiers, manifest values, images, and error payloads never enter messages. Pure tokens and value types remain silent. + +The package's neutral notebook fixtures cover conditional creation races, interrupted provisioning, lost acknowledgements, known missing/malformed manifests, repeated account notifications during identity resolution, stale asset/ledger/materialization results, fixed-client A → B → A restoration, and pending local writes across suspension. Existing engine/sharing tests remain required regression coverage. Consumers additionally verify their own durable store, UI-generation guards, signing, initial-empty barrier, and real account transitions. diff --git a/Sources/CloudSaveKit/CloudSaveKit.docc/Troubleshooting.md b/Sources/CloudSaveKit/CloudSaveKit.docc/Troubleshooting.md index 9ee2399..d426096 100644 --- a/Sources/CloudSaveKit/CloudSaveKit.docc/Troubleshooting.md +++ b/Sources/CloudSaveKit/CloudSaveKit.docc/Troubleshooting.md @@ -40,6 +40,8 @@ Verify that participants use `sharedCloudDatabase` and the accepted owner-qualif ## Startup or recovery fails +For personal sessions, ``CloudSavePrivateError/missingTrustedManifest``, ``CloudSavePrivateError/malformedManifest``, and ``CloudSavePrivateError/manifestIdentityMismatch`` require integrity recovery. Preserve local bytes and do not replace the collection. ``CloudSavePrivateError/sessionInvalidated`` rejects an old account generation; obtain a new verified bootstrap before creating another session. Temporary unavailability pauses cloud work while preserving local writes; see for cold-launch trust and restart ordering. + ``CloudSaveEngineError/notStarted`` means no successful ``CloudSaveEngine/start()`` has completed. ``CloudSaveEngineError/hostRecoveryRequired`` means a client persistence callback failed; repair the local store and restart the engine. ``CloudSaveEngineError/reconfigurationRequired`` means the configured shared topology is no longer available. Do not fix checkpoint problems by deleting user data, pending mutations, or server system fields. Bind opaque state to host-owned provenance and make any nil-state recovery explicit, bounded, and observable. diff --git a/Sources/CloudSaveKit/CloudSaveOperationResult.swift b/Sources/CloudSaveKit/CloudSaveOperationResult.swift new file mode 100644 index 0000000..be7313a --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveOperationResult.swift @@ -0,0 +1,25 @@ +import Foundation +import Synchronization + +/// Retains a single CloudKit item result until its enclosing operation has completed. +final class CloudSaveOperationResult: Sendable { + private let result = Mutex?>(nil) + + /// Records the per-item outcome before CloudKit invokes the operation completion. + func set(_ value: Result) { + result.withLock { $0 = value } + } + + /// Prefers item errors over aggregate partial failures, without treating missing results as success. + func finish(_ operationResult: Result) throws -> Value { + let item = result.withLock { $0 } + if case .failure(let error) = item { + throw error + } + try operationResult.get() + if let item { + return try item.get() + } + throw CloudSavePrivateError.transport(.unknown(code: 0), retryAfterSeconds: nil) + } +} diff --git a/Sources/CloudSaveKit/CloudSavePrivateBootstrap.swift b/Sources/CloudSaveKit/CloudSavePrivateBootstrap.swift new file mode 100644 index 0000000..5aae2f9 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSavePrivateBootstrap.swift @@ -0,0 +1,11 @@ +public import CloudKit +import Foundation + +/// A canonical manifest identity obtained in one verified account generation. +public struct CloudSavePrivateBootstrap: Sendable { + public let account: CloudSaveVerifiedAccount + public let zoneID: CKRecordZone.ID + public let value: Value + let manifestRecordID: CKRecord.ID + let validateManifest: @Sendable (CKRecord) throws -> Void +} diff --git a/Sources/CloudSaveKit/CloudSavePrivateClient.swift b/Sources/CloudSaveKit/CloudSavePrivateClient.swift new file mode 100644 index 0000000..fe8fd1a --- /dev/null +++ b/Sources/CloudSaveKit/CloudSavePrivateClient.swift @@ -0,0 +1,74 @@ +import CloudKit + +/// Permanently binds callbacks to one host store and rejects work after session revocation. +struct CloudSavePrivateClient: CloudSaveClient { + let client: any CloudSaveClient + let context: CloudSavePrivateSessionContext + + /// Reads only the original store's ledger and fences results that crossed invalidation. + func pendingChanges() async throws -> [CloudSavePendingChange] { + let changes = try await context.withClientCallback { try await client.pendingChanges() } + try context.validatePending(changes) + return changes + } + + /// Rejects stale record materialization before it can enter another account's cloud queue. + func record(for recordID: CKRecord.ID) async throws -> CKRecord? { + try await context.withClientCallback { try await client.record(for: recordID) } + } + + /// Persists only a checkpoint admitted by this session's gate. + func persist(stateSerialization: CKSyncEngine.State.Serialization) async throws { + try await context.withClientCallback { try await client.persist(stateSerialization: stateSerialization) } + } + + /// A callback already admitted may finish in its original store, never a replacement store. + func applyFetchedChanges(records: [CKRecord], deletedRecordIDs: [CKRecord.ID]) async throws { + try context.validateFetchedChanges(records: records, deletedRecordIDs: deletedRecordIDs) + try await context.withClientCallback { + try await client.applyFetchedChanges(records: records, deletedRecordIDs: deletedRecordIDs) + } + } + + /// A missing trusted zone requires recovery without destructive host clearing. + func applyDeletedZones(_ zoneIDs: [CKRecordZone.ID]) async throws { + throw CloudSavePrivateError.missingTrustedManifest + } + + /// Acknowledges uploads only in the original immutable store. + func didSave(records: [CKRecord]) async throws { + try await context.withClientCallback { try await client.didSave(records: records) } + } + + /// Acknowledges deletions only in the original immutable store. + func didDelete(recordIDs: [CKRecord.ID]) async throws { + try await context.withClientCallback { try await client.didDelete(recordIDs: recordIDs) } + } + + /// Clears record metadata only for the existing verified zone. + func clearServerRecord(for recordID: CKRecord.ID) async throws { + try await context.withClientCallback { try await client.clearServerRecord(for: recordID) } + } + + /// Keeps application merge policy in the original host persistence boundary. + func resolve(conflict: CloudSaveConflict) async throws -> CloudSaveConflictResolution { + try await context.withClientCallback { try await client.resolve(conflict: conflict) } + } + + /// Commits a merged value before admitting its retry. + func persistResolvedRecord(_ record: CKRecord) async throws { + try await context.withClientCallback { try await client.persistResolvedRecord(record) } + } + + /// Fixed sessions never ask a mutable host callback to select another account's store. + func handle(accountChange: CloudSaveAccountChange) throws { + try context.validate(accountChange) + } + + /// Discards diagnostics from a retired session rather than publishing into new host state. + func handle(failure: CloudSaveFailure, recordID: CKRecord.ID?) async { + try? await context.withClientCallback { + await client.handle(failure: failure, recordID: recordID) + } + } +} diff --git a/Sources/CloudSaveKit/CloudSavePrivateCoordinator.swift b/Sources/CloudSaveKit/CloudSavePrivateCoordinator.swift new file mode 100644 index 0000000..d9661a2 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSavePrivateCoordinator.swift @@ -0,0 +1,416 @@ +public import CloudKit +import Foundation + +/// Owns account verification and conditional private-manifest provisioning for one container. +public actor CloudSavePrivateCoordinator { + /// One consumer observes invalidation before clearing its visible account-scoped state. + public nonisolated let accountEvents: AsyncStream + private let continuation: AsyncStream.Continuation + private let coordinatorID = UUID() + private let transport: any CloudSavePrivateTransport + private let database: CKDatabase? + private let changes: AsyncStream + private let notifications: CloudSaveAccountNotifications? + private let requests: CloudSavePrivateRequestGate + private let onCallbackDrainWait: @Sendable () -> Void + private var monitor: Task? + private var callbackRetirement: Task? + private var accountGate = CloudSaveAccessGate() + private var generation = UUID() + private var verifiedAccount: CloudSaveVerifiedAccount? + private var refresh: (id: UUID, task: Task)? + private var bootstraps: [BootstrapKey: Task] = [:] + private var sessions: [WeakSession] = [] + + /// Creates the container and registers account observation before any cloud work can start. + public init(containerIdentifier: String) { + let transport = CloudSaveCloudKitTransport(containerIdentifier: containerIdentifier) + let notifications = CloudSaveAccountNotifications() + self.transport = transport + database = transport.database + self.notifications = notifications + changes = notifications.changes + requests = CloudSavePrivateRequestGate() + onCallbackDrainWait = {} + let channel = AsyncStream.makeStream() + accountEvents = channel.stream + continuation = channel.continuation + } + + /// Injects account events and exact transport operations without requiring CloudKit entitlements. + init( + transport: any CloudSavePrivateTransport, + changes: AsyncStream = AsyncStream { $0.finish() }, + requests: CloudSavePrivateRequestGate = CloudSavePrivateRequestGate(), + onCallbackDrainWait: @escaping @Sendable () -> Void = {} + ) { + self.transport = transport + self.changes = changes + self.requests = requests + self.onCallbackDrainWait = onCallbackDrainWait + database = nil + notifications = nil + let channel = AsyncStream.makeStream() + accountEvents = channel.stream + continuation = channel.continuation + } + + deinit { + accountGate.close() + monitor?.cancel() + refresh?.task.cancel() + for task in bootstraps.values { + task.cancel() + } + continuation.finish() + } + + /// Starts the account-event consumer idempotently; notification registration already exists. + public func startMonitoring() { + guard monitor == nil else { + return + } + monitor = Task { [weak self, changes] in + for await _ in changes { + guard !Task.isCancelled else { + break + } + await self?.accountDidChange() + } + } + } + + /// Revalidates the foreground system context, coalescing overlapping callers. + /// Every new reconciliation invalidates previous account tokens before its first suspension. + public func refreshAccount() async throws -> CloudSaveAccountSnapshot { + startMonitoring() + let work = refresh ?? startRefresh() + defer { if refresh?.id == work.id { refresh = nil } } + let snapshot = try await work.task.value + guard generation == work.id else { + throw CloudSavePrivateError.sessionInvalidated + } + try Task.checkCancellation() + return snapshot + } + + /// Reads or conditionally creates an exact manifest and returns only the canonical server identity. + public func bootstrapManifest( + for account: CloudSaveVerifiedAccount, + zoneName: String, + recordType: String, + recordName: String, + expectedIdentity: Value?, + makeInitialRecord: @escaping @Sendable (CKRecord.ID) throws -> CKRecord, + decodeIdentity: @escaping @Sendable (CKRecord) throws -> Value + ) async throws -> CloudSavePrivateBootstrap { + try validate(account) + let recordID = CKRecord.ID(recordName: recordName, zoneID: CKRecordZone.ID(zoneName: zoneName)) + let key = BootstrapKey( + generation: account.generation, recordID: recordID, + recordType: recordType, permitsCreation: expectedIdentity == nil + ) + let ownsWork = bootstraps[key] == nil + let task: Task + if let existing = bootstraps[key] { + task = existing + } else { + let gate = accountGate + task = Task { + try await self.canonicalManifest( + key: key, gate: gate, makeInitialRecord: makeInitialRecord + ) + } + bootstraps[key] = task + } + defer { if ownsWork { bootstraps[key] = nil } } + do { + let record = try await task.value + try Task.checkCancellation() + try validate(account) + guard record.recordType == recordType, record.recordID == recordID else { + throw CloudSavePrivateError.malformedManifest + } + let identity: Value + do { identity = try decodeIdentity(record) } catch { throw CloudSavePrivateError.malformedManifest } + if let expectedIdentity, expectedIdentity != identity { + throw CloudSavePrivateError.manifestIdentityMismatch + } + CloudSavePrivateLogging.emit(.bootstrap, .verified) + return CloudSavePrivateBootstrap( + account: account, zoneID: recordID.zoneID, value: identity, manifestRecordID: recordID, + validateManifest: { record in + guard record.recordType == recordType, record.recordID == recordID else { + throw CloudSavePrivateError.malformedManifest + } + let incoming: Value + do { incoming = try decodeIdentity(record) } catch { throw CloudSavePrivateError.malformedManifest } + guard incoming == identity else { throw CloudSavePrivateError.manifestIdentityMismatch } + } + ) + } catch { + let failure = CloudSavePrivateError.normalize(error) + if failure as? CloudSavePrivateError == .accountTemporarilyUnavailable { + suspendAccount() + } + CloudSavePrivateLogging.emit(.bootstrap, CloudSavePrivateLogging.outcome(failure)) + throw failure + } + } + + /// Creates an immutable account/zone/client session; no live CloudKit service escapes to the host. + public func makeSession( + for bootstrap: CloudSavePrivateBootstrap, + stateSerialization: CKSyncEngine.State.Serialization?, + client: any CloudSaveClient, + automaticallySync: Bool = true + ) throws -> CloudSavePrivateSession { + try validate(bootstrap.account) + guard let database else { + throw CloudSavePrivateError.transport(.configuration, retryAfterSeconds: nil) + } + let session = CloudSavePrivateSession( + context: try sessionContext(for: bootstrap), + database: database, stateSerialization: stateSerialization, client: client, + automaticallySync: automaticallySync, transport: transport, requests: requests + ) + sessions.removeAll { $0.value == nil } + sessions.append(WeakSession(value: session)) + return session + } + + /// Shares the real generation gate and manifest policy with the engine and deterministic tests. + func sessionContext( + for bootstrap: CloudSavePrivateBootstrap + ) throws -> CloudSavePrivateSessionContext { + try validate(bootstrap.account) + return CloudSavePrivateSessionContext( + account: bootstrap.account, accountGate: accountGate, + zoneID: bootstrap.zoneID, manifestRecordID: bootstrap.manifestRecordID, + validateManifest: bootstrap.validateManifest, + onInvalidation: { [weak self] reason in + await self?.sessionInvalidated(reason, generation: bootstrap.account.generation) + } + ) + } + + // MARK: - Private + + /// Rejects tokens from other coordinators or an invalidated account generation. + private func validate(_ account: CloudSaveVerifiedAccount) throws { + try accountGate.check() + guard account.coordinatorID == coordinatorID, account.generation == generation, + account == verifiedAccount + else { throw CloudSavePrivateError.sessionInvalidated } + } + + /// Fences every old session synchronously and publishes invalidation before cancellation awaits. + private func beginRevalidation() -> CloudSaveAccessGate { + let retiredGate = accountGate + accountGate.close() + generation = UUID() + accountGate = CloudSaveAccessGate(onDrainWait: onCallbackDrainWait) + verifiedAccount = nil + for task in bootstraps.values { + task.cancel() + } + bootstraps.removeAll() + let oldSessions = sessions.compactMap(\.value) + sessions.removeAll() + continuation.yield(.invalidated) + CloudSavePrivateLogging.emit(.account, .invalidated) + Task { for session in oldSessions { await session.invalidate() } } + return retiredGate + } + + /// Resolves availability and identity only in the generation that initiated the request. + private func resolveAccount(generation identifier: UUID) async throws -> CloudSaveAccountSnapshot { + guard generation == identifier else { + throw CloudSavePrivateError.sessionInvalidated + } + let gate = accountGate + do { + let status = try await requests.perform( + validate: { try gate.check() }, + operation: { + try await self.transport.accountStatus() + }) + let snapshot: CloudSaveAccountSnapshot + switch status { + case .available: + let recordID = try await requests.perform( + validate: { try gate.check() }, + operation: { + try await self.transport.userRecordID() + }) + guard !recordID.recordName.isEmpty else { + throw CloudSavePrivateError.sessionInvalidated + } + let account = CloudSaveVerifiedAccount( + recordName: recordID.recordName, coordinatorID: coordinatorID, generation: identifier + ) + try gate.check() + guard generation == identifier else { + throw CloudSavePrivateError.sessionInvalidated + } + verifiedAccount = account + snapshot = .verified(account) + case .noAccount: snapshot = .noAccount + case .restricted: snapshot = .restricted + case .temporarilyUnavailable: snapshot = .temporarilyUnavailable + case .couldNotDetermine: snapshot = .indeterminate + @unknown default: snapshot = .indeterminate + } + try gate.check() + guard generation == identifier else { + throw CloudSavePrivateError.sessionInvalidated + } + continuation.yield(.resolved(snapshot)) + CloudSavePrivateLogging.emit(.account, CloudSavePrivateLogging.outcome(snapshot)) + return snapshot + } catch { + guard generation == identifier else { + throw CloudSavePrivateError.sessionInvalidated + } + let failure = CloudSavePrivateError.normalize(error) + let snapshot: CloudSaveAccountSnapshot = + failure as? CloudSavePrivateError == .accountTemporarilyUnavailable + ? .temporarilyUnavailable : .indeterminate + continuation.yield(.resolved(snapshot)) + CloudSavePrivateLogging.emit(.account, CloudSavePrivateLogging.outcome(failure)) + throw failure + } + } + + /// Supersedes an in-flight verification when the system reports identity uncertainty. + private func accountDidChange() { + refresh?.task.cancel() + refresh = nil + let work = startRefresh() + Task { [weak self] in + _ = try? await work.task.value + await self?.finishRefresh(work.id) + } + } + + /// Starts verification without blocking consumption of later account notifications. + private func startRefresh() -> (id: UUID, task: Task) { + let retiredGate = beginRevalidation() + let identifier = generation + let previousRetirement = callbackRetirement + let retirement = Task { + await previousRetirement?.value + await retiredGate.waitForCallbacks() + CloudSavePrivateLogging.emit(.account, .drained) + } + callbackRetirement = retirement + let task = Task { + await retirement.value + return try await self.resolveAccount(generation: identifier) + } + let work = (id: identifier, task: task) + refresh = work + return work + } + + /// Clears only the completed verification, preserving any newer notification-triggered work. + private func finishRefresh(_ identifier: UUID) { + if refresh?.id == identifier { + refresh = nil + } + } + + /// Reconciles engine account events through the same pre-bootstrap monitoring owner. + private func sessionInvalidated(_ reason: CloudSavePrivateError, generation identifier: UUID) async { + guard generation == identifier else { + return + } + if reason == .accountTemporarilyUnavailable { + suspendAccount() + } else if reason == .sessionInvalidated { + accountDidChange() + } + } + + /// Retains local trust while forbidding further CloudKit work until fresh account verification. + private func suspendAccount() { + guard accountGate.close(.accountTemporarilyUnavailable) else { + return + } + continuation.yield(.resolved(.temporarilyUnavailable)) + let currentSessions = sessions.compactMap(\.value) + Task { for session in currentSessions { await session.suspend() } } + CloudSavePrivateLogging.emit(.account, .suspended) + } + + /// Performs only exact reads and checked first provisioning, then rereads the server winner. + private func canonicalManifest( + key: BootstrapKey, gate: CloudSaveAccessGate, + makeInitialRecord: @Sendable (CKRecord.ID) throws -> CKRecord + ) async throws -> CKRecord { + let recordID = key.recordID + do { + if let existing = try await read(recordID, gate: gate) { + return existing + } + } catch { + guard (error as? CKError)?.code == .zoneNotFound else { + throw error + } + guard key.permitsCreation else { + throw CloudSavePrivateError.missingTrustedManifest + } + try await requests.perform( + validate: { try gate.check() }, + operation: { + try await self.transport.ensureZone(CKRecordZone(zoneID: recordID.zoneID)) + }) + } + guard key.permitsCreation else { + throw CloudSavePrivateError.missingTrustedManifest + } + try gate.check() + let proposed: CKRecord + do { proposed = try makeInitialRecord(recordID) } catch { throw CloudSavePrivateError.malformedManifest } + guard proposed.recordID == recordID, proposed.recordType == key.recordType, + proposed.recordChangeTag == nil + else { throw CloudSavePrivateError.malformedManifest } + do { + try await requests.perform( + validate: { try gate.check() }, + operation: { + do { try await self.transport.createIfAbsent(proposed) } catch { + throw CloudSavePrivateError.itemError(error, for: recordID) + } + }) + } catch { + let itemError = CloudSavePrivateError.itemError(error, for: recordID) + guard (itemError as? CKError)?.code == .serverRecordChanged else { + throw itemError + } + } + guard let winner = try await read(recordID, gate: gate) else { + throw CloudSavePrivateError.missingTrustedManifest + } + return winner + } + + /// Fetches one exact manifest through the shared throttle and lifecycle gate. + private func read(_ recordID: CKRecord.ID, gate: CloudSaveAccessGate) async throws -> CKRecord? { + try await requests.perform( + validate: { try gate.check() }, operation: { try await self.transport.fetchRecord(recordID) }) + } + + /// Separates creation permission so a trusted lookup cannot join an untrusted creator's repair. + private struct BootstrapKey: Hashable { + let generation: UUID + let recordID: CKRecord.ID + let recordType: String + let permitsCreation: Bool + } + + /// Avoids keeping retired sessions and their host stores alive through coordinator ownership. + private struct WeakSession { + weak var value: CloudSavePrivateSession? + } +} diff --git a/Sources/CloudSaveKit/CloudSavePrivateError.swift b/Sources/CloudSaveKit/CloudSavePrivateError.swift new file mode 100644 index 0000000..fa44e27 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSavePrivateError.swift @@ -0,0 +1,57 @@ +import CloudKit +import Foundation + +/// Normalized private-session failures; hosts never need to interpret CloudKit error codes. +public enum CloudSavePrivateError: Error, Equatable, Sendable { + case accountTemporarilyUnavailable + case fetchIncomplete + case hostRecoveryRequired + case malformedManifest + case manifestIdentityMismatch + case missingTrustedManifest + case notStarted + case sessionInvalidated + case suspended + case transport(CloudSaveFailure, retryAfterSeconds: Double?) + case wrongZone + + /// Whether a verified same-account local cache may remain usable after this failure. + public var permitsOfflineUse: Bool { + switch self { + case .accountTemporarilyUnavailable, .fetchIncomplete, .suspended, .transport(.networkUnavailable, _): true + default: false + } + } + + /// Converts internal transport or engine failures without exposing their user-info payloads. + static func normalize(_ error: any Error) -> any Error { + if let error = error as? Self { + return error + } + if CloudSaveRetryPolicy.isCancellation(error) { + return CancellationError() + } + if let error = error as? CloudSaveEngineError { + return switch error { + case .hostRecoveryRequired: Self.hostRecoveryRequired + case .reconfigurationRequired: Self.missingTrustedManifest + case .freshFetchNotObserved, .configuredZoneFetchFailed: Self.fetchIncomplete + case .notStarted: Self.notStarted + } + } + let cloudError = error as? CKError + if cloudError?.code == .accountTemporarilyUnavailable { + return Self.accountTemporarilyUnavailable + } + return Self.transport(CloudSaveFailure(error: error), retryAfterSeconds: cloudError?.retryAfterSeconds) + } + + /// Extracts the exact single-item failure, preserving nested account and permission errors. + static func itemError(_ error: any Error, for item: AnyHashable) -> any Error { + guard let cloudError = error as? CKError, + cloudError.code == .partialFailure, + let itemError = cloudError.partialErrorsByItemID?[item] + else { return error } + return Self.itemError(itemError, for: item) + } +} diff --git a/Sources/CloudSaveKit/CloudSavePrivateLogging.swift b/Sources/CloudSaveKit/CloudSavePrivateLogging.swift new file mode 100644 index 0000000..7f63871 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSavePrivateLogging.swift @@ -0,0 +1,74 @@ +import AppLogger +import Foundation + +/// Formats bounded private-session events without account, record, manifest, or host content. +enum CloudSavePrivateLogging { + /// Package-owned boundaries whose outcome diagnoses personal synchronization. + enum Operation: String, CaseIterable { + case account + case bootstrap + case engine + case recordFetch = "record-fetch" + case session + } + + /// Fixed diagnostic tokens that never contain user or CloudKit identifiers. + enum Outcome: String, CaseIterable { + case cancelled + case drained + case failed + case indeterminate + case integrityFailure = "integrity-failure" + case invalidated + case missing + case noAccount = "no-account" + case restricted + case stale + case succeeded + case suspended + case verified + + /// Expected cancellation and stale rejection do not become operational faults. + var level: AppLogLevel { + switch self { + case .failed, .integrityFailure: .error + case .invalidated, .noAccount, .restricted, .suspended, .verified: .info + default: .debug + } + } + } + + /// Routes each event through the package's existing canonical logging gateway. + static func emit(_ operation: Operation, _ outcome: Outcome) { + CloudSaveLogging.log(level: outcome.level, message(operation, outcome)) + } + + /// Renders one independently testable single-line event body. + static func message(_ operation: Operation, _ outcome: Outcome) -> String { + "private \(operation.rawValue) | result=\(outcome.rawValue)" + } + + /// Classifies failures without interpolating error descriptions or metadata. + static func outcome(_ error: any Error) -> Outcome { + if error is CancellationError { + return .cancelled + } + switch error as? CloudSavePrivateError { + case .sessionInvalidated: return .stale + case .accountTemporarilyUnavailable, .suspended: return .suspended + case .malformedManifest, .manifestIdentityMismatch, .missingTrustedManifest: return .integrityFailure + default: return .failed + } + } + + /// Records availability separately from opaque verified identity. + static func outcome(_ snapshot: CloudSaveAccountSnapshot) -> Outcome { + switch snapshot { + case .indeterminate: .indeterminate + case .noAccount: .noAccount + case .restricted: .restricted + case .temporarilyUnavailable: .suspended + case .verified: .verified + } + } +} diff --git a/Sources/CloudSaveKit/CloudSavePrivateRequestGate.swift b/Sources/CloudSaveKit/CloudSavePrivateRequestGate.swift new file mode 100644 index 0000000..5d0bdb2 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSavePrivateRequestGate.swift @@ -0,0 +1,47 @@ +import CloudKit +import Foundation + +/// Retains server retry timing across manual requests without creating another sync scheduler. +actor CloudSavePrivateRequestGate { + private let now: @Sendable () -> ContinuousClock.Instant + private let sleep: @Sendable (Duration) async throws -> Void + private var nextRequest: ContinuousClock.Instant? + + /// Allows deterministic retry tests without wall-clock sleeps. + init( + now: @escaping @Sendable () -> ContinuousClock.Instant = { .now }, + sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) } + ) { + self.now = now + self.sleep = sleep + } + + /// Waits out a server throttle and checks lifecycle both before and after transport suspension. + func perform( + validate: @Sendable () throws -> Void, + operation: @Sendable () async throws -> Value + ) async throws -> Value { + try Task.checkCancellation() + try validate() + while let nextRequest, nextRequest > now() { + try await sleep(now().duration(to: nextRequest)) + try Task.checkCancellation() + try validate() + } + try Task.checkCancellation() + try validate() + do { + let value = try await operation() + try Task.checkCancellation() + try validate() + return value + } catch { + try validate() + if let delay = (error as? CKError)?.retryAfterSeconds, delay.isFinite, delay > 0 { + let deadline = now().advanced(by: .seconds(delay)) + nextRequest = max(nextRequest ?? deadline, deadline) + } + throw error + } + } +} diff --git a/Sources/CloudSaveKit/CloudSavePrivateSession.swift b/Sources/CloudSaveKit/CloudSavePrivateSession.swift new file mode 100644 index 0000000..1384942 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSavePrivateSession.swift @@ -0,0 +1,152 @@ +public import CloudKit +import Foundation + +/// Synchronizes one permanently bound account, zone, and host store through CloudSaveEngine. +public final actor CloudSavePrivateSession { + public nonisolated let statusUpdates: AsyncStream + private let context: CloudSavePrivateSessionContext + private let engine: any CloudSaveSessionEngine + private let transport: any CloudSavePrivateTransport + private let requests: CloudSavePrivateRequestGate + private var isRetired = false + private var recordRequests: [UUID: Task] = [:] + + /// Creates the package-owned engine with fixed-account and trusted-manifest policies. + init( + context: CloudSavePrivateSessionContext, + database: CKDatabase, stateSerialization: CKSyncEngine.State.Serialization?, + client: any CloudSaveClient, automaticallySync: Bool, + transport: any CloudSavePrivateTransport, requests: CloudSavePrivateRequestGate + ) { + var configuration = CloudSaveConfiguration( + database: database, stateSerialization: stateSerialization, + zone: CKRecordZone(zoneID: context.zoneID), automaticallySync: automaticallySync + ) + configuration.privateSession = context + let engine = CloudSaveEngine( + configuration: configuration, client: CloudSavePrivateClient(client: client, context: context) + ) + self.context = context + self.engine = engine + self.transport = transport + self.requests = requests + statusUpdates = engine.statusUpdates + } + + /// Injects synchronization operations for credential-free session lifecycle tests. + init( + context: CloudSavePrivateSessionContext, engine: any CloudSaveSessionEngine, + transport: any CloudSavePrivateTransport, requests: CloudSavePrivateRequestGate = CloudSavePrivateRequestGate() + ) { + self.context = context + self.engine = engine + self.transport = transport + self.requests = requests + statusUpdates = engine.statusUpdates + } + + deinit { + context.sessionGate.close() + for task in recordRequests.values { + task.cancel() + } + let engine = engine + Task { await engine.retire() } + } + + /// Restores the durable pending ledger; this is not an initial remote-fetch barrier. + public func start() async throws { try await perform { try await self.engine.start() } } + + /// Waits for the existing engine's fresh fetch and host-apply barrier. + public func fetchNow() async throws { try await perform { try await self.engine.fetchNow() } } + + /// Sends locally committed changes, preserving failed uploads in the host ledger. + public func sendNow() async throws { try await perform { try await self.engine.sendNow() } } + + /// Fetches and sends through the same serialized engine operations. + public func syncNow() async throws { try await perform { try await self.engine.syncNow() } } + + /// Enqueues only durable changes belonging to this active account and zone. + public func enqueue(_ changes: [CloudSavePendingChange]) async throws { + try context.validatePending(changes) + try await perform { await self.engine.enqueue(changes) } + } + + /// Fetches an exact record, including assets, and rejects late or wrong-zone results. + public func fetchRecord(_ recordID: CKRecord.ID) async throws -> CKRecord? { + try context.check() + guard recordID.zoneID == context.zoneID else { + throw CloudSavePrivateError.wrongZone + } + let identifier = UUID() + let task = Task { [requests, transport, context] in + let record = try await requests.perform(validate: { try context.check() }) { + try await transport.fetchRecord(recordID) + } + if recordID == context.manifestRecordID { + try context.validateFetchedChanges( + records: record.map { [$0] } ?? [], deletedRecordIDs: record == nil ? [recordID] : [] + ) + } + return record + } + recordRequests[identifier] = task + defer { recordRequests[identifier] = nil } + return try await withTaskCancellationHandler { + try await perform { try await task.value } + } onCancel: { + task.cancel() + } + } + + /// Permanently pauses this handle; a fresh verified session resumes its host's durable ledger. + public func suspend() async { + await retire(reason: .suspended) + } + + /// Permanently revokes this handle before awaiting engine cancellation. + public func invalidate() async { + await retire(reason: .sessionInvalidated) + } + + // MARK: - Private + + /// Normalizes all failures and rejects asynchronous results after terminal lifecycle changes. + private func perform(_ operation: @Sendable () async throws -> Value) async throws -> Value { + try Task.checkCancellation() + try context.check() + do { + let result = try await operation() + try Task.checkCancellation() + try context.check() + return result + } catch { + do { try context.validateTransportError(error) } catch let reason as CloudSavePrivateError { + context.sessionGate.close(reason) + await context.onInvalidation(reason) + await retire(reason: reason) + throw reason + } + try context.check() + throw CloudSavePrivateError.normalize(error) + } + } + + /// Closes gates first; cancellation never controls which account can publish a result. + private func retire(reason: CloudSavePrivateError) async { + guard !isRetired else { + await context.sessionGate.waitForCallbacks() + return + } + isRetired = true + context.sessionGate.close(reason) + for task in recordRequests.values { + task.cancel() + } + recordRequests.removeAll() + CloudSavePrivateLogging.emit(.session, CloudSavePrivateLogging.outcome(reason)) + await engine.retire() + await context.sessionGate.waitForCallbacks() + CloudSavePrivateLogging.emit(.session, .drained) + } +} diff --git a/Sources/CloudSaveKit/CloudSavePrivateSessionContext.swift b/Sources/CloudSaveKit/CloudSavePrivateSessionContext.swift new file mode 100644 index 0000000..38a6dfc --- /dev/null +++ b/Sources/CloudSaveKit/CloudSavePrivateSessionContext.swift @@ -0,0 +1,106 @@ +import CloudKit +import Foundation + +/// Immutable account and zone ownership shared by transport, engine, and host-callback guards. +final class CloudSavePrivateSessionContext: Sendable { + let account: CloudSaveVerifiedAccount + let accountGate: CloudSaveAccessGate + let sessionGate: CloudSaveAccessGate + let zoneID: CKRecordZone.ID + let manifestRecordID: CKRecord.ID + let validateManifest: @Sendable (CKRecord) throws -> Void + let onInvalidation: @Sendable (CloudSavePrivateError) async -> Void + + /// Captures the exact verified generation and host topology for this session's lifetime. + init( + account: CloudSaveVerifiedAccount, accountGate: CloudSaveAccessGate, + sessionGate: CloudSaveAccessGate = CloudSaveAccessGate(), + zoneID: CKRecordZone.ID, manifestRecordID: CKRecord.ID, + validateManifest: @escaping @Sendable (CKRecord) throws -> Void, + onInvalidation: @escaping @Sendable (CloudSavePrivateError) async -> Void + ) { + self.account = account + self.accountGate = accountGate + self.sessionGate = sessionGate + self.zoneID = zoneID + self.manifestRecordID = manifestRecordID + self.validateManifest = validateManifest + self.onInvalidation = onInvalidation + } + + /// Checks both account-wide revocation and this particular engine's terminal lifecycle. + func check() throws { + try accountGate.check() + try sessionGate.check() + } + + /// Tracks every admitted host callback across both session retirement and account generations. + func withClientCallback( + _ operation: @Sendable () async throws -> Value + ) async throws -> Value { + try accountGate.beginCallback() + defer { accountGate.endCallback() } + try sessionGate.beginCallback() + defer { sessionGate.endCallback() } + let value = try await operation() + try check() + return value + } + + /// Rejects manifest drift before applying any domain record in the same batch. + func validateFetchedChanges(records: [CKRecord], deletedRecordIDs: [CKRecord.ID]) throws { + try check() + do { + if deletedRecordIDs.contains(manifestRecordID) { + throw CloudSavePrivateError.missingTrustedManifest + } + for record in records where record.recordID == manifestRecordID { + try validateManifest(record) + } + } catch let reason as CloudSavePrivateError { + sessionGate.close(reason) + throw reason + } + } + + /// Only a matching sign-in may reconcile the original client's durable ledger. + func validate(_ change: CloudSaveAccountChange) throws { + try check() + guard case .signedIn(let identity) = change, identity == account.recordName else { + sessionGate.close() + throw CloudSavePrivateError.sessionInvalidated + } + } + + /// Prevents the host ledger from altering its independently provisioned manifest. + func validatePending(_ changes: [CloudSavePendingChange]) throws { + try check() + guard changes.allSatisfy({ $0.recordID.zoneID == zoneID && $0.recordID != manifestRecordID }) else { + throw CloudSavePrivateError.wrongZone + } + } + + /// Extracts fatal account/zone failures before engine retry or destructive recovery callbacks. + func validateTransportError(_ error: any Error) throws { + if let reason = error as? CloudSavePrivateError { + switch reason { + case .accountTemporarilyUnavailable, .sessionInvalidated, .missingTrustedManifest, + .manifestIdentityMismatch, .malformedManifest: + throw reason + default: + break + } + } + let cloudError = error as? CKError + switch cloudError?.code { + case .accountTemporarilyUnavailable: throw CloudSavePrivateError.accountTemporarilyUnavailable + case .notAuthenticated: throw CloudSavePrivateError.sessionInvalidated + case .zoneNotFound, .userDeletedZone: throw CloudSavePrivateError.missingTrustedManifest + case .partialFailure: + for nested in cloudError?.partialErrorsByItemID?.values ?? [:].values { + try validateTransportError(nested) + } + default: break + } + } +} diff --git a/Sources/CloudSaveKit/CloudSavePrivateTransport.swift b/Sources/CloudSaveKit/CloudSavePrivateTransport.swift new file mode 100644 index 0000000..427fd08 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSavePrivateTransport.swift @@ -0,0 +1,10 @@ +import CloudKit + +/// Internal CloudKit seam for deterministic account and conditional-provisioning tests. +protocol CloudSavePrivateTransport: Sendable { + func accountStatus() async throws -> CKAccountStatus + func userRecordID() async throws -> CKRecord.ID + func fetchRecord(_ recordID: CKRecord.ID) async throws -> CKRecord? + func ensureZone(_ zone: CKRecordZone) async throws + func createIfAbsent(_ record: CKRecord) async throws +} diff --git a/Sources/CloudSaveKit/CloudSaveSessionEngine.swift b/Sources/CloudSaveKit/CloudSaveSessionEngine.swift new file mode 100644 index 0000000..b4c0f9f --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveSessionEngine.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Narrow internal synchronization seam; production sessions always use the existing engine. +protocol CloudSaveSessionEngine: Actor { + nonisolated var statusUpdates: AsyncStream { get } + func start() async throws + func fetchNow() async throws + func sendNow() async throws + func syncNow() async throws + func enqueue(_ changes: [CloudSavePendingChange]) async + func retire() async +} + +extension CloudSaveEngine: CloudSaveSessionEngine {} diff --git a/Sources/CloudSaveKit/CloudSaveVerifiedAccount.swift b/Sources/CloudSaveKit/CloudSaveVerifiedAccount.swift new file mode 100644 index 0000000..c2c6ab5 --- /dev/null +++ b/Sources/CloudSaveKit/CloudSaveVerifiedAccount.swift @@ -0,0 +1,9 @@ +import Foundation + +/// A current account identity verified by one coordinator, never reconstructed from persisted text. +public struct CloudSaveVerifiedAccount: Hashable, Sendable { + /// An opaque CloudKit identity suitable for private local-store provenance, never logging. + public let recordName: String + let coordinatorID: UUID + let generation: UUID +} diff --git a/Tests/CloudSaveKitTests/CloudSavePrivateCoordinatorTests.swift b/Tests/CloudSaveKitTests/CloudSavePrivateCoordinatorTests.swift new file mode 100644 index 0000000..75f566a --- /dev/null +++ b/Tests/CloudSaveKitTests/CloudSavePrivateCoordinatorTests.swift @@ -0,0 +1,207 @@ +import CloudKit +import Foundation +import Testing + +@testable import CloudSaveKit + +/// Verifies conditional provisioning and verified-account provenance with no CloudKit credentials. +struct CloudSavePrivateCoordinatorTests { + @Test func repeatedAccountNotificationsSupersedeSuspendedIdentityResolution() async throws { + let server = CloudSavePrivateTransportMock() + let notifications = AsyncStream.makeStream() + let coordinator = CloudSavePrivateCoordinator(transport: server, changes: notifications.stream) + let suspension = CloudSaveTestSuspension() + await server.pauseIdentity(suspension) + let original = Task { try await coordinator.refreshAccount() } + await suspension.waitUntilSuspended() + var updates = coordinator.accountEvents.makeAsyncIterator() + #expect(await updates.next() == .invalidated) + await server.setAccount("account-b") + notifications.continuation.yield(()) + #expect(await updates.next() == .invalidated) + await suspension.waitUntilSuspended(count: 2) + await server.setAccount("account-a") + notifications.continuation.yield(()) + #expect(await updates.next() == .invalidated) + await suspension.waitUntilSuspended(count: 3) + await suspension.resume() + await #expect(throws: CloudSavePrivateError.sessionInvalidated) { try await original.value } + guard case .resolved(.verified(let current)) = await updates.next() else { + Issue.record("Only the final account generation may publish verification.") + return + } + #expect(current.recordName == "account-a") + #expect(try await CloudSaveManifestFixture.bootstrap(coordinator, account: current).value == 1) + } + + @Test func failedColdIdentityResolutionNeverExposesAUsableAccount() async throws { + let server = CloudSavePrivateTransportMock() + await server.failIdentity(CKError(.networkUnavailable)) + let coordinator = CloudSavePrivateCoordinator(transport: server) + await #expect(throws: CloudSavePrivateError.transport(.networkUnavailable, retryAfterSeconds: nil)) { + try await coordinator.refreshAccount() + } + var updates = coordinator.accountEvents.makeAsyncIterator() + #expect(await updates.next() == .invalidated) + #expect(await updates.next() == .resolved(.indeterminate)) + #expect(await server.fetchCount == 0) + } + + @Test func nestedPerRecordConflictRereadsCanonicalWinner() async throws { + let server = CloudSavePrivateTransportMock() + await server.seed() + let conflict = CKError( + .partialFailure, + userInfo: [CKPartialErrorsByItemIDKey: [CloudSaveManifestFixture.recordID: CKError(.serverRecordChanged)]] + ) + await server.conflictOnCreate(winner: CloudSaveManifestFixture.record(identity: 42), error: conflict) + let coordinator = CloudSavePrivateCoordinator(transport: server) + let account = try await CloudSaveManifestFixture.account(coordinator) + #expect(try await CloudSaveManifestFixture.bootstrap(coordinator, account: account).value == 42) + #expect(await server.createCount == 1) + } + + @Test func firstAndRepeatedProvisioningReadCanonicalServerIdentity() async throws { + let server = CloudSavePrivateTransportMock() + let coordinator = CloudSavePrivateCoordinator(transport: server) + let account = try await CloudSaveManifestFixture.account(coordinator) + let first = try await CloudSaveManifestFixture.bootstrap(coordinator, account: account, proposed: 7) + let next = try await CloudSaveManifestFixture.bootstrap(coordinator, account: account, expected: 7, proposed: 8) + #expect(first.value == 7) + #expect(next.value == 7) + #expect(await server.zoneSaveCount == 1) + #expect(await server.createCount == 1) + } + + @Test func concurrentClientsAlwaysBindTheConditionalCreateWinner() async throws { + let server = CloudSavePrivateTransportMock() + await server.seed() + let suspension = CloudSaveTestSuspension() + await server.pauseFetch(suspension) + let phone = CloudSavePrivateCoordinator(transport: server) + let television = CloudSavePrivateCoordinator(transport: server) + let phoneAccount = try await CloudSaveManifestFixture.account(phone) + let televisionAccount = try await CloudSaveManifestFixture.account(television) + let first = Task { try await CloudSaveManifestFixture.bootstrap(phone, account: phoneAccount, proposed: 7) } + let second = Task { + try await CloudSaveManifestFixture.bootstrap(television, account: televisionAccount, proposed: 8) + } + await suspension.waitUntilSuspended(count: 2) + await suspension.resume() + let results = try await (first.value, second.value) + #expect(results.0.value == results.1.value) + #expect([7, 8].contains(results.0.value)) + #expect(await server.createCount == 2) + #expect(await server.zoneSaveCount == 0) + } + + @Test func interruptedZoneOnlyProvisioningFinishesWithoutReplacingAnything() async throws { + let server = CloudSavePrivateTransportMock() + await server.seed() + let coordinator = CloudSavePrivateCoordinator(transport: server) + let account = try await CloudSaveManifestFixture.account(coordinator) + #expect(try await CloudSaveManifestFixture.bootstrap(coordinator, account: account).value == 1) + #expect(await server.zoneSaveCount == 0) + #expect(await server.createCount == 1) + } + + @Test func lostCreateAcknowledgementIsRediscoveredWithoutSecondCreate() async throws { + let server = CloudSavePrivateTransportMock() + await server.loseNextCreateAcknowledgement() + let coordinator = CloudSavePrivateCoordinator(transport: server) + let account = try await CloudSaveManifestFixture.account(coordinator) + await #expect(throws: CloudSavePrivateError.transport(.networkUnavailable, retryAfterSeconds: nil)) { + try await CloudSaveManifestFixture.bootstrap(coordinator, account: account, proposed: 7) + } + #expect(try await CloudSaveManifestFixture.bootstrap(coordinator, account: account, proposed: 8).value == 7) + #expect(await server.createCount == 1) + } + + @Test(arguments: [false, true]) + func knownMissingManifestNeverCreatesAReplacement(zoneExists: Bool) async throws { + let server = CloudSavePrivateTransportMock() + await server.seed(zoneExists: zoneExists) + let coordinator = CloudSavePrivateCoordinator(transport: server) + let account = try await CloudSaveManifestFixture.account(coordinator) + await #expect(throws: CloudSavePrivateError.missingTrustedManifest) { + try await CloudSaveManifestFixture.bootstrap(coordinator, account: account, expected: 7) + } + #expect(await server.createCount == 0) + #expect(await server.zoneSaveCount == 0) + } + + @Test func networkFailureNeverMasqueradesAsAbsence() async throws { + let server = CloudSavePrivateTransportMock() + await server.failFetch(CKError(.networkFailure)) + let coordinator = CloudSavePrivateCoordinator(transport: server) + let account = try await CloudSaveManifestFixture.account(coordinator) + await #expect(throws: CloudSavePrivateError.transport(.networkUnavailable, retryAfterSeconds: nil)) { + try await CloudSaveManifestFixture.bootstrap(coordinator, account: account) + } + #expect(await server.createCount == 0) + #expect(await server.zoneSaveCount == 0) + } + + @Test func malformedAndContradictoryManifestFailClosed() async throws { + let server = CloudSavePrivateTransportMock() + let coordinator = CloudSavePrivateCoordinator(transport: server) + let account = try await CloudSaveManifestFixture.account(coordinator) + await server.seed(record: CloudSaveManifestFixture.record(identity: 8)) + await #expect(throws: CloudSavePrivateError.manifestIdentityMismatch) { + try await CloudSaveManifestFixture.bootstrap(coordinator, account: account, expected: 7) + } + await server.seed(record: CKRecord(recordType: "NotebookManifest", recordID: CloudSaveManifestFixture.recordID)) + await #expect(throws: CloudSavePrivateError.malformedManifest) { + try await CloudSaveManifestFixture.bootstrap(coordinator, account: account) + } + #expect(await server.createCount == 0) + } + + @Test func staleOrForeignVerifiedTokensCannotProvision() async throws { + let server = CloudSavePrivateTransportMock() + let first = CloudSavePrivateCoordinator(transport: server) + let second = CloudSavePrivateCoordinator(transport: server) + let oldAccount = try await CloudSaveManifestFixture.account(first) + _ = try await CloudSaveManifestFixture.account(second) + await #expect(throws: CloudSavePrivateError.sessionInvalidated) { + try await CloudSaveManifestFixture.bootstrap(second, account: oldAccount) + } + await server.setAccount("account-b") + _ = try await CloudSaveManifestFixture.account(first) + await #expect(throws: CloudSavePrivateError.sessionInvalidated) { + try await CloudSaveManifestFixture.bootstrap(first, account: oldAccount) + } + #expect(await server.fetchCount == 0) + } + + @Test(arguments: [CKAccountStatus.noAccount, .restricted, .temporarilyUnavailable, .couldNotDetermine]) + func unverifiedAvailabilityNeverReadsIdentityOrRecords(status: CKAccountStatus) async throws { + let server = CloudSavePrivateTransportMock() + await server.setAccount("unverified", status: status) + let coordinator = CloudSavePrivateCoordinator(transport: server) + _ = try await coordinator.refreshAccount() + #expect(await server.identityCount == 0) + #expect(await server.fetchCount == 0) + } + + @Test func accountNotificationFencesSuspendedBootstrapBeforeResolvingNewIdentity() async throws { + let server = CloudSavePrivateTransportMock() + let events = AsyncStream.makeStream() + let coordinator = CloudSavePrivateCoordinator(transport: server, changes: events.stream) + let account = try await CloudSaveManifestFixture.account(coordinator) + var updates = coordinator.accountEvents.makeAsyncIterator() + #expect(await updates.next() == .invalidated) + #expect(await updates.next() == .resolved(.verified(account))) + let suspension = CloudSaveTestSuspension() + await server.pauseFetch(suspension) + let operation = Task { try await CloudSaveManifestFixture.bootstrap(coordinator, account: account) } + await suspension.waitUntilSuspended() + await server.setAccount("account-b") + events.continuation.yield(()) + #expect(await updates.next() == .invalidated) + await suspension.resume() + await #expect(throws: CloudSavePrivateError.sessionInvalidated) { try await operation.value } + #expect(await server.zoneSaveCount == 0) + #expect(await server.createCount == 0) + } +} diff --git a/Tests/CloudSaveKitTests/CloudSavePrivateIntegrityTests.swift b/Tests/CloudSaveKitTests/CloudSavePrivateIntegrityTests.swift new file mode 100644 index 0000000..2158be4 --- /dev/null +++ b/Tests/CloudSaveKitTests/CloudSavePrivateIntegrityTests.swift @@ -0,0 +1,226 @@ +import CloudKit +import Foundation +import Testing + +@testable import CloudSaveKit + +/// Exercises the policies used by production event handling and host callback admission. +struct CloudSavePrivateIntegrityTests { + @Test func unchangedManifestIsAppliedButChangedIdentityFencesTheWholeBatch() async throws { + let (context, coordinator) = try await context() + defer { withExtendedLifetime(coordinator) {} } + let host = CloudSavePrivateClientMock() + let client = CloudSavePrivateClient(client: host, context: context) + let unchanged = CloudSaveManifestFixture.record(identity: 7) + try await client.applyFetchedChanges(records: [unchanged], deletedRecordIDs: []) + #expect(await host.appliedRecords.count == 1) + let domain = CKRecord(recordType: "Note", recordID: noteID) + await #expect(throws: CloudSavePrivateError.manifestIdentityMismatch) { + try await client.applyFetchedChanges( + records: [domain, CloudSaveManifestFixture.record(identity: 8)], deletedRecordIDs: []) + } + #expect(await host.appliedRecords.count == 1) + #expect(throws: CloudSavePrivateError.manifestIdentityMismatch) { + try context.validatePending([.save(noteID)]) + } + } + + @Test func malformedManifestFencesDomainApplyAndPendingSend() async throws { + let (context, coordinator) = try await context() + defer { withExtendedLifetime(coordinator) {} } + let host = CloudSavePrivateClientMock() + let client = CloudSavePrivateClient(client: host, context: context) + let malformed = CKRecord(recordType: "NotebookManifest", recordID: CloudSaveManifestFixture.recordID) + await #expect(throws: CloudSavePrivateError.malformedManifest) { + try await client.applyFetchedChanges(records: [malformed], deletedRecordIDs: []) + } + let engine = CloudSaveSessionEngineMock(client: client) + let session = CloudSavePrivateSession( + context: context, engine: engine, transport: CloudSavePrivateTransportMock()) + await #expect(throws: CloudSavePrivateError.malformedManifest) { try await session.sendNow() } + #expect(await host.appliedRecords.isEmpty) + #expect(await engine.sendCount == 0) + } + + @Test func deletedManifestUsesSameEventPolicyAndNeverAppliesDomainData() async throws { + let (context, coordinator) = try await context() + defer { withExtendedLifetime(coordinator) {} } + #expect(throws: CloudSavePrivateError.missingTrustedManifest) { + try context.validateFetchedChanges(records: [], deletedRecordIDs: [CloudSaveManifestFixture.recordID]) + } + #expect(throws: CloudSavePrivateError.missingTrustedManifest) { try context.check() } + } + + @Test func privateFetchRetainsNetworkFailureWhileGenericFetchKeepsItsExistingError() async throws { + let fetches = CloudSaveFetchCoordinator() + let request = try await fetches.prepareRequest() + await fetches.beginFetch() + await fetches.failConfiguredZoneFetch(error: CKError(.networkUnavailable)) + await fetches.completeFetch() + do { + try await fetches.validate(request, preservingTransportError: true) + Issue.record("Configured-zone network failure must fail the fetch barrier.") + } catch { + let normalized = CloudSavePrivateError.normalize(error) as? CloudSavePrivateError + #expect(normalized == .transport(.networkUnavailable, retryAfterSeconds: nil)) + #expect(normalized?.permitsOfflineUse == true) + } + await #expect(throws: CloudSaveEngineError.configuredZoneFetchFailed) { try await fetches.validate(request) } + #expect( + CloudSavePrivateError.normalize(CloudSaveEngineError.hostRecoveryRequired) as? CloudSavePrivateError + == .hostRecoveryRequired) + #expect( + CloudSavePrivateError.normalize(CloudSaveEngineError.freshFetchNotObserved) as? CloudSavePrivateError + == .fetchIncomplete) + #expect(!CloudSavePrivateError.hostRecoveryRequired.permitsOfflineUse) + #expect(!CloudSavePrivateError.missingTrustedManifest.permitsOfflineUse) + } + + @Test func configuredZoneIntegrityFailureRetainsTheTerminalSessionReason() async throws { + let (context, coordinator) = try await context() + defer { withExtendedLifetime(coordinator) {} } + #expect(throws: CloudSavePrivateError.missingTrustedManifest) { + try context.validateTransportError(CKError(.zoneNotFound)) + } + let fetches = CloudSaveFetchCoordinator() + let request = try await fetches.prepareRequest() + await fetches.beginFetch() + await fetches.failConfiguredZoneFetch(error: CKError(.permissionFailure)) + await fetches.completeFetch() + do { try await fetches.validate(request, preservingTransportError: true) } catch { + #expect( + CloudSavePrivateError.normalize(error) as? CloudSavePrivateError + == .transport(.restricted, retryAfterSeconds: nil)) + } + } + + @Test func accountReverificationDrainsOldCheckpointWritesBeforeReturningToTheSameStore() async throws { + let server = CloudSavePrivateTransportMock() + let changes = AsyncStream.makeStream() + let drains = AsyncStream.makeStream() + let coordinator = CloudSavePrivateCoordinator( + transport: server, changes: changes.stream, onCallbackDrainWait: { drains.continuation.yield(()) }) + let account = try await CloudSaveManifestFixture.account(coordinator) + let bootstrap = try await CloudSaveManifestFixture.bootstrap(coordinator, account: account, proposed: 7) + let oldContext = try await coordinator.sessionContext(for: bootstrap) + let durable = CheckpointStore() + let write = CloudSaveTestSuspension() + let oldWrite = Task { + try await oldContext.withClientCallback { await durable.persist(1, after: write) } + } + await write.waitUntilSuspended() + var events = coordinator.accountEvents.makeAsyncIterator() + _ = await events.next() + _ = await events.next() + await server.setAccount("account-b") + changes.continuation.yield(()) + #expect(await events.next() == .invalidated) + var drainEvents = drains.stream.makeAsyncIterator() + _ = await drainEvents.next() + await server.setAccount("account-a") + changes.continuation.yield(()) + #expect(await events.next() == .invalidated) + #expect(await server.identityCount == 1) + await write.resume() + await #expect(throws: CloudSavePrivateError.sessionInvalidated) { try await oldWrite.value } + guard case .resolved(.verified(let returning)) = await events.next() else { + Issue.record("Only the returning account can publish after old callbacks drain.") + return + } + #expect(returning.recordName == account.recordName) + let nextBootstrap = try await CloudSaveManifestFixture.bootstrap(coordinator, account: returning, expected: 7) + let next = try await coordinator.sessionContext(for: nextBootstrap) + try await next.withClientCallback { await durable.persist(2) } + #expect(await durable.writes == [1, 2]) + #expect(await durable.current == 2) + } + + @Test func exactManifestReadRetiresItsEngineWhenIdentityDrifts() async throws { + let (context, coordinator) = try await context() + defer { withExtendedLifetime(coordinator) {} } + let transport = CloudSavePrivateTransportMock() + await transport.seed(record: CloudSaveManifestFixture.record(identity: 8)) + let engine = CloudSaveSessionEngineMock() + let session = CloudSavePrivateSession(context: context, engine: engine, transport: transport) + await #expect(throws: CloudSavePrivateError.manifestIdentityMismatch) { + try await session.fetchRecord(CloudSaveManifestFixture.recordID) + } + #expect(await engine.retireCount == 1) + await #expect(throws: CloudSavePrivateError.manifestIdentityMismatch) { try await session.sendNow() } + #expect(await engine.sendCount == 0) + } + + @Test func publicSessionFetchPreservesConfiguredZoneNetworkClassification() async throws { + let (context, coordinator) = try await context() + defer { withExtendedLifetime(coordinator) {} } + let fetches = CloudSaveFetchCoordinator() + let engine = CloudSaveSessionEngineMock(fetch: { + let request = try await fetches.prepareRequest() + await fetches.beginFetch() + await fetches.failConfiguredZoneFetch(error: CKError(.networkUnavailable)) + await fetches.completeFetch() + try await fetches.validate(request, preservingTransportError: true) + }) + let session = CloudSavePrivateSession( + context: context, engine: engine, transport: CloudSavePrivateTransportMock()) + await #expect(throws: CloudSavePrivateError.transport(.networkUnavailable, retryAfterSeconds: nil)) { + try await session.fetchNow() + } + #expect(await engine.retireCount == 0) + } + + @Test func recoveryReplacementWaitsForOldHostWritesEvenAfterEngineCancellationReturns() async throws { + let (original, coordinator) = try await context() + defer { withExtendedLifetime(coordinator) {} } + let drains = AsyncStream.makeStream() + let gate = CloudSaveAccessGate(onDrainWait: { drains.continuation.yield(()) }) + let context = CloudSavePrivateSessionContext( + account: original.account, accountGate: original.accountGate, sessionGate: gate, + zoneID: original.zoneID, manifestRecordID: original.manifestRecordID, + validateManifest: original.validateManifest, onInvalidation: { _ in } + ) + let durable = CheckpointStore() + let paused = CloudSaveTestSuspension() + let oldWrite = Task { try await context.withClientCallback { await durable.persist(1, after: paused) } } + await paused.waitUntilSuspended() + let engine = CloudSaveSessionEngineMock() + let session = CloudSavePrivateSession( + context: context, engine: engine, transport: CloudSavePrivateTransportMock()) + let replacement = Task { + await session.invalidate() + // Recovery reads and replaces the checkpoint only after retirement's host barrier. + try await original.withClientCallback { await durable.persist(2) } + } + var events = drains.stream.makeAsyncIterator() + _ = await events.next() + #expect(await engine.retireCount == 1) + #expect(await durable.writes.isEmpty) + await paused.resume() + await #expect(throws: CloudSavePrivateError.sessionInvalidated) { try await oldWrite.value } + try await replacement.value + #expect(await durable.current == 2) + #expect(await durable.writes == [1, 2]) + } + + private var noteID: CKRecord.ID { CKRecord.ID(recordName: "note", zoneID: CloudSaveManifestFixture.zoneID) } + + /// Uses the coordinator's actual captured decoder and generation gate, not a test-only policy. + private func context() async throws -> (CloudSavePrivateSessionContext, CloudSavePrivateCoordinator) { + let coordinator = CloudSavePrivateCoordinator(transport: CloudSavePrivateTransportMock()) + let account = try await CloudSaveManifestFixture.account(coordinator) + let bootstrap = try await CloudSaveManifestFixture.bootstrap(coordinator, account: account, proposed: 7) + return (try await coordinator.sessionContext(for: bootstrap), coordinator) + } +} + +/// Models opaque checkpoint writes at the same callback lease boundary used by client.persist. +private actor CheckpointStore { + private(set) var current = 0 + private(set) var writes: [Int] = [] + + func persist(_ value: Int, after suspension: CloudSaveTestSuspension? = nil) async { + await suspension?.suspend() + current = value + writes.append(value) + } +} diff --git a/Tests/CloudSaveKitTests/CloudSavePrivateLoggingTests.swift b/Tests/CloudSaveKitTests/CloudSavePrivateLoggingTests.swift new file mode 100644 index 0000000..e293eb4 --- /dev/null +++ b/Tests/CloudSaveKitTests/CloudSavePrivateLoggingTests.swift @@ -0,0 +1,35 @@ +import AppLogger +import Foundation +import Testing + +@testable import CloudSaveKit + +/// Verifies the private lifecycle's complete vocabulary without querying OS log storage. +struct CloudSavePrivateLoggingTests { + @Test(arguments: CloudSavePrivateLogging.Operation.allCases, CloudSavePrivateLogging.Outcome.allCases) + func eventMessagesRemainBoundedPrefixedAndSingleLine( + operation: CloudSavePrivateLogging.Operation, outcome: CloudSavePrivateLogging.Outcome + ) { + let message = CloudSaveLogging.formatted(CloudSavePrivateLogging.message(operation, outcome)) + #expect(message == "☁️ private \(operation.rawValue) | result=\(outcome.rawValue)") + #expect(!message.contains("\n")) + #expect(CloudSaveLogging.subsystem == "com.thatfactory.cloudsavekit") + } + + @Test func accountIdentityAndErrorPayloadNeverEnterEvents() { + let account = CloudSaveVerifiedAccount( + recordName: "private-account-must-not-appear", coordinatorID: UUID(), generation: UUID() + ) + #expect(CloudSavePrivateLogging.outcome(.verified(account)) == .verified) + let error = NSError( + domain: "private-record-must-not-appear", code: 17, userInfo: [NSLocalizedDescriptionKey: "private"]) + #expect(CloudSavePrivateLogging.outcome(error) == .failed) + #expect(CloudSavePrivateLogging.outcome(CancellationError()) == .cancelled) + #expect(CloudSavePrivateLogging.outcome(CloudSavePrivateError.sessionInvalidated) == .stale) + #expect(CloudSavePrivateLogging.outcome(CloudSavePrivateError.accountTemporarilyUnavailable) == .suspended) + #expect(CloudSavePrivateLogging.outcome(CloudSavePrivateError.missingTrustedManifest) == .integrityFailure) + #expect(CloudSavePrivateLogging.Outcome.failed.level == .error) + #expect(CloudSavePrivateLogging.Outcome.cancelled.level == .debug) + #expect(CloudSavePrivateLogging.Outcome.verified.level == .info) + } +} diff --git a/Tests/CloudSaveKitTests/CloudSavePrivateSessionTests.swift b/Tests/CloudSaveKitTests/CloudSavePrivateSessionTests.swift new file mode 100644 index 0000000..1a01949 --- /dev/null +++ b/Tests/CloudSaveKitTests/CloudSavePrivateSessionTests.swift @@ -0,0 +1,199 @@ +import CloudKit +import Foundation +import Synchronization +import Testing + +@testable import CloudSaveKit + +/// Tests immutable client binding, late completion fencing, suspension, and guarded recovery policy. +struct CloudSavePrivateSessionTests { + @Test func accountInvalidationIsPublishedBeforeAwaitingEngineCancellation() async throws { + let signal = AsyncStream.makeStream() + let context = CloudSavePrivateSessionContext( + account: CloudSaveVerifiedAccount(recordName: "account-a", coordinatorID: UUID(), generation: UUID()), + accountGate: CloudSaveAccessGate(), zoneID: CloudSaveManifestFixture.zoneID, + manifestRecordID: CloudSaveManifestFixture.recordID, validateManifest: { _ in }, + onInvalidation: { signal.continuation.yield($0) } + ) + let engine = CloudSaveSessionEngineMock() + let retirement = CloudSaveTestSuspension() + await engine.pauseRetirement(retirement) + await engine.failSend(CKError(.notAuthenticated)) + let session = CloudSavePrivateSession( + context: context, engine: engine, transport: CloudSavePrivateTransportMock()) + let request = Task { try await session.sendNow() } + var events = signal.stream.makeAsyncIterator() + #expect(await events.next() == .sessionInvalidated) + await retirement.waitUntilSuspended() + #expect(throws: CloudSavePrivateError.sessionInvalidated) { try context.check() } + await retirement.resume() + await #expect(throws: CloudSavePrivateError.sessionInvalidated) { try await request.value } + } + + @Test func invalidationRejectsLateAssetWithoutPublishingOrStartingMoreRequests() async throws { + let server = CloudSavePrivateTransportMock() + await server.seed(record: CloudSaveManifestFixture.record(identity: 7)) + let suspension = CloudSaveTestSuspension() + await server.pauseFetch(suspension) + let context = Self.context() + let engine = CloudSaveSessionEngineMock() + let session = CloudSavePrivateSession(context: context, engine: engine, transport: server) + let operation = Task { try await session.fetchRecord(CloudSaveManifestFixture.recordID) } + await suspension.waitUntilSuspended() + await session.invalidate() + await suspension.resume() + await #expect(throws: CloudSavePrivateError.sessionInvalidated) { try await operation.value } + await #expect(throws: CloudSavePrivateError.sessionInvalidated) { + try await session.fetchRecord(CloudSaveManifestFixture.recordID) + } + #expect(await server.fetchCount == 1) + #expect(await engine.retireCount == 1) + } + + @Test func wrongZoneFailsBeforeTransport() async throws { + let server = CloudSavePrivateTransportMock() + let session = CloudSavePrivateSession( + context: Self.context(), engine: CloudSaveSessionEngineMock(), transport: server) + await #expect(throws: CloudSavePrivateError.wrongZone) { + try await session.fetchRecord(CKRecord.ID(recordName: "asset", zoneID: CKRecordZone.ID(zoneName: "Other"))) + } + #expect(await server.fetchCount == 0) + } + + @Test func temporaryFailureRetiresSessionAndPreservesLocalCommitForFreshSession() async throws { + let client = CloudSavePrivateClientMock() + let server = CloudSavePrivateTransportMock() + let firstContext = Self.context() + let firstEngine = CloudSaveSessionEngineMock( + client: CloudSavePrivateClient(client: client, context: firstContext)) + let first = CloudSavePrivateSession(context: firstContext, engine: firstEngine, transport: server) + await firstEngine.failSend(CKError(.accountTemporarilyUnavailable)) + await #expect(throws: CloudSavePrivateError.accountTemporarilyUnavailable) { try await first.sendNow() } + let pending = CloudSavePendingChange.save(Self.recordID) + await client.commit(pending) + await #expect(throws: CloudSavePrivateError.accountTemporarilyUnavailable) { + try await first.enqueue([pending]) + } + await #expect(throws: CloudSavePrivateError.accountTemporarilyUnavailable) { try await first.sendNow() } + #expect(await firstEngine.sendCount == 1) + #expect(await firstEngine.enqueued.isEmpty) + let nextContext = Self.context() + let nextEngine = CloudSaveSessionEngineMock( + client: CloudSavePrivateClient(client: client, context: nextContext)) + let next = CloudSavePrivateSession(context: nextContext, engine: nextEngine, transport: server) + try await next.start() + #expect(await nextEngine.restored == [pending]) + #expect(await client.pendingChanges() == [pending]) + } + + @Test func staleLedgerAndMaterializationCannotCrossAccountBoundary() async throws { + let client = CloudSavePrivateClientMock() + let suspension = CloudSaveTestSuspension() + await client.commit(.save(Self.recordID)) + await client.pauseReads(suspension) + let context = Self.context() + let guarded = CloudSavePrivateClient(client: client, context: context) + let ledger = Task { try await guarded.pendingChanges() } + let record = Task { try await guarded.record(for: Self.recordID) } + await suspension.waitUntilSuspended(count: 2) + context.accountGate.close() + await suspension.resume() + await #expect(throws: CloudSavePrivateError.sessionInvalidated) { try await ledger.value } + await #expect(throws: CloudSavePrivateError.sessionInvalidated) { try await record.value } + #expect(await client.pendingChanges() == [.save(Self.recordID)]) + } + + @Test func fixedAccountNeverCallsMutableHostRebindingOrClearsMissingZone() async throws { + let client = CloudSavePrivateClientMock() + let context = Self.context() + let guarded = CloudSavePrivateClient(client: client, context: context) + try guarded.handle(accountChange: .signedIn(currentAccountID: "account-a")) + #expect(throws: CloudSavePrivateError.sessionInvalidated) { + try guarded.handle(accountChange: .signedIn(currentAccountID: "account-b")) + } + await #expect(throws: CloudSavePrivateError.missingTrustedManifest) { + try await guarded.applyDeletedZones([CloudSaveManifestFixture.zoneID]) + } + #expect(await client.accountCallbackCount == 0) + #expect(await client.deletedZoneCallbackCount == 0) + } + + @Test func accountAToBToARestoresOnlyEachOriginalLedger() async throws { + let firstClient = CloudSavePrivateClientMock() + let secondClient = CloudSavePrivateClientMock() + await firstClient.commit(.save(Self.recordID)) + await secondClient.commit(.delete(Self.recordID)) + let firstContext = Self.context() + let first = CloudSavePrivateClient(client: firstClient, context: firstContext) + #expect(try await first.pendingChanges() == [.save(Self.recordID)]) + firstContext.accountGate.close() + let secondContext = Self.context(account: "account-b") + let second = CloudSavePrivateClient(client: secondClient, context: secondContext) + #expect(try await second.pendingChanges() == [.delete(Self.recordID)]) + secondContext.accountGate.close() + let returning = CloudSavePrivateClient(client: firstClient, context: Self.context()) + #expect(try await returning.pendingChanges() == [.save(Self.recordID)]) + await #expect(throws: CloudSavePrivateError.sessionInvalidated) { try await first.pendingChanges() } + await #expect(throws: CloudSavePrivateError.sessionInvalidated) { try await second.pendingChanges() } + } + + @Test(arguments: [CKError.Code.zoneNotFound, .userDeletedZone, .accountTemporarilyUnavailable, .notAuthenticated]) + func automaticEventPolicyRetainsFatalFailuresInsidePartialErrors(code: CKError.Code) { + let expected: CloudSavePrivateError = + switch code { + case .zoneNotFound, .userDeletedZone: .missingTrustedManifest + case .accountTemporarilyUnavailable: .accountTemporarilyUnavailable + default: .sessionInvalidated + } + let error = CKError(.partialFailure, userInfo: [CKPartialErrorsByItemIDKey: [Self.recordID: CKError(code)]]) + #expect(throws: expected) { try Self.context().validateTransportError(error) } + } + + @Test func manifestCannotEnterLocalMutationLedger() async throws { + let client = CloudSavePrivateClientMock() + await client.commit(.delete(CloudSaveManifestFixture.recordID)) + let guarded = CloudSavePrivateClient(client: client, context: Self.context()) + await #expect(throws: CloudSavePrivateError.wrongZone) { try await guarded.pendingChanges() } + } + + @Test func throttlePersistsAcrossCallsAndRechecksInvalidationAfterWaiting() async throws { + let instant = ContinuousClock.now + let delays = Mutex<[Duration]>([]) + let access = CloudSaveAccessGate() + let gate = CloudSavePrivateRequestGate( + now: { instant }, + sleep: { duration in + delays.withLock { $0.append(duration) } + access.close() + } + ) + let rateLimit = CKError(.requestRateLimited, userInfo: [CKErrorRetryAfterKey: 12.0]) + await #expect(throws: CKError.self) { + try await gate.perform(validate: { try access.check() }, operation: { throw rateLimit }) + } + await #expect(throws: CloudSavePrivateError.sessionInvalidated) { + try await gate.perform( + validate: { try access.check() }, operation: { Issue.record("Stale request reached transport") }) + } + #expect(delays.withLock { $0 } == [.seconds(12)]) + } + + @Test func perItemFailurePrecedesAggregateAndAggregateFailureCannotBecomeSuccess() throws { + let result = CloudSaveOperationResult() + result.set(.failure(CKError(.permissionFailure))) + #expect(throws: CKError(.permissionFailure)) { try result.finish(.failure(CKError(.partialFailure))) } + result.set(.success(7)) + #expect(throws: CKError(.networkFailure)) { try result.finish(.failure(CKError(.networkFailure))) } + } + + private static let recordID = CKRecord.ID(recordName: "note-1", zoneID: CloudSaveManifestFixture.zoneID) + + /// Creates a package-internal token only for focused policy tests; public callers cannot forge it. + private static func context(account: String = "account-a") -> CloudSavePrivateSessionContext { + CloudSavePrivateSessionContext( + account: CloudSaveVerifiedAccount(recordName: account, coordinatorID: UUID(), generation: UUID()), + accountGate: CloudSaveAccessGate(), zoneID: CloudSaveManifestFixture.zoneID, + manifestRecordID: CloudSaveManifestFixture.recordID, validateManifest: { _ in }, onInvalidation: { _ in } + ) + } +} diff --git a/Tests/CloudSaveKitTests/Mocks/CloudSaveManifestFixture.swift b/Tests/CloudSaveKitTests/Mocks/CloudSaveManifestFixture.swift new file mode 100644 index 0000000..42abac0 --- /dev/null +++ b/Tests/CloudSaveKitTests/Mocks/CloudSaveManifestFixture.swift @@ -0,0 +1,46 @@ +import CloudKit +import Foundation + +@testable import CloudSaveKit + +/// Uses a neutral integer identity, alternate zone, and non-vocabulary schema to demonstrate reuse. +enum CloudSaveManifestFixture { + static let zoneID = CKRecordZone.ID(zoneName: "NotebookPrivate") + static let recordID = CKRecord.ID(recordName: "settings", zoneID: zoneID) + + /// Creates a neutral manifest without any WortJagd identifiers or schema fields. + static func record(identity: Int) -> CKRecord { + let record = CKRecord(recordType: "NotebookManifest", recordID: recordID) + record["revision"] = NSNumber(value: identity) + return record + } + + /// Performs the host's pure schema decoding. + static func decode(_ record: CKRecord) throws -> Int { + guard let revision = record["revision"] as? NSNumber else { + throw CocoaError(.coderInvalidValue) + } + return revision.intValue + } + + /// Obtains a genuine coordinator-issued token for tests. + static func account(_ coordinator: CloudSavePrivateCoordinator) async throws -> CloudSaveVerifiedAccount { + guard case .verified(let account) = try await coordinator.refreshAccount() else { + throw CloudSavePrivateError.sessionInvalidated + } + return account + } + + /// Exercises the public generic API with one neutral host schema. + static func bootstrap( + _ coordinator: CloudSavePrivateCoordinator, + account: CloudSaveVerifiedAccount, + expected: Int? = nil, + proposed: Int = 1 + ) async throws -> CloudSavePrivateBootstrap { + try await coordinator.bootstrapManifest( + for: account, zoneName: zoneID.zoneName, recordType: "NotebookManifest", recordName: recordID.recordName, + expectedIdentity: expected, makeInitialRecord: { _ in record(identity: proposed) }, decodeIdentity: decode + ) + } +} diff --git a/Tests/CloudSaveKitTests/Mocks/CloudSavePrivateClientMock.swift b/Tests/CloudSaveKitTests/Mocks/CloudSavePrivateClientMock.swift new file mode 100644 index 0000000..cd93ef5 --- /dev/null +++ b/Tests/CloudSaveKitTests/Mocks/CloudSavePrivateClientMock.swift @@ -0,0 +1,62 @@ +import CloudKit +import Foundation + +@testable import CloudSaveKit + +/// Retains an independent durable ledger and suspension points for immutable host-boundary tests. +actor CloudSavePrivateClientMock: CloudSaveClient { + private var ledger: [CloudSavePendingChange] = [] + private var suspension: CloudSaveTestSuspension? + private(set) var accountCallbackCount = 0 + private(set) var deletedZoneCallbackCount = 0 + private(set) var appliedRecords: [CKRecord] = [] + + /// Represents an explicit local transaction that remains durable while cloud work is paused. + func commit(_ change: CloudSavePendingChange) { ledger.append(change) } + + /// Holds ledger and record reads to test post-suspension fencing. + func pauseReads(_ suspension: CloudSaveTestSuspension) { self.suspension = suspension } + + /// Captures the original ledger even if account invalidation occurs during its read. + func pendingChanges() async -> [CloudSavePendingChange] { + let changes = ledger + await suspension?.suspend() + return changes + } + + /// Creates a neutral record for the original store's pending mutation. + func record(for recordID: CKRecord.ID) async -> CKRecord? { + await suspension?.suspend() + return CKRecord(recordType: "Note", recordID: recordID) + } + + /// Satisfies opaque checkpoint persistence for this in-memory client fixture. + func persist(stateSerialization: CKSyncEngine.State.Serialization) {} + + /// Captures downloaded records without replacing the fixture's durable local ledger. + func applyFetchedChanges(records: [CKRecord], deletedRecordIDs: [CKRecord.ID]) { appliedRecords += records } + + /// Counts the destructive callback that personal sessions must never invoke. + func applyDeletedZones(_ zoneIDs: [CKRecordZone.ID]) { deletedZoneCallbackCount += 1 } + + /// Keeps acknowledgements irrelevant to the session-isolation fixture. + func didSave(records: [CKRecord]) {} + + /// Keeps acknowledgements irrelevant to the session-isolation fixture. + func didDelete(recordIDs: [CKRecord.ID]) {} + + /// Keeps server metadata irrelevant to the session-isolation fixture. + func clearServerRecord(for recordID: CKRecord.ID) {} + + /// Preserves application ownership of merge decisions in the neutral fixture. + func resolve(conflict: CloudSaveConflict) -> CloudSaveConflictResolution { .requiresUserDecision } + + /// Keeps conflict persistence irrelevant to the session-isolation fixture. + func persistResolvedRecord(_ record: CKRecord) {} + + /// Detects accidental use of the legacy mutable-account host callback. + func handle(accountChange: CloudSaveAccountChange) { accountCallbackCount += 1 } + + /// Keeps diagnostics outside the test's stored domain data. + func handle(failure: CloudSaveFailure, recordID: CKRecord.ID?) {} +} diff --git a/Tests/CloudSaveKitTests/Mocks/CloudSavePrivateTransportMock.swift b/Tests/CloudSaveKitTests/Mocks/CloudSavePrivateTransportMock.swift new file mode 100644 index 0000000..17f015a --- /dev/null +++ b/Tests/CloudSaveKitTests/Mocks/CloudSavePrivateTransportMock.swift @@ -0,0 +1,112 @@ +import CloudKit +import Foundation + +@testable import CloudSaveKit + +/// Simulates a private server's atomic conditional creates and suspended account/record responses. +actor CloudSavePrivateTransportMock: CloudSavePrivateTransport { + var status: CKAccountStatus = .available + var account = "account-a" + var zoneExists = false + var record: CKRecord? + var fetchError: CKError? + var createError: CKError? + var identityError: CKError? + var concurrentWinner: CKRecord? + var losesCreateAcknowledgement = false + var fetchSuspension: CloudSaveTestSuspension? + var identitySuspension: CloudSaveTestSuspension? + private(set) var fetchCount = 0 + private(set) var identityCount = 0 + private(set) var zoneSaveCount = 0 + private(set) var createCount = 0 + + /// Configures server contents before a test starts requests. + func seed(zoneExists: Bool = true, record: CKRecord? = nil) { + self.zoneExists = zoneExists + self.record = record + } + + /// Changes system identity without exposing any real account or credentials. + func setAccount(_ account: String, status: CKAccountStatus = .available) { + self.account = account + self.status = status + } + + /// Injects a specific fetch failure; absence remains a separate server outcome. + func failFetch(_ error: CKError?) { fetchError = error } + + /// Fails identity verification so a last-used account cannot become a verified offline token. + func failIdentity(_ error: CKError?) { identityError = error } + + /// Injects an item-level creation conflict with a separately committed canonical winner. + func conflictOnCreate(winner: CKRecord, error: CKError) { + concurrentWinner = winner + createError = error + } + + /// Simulates a server commit whose acknowledgement never reaches the original caller. + func loseNextCreateAcknowledgement() { losesCreateAcknowledgement = true } + + /// Holds reads after their result is captured to exercise stale-result rejection. + func pauseFetch(_ suspension: CloudSaveTestSuspension) { fetchSuspension = suspension } + + /// Holds identity resolution after capturing the old identity. + func pauseIdentity(_ suspension: CloudSaveTestSuspension) { identitySuspension = suspension } + + /// Returns the configured availability. + func accountStatus() -> CKAccountStatus { status } + + /// Delivers even a cancelled late identity result, leaving the coordinator to reject it. + func userRecordID() async throws -> CKRecord.ID { + identityCount += 1 + let identity = CKRecord.ID(recordName: account) + let failure = identityError + await identitySuspension?.suspend() + if let failure { + throw failure + } + return identity + } + + /// Returns only the exact record, with positive missing-zone and missing-record outcomes. + func fetchRecord(_ recordID: CKRecord.ID) async throws -> CKRecord? { + fetchCount += 1 + let result: Result = Result { + if let fetchError { + throw fetchError + } + guard zoneExists else { + throw CKError(.zoneNotFound) + } + return record?.recordID == recordID ? record : nil + } + await fetchSuspension?.suspend() + return try result.get() + } + + /// Counts provisioning and preserves any existing zone contents. + func ensureZone(_ zone: CKRecordZone) { + zoneSaveCount += 1 + zoneExists = true + } + + /// Commits atomically or reports the existing server winner as a conflict. + func createIfAbsent(_ record: CKRecord) throws { + createCount += 1 + if let concurrentWinner { + self.record = concurrentWinner + } + if let createError { + throw createError + } + guard self.record == nil else { + throw CKError(.serverRecordChanged) + } + self.record = record + if losesCreateAcknowledgement { + losesCreateAcknowledgement = false + throw CKError(.networkFailure) + } + } +} diff --git a/Tests/CloudSaveKitTests/Mocks/CloudSaveSessionEngineMock.swift b/Tests/CloudSaveKitTests/Mocks/CloudSaveSessionEngineMock.swift new file mode 100644 index 0000000..b743dc2 --- /dev/null +++ b/Tests/CloudSaveKitTests/Mocks/CloudSaveSessionEngineMock.swift @@ -0,0 +1,54 @@ +import Foundation + +@testable import CloudSaveKit + +/// Exercises the public session facade while CKSyncEngine transport remains credential-free. +actor CloudSaveSessionEngineMock: CloudSaveSessionEngine { + nonisolated let statusUpdates = AsyncStream { $0.finish() } + private let client: (any CloudSaveClient)? + private let fetch: @Sendable () async throws -> Void + private var sendError: (any Error)? + private var retirement: CloudSaveTestSuspension? + private(set) var restored: [CloudSavePendingChange] = [] + private(set) var enqueued: [CloudSavePendingChange] = [] + private(set) var retireCount = 0 + private(set) var sendCount = 0 + + /// Optionally restores an actual guarded client's ledger through the same persistence seam. + init(client: (any CloudSaveClient)? = nil, fetch: @escaping @Sendable () async throws -> Void = {}) { + self.client = client + self.fetch = fetch + } + + /// Injects an engine transport failure. + func failSend(_ error: any Error) { sendError = error } + + /// Holds teardown so tests can prove invalidation is visible before cancellation finishes. + func pauseRetirement(_ suspension: CloudSaveTestSuspension) { retirement = suspension } + + /// Restores the fixture's durable pending mutations. + func start() async throws { restored = try await client?.pendingChanges() ?? [] } + + /// Completes a test fetch without network work. + func fetchNow() async throws { try await fetch() } + + /// Emits the configured send failure before acknowledgements. + func sendNow() throws { + sendCount += 1 + if let sendError { + throw sendError + } + } + + /// Uses the same send outcome for combined-operation tests. + func syncNow() throws { try sendNow() } + + /// Counts only enqueues that crossed the session's active gate. + func enqueue(_ changes: [CloudSavePendingChange]) { enqueued += changes } + + /// Records terminal shutdown without deleting local mutations. + func retire() async { + retireCount += 1 + await retirement?.suspend() + } +} diff --git a/Tests/CloudSaveKitTests/Mocks/CloudSaveTestSuspension.swift b/Tests/CloudSaveKitTests/Mocks/CloudSaveTestSuspension.swift new file mode 100644 index 0000000..0f29fce --- /dev/null +++ b/Tests/CloudSaveKitTests/Mocks/CloudSaveTestSuspension.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Holds deterministic asynchronous operations at a chosen lifecycle boundary without sleeps. +actor CloudSaveTestSuspension { + private var arrivals = 0 + private var released = false + private var waiting: [CheckedContinuation] = [] + private var observers: [(Int, CheckedContinuation)] = [] + + /// Signals entry and waits until the test releases all current and future callers. + func suspend() async { + arrivals += 1 + let ready = observers.filter { $0.0 <= arrivals } + observers.removeAll { $0.0 <= arrivals } + for (_, continuation) in ready { + continuation.resume() + } + guard !released else { + return + } + await withCheckedContinuation { waiting.append($0) } + } + + /// Waits until the requested number of operations have reached this boundary. + func waitUntilSuspended(count: Int = 1) async { + guard arrivals < count else { + return + } + await withCheckedContinuation { observers.append((count, $0)) } + } + + /// Releases operations independently of task cancellation to exercise late completions. + func resume() { + released = true + let continuations = waiting + waiting.removeAll() + for continuation in continuations { + continuation.resume() + } + } +} diff --git a/VERSION b/VERSION index 53a75d6..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.6 +0.3.0