Historical audit as of July 14, 2026.
Startup cleanup policy was superseded on September 20, 2026 by ADR 0011. Statements below about one-time detached-row deletion describe the audited July behavior, not current startup. A missing parent can represent an incomplete CloudKit import; current startup preserves such rows. Explicit owned-row replacement and confirmed deletion policies remain in effect.
This note records how persisted Cookle data is deleted after the conservative deletion-policy refactor. It distinguishes the implementation evidence captured at that time from the forward design policy. Consult subsequent decisions for current behavior.
The working rule at the time was:
- keep non-Object persisted records conservatively
- delete parent-owned Object rows when they lose their parent
- prefer unlink over delete
- treat
Delete Alland debug raw deletion as explicit exception paths
Evidence labels used below:
[runtime confirmed]: confirmed by repository tests[source confirmed]: confirmed directly from models, services, UI, intents, or container/bootstrap code[source-based inference]: inferred from source shape where SwiftData macro behavior is not fully visible in code
Representative evidence:
CookleLibrary/Sources/Recipe/RecipeService.swiftCookleLibrary/Sources/Recipe/RecipeFormService.swiftCookleLibrary/Sources/Diary/DiaryService.swiftCookleLibrary/Sources/DataManagement/DetachedObjectCleanupService.swiftCookleLibrary/Sources/Persistence/ModelContainerFactory.swiftCookle/Sources/Features/Photo/Views/PhotoView.swiftCookle/Sources/Features/Tag/Views/TagView.swiftCookle/Sources/Features/Tag/Intents/DeleteCategoryIntent.swiftCookle/Sources/Features/Tag/Intents/DeleteIngredientIntent.swiftCookleLibrary/Tests/Default/DataManagement/DeletionPolicyAuditRootModelTests.swiftCookleLibrary/Tests/Default/DataManagement/DeletionPolicyAuditObjectLifecycleTests.swiftCookleLibrary/Tests/Default/DataManagement/DetachedObjectCleanupServiceTests.swiftCookleLibrary/Tests/Default/Photo/RecipePhotoRemovalTests.swift
- Role: aggregate root for recipe content.
[source confirmed] - Explicit delete entrypoints:
RecipeService.deleteWithOutcome,DeleteRecipeButton,DeleteRecipeIntent,DebugContentView,DataResetService.deleteAll.[source confirmed] - Cascade source: deleting a
Recipecascades toPhotoObject,IngredientObject, andDiaryObjectthroughRecipe.photoObjects,Recipe.ingredientObjects, andRecipe.diaryObjects.[source confirmed] - Automatic cleanup: none for the
Reciperow itself.[source confirmed] - Reset / migration / maintenance path:
deleted by
DataResetService.deleteAll; no schema migration stage deletes individualReciperows becauseCookleMigrationPlan.stagesis empty.[source confirmed] - Orphan allowance: not applicable as a root record. Deleting a
Recipekeeps sharedPhoto,Ingredient, andCategoryrecords.[runtime confirmed] - Coverage:
RecipeServiceTests,DeletionPolicyAuditRootModelTests.[runtime confirmed]
- Role: aggregate root for one day of diary content.
[source confirmed] - Explicit delete entrypoints:
DiaryService.deleteWithOutcome,DeleteDiaryButton,DeleteDiaryIntent,DebugContentView,DataResetService.deleteAll.[source confirmed] - Cascade source: deleting a
Diarycascades toDiaryObjectthroughDiary.objects.[source confirmed] - Automatic cleanup: none for the
Diaryrow itself.[source confirmed] - Reset / migration / maintenance path:
deleted by
DataResetService.deleteAll; schema migration still has no row deletion stage.[source confirmed] - Orphan allowance: not applicable as a root record.
Diarysurvives unrelatedRecipedeletion while its ownedDiaryObjectrows are removed.[runtime confirmed] - Coverage:
DiaryServiceQueryTests,DeletionPolicyAuditRootModelTests.[runtime confirmed]
- Role: shared asset record reused across recipe photo rows and now shown in
the photo gallery even when unlinked.
[source confirmed] - Explicit delete entrypoints:
PhotoView,DebugContentView, andDataResetService.deleteAll. Ordinary recipe photo removal remains unlink-only, while photo detail now exposes explicit asset delete with recipe-row impact confirmation.[source confirmed] - Cascade source: deleting a
Photocascades toPhotoObjectthroughPhoto.objects.[source confirmed] - Automatic cleanup: none for the
Photoasset itself.RecipeService.removePhotoWithOutcomeis unlink-only and deletes only thePhotoObjectrow.[runtime confirmed] - Reset / migration / maintenance path:
deleted by
DataResetService.deleteAll; detached-object maintenance never deletesPhotoroots.[source confirmed] - Orphan allowance: yes by design. A
Photocan remain stored with no linkedRecipe, and the Photos tab now surfaces those assets.[runtime confirmed] - Coverage:
RecipePhotoRemovalTests,DeletionPolicyAuditObjectLifecycleTests,DetachedObjectCleanupServiceTests.[runtime confirmed]
- Role: shared tag record for classification and filtering.
[source confirmed] - Explicit delete entrypoints:
TagView,DeleteCategoryIntent,DebugContentView, andDataResetService.deleteAll. Ordinary category delete now uses recipe-impact confirmation in both product UI and App Intents.[source confirmed] - Cascade source: none declared in source.
[source confirmed] - Automatic cleanup: none.
[source confirmed] - Reset / migration / maintenance path:
deleted by
DataResetService.deleteAll; detached-object maintenance never deletesCategoryroots.[source confirmed] - Orphan allowance: yes by design. Categories remain stored even when no recipe
currently uses them.
[source confirmed] - Coverage:
TagServiceTests,OperationsMutationEffectPropagationTests.[runtime confirmed]
- Role: shared tag record reused by ingredient rows.
[source confirmed] - Explicit delete entrypoints:
TagView,DeleteIngredientIntent,DebugContentView, andDataResetService.deleteAll. Ordinary ingredient delete now succeeds only when the ingredient is unused.[source confirmed] - Cascade source: deleting an
Ingredientcascades toIngredientObjectthroughIngredient.objects.[source confirmed] - Automatic cleanup: none for the
Ingredientroot itself.[source confirmed] - Reset / migration / maintenance path:
deleted by
DataResetService.deleteAll; detached-object maintenance never deletesIngredientroots.[source confirmed] - Orphan allowance: yes by design. Ingredients remain stored even when no
recipe currently uses them.
[runtime confirmed] - Coverage:
TagServiceTests,DeletionPolicyAuditObjectLifecycleTests,DetachedObjectCleanupServiceTests.[runtime confirmed]
- Role: parent-owned subobject that places a
Recipeinto aDiarymeal slot.[source confirmed] - Explicit delete entrypoints: no user-facing root delete flow. Removed
indirectly by
Diarydeletion,Recipedeletion, detached-object maintenance,DataResetService.deleteAll, andDebugContentView.[source confirmed] - Cascade source: deleting a
Diarycascades throughDiary.objects; deleting aRecipecascades throughRecipe.diaryObjects.[source confirmed] - Automatic cleanup:
DiaryService.updateWithOutcomedeletes replaced rows after rebuilding the owned collection.[runtime confirmed] - Reset / migration / maintenance path:
DetachedObjectCleanupServicedeletes ownerless rows one time during live app container preparation.[runtime confirmed] - Orphan allowance: no in steady state. Update flows and one-time maintenance
remove detached rows.
[runtime confirmed] - Coverage:
DeletionPolicyAuditObjectLifecycleTests,DetachedObjectCleanupServiceTests.[runtime confirmed]
- Role: parent-owned subobject that keeps photo order and the link to
Photo.[source confirmed] - Explicit delete entrypoints: no user-facing root delete flow. Removed
indirectly by
Recipedeletion,RecipeService.removePhotoWithOutcome, detached-object maintenance,DataResetService.deleteAll, andDebugContentView.[source confirmed] - Cascade source: deleting a
Recipecascades throughRecipe.photoObjects; deleting aPhotocascades throughPhoto.objects.[source confirmed] - Automatic cleanup:
RecipeFormService.updateWithOutcomedeletes replaced rows after rebuilding the owned collection.[runtime confirmed] - Reset / migration / maintenance path:
DetachedObjectCleanupServicedeletes ownerless rows one time during live app container preparation.[runtime confirmed] - Orphan allowance: no in steady state. Detached rows are no longer tolerated
after update flows or maintenance.
[runtime confirmed] - Coverage:
RecipePhotoRemovalTests,DeletionPolicyAuditObjectLifecycleTests,DetachedObjectCleanupServiceTests.[runtime confirmed]
- Role: parent-owned subobject that keeps ingredient order, amount text, and
the link to
Ingredient.[source confirmed] - Explicit delete entrypoints: no user-facing root delete flow. Removed
indirectly by
Recipedeletion, detached-object maintenance,DataResetService.deleteAll, andDebugContentView.[source confirmed] - Cascade source: deleting a
Recipecascades throughRecipe.ingredientObjects; deleting anIngredientcascades throughIngredient.objects.[source confirmed] - Automatic cleanup:
RecipeFormService.updateWithOutcomedeletes replaced rows after rebuilding the owned collection.[runtime confirmed] - Reset / migration / maintenance path:
DetachedObjectCleanupServicedeletes ownerless rows one time during live app container preparation.[runtime confirmed] - Orphan allowance: no in steady state. Detached rows are no longer tolerated
after update flows or maintenance.
[runtime confirmed] - Coverage:
DeletionPolicyAuditObjectLifecycleTests,DetachedObjectCleanupServiceTests.[runtime confirmed]
Recipe: explicit delete from detail UI, list UI, and App Intent.[source confirmed]Diary: explicit delete from detail UI and App Intent.[source confirmed]Photo: explicit asset delete from photo detail. The confirmation reports how many linked recipe photo rows will be removed while preserving the recipes.[source confirmed]Category: explicit delete from tag detail and App Intent. The confirmation reports how many recipe relations will be removed.[source confirmed]Ingredient: explicit delete from tag detail and App Intent only when the ingredient is unused. The UI disables deletion for an in-use ingredient, and the App Intent returns an in-use rejection.[source confirmed]
DataResetService.deleteAlldeletes all eight persisted model types when run against saved data.[runtime confirmed]DatabaseMigrator.removeLegacyStoreFilesIfNeededremoves whole legacy store files after relocation validation. This is store-file cleanup, not typed row cleanup.[source confirmed]DetachedObjectCleanupService.runIfNeededdeletes ownerlessDiaryObject,PhotoObject, andIngredientObjectrows one time during live app container preparation.[runtime confirmed]
DebugContentViewexposes direct raw deletion throughmodels[index].delete(), bypassing service-layer policy guards.[source confirmed]
RecipeFormService.updateWithOutcomedeletes replacedPhotoObjectandIngredientObjectrows after rebuilding the new owned collections.[runtime confirmed]DiaryService.updateWithOutcomedeletes replacedDiaryObjectrows after rebuilding the new owned collection.[runtime confirmed]RecipeService.removePhotoWithOutcomedeletes thePhotoObjectrow and unlinks thePhotoasset, but it no longer deletes the asset itself.[runtime confirmed]
Photois now a conservative shared asset. It remains stored after unlink, and the Photos tab presents all stored assets instead of only recipe-linked assets.[runtime confirmed]CategoryandIngredientare both shared tags with explicit delete surfaces, but their safeguards differ. Category deletion confirms recipe impact, while ingredient deletion is available only when the ingredient is unused.[source confirmed]DiaryObject,PhotoObject, andIngredientObjectare now consistently parent-owned rows. Root deletion, update flows, and one-time maintenance all treat them as disposable when detached.[runtime confirmed]Recipe,Diary,Photo,Category, and unusedIngredientrecords expose explicit product delete surfaces. Parent-owned Object rows remain excluded from standalone deletion.[source confirmed]
- Cookle does not enforce a global "no orphan data" rule across every model.
Instead, it now uses an explicit split policy.
[runtime confirmed] - Shared and root records are preserved conservatively:
Photo,Category,Ingredient, and shared records behind deleted or updated parents are allowed to remain stored.[runtime confirmed] - Parent-owned Object rows are not allowed to remain detached in the intended
steady state. Update flows delete replaced rows, and one-time maintenance
clears legacy detached rows.
[runtime confirmed] - Root deletion still removes owned child rows through cascade, while shared
roots remain stored.
[runtime confirmed]
- Detached-object maintenance runs only from live app container preparation.
Preview and in-memory test containers do not invoke it automatically unless a
test calls the service directly.
[source confirmed] DebugContentViewstill bypasses all ordinary policy checks and can delete any persisted model directly. That remains an intentional maintenance exception.[source confirmed]
This section is the source of truth for future delete-related product and implementation work.
RecipeandDiarymay continue to expose ordinary explicit delete because they are user-authored root records.Recipedelete should disclose its cross-model impact. The confirmation flow should explain how many diary meal rows will be removed when the recipe is deleted.Diarydelete should remain a self-contained delete flow. Its confirmation should describe removal of the diary and its ownedDiaryObjectrows without implying deletion of other root records.Photomust keep unlink and asset delete as separate actions. Removing a photo from a recipe should stay unlink-only, while explicit asset delete should disclose how many recipe photo rows will be affected.Categorymay expose ordinary explicit delete, but the confirmation flow should disclose how many recipes will lose that category relation.Ingredientshould only be deletable when unused. If any recipe still references the ingredient, ordinary delete should be rejected because the delete would remove recipe ingredient rows and their amount text.DiaryObject,PhotoObject, andIngredientObjectare parent-owned rows. They should not gain ordinary standalone delete surfaces and should continue to be removed only by parent mutation, parent deletion, cascade, or explicit maintenance cleanup.- Prefer unlink over delete when mutating shared or reusable persisted records.
- Keep
Delete Alland debug raw delete as explicit maintenance exceptions, not as ordinary product policy.