Blog

August 15, 2026 · 12 min read

Swift Package Split: The Core Moved but the Build Read the Local Path

The boundary drawn when the AI core moved out of the app as a Swift Package, and the gap between the declaration and the actual build. Four functions and nine failure kinds are the boundary.

  • Swift Package
  • modularization
  • iOS
  • solo development

I moved the AI summarization core to a separate repository. Then I brought it back into the app repository. The current build links JustSendMemoryCore inside the app repository.

While it was separated, the documentation and source diverged. The documentation said there was one kind of slot for the model to fill, while the source had two: summaryChunk and summaryTitle.

I narrowed what the app implements to four functions

Splitting out the core raises the first question: what should the app give the core?

I fixed the answer as four protocols. Only one of them calls the model.

// ProfileContracts.swift:480-482
public protocol ProfileModelExecuting: Sendable {
  func generate(_ request: ProfileModelRequest) async throws -> Data
}

It is one line. Take a request and return data. The core does not know Apple's FoundationModels. It does not know LanguageModelSession, @Generable, or thermal management. Those belong to the app.

There are only five things in the request.

// ProfileContracts.swift:451-456
public struct ProfileModelRequest: Codable, Hashable, Sendable {
  public let stepID: String              // 어느 단계의 호출인가
  public let schema: ProfileModelSchema  // 어떤 모양으로 받을 것인가
  public let instructions: String        // 모델에게 줄 지시
  public let prompt: String              // 이번에 넣을 원문
  public let responseTokens: Int         // 답의 길이 상한
}

The request and response are Codable. That lets me run the core in tests without a model. I put in a request saved to a file and return a response saved to a file; the test checks only the core's decisions.

Swift Package is Apple's way of grouping code into a reusable unit. It usually points to a tag in a remote repository, but it can also point to a directory inside the repository. The latter is called a local path package.

The app fills the other three as well

Protocol What the app does Function count
ProfileModelExecuting Calls the model 1
SummaryTranslating Translates several sentences at once 1
ProfileDocumentProviding Reads pages of a document 3
ProfileExecutionStoring Stores progress 5

The translation protocol has one condition.

// ProfileContracts.swift:484-493
/// 반환 배열은 입력과 같은 순서·개수여야 한다. core는 번역 뒤 각 필드의
/// 대상 언어를 다시 증명한 뒤에만 결과를 승격한다.
public protocol SummaryTranslating: Sendable {
  func translate(_ texts: [String], from sourceLanguageIdentifier: String,
                 to targetLanguageIdentifier: String) async throws -> [String]
}

If the translator accepts five sentences and returns four, the order is broken. The core checks the count and verifies again that the translated sentences are actually in the target language before writing the result. It does not blindly trust the value supplied by the app.

The document provider has one more comment

// ProfileContracts.swift:496-501
public protocol ProfileDocumentProviding: Sendable {
  func pageCount(for attachmentID: String) async throws -> Int
  func page(at index: Int, for attachmentID: String) async throws -> PageContent
  /// 원문이 실제로 몇 쪽인가. `pageCount`는 요약 상한이 적용된 **뒤**의 수이므로
  /// 둘이 다르면 요약이 문서의 일부만 봤다는 뜻이다. 그 사실을 말할 수 있어야 한다.
  func originalPageCount(for attachmentID: String) async throws -> Int
}

I ask for the page count twice: the number of pages to summarize and the number in the original. For a 100-page document capped at 30 pages, the first is 30 and the second is 100.

Without distinguishing the two, the user believes the summary covers the entire document. To show “I read only the first 30 pages,” the core needs both numbers.

The comments explain why I split the schema in two

ProfileModelSchema defines the shape of the slot the model fills. There are two now.

// ProfileContracts.swift:443-449
public enum ProfileModelSchema: String, Codable, Hashable, Sendable {
  /// 조각의 헤드라인과 사실 요점.
  case summaryChunk
  /// 여러 조각을 아우르는 선택 제목. 조각 스키마를 재사용하지 않아 불필요한
  /// `points` 생성과 토큰을 없앤다.
  case summaryTitle
}

summaryChunk receives one title and several key points. I use it when a long text is split into chunks and each chunk is summarized.

summaryTitle receives only one title covering the chunks. If I reuse summaryChunk here, the model also creates key points. That creates points I will throw away where only a title is needed, and spends those tokens.

With an on-device model, that waste appears directly as time. That is why I kept two different answer shapes.

Adding a schema costs something

Adding one more slot shape means changing the app too. The app executor prepares a different @Generable type for each schema and passes it to Apple's model. Add one case to the core and the app needs another type and another encoding path.

If I add a schema What must change
Core Add a case to the enum
App @Generable type, executor branch, JSON encoding
Tests Fixed response for that schema

Having to change both places is the cost of separation. In one repository it would have ended in a single commit.

So when adding a feature, I first ask whether the existing two slots can express it. Most of the time they can.

I distinguish nine kinds of failure

Once a boundary is split, I need to be able to say where a failure happened. The core distinguishes nine kinds.

// ProfileContracts.swift:387-397
public enum ProfileFailure: Codable, Hashable, Sendable {
  case invalidInput(String)                      // 입력이 비었거나 규격 밖
  case contextWouldOverflow(estimatedTokens: Int) // 창을 넘길 것으로 추정
  case malformedModelOutput(String)              // 모델이 규격을 벗어난 답을 줌
  case modelUnavailable                          // 모델을 쓸 수 없는 기기·상태
  case modelFailed(String)                       // 모델 호출 자체가 실패
  case modelCallBudgetExceeded(limit: Int)       // 요청당 호출 상한을 넘김
  case unsupportedProfile(ProfileIdentifier)     // 등록되지 않은 프로필
  case missingCheckpoint                         // 이어갈 진행 기록이 없음
  case executionStoreUnavailable                 // 저장소를 열 수 없음
}

The first six concern the model and its input; the last three concern adapters supplied by the app. executionStoreUnavailable is not the core's fault. The store supplied by the app could not be opened.

Without this distinction, there is only one sentence to show: “The summary failed.” The user cannot tell whether to try again, change devices, or check the file.

The app-side executor also narrows its errors to four before returning them to the core.

// ProfileContracts.swift:473-478
public enum ProfileModelExecutionError: Error, Codable, Equatable, Sendable {
  case unavailable        // 지금 이 기기에서 못 씁니다
  case transient(String)  // 다시 하면 될 수도 있습니다
  case rejected(String)   // 이 입력은 거부됐습니다
  case permanent(String)  // 다시 해도 안 됩니다
}

The split between transient and permanent is the basis for retry policy. The core calls again for transient and stops for permanent. The app translates Apple's dozens of errors into these four.

The core defines progress and the app stores it

// ProfileContracts.swift:420-425
/// Durable page progress. Re-submit the identical `ProfileCommand` to continue.
public struct ProfileCheckpoint: Codable, Hashable, Sendable {
  public let command: ProfileCommand   // 어떤 요청이었나
  public let phase: String             // 어디까지 갔나
  public let payload: Data             // 중간 결과
  public let receipt: ProfileExecutionReceipt  // 단계별 기록
}

That one comment is the whole contract: submit the same ProfileCommand again to continue.

If the app dies while summarizing a 100-page document, the result through page 30 remains. When the user opens it again, it starts at page 31. The core defines this structure, while the app's ProfileExecutionStoring performs the actual storage.

Keeping it as one Data value may look odd. It lets the core change the shape of an intermediate result freely. The app does not need to know what those bytes mean.

Instead of fixing the document, I removed it

The same facts were stated differently in two places.

What What the document said What the source had
Model schema One kind, summaryChunk Two kinds, summaryChunk, summaryTitle
How the core is consumed Pin tag 0.4.15 path: JustSendMemoryCore

The first row meant the documentation was not updated when summaryTitle was added.

The second row was more confusing. The document said the shared contract lived in an independent repository and that its pinned tag was the only fact. But project.yml says this:

# ios-prod/app/project.yml:29-30
packages:
  justsend-core:
    path: JustSendMemoryCore     # 원격 태그가 아니라 저장소 안의 디렉터리

There was a script for finding stale references. scripts/check-doc-anchors.py scans the path:line citations in the documentation. When I ran it today, it reported 6 stale anchors out of 471. The schema-count mismatch was not among those six. The script checks only whether the file exists and whether the line range is within its length. Its own description says so: “It does not check whether the content is still correct. A person must read it.”

I removed the documentation from the source of truth

On 2026-08-15, I moved docs/ai-core/ to docs/archive/2026-08-15/ai-core/. The anchor check followed the archive path. app/AGENTS.md then said its own sentence was stale: “The sentence from when tags were pinned (0.4.15 is the only source of truth) is stale.”

The remote repository <org>/justsend-core has 20 tags, the last being 0.4.15. There have been no tags since. The side that published releases stopped, and the source in the app repository became canonical.

The reason for bringing it back is in the first comments of Package.swift.

// app/JustSendMemoryCore/Package.swift:4-8
// 예전에는 별도 원격 저장소(<org>/justsend-core)에 있었고 앱이 태그로 핀했다.
// 그 구조는 계약 하나를 바꿀 때마다 커밋·태그·핀 갱신 세 걸음을 요구했고,
// 실제로 앱이 핀한 버전과 작업 중인 갈래가 서로 다른 아키텍처로 벌어졌다.
// 지금은 앱 저장소 안의 로컬 패키지다 — 모듈 이름을 유지하므로 `import
// JustSendMemoryCore` 112곳은 그대로다.

Those three steps had a cost. To change one line of the contract, I had to commit the core, create a tag, and raise the pin in the app. Between those steps, the core seen by the app and the core being worked on diverged.

When I brought it back, I kept the module name. I did not change the 112 import statements. Only the package location changed from remote to local; the way code calls the core is the same.

I counted the package's current size.

Value
Source files 32
Source lines 6,921
Test files 14
Places importing from the app 112

Tests are close to half the number of source files. That is one benefit that remained after splitting out the core. Because the code runs without a model, tests attach to it. Combining the repositories did not change that property.

The boundary itself remains. The line justsend-core: path: JustSendMemoryCore is unchanged. What disappeared was one repository; what remains is one package boundary. The core still does not know Apple's model API.

Tests are the rule for fixing divergence

app/AGENTS.md has a rule for this situation: when contract wording and tests disagree, tests are the truth. When a regression appears, first decide which contract is correct before fixing it. Prose proves neither side.

I wrote down what was lost

Plane records contain work from before and after the split.

IOSPROD-12 split the core into an independent repository and changed the app to consume it as an external Swift Package. IOSPROD-13 integrated orchestration and the prototype from the separated core back into the app. IOSPROD-16 cut over the core version and consolidated runtime ownership.

The fact that the three appear consecutively says enough. Bringing it back after splitting it out was a separate task.

In IOSPROD-142, I traced that wiring again from the source alone. I found four correctness defects to block before release: deleting failed chunks after only some chunks succeeded and overwriting the canonical body with a completed one; silently skipping a document-page read failure without reporting the missing range; exposing text-chunk numbers on screen as if they were actual page numbers; and losing the production-path verification that numbers, dates, amounts, and names exist in the original.

All four defects were mismatches in the meaning of values crossing the boundary, not defects in the boundary itself. The core passed a chunk number and the app drew it as a page number. The function signatures were correct.

What I gained What I pay
I can test the core without a model The app maintains four adapters
The core's decisions live in one place A contract change requires edits in two places
I can describe failures in nine kinds A person must catch stale documentation
The app cares only about Apple APIs Integration and cutover become separate work

When I develop alone, the right column is all my time. I still made the split for the first row on the left. If the core can run without calling a model, I can catch a regression in summary logic in a few seconds.

I would not split it if there were only one input

If the core still did only summarization, there would be no reason to split it. Nine ProfileFailure cases and four adapters are excessive for one kind of summary.

The structure began to pay for itself when the inputs grew. Notes, documents, web links, and voice transcripts all enter the same engine. If I had split it when there was only one input, I would have paid the cost first.

Core boundary