Purvakalpa Studio

Docs / Tasker

Data and sync

SwiftData models, frozen and versioned schemas, the sync provider seam, and the field-level rules that merge edits made on two devices.

Models

All persisted state is SwiftData. The schema has grown in five additive versions, and every version is safe to mirror to CloudKit.

VersionAddedModels
1.0.0Tasks: TaskItem, TaskList, Subtask, WorkflowStage, TaskAttachment, Project6
2.0.0Pages: DocSpace, DocPage8
3.0.0Agents: AgentProfile, AgentRun, ApprovalRequest, AgentEvent12
4.0.0Scheduled delegation: AgentAutomation13
5.0.0Team-ready foundation: Workspace, Member, TaskAssignment16

TaskItem carries the planning fields (estimate, energy, defer date, recurrence and streaks), an optional geofence, free-form contexts, a parent for task trees, and blockedBy references for dependencies.

Version 5 is “solo today, team-ready”. A fresh store gets exactly one personal workspace with you as its owner, so nothing changes for a single user. Assignment is a join row between a task and a member, which is why TaskItem itself did not have to change.

Schema freeze

Once a user has synced, the schema lives in their iCloud account and on every device, so changing a model’s shape is a migration, not an edit. Each released version is frozen:

  • SchemaFreezeTests read SwiftData’s own reflection and compare every entity’s persisted field set with a frozen baseline. An added, removed or renamed field fails CI with a precise diff.
  • MirrorSafetyTests lock the CloudKit rules: no @Attribute(.unique), every stored property optional or defaulted, every relationship optional with an inverse, and the model count pinned.
  • Every version so far only adds new @Model types, so each step in TaskMigrationPlan is a lightweight migration and no frozen version is ever edited.

The sync seam

TaskCore never hard-codes a backend:

public protocol TaskSyncProvider: Sendable {
    func configure(store: TaskStore) async throws
    func push(_ changes: [TaskChange]) async throws
    func pull(since token: SyncToken?) async throws -> TaskChangeSet
    var status: AsyncStream<SyncStatus> { get }
}

The default CloudKitTaskSyncProvider relies on SwiftData’s mirror to the CloudKit private database to move the bytes, and adds two things on top: field-level conflict hardening and status reporting. A REST or other custom provider conforms to the same protocol without touching models or UI.

iCloud sync is off by default. If iCloud is unavailable, the app falls back to local storage so it always launches.

Merge rules

CloudKit’s default policy is last writer wins for a whole record, which is too coarse for long-lived rows like tasks. TaskConflictResolver merges field by field, in this order:

  1. Deletion is sticky. Once either side deletes a row, the merged row stays deleted.
  2. Completion is sticky. A completed task stays completed unless the other side reopened it later.
  3. Some fields only move forward. A recurring task’s due date takes the later of the two, because both devices may have advanced it. Streak counts keep the higher value.
  4. Dependencies are a union. blockedBy keeps every reference either side added.
  5. Everything else is last writer wins, by updatedAt.
EntityRules
TaskItemAll five rules above
TaskListSticky delete; a list marked as the default stays the default; the rest is last writer wins
WorkflowStageSticky delete; the built-in and completion flags are structural and never change through a merge
SubtaskSticky delete; a completion survives the merge; the rest is last writer wins
ProjectSticky delete; the rest is last writer wins

Pages and assistants have their own resolvers, used when you import a bundle or sync over Wi-Fi (the CloudKit mirror merges those records itself):

  • Pages never lose an edit. The newer body wins, and if the two bodies differ, the losing one is kept as a sibling “conflicted copy” page. Links are a union.
  • Assistants are merged conservatively. A merge never grants more power than either side had: trust takes the lower level, a pause on either side sticks, a decided approval keeps the first answer, and a scheduled run that already fired never fires again. Key digests are never merged or imported.

A recurring task, completed on two devices

tap Complete on iPhone
  → recurrence advances: dueAt = next occurrence, completedAt cleared
  → save bumps updatedAt; notifications reschedule
  → CloudKit mirror exports the record
        ⇅
Mac imports the change
  → CloudKitTaskSyncProvider spots a concurrent edit
  → TaskConflictResolver.merge: recurring dueAt = later date, streak = max
  → save; status stream reports up to date

Your devices over Wi-Fi

Your iCloud database is private to your Apple ID. To move work between devices without iCloud, or to share with someone nearby, Tasker pairs devices directly:

  • Pair by scanning a QR code. A paired device is trusted; nothing else can join.
  • On macOS the app advertises a Bonjour service on the local network and accepts connections only from paired devices. No account, no server and no internet are involved.
  • The link carries .unkt task bundles, full backups for automatic two-way sync (each side merges with the rules above), and AI work: a paired device can run “plan my day” or an assistant turn for a device that has no model of its own (see Planning).

You can also send a .unkt file in Messages or AirDrop and import it on the other side. Live shared lists through CloudKit sharing are on the roadmap.