Time Bomb Radar
SkillProductivityFinds deferred operations that crash on aged data -- code that passes every test but breaks weeks or months after release. Covers cascade deletes, cache expiry, trial paths, background accumulation, date-threshold transitions, and scheduled side effects. Triggers: "time bomb", "time-bomb", "/time-bomb-radar", "aged data", "deferred deletion".
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Time Bomb Radar skill
What this skill tells your AI
The instructions your AI receives, as published by terryc21/radar-suite in skills/time-bomb-radar/SKILL.md and read by ahel’s review.
Finds code that works today but crashes after the data gets old enough.
Time bombs are deferred operations that pass every test, every code review, every pattern matcher, then crash your app weeks or months after release. The trigger is data age + environment state, not code paths. They produce 1-star reviews from your most loyal users -- the ones who kept the app long enough for the timer to fire.
Origin: A production-class crash where SafeDeletionManager archived items for 30 days, then cascade-deleted them, triggering a SwiftData _FullFutureBackingData fatal error on unresolved iCloud .externalStorage faults. The bug was invisible during development because no test data was 30 days old. If shipped, every user would have crashed on day 31.
Quick commands
| Command | What it does |
|---|---|
/time-bomb-radar | Full audit across all 7 patterns |
/time-bomb-radar deferred-deletes | Pattern 1 only -- cascade deletes on aged data |
/time-bomb-radar cache-expiry | Pattern 2 only -- cache purge with model relationships |
/time-bomb-radar trial-expiry | Pattern 3 only -- subscription/trial expiry paths |
/time-bomb-radar background-tasks | Pattern 4 only -- accumulated background work |
/time-bomb-radar date-transitions | Pattern 5 only -- date-threshold state changes |
/time-bomb-radar scheduled-side-effects | Pattern 6 only -- notifications/reminders scheduled from aged data |
/time-bomb-radar cascade-live-refs | Pattern 7 only -- cascade delete with live child references |
--show-suppressed | Show findings suppressed by known-intentional entries (see § Intentional Suppression Flags) |
--accept-intentional | Mark current finding as known-intentional (see § Intentional Suppression Flags) |
Intentional Suppression Flags
Both flags wrap the protocol in radar-suite-core.md § Known-Intentional Suppression, which owns the canonical spec for .radar-suite/known-intentional.yaml (file format, fields, matching rules).
| Flag | Behavior |
|---|---|
--show-suppressed | After the scan completes, list every finding that was suppressed by a matching entry in .radar-suite/known-intentional.yaml this session. Output includes the finding (file:line + pattern), the suppression entry that matched, and the date the entry was added. Read-only — does not modify the suppression file. |
--accept-intentional | Interactive flow that appends a new entry to .radar-suite/known-intentional.yaml for the most recently presented finding in the current conversation. Asks via AskUserQuestion to confirm the file:line + pattern_fingerprint + reason text before writing. Requires the conversation to contain at least one finding emitted by this skill (refuses with "No recent finding to accept" otherwise). |
Future scans (this session or later) will silently skip findings matching accepted entries and increment the intentional_suppressed counter per § Pre-Scan Startup.
Shared Patterns
See radar-suite-core.md for: Tier System, Pipeline UX Enhancements, Table Format, Progress Banner, Issue Rating Tables, Handoff YAML schema, Known-Intentional Suppression, Pattern Reintroduction Detection, Experience-Level Output Rules, Session Persistence, short_title requirement.
Key concepts
These concepts appear throughout the 7 patterns. Understanding them makes the patterns easier to apply regardless of framework.
Lazy loading and faults
Most ORMs don't load related objects until you access them. A User object with 50 photos doesn't load those photos into memory just because you fetched the user. Instead, the photos are represented as faults -- lightweight placeholders that get filled in when you access them.
This is efficient for normal use. It becomes dangerous when:
- The real data is stored remotely (cloud sync, external storage) and hasn't been downloaded
- The object is being deleted and the ORM tries to resolve all its faults to track the cascade
- The app has been idle for weeks and the local cache has been evicted
In SwiftData: Faults are _FullFutureBackingData<T> objects. Accessing them triggers resolution. If resolution fails (data not available), it's a fatalError -- not a throwing error. You cannot catch it.
In Core Data: Faults are NSManagedObject subclasses with isFault == true. Accessing a property triggers resolution. If the store is unavailable, you get NSObjectInaccessibleException.
In Django/SQLAlchemy/ActiveRecord: Lazy-loaded relationships raise database errors if the connection is lost or the row was deleted. The ORM equivalent of "this object doesn't exist anymore."
In any ORM with cloud sync: The object exists in the schema but the data hasn't been synced to this device. The fault resolution goes to the network, which may be unavailable.
Cascade deletes
When you delete a parent object, the ORM can automatically delete its children. This is configured via delete rules (.cascade in SwiftData/Core Data, on_delete=CASCADE in Django, dependent: :destroy in Rails).
The problem: cascade deletion forces the ORM to find and visit every child before deleting them. If any child is a fault whose data isn't locally available, the visit fails.
Object-level cascade delete: ORM loads each child into memory, snapshots it for change tracking, then deletes it. Triggers fault resolution. Dangerous on aged data.
Batch/SQL-level delete: ORM issues DELETE FROM children WHERE parent_id = ? directly. Never loads objects. Never triggers faults. Safe on aged data.
External storage
Some ORMs store large binary data (photos, PDFs, audio) outside the main database file. SwiftData uses .externalStorage to put Data properties on disk instead of inline in SQLite. Core Data has "Allows External Storage" in the model editor. Other frameworks use file references.
External storage is the highest-risk target for time bombs because:
- The file may not be downloaded from the cloud yet
- The file may have been evicted from the local cache
- The ORM may not distinguish between "file not downloaded yet" and "file doesn't exist"
Why testing misses these
- No test data is 30 days old
- Simulators/emulators have perfect local data (no cloud sync delays)
- Unit tests use in-memory stores (no external storage faults)
- CI runs on fresh environments every time
- The developer's device has good Wi-Fi and fully synced data
To catch a time bomb manually, you'd need to: create data, archive it, set your device clock forward 30-90 days, disconnect from the network, and relaunch. Nobody does this.
Skill Introduction (MANDATORY — run before scanning)
This section replaces radar-suite-core.md § Session Setup for the time-bomb-radar entry point. Do NOT also run core's 4-question Session Setup — its questions are consolidated below. On first invocation, ask all setup questions in a single AskUserQuestion call:
Question 1: "What's your experience level with Swift/SwiftUI?"
- Beginner — New to Swift. Plain language, analogies, define terms on first use.
- Intermediate — Comfortable with SwiftUI basics. Standard terms, explain non-obvious patterns.
- Experienced (Recommended) — Fluent with SwiftUI. Concise findings, no definitions.
- Senior/Expert — Deep expertise. Terse, file:line only, skip explanations.
Question 2: "Table format?"
- Full tables (Recommended) — full Issue Rating Tables
- Compact tables — 3-column with details below
Question 3: "Would you like a brief explanation of what this skill does?"
- No, let's go (Recommended) — Skip explanation, proceed to scan.
- Yes, explain it — Show one of the explanations below adapted to experience level, then proceed.
Store as: USER_EXPERIENCE, TABLE_FORMAT. Apply to ALL output for the session, per radar-suite-core.md § Experience-Level Output Rules. Also persist to .radar-suite/session-prefs.yaml per radar-suite-core.md § Session Persistence.
Note on fix mode: Time-bomb-radar is a read-only audit skill — it identifies bombs and writes them to the handoff/ledger, but does NOT apply fixes directly. The capstone-radar and roundtrip-radar skills consume time-bomb findings and drive the fix work. So no FIX_MODE question is asked here; the allowed-tools list includes Edit/Write only for handoff/ledger persistence, not for source modification.
Experience-adapted explanations for Time Bomb Radar:
- Beginner: "I'll search your codebase for operations that fire after a time delay — deletions, cache purges, trial expirations, background tasks, and date-based state changes. For each one, I check whether it can crash on data that's been sitting idle for weeks or months with incomplete cloud sync. Think of it like asking: 'If this code runs 90 days after the data was created, on a phone with bad Wi-Fi, what breaks?' Bugs found this way don't show up in tests — they only appear in production, weeks after release, on your most loyal users' devices."
- Intermediate: "Time-bomb-radar audits deferred operations that pass tests but fail on aged data with incomplete sync. Covers 7 patterns: cascade deletes (1), cache expiry (2), trial expiry (3), background task accumulation (4), date-threshold transitions (5), scheduled side effects (6), and cascade delete with live child references (7). Outputs BOMB/Risky/Safe ratings with grep evidence and file:line citations."
- Experienced: "Time bomb audit across 7 patterns: deferred cascade deletes, cache expiry with model relationships, trial/subscription expiry paths, background task accumulation, date-threshold state transitions, scheduled side effects from aged data, and cascade delete with live child references. Outputs rated findings with grep evidence."
- Senior/Expert: "7-pattern aged-data audit. BOMB/Risky/Safe + grep evidence + file:line."
User impact explanations: Can be toggled at any time with --explain / --no-explain. When enabled, each finding gets a 3-line companion explanation (what's wrong, fix, user experience before/after). See radar-suite-core.md for format and rules. Store as EXPLAIN_FINDINGS (default: false).
Experience-level auto-apply (time-bomb-radar local): If USER_EXPERIENCE = Beginner, auto-set EXPLAIN_FINDINGS = true and default sort to impact. If Senior/Expert, default sort to effort. Apply all output rules from radar-suite-core.md § Experience-Level Output Rules.
Pre-Scan Startup (MANDATORY — before any pattern scan)
-
Known-intentional suppression: Run the protocol in
radar-suite-core.md § Known-Intentional Suppression. Core owns this — do not restate the steps here. -
Pattern reintroduction detection: Run the protocol in
radar-suite-core.md § Pattern Reintroduction Detection. Core owns this.
Step 0: Codebase scan
Before checking individual patterns, collect baseline information:
Swift/Apple projects
- Persistence framework: SwiftData, Core Data, GRDB, Realm, or plain files?
- Cloud sync: iCloud/CloudKit, Firebase, custom backend, or local-only?
- External storage: Any
.externalStorageattributes or large binary data stored outside the main database? - Subscription/trial system: StoreKit, RevenueCat, custom, or none?
Grep pattern="@Model|NSManagedObject|@Table" glob="**/*.swift" output_mode="files_with_matches"
Grep pattern="\.externalStorage|Allows External Storage" glob="**/*.swift" output_mode="content"
Grep pattern="cloudKit|CKContainer|iCloud|FirebaseFirestore" glob="**/*.swift" output_mode="files_with_matches"
Grep pattern="StoreKit|SubscriptionManager|TrialManager|RevenueCat" glob="**/*.swift" output_mode="files_with_matches"
Other frameworks (Django, Rails, Node, etc.)
Grep pattern="on_delete.*CASCADE|dependent.*destroy|CASCADE" glob="**/*.{py,rb,ts,js}" output_mode="content"
Grep pattern="expires_at|ttl|max_age|cache_expiry" glob="**/*.{py,rb,ts,js}" output_mode="content"
Grep pattern="trial|subscription.*expir|free_tier" glob="**/*.{py,rb,ts,js}" output_mode="content"
Grep pattern="cron|scheduler|background_job|sidekiq|celery|delayed_job" glob="**/*.{py,rb,ts,js,yaml,yml}" output_mode="files_with_matches"
Output
Persistence: [framework]
Cloud sync: [yes/no, which service]
External storage: [list of models/properties]
Subscription system: [yes/no, which framework]
Pattern Relevance Matrix
Use the Step 0 output to decide which patterns to run. Pattern 1 always applies if there's any persistence framework; the rest scale with the codebase's characteristics.
| Codebase characteristic | Patterns to run |
|---|---|
| Any persistence framework (always) | 1, 4 |
| Has cache layer with TTL or expires_at | + 2 |
| Has freemium/trial/subscription system | + 3 |
| Has date-based state transitions (warranties, loans, password expiry, etc.) | + 5 |
| Schedules notifications, reminders, calendar events, or emails | + 6 |
Swift + SwiftData/Core Data + SwiftUI with .cascade delete rules and views holding child refs | + 7 |
A full audit (/time-bomb-radar with no arguments) runs all patterns whose characteristic matches the Step 0 output and skips the rest. The skill announces which patterns were skipped and why in the opening banner so the user can override with a per-pattern command if needed.
Pattern 1: Deferred deletion with cascade relationships
The general problem: Code that soft-deletes objects (archive, trash, recycle bin), then permanently deletes them after a time threshold. The permanent delete triggers cascade rules that try to visit related objects. If those objects have remote or externally stored data that isn't locally available, the visit fails.
This is the most dangerous pattern because the crash is usually uncatchable. The ORM hits a fatal error during internal bookkeeping (snapshot creation, change tracking), not during your code.
Severity: CRITICAL when cascade targets include external storage or cloud-synced data.
How to find them (Swift)
Grep pattern="byAdding.*day.*value.*-|byAdding.*month.*value.*-" glob="**/*.swift" output_mode="content"
For each hit, check if the same file or calling chain includes:
Grep pattern="\.delete|context\.delete|modelContext\.delete|remove|purge|cleanup" path="[file from above]" output_mode="content"
How to find them (other frameworks)
# Python/Django
Grep pattern="timedelta.*days|datetime.*now.*-" glob="**/*.py" output_mode="content"
# Then check same files for .delete(), bulk_delete, QuerySet.delete()
# Ruby/Rails
Grep pattern="ago|days\.ago|months\.ago" glob="**/*.rb" output_mode="content"
# Then check same files for destroy, destroy_all, delete, delete_all
# Node/TypeScript
Grep pattern="Date\.now.*-|subtract.*days|moment.*subtract" glob="**/*.{ts,js}" output_mode="content"
# Then check same files for .remove(), .delete(), .destroy()
What to verify for each hit
Enumerate-then-verify: Don't stop at "does it have cascade targets with external storage?" Enumerate ALL cascade children, then check each one. The bug hides in the gap between what was handled and what exists.
- Enumerate: List every cascade relationship from the parent model. Include grandchildren (e.g., Parent -> Child -> Grandchild where both relationships are cascade).
- Check external storage: For each child/grandchild, check if it has
.externalStorage(SwiftData),Allows External Storage(Core Data), or file references (other ORMs). - Check coverage: For each child/grandchild, check if it's covered by a batch delete in the deletion code. The finding is in the gap between what exists in the model and what's covered by batch deletes.
- Check sync: Do the cascade targets sync with a cloud service?
- Check delete method: Is the deletion done via batch/SQL-level delete or object-level delete?
Common miss: Existing code already handles the obvious case (e.g., photos) with comments explaining why. A human reading that assumes "they handled it." The skill must verify completeness -- enumerate all children, not just confirm the documented ones.
Classification
| Delete method | Cascade target | Rating |
|---|---|---|
| Batch/SQL-level delete | Any | Safe |
| Object-level delete | No cascade | Safe |
| Object-level delete | Cascade to normal properties | Risky |
| Object-level delete | Cascade to external storage or cloud-synced data | BOMB |
Swift-specific details
Safe: context.delete(model: T.self, where:) operates at the SQL level. Never materializes objects. Never triggers faults.
Unsafe: context.delete(object) with .cascade rule. Forces materialization of all related objects via ModelSnapshot creation. If any child has _FullFutureBackingData (unresolved iCloud .externalStorage), it's a fatal error.
Fix: Two-phase batch delete. Delete children first (by predicate), then delete parents. Requires stored properties used in predicates to be internal (not private).
// Phase 1: Batch-delete child objects (SQL-level, no materialization)
let childPredicate = #Predicate<ChildModel> {
$0.parent?.statusRaw == "archived"
}
try? context.delete(model: ChildModel.self, where: childPredicate)
// Phase 2: Batch-delete parent objects (cascade is now a no-op)
let parentPredicate = #Predicate<ParentModel> {
$0.statusRaw == "archived"
}
try? context.delete(model: ParentModel.self, where: parentPredicate)
Django-specific details
Safe: MyModel.objects.filter(archived_before=threshold).delete() uses SQL-level CASCADE. No object loading if no signals/overrides.
Unsafe: Looping with obj.delete() when pre_delete/post_delete signals access related objects that may have been deleted by another process or have stale foreign keys.
Additional risk: Django's on_delete=CASCADE at the database level is safe, but Python-level cascade (on_delete=models.CASCADE with signal handlers) loads objects.
Pattern 2: Cache expiry with model relationships
The general problem: Cache entries (API responses, OCR results, AI outputs, thumbnails) with a TTL that expire after N days. The cache works fine for fresh entries. When the purge runs on old entries, it may trigger relationship resolution or external data access on stale objects.
This is Pattern 1 in disguise, with a different trigger (TTL vs archive age) and often a different location in the codebase (cache managers vs deletion managers).
How to find them (Swift)
Grep pattern="cacheExpiry|expiresAt|isExpired|ttl|maxAge|cacheExpiryDays" glob="**/*.swift" output_mode="content"
Exclude warranty/coverage/subscription business logic (those are Pattern 5).
How to find them (other frameworks)
Grep pattern="expires_at|ttl|max_age|cache_timeout|CACHE_TTL" glob="**/*.{py,rb,ts,js,yaml}" output_mode="content"
Grep pattern="redis.*expire|memcache.*expir|cache\.delete" glob="**/*.{py,rb,ts,js}" output_mode="content"
What to verify for each hit
- Is the cache entry a persisted model or an external store (Redis, Memcached, files)?
- Does it have relationships to other models?
- How is the purge done -- batch delete or object-level loop?
- Does the cache store binary data externally?
Check ALL delete paths, not just expiry: Once you find a cache model with .externalStorage, check every method that deletes instances of that model -- not just the TTL-triggered purge. User-triggered operations like "Clear Cache" and "Clear Cache for Item" have the same .externalStorage crash risk. The trigger is different (user action vs timer) but the fault resolution crash is identical.
Classification
| Cache storage | Relationships | Purge method | Rating |
|---|---|---|---|
| UserDefaults, files, Redis, Memcached | N/A | Any | Safe |
@Model / ORM model, no relationships | N/A | Any | Safe |
@Model / ORM model, has relationships | N/A | Batch | Safe |
@Model / ORM model, has relationships | N/A | Object-level | Risky |
@Model / ORM model, .externalStorage | N/A | Object-level | BOMB |
Pattern 3: Trial and subscription expiry paths
The general problem: Features gated behind a time-limited trial or subscription. The risk isn't the paywall UI. It's what happens to in-flight operations, initialized sessions, and cached permissions when the authorization state changes after weeks of being valid.
This pattern exists in every app with a freemium model, regardless of platform. The specific risk varies:
- Mobile apps: StoreKit/Google Play billing not initialized because the feature was always available during development
- SaaS: API keys or JWT tokens issued during trial that aren't invalidated on expiry
- Desktop apps: License files checked on startup but not re-validated during long-running sessions
How to find them (Swift)
Grep pattern="daysRemaining|trialEnd|subscriptionExpir|canUse|isSubscribed|queriesRemaining" glob="**/*.swift" output_mode="content"
How to find them (other frameworks)
Grep pattern="trial_end|subscription_expir|is_subscribed|can_use_feature|free_tier" glob="**/*.{py,rb,ts,js}" output_mode="content"
Grep pattern="billing.*check|license.*valid|entitlement" glob="**/*.{py,rb,ts,js}" output_mode="content"
What to verify for each hit
- Session initialization: Is the feature's session/manager initialized with trial-era permissions? Does it handle the transition to expired state mid-session?
- UI fallback: When the trial expires, does the UI show a working paywall? Or does it show a broken state because the purchase system (StoreKit, Stripe, Google Play) wasn't initialized since the feature was always available?
- Data access: Can the user still read data they created during the trial? Or does the expiry gate lock them out of their own content?
- Edge case: What if the trial expires while the app is in the background/inactive, and the user returns to a view that assumes trial access?
Classification
| Behavior on expiry | Rating |
|---|---|
| Gate checks at view/route level with graceful fallback | Safe |
| User can still read their own data (read-only) | Safe |
| Feature session assumes trial is active, no expiry handling | Risky |
| User loses access to data they created during trial | BOMB |
| Purchase/subscribe button broken because billing not initialized | BOMB |
How to test
Set the device date (or server clock) forward past the trial end date. Launch the app. Verify:
- Paywall/upgrade prompt appears and the purchase flow works
- Previously created data is still accessible (read-only at minimum)
- No crashes from expired session objects or revoked permissions
Pattern 4: Background task accumulation
The general problem: Background tasks (thumbnail generation, sync reconciliation, data cleanup, analytics upload, email queues) that process accumulated items. They work fine on 5 items. After weeks of the app (or service) being idle, they wake up to hundreds or thousands.
This affects every platform:
- iOS:
BGTaskSchedulertasks with 30-second execution limits - Android:
WorkManagerjobs with battery-aware scheduling - Server: Cron jobs, Sidekiq/Celery workers, Lambda functions triggered by queue depth
- Desktop: LaunchAgent/scheduled tasks that process accumulated local data
How to find them (Swift)
Grep pattern="BGTaskScheduler|scheduleCleanup|scheduleOnLaunch|performAfter|backgroundTask" glob="**/*.swift" output_mode="content"
How to find them (other frameworks)
Grep pattern="cron|scheduler|background_job|sidekiq|celery|delayed_job|bull|agenda" glob="**/*.{py,rb,ts,js,yaml,yml}" output_mode="files_with_matches"
Grep pattern="WorkManager|JobScheduler|AlarmManager" glob="**/*.{kt,java}" output_mode="files_with_matches"
What to verify for each hit
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 20
- Forks
- 1
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
time-bomb-radar- Source
- github.com/terryc21/radar-suite