The feature is one sentence. Type "what do I send someone who hasn't paid?" into your own snippet library and get back a sentence naming a real snippet, with the list below filtered to the snippets it looked at.The obvious way to build that on iOS 27 is now roughly four lines, which is exactly the problem. Apple ships SpotlightSearchTool, you hand it to a LanguageModelSession, and the model can search your app's index and answer from what it finds. No embeddings, no vector store, no server. It compiles, it runs on device, and it produces beautiful answers immediately.I shipped that version to my iPad on 13 August and asked it about twenty questions. Every answer was good. I wrote two conclusions into my planning document on the strength of them.Both conclusions were false, and so were most of the answers.The wrong outputHere is what it told me, and here is what was actually in the library:The model reportedThe library actually contains"Payment Reminder""Invoice footer""Late Invoice Chase""Standard reply""Snippet 10 invoice"Those titles on the left do not exist. They have never existed. They are not near-misses or stale cache entries or renamed items. The model produced plausible names for snippets a person with my library would probably have, and presented them as things it had found.The worst detail is not the invention. It is this: the model mostly did not call the tool at all. No query was ever compiled. It had a retrieval tool, it declined to use it, it answered from what a snippet library sounds like, and nothing anywhere in the system objected.And two claims I had already written down as findings turned out to be sentences from a model that was making things up. I had recorded that the retrieval was semantic, because the answers behaved as though it were. I had recorded that the tool was correctly scoped to my app's index rather than the whole device, because when I asked, it told me it had no access to my email. Both of those are now struck from my notes. A confabulating feature does not only produce wrong output. It produces wrong documentation, because you write down what it appears to be doing.How it was caught, which is the paragraph that mattersI did not catch this by reading answers. I read about twenty of them and accepted all twenty. They were fluent, they were the right length, they named the right kind of thing, and the app around them looked fine.I caught it because the cosmetic half of the feature kept failing.The design says that after the model answers, the list below filters down to the snippets involved. That filter works by matching the titles the model named against the titles it was shown. It kept coming back empty. I assumed for a while that my string matching was broken.The string matching was fine. It was matching a title that did not exist against a library that did not contain it, and correctly finding nothing.That is the whole lesson of this article, and it generalises past Apple, past Swift, and past this model. **The model's output and the retrieved data were two independent things that had to agree, and one day they disagreed mechanically.**Not stylistically, not in a way that needed judgment. A set intersection was empty when it should not have been. No amount of reading answers would have found this, because a fabricated answer reads exactly like a real one. That is what fabrication is.If you are bolting a language model onto your app this year, the useful question is not "how do I stop it hallucinating". It is: what in my UI will break, loudly and mechanically, if the model makes something up? If the answer is nothing, you will ship this bug and demo it beautifully.The obvious fix does not workThe first thing anyone tries here is to force the tool call. Foundation Models has a setting for it:session.toolCallingMode = .requiredThat does not mean "call the tool before answering". It means a tool call on every turn, including the turn that would produce the answer. So the model searched, was asked to continue, searched again, was asked to continue, and the app sat there querying a single word forever. It hangs.There is no setting that means "retrieve first, then answer, and only from what you retrieved". That is not a configuration. That is an architecture.The measurement that killed the premiseBefore rebuilding I checked the assumption the whole feature was built on, which was that the Spotlight index on 27 does semantic matching. Same term, both APIs, on the device:[compare] CSSearchQuery(invoice) -> 2[compare] CSUserQuery(invoice) -> 4[semantic] query "paid" -> 0CSUserQuery is better than CSSearchQuery, and it works. But paid returns nothing, because no snippet in that library contains the word "paid". A meaning-based index would have reached "Invoice footer" from "paid". This one does not, and the _CSKeywordsPredicate that the Foundation Models tool compiles internally says the same thing.There is no semantic retrieval here. That premise was in my plan, written down as a fact, and it is false as far as this app can observe on iPadOS 27.Which is annoying and also clarifying, because it tells you exactly what job the model should have. The bridge from "what do I send someone who hasn't paid?" to the word "invoice" has to be built by somebody. That is language work. That is the one thing a language model is genuinely reliable at.The rebuild, in three partsThree stages, each doing only the thing it cannot get catastrophically wrong.1. The model turns the question into search words. Failing at this is cheap: the worst case is a search term that finds nothing, not an invented snippet.let session = LanguageModelSession(model: model, instructions: """ Turn a question into search keywords for someone's personal \ snippet library. Reply with three to six single words, lowercase, comma \ separated, and nothing else. Give the words that would appear \ INSIDE such a snippet, not the words of the question. For "what \ do I send someone who has not paid" you would answer: invoice, \ payment, reminder, overdue. """)let response = try await session.respond( to: question, options: GenerationOptions(temperature: 0))Single words, not phrases, because CSUserQuery matches tokens and a phrase finds less than its parts. The model's terms are tried first, then progressively barer literal forms of the question itself, so a question that already contains the right word does not depend on the model repeating it.2. Core Spotlight retrieves real rows. Deterministic, and it cannot invent anything, because it returns rows from an index this app donated to. Every row is then checked again on the way out:// NOT filtered on `domainIdentifier`. It is only populated if it// was fetched, and discarding on a field we did not ask for// threw every result away. Parsing the identifier is the real// check anyway: only rows this app donated carry the// `SnippetEntity/` form, so anything else fails here.guard let id = SpotlightIndex.snippetID(fromIdentifier: item.uniqueIdentifier), seen.insert(id).insertedelse { return nil }The privacy claim now rests on a parse I control rather than on an API default, which is a much better thing to put in an App Store review note.3. The model picks from those real titles. It is handed a numbered list and told to quote from it:let session = LanguageModelSession(model: model, instructions: """ You help someone find one of their own saved snippets. You will be given their question and a numbered list of snippets \ from their library. Name EVERY snippet that fits the question, \ not only the best one, quoting each title exactly as written in \ the list. Most questions have one answer; some have several, and \ leaving one out is worse than mentioning it briefly. Never name a snippet that is not in the list. Never invent a \ title. If none of them fit, say so plainly and name none. Reply in one or two short sentences, naming each one you found. \ No preamble, no bullet points, no numbering. """)Note what those instructions are not doing. They are not the safety mechanism. "Never invent a title" is a request, and a request is not a guarantee. The guarantee is structural: the model is never asked to emit a title, only to choose among titles that came out of the index. It cannot invent a snippet by doing its job badly, because inventing one is not a way of doing this job at all.And the filter that caught the original bug is now the permanent check:/// Which of the snippets we showed it the answer actually names.////// Matched against the CANDIDATES rather than the whole library, which is/// what makes this exact instead of a guess: the model was handed these/// titles and told to quote one, so a hit is the snippet it chose. Anything/// it invents matches nothing and filters nothing, which is the correct/// outcome for an invented title.private static func named(in answer: String, among candidates: [Candidate]) -> [UUID] { let haystack = answer.lowercased() return candidates .filter { $0.title.count >= 3 && haystack.contains($0.title.lowercased()) } .map(\.id)}The accident became the invariant. If the model ever names something it was not shown, that title matches nothing, filters nothing, and the disagreement is visible on screen rather than buried.Measured end to end on the device: "what do I send someone who hasn't paid?" produced the terms invoice, payment, reminder, overdue, retrieved five real snippets, and named one of them.Three failures on the way, none of them the one I expectedThe token limit, and a diagnostic worth stealing.Provided 13 672 tokens, but the maximum allowed is 8 192The obvious cause is that I was sending too many search results. So I capped the results hard and measured again. The number moved by three.Three tokens is the length of the question. That single number ruled out the results as the cause, and pointed at the only other thing in the prompt: the tool's own guidance, which defaults to Guide(level: .complete, format: .structured) and is enormous. .focused(.items) with .compact cleared it.The technique is the point. When you cut the suspected cause in half and the symptom does not halve, you have the wrong cause. That took one measurement and saved an afternoon of trimming results that were never the problem.The guardrails refused an ordinary question.May contain unsafe contentThat was the default guardrails, on a personal library of email signatures and invoice footers, asked a question by the person who wrote them. The documented setting for content the user authored themselves is:let model = SystemLanguageModel(guardrails: .permissiveContentTransformations)Worth knowing before you demo. Default guardrails are tuned for content arriving from strangers, and a private notes app is the opposite of that.Both failures looked identical on screen. A nil answer clears the banner, which is correct behaviour and meant a token overflow and a content refusal produced exactly the same blank UI. The console was the only thing that could tell them apart. Two distinct failures that render identically is its own bug, and it is the kind you only notice when you have two of them at once.The bug a review round found, which is the same bug in a different coatLater, a review pass over this file found something I had not considered. Retrieval is the Spotlight index. The app has a privacy setting that turns Spotlight indexing off and empties that index.With indexing off, CSUserQuery returns nothing, so candidates is empty, so the code says:"Nothing in your library matches that."Over a completely intact library. A confident, plainly-worded, entirely false statement about the user's own data, reached without the model doing anything wrong at all. The fix is a guard before the model is ever involved:// Retrieval is `CSUserQuery` over this app's own index and nothing else,// and the privacy toggle's `forget()` empties exactly that index. Without// this guard the honest "nothing matches" below becomes a confident lie// about a perfectly intact library - the failure shape this whole file// exists to prevent, reached through a setting rather than the model.guard SpotlightIndex.isEnabled else { return Answer(text: String(localized: "Asking searches Spotlight, and Spotlight indexing is turned off in Settings."), snippetIDs: [])}I find this one more interesting than the original. I had removed the model's ability to lie and left a path where the architecture lied on its behalf. If your retrieval layer can be empty for a reason that is not "no results", you need to say which one it was.Two platform facts, cheaply learnedFoundation Models refuses languages, not just content. It throws unsupportedLanguageOrLocale, rendered as "Unsupported language.", for text in a language Apple Intelligence does not support, and Hungarian is not supported. I found that on a real Hungarian PDF. The model was available and refused the content, which was not a state my code had a name for. The language is now checked before the call, because the throw costs a model spin-up that can never succeed:let recognizer = NLLanguageRecognizer()// A sample is plenty and keeps this cheap - it runs on every capture.recognizer.processString(String(text.prefix(1_000)))// No dominant language means short or ambiguous text. Let the model// decide rather than refusing on a guess.guard let dominant = recognizer.dominantLanguage else { return true }let language = Locale.Language(identifier: dominant.rawValue)return SystemLanguageModel.default.supportedLanguages.contains { $0.languageCode == language.languageCode}Apple Intelligence has no Intel Mac. My Mac app builds universal, so the compiler check alone was not enough:#if compiler(>=6.4) && arch(arm64)The framework that bridges Spotlight and Foundation Models ships arm64e interfaces only, so the x86_64 slice cannot see the tool at all. Consistent rather than surprising, once you notice, and a link error until you do.And one that is not about models at all, but cost me a green build: a new Swift file is invisible until the project generator runs. SemanticSearch.swift compiled clean on the command line while not being in the Xcode project. A passing build of a file that is not compiled is not a passing build.What this cost, and what it boughtThe rebuilt version is slower and more code than the four-line version, and the trade is worth stating plainly. Literal search in this app is measured at 4.6 ms against a 50 ms budget, 5,046 items, release build on an iPad Pro M5, and it runs on every keystroke. A model round trip is orders of magnitude slower than that and must never sit between a keystroke and a result. So asking is a separate, clearly labelled mode that runs once, on Return. It is not the search field getting smarter.That is the honest shape of on-device intelligence in a shipping app right now. It is not a layer you sprinkle over your existing feature. It is a second feature with a different latency budget, a different failure mode, and a different set of things it is allowed to be trusted with.What to take awayNever let a model be the only thing that knows whether its answer is real. Give it a job whose output you can check against something you retrieved yourself, and then actually check it. My check started life as a cosmetic list filter and is now the only reason I know the feature works.Ask what breaks mechanically when the model is wrong. Reading answers does not work, because a fabricated answer reads like a real one. Twenty in a row read like real ones. You need a comparison in your code that comes out empty, a count that disagrees, a set intersection that is smaller than it should be. If nothing in your UI can disagree with the model, you have no test.Structure beats instruction. "Never invent a title" is a request. Only being able to choose from a list you retrieved is a guarantee. Prefer the arrangement where the failure you are worried about is not expressible.When halving the suspected cause does not halve the symptom, it was not the cause. The prompt was 13,672 tokens. Cutting the results moved it by three.And check what you wrote down while the model was lying to you. Two "findings" in my planning document were things a confabulating model had told me about itself. That is the failure mode nobody warns you about: it does not only corrupt your output, it corrupts your notes.Fingertips is a snippet library for iPhone, iPad and Mac, built for RevenueCat's Shipaton 2026. It is at usefingertips.com. Earlier in this series: an entitlement check that turned an unreachable RevenueCat into an unlimited licence, and why you cannot give a hackathon judge a free unlock before you submit.