Blog

August 15, 2026 · 10 min read

Foundation Models Errors: Designing a guardrailViolation Fallback

Ordinary Korean prose was rejected as a guardrail violation, leaving summaries empty. Folding eight failure kinds into four retry branches and a four-step fallback ladder.

  • Apple Foundation Models
  • error handling
  • guardrailViolation
  • on-device LLM

Apple's on-device model rejects input caught by its safety filter. That filter also caught an ordinary Korean sentence.

When it rejects a request, there is no summary. If the screen is empty, the user thinks the app is broken, even though the text they sent is still there.

What to show instead when it fails, and how to record that failure so it can be fixed later: this article covers both. In production, using an on-device model means the failure path contains more code than the success path.

Splitting errors by string fails quietly

At first I scanned the error description as a string, looking for words such as guardrail, safety, and refus.

This is the text Apple gives for a safety-filter rejection.

"May contain sensitive content"

None of the words I searched for appears. The safety rejection was not classified and fell through as “unknown error.”

The screen showed “Something went wrong while processing · Try again.” The user tapped it. The same input was rejected again. Every tap ran the model from the beginning, and the real device became hot.

I classify by type

I inspect the cases of LanguageModelSession.GenerationError directly.

// FoundationProfileModelExecutor.swift:126-136
switch generation {
case .guardrailViolation: return .rejected("guardrailViolation")
case .refusal: return .rejected("refusal")
case .rateLimited, .concurrentRequests: return .transient("rateLimited")
case .assetsUnavailable: return .unavailable
case .exceededContextWindowSize: return .permanent("exceededContextWindowSize")
case .unsupportedGuide: return .permanent("unsupportedGuide")
case .unsupportedLanguageOrLocale: return .permanent("unsupportedLanguageOrLocale")
case .decodingFailure: return .permanent("decodingFailure")
@unknown default: return .permanent("unknownGenerationError")
}

Apple can change the wording while the case remains. @unknown default also keeps compilation intact if a new case is added.

I fold eight cases into four.

Classification Meaning Retry button
rejected This input was rejected Do not show it
transient Try again later Show it
unavailable It cannot run on this device now Do not show it
permanent Trying again will not help Do not show it

These four branches are the basis for retry policy. If I collapse them into one, every failure gets a retry button.

Strings crossing the boundary are machine tokens

The string in rejected("guardrailViolation") is not text for the user. It is a stable name that lets the upper layer branch on the reason.

If I send an error description containing the original text, it remains in the receipt. The user's writing leaks into logs and storage.

// FoundationProfileModelExecutor.swift:104-109
static func signature(of error: Error) -> String {
  let described = String(describing: error)
  let head = described.prefix { $0 != "(" && $0 != ":" && !$0.isNewline }
  return head.trimmingCharacters(in: .whitespaces)
}

I cut at an opening parenthesis or colon because Foundation Models' error description includes the prompt. Diagnostics need to know which failure occurred, not what the user wrote.

I store three origins for each chunk

I must not remove a failed chunk from the result. If one of seven chunks fails and I store six, the user believes the document had six chunks.

So I store how each chunk was made.

// SummaryEngine.swift:11-18
public enum SummaryOrigin: String, Codable, Hashable, Sendable {
  /// 모델이 답했다.
  case model
  /// 모델이 세 번 다 쓸 만한 답을 내지 못해, 원문에서 문장을 골라 세웠다.
  case extracted
  /// 어느 방법으로도 만들지 못했다.
  case none
}

The comments explain why this enum exists. A failed unit simply disappeared from the result array, and the document was marked complete with the remainder. To the consumer, a seven-page document looked like one chunk, with no way to know what was lost.

That amounted to saying a thing was summarized when it was not.

Origin What appears on screen
model Summary
extracted Sentences selected from the source
none Marker saying this part could not be summarized

The third state is what makes the screen honest.

I keep rejection history even for successful chunks

SummaryPiece has rejections, the reasons for answers rejected while making this chunk.

// SummaryEngine.swift:26-31
/// 이 조각을 만들며 되돌려보낸 답들의 이유. 순서는 시도 순서다.
///
/// 진단을 위해 남긴다. `origin == .model`인데 이 목록이 비어 있지 않다면 첫 답이
/// 거부됐고 다음 시도가 통과했다는 뜻이다. 프롬프트가 몇 번째에 먹히는지
/// 아는 유일한 방법이고, 그것 없이는 프롬프트를 고칠 근거가 없다.
public let rejections: [String]

It is easy to think that success means there is nothing to record. But succeeding on the first attempt and succeeding on the third are different.

I need this number to know whether a prompt change helped. Looking only at the final result makes both look like success.

Rejection reasons accumulate in attempt order.

// SummaryEngine.swift:460-468
case .reject(let reason):
  rejections.append(reason.rawValue)   // 몇 번째 시도에서 왜 거부됐는지
  modelDraft = nil
}
} catch is CancellationError {
  throw CancellationError()
} catch {
  rejections.append(SummaryJudgment.Reason.modelFailed.rawValue)
  modelDraft = nil
}

Cancellation is different. Closing the screen is not a failure, so I do not record it and let it propagate unchanged.

The fallback ladder has four steps

The flow for making one chunk is this.

Order What happens If it fails
1 Ask the model Record the reason and go to 2
2 Check whether it passes judgment, up to three times If all three fail, go to 3
3 Extract sentences from the source If there are none, go to 4
4 Put an empty chunk in place

At the second step I do not simply call it again. I include earlier rejection reasons in the next instruction. Three identical requests produce three identical answers.

The third step is extraction.

// SummaryEngine.swift:505-527
if let extracted = ExtractiveSummary.summarize(text: chunk) {
  let fields: [String]
  do {
    fields = try await localizeFields(...)
  } catch let error as ... {
    // 모델 초안과 달리 발췌는 원문에서 고른 값이므로 원문 언어 그대로 보존해도 안전하다.
    rejections.append(error.reason.rawValue)
    fields = [extracted.headline] + extracted.points
  }
  return SummaryPiece(index: index, ..., origin: .extracted, rejections: rejections)
}

Extraction does not call the model. It selects and arranges sentences from the source. It is not a summary, but the user can see what they sent.

Extraction has conditions under which it returns nothing

Extraction is not a verbatim copy. It selects and cuts sentences. If it cannot guarantee its own compression ratio, it returns nothing.

Value What it prevents
Compression cap 0.4 Discards a result over 40% of the source as not a summary
Point length cap 120 characters Above this, it reads like a paragraph rather than a sentence
Headline cap 60 characters It must read as one line in a list
Minimum sentence 8 characters Headers and page numbers fail here

The score for selecting a sentence is fixed too. A number gives points equal to its count (up to 6), % or percent gives 2, won, people, or items gives 1, a length between 20 and 120 characters gives 2, and one of the first three sentences gives 1. This scoring moves sentences containing measurements upward.

The title is not selected by score. If the first line is between 8 and 60 characters, I use the author's title. Choosing a title by score alone could make one table row or one KPI represent the whole record.

Repeated sentences are common in originals: a header repeated on every page or a scan error. I fold duplicates before choosing. Otherwise the extracted result inherits the repetition.

The last step is an empty chunk.

// SummaryEngine.swift:530-531
return SummaryPiece(
  index: index, headline: "", points: [], origin: .none, rejections: rejections)

The index remains even in an empty chunk. The order lets the screen say “The third part could not be summarized.”

Audio describes rejected ranges by time

Transcription has one more state. The recording is split into chunks for summarization, and one chunk is caught by the safety filter.

I mark that chunk excludedBySafetyPolicy and finish the other summaries. The result becomes readyWithExclusions rather than ready, and the screen says which minutes and seconds were excluded.

The whole point of this path is leaving the rejected chunk's place as a time range instead of deleting it. The user knows which part of their recording is missing and can listen to that range directly.

I do not translate extracted sentences

There is a separate judgment at the translation stage.

A model draft can be translated. If the model answers in the source language while the user's setting is English, I need to translate it.

Extraction is different. It is a sentence selected unchanged from the source. If translation fails, leaving it in the source language is safe because it is the text the user sent.

The comment records this distinction.

모델 초안과 달리 발췌는 원문에서 고른 값이므로 원문 언어 그대로 보존해도 안전하다.

If translation of a model draft fails, I run one more check, a gate that verifies the model-made sentence is grounded in the source. Extraction does not need that gate.

I also check the translator's result

I do not blindly trust the translator supplied by the app.

If I provide five sentences and four come back, the order is broken. I also check that the translated sentences are actually in the target language.

Check If it fails
Is the count the same? Discard the translation
Is it the target language? Use the source text

Without both checks, I would not notice Korean coming back when I asked for Korean to be translated into English.

I propagate nine kinds of failure

The app's four branches become nine branches in the core.

// 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 input. The last three concern adapters supplied by the app.

This distinction determines the screen text. For modelUnavailable I explain Settings; for modelCallBudgetExceeded I say the document is too long; for executionStoreUnavailable I tell the user to check storage.

app/AGENTS.md states the rule in one line.

모든 모델 단계에 폴백이 있어야 하고, 폴백은 언제나 "기록으로 남긴다"다.

Even if summarization fails, the user's text remains. Keeping the original is independent of the success of derived work.